[WL-813x2] NullReference 모달 원인 추적 · 가드 · 런 중 모달 차단 (#813)
This commit is contained in:
parent
2b788eed3c
commit
828cfeaae8
|
|
@ -0,0 +1,40 @@
|
|||
# WL813x2 Probe 2026-09-09 16:13:10 (edit mode · Play 0)
|
||||
[PASS] ① 에셋 로드 — WL/WLErrorGuardSettings 존재
|
||||
[PASS] ① Enabled — enabled=True RuntimeDisabled=False
|
||||
[PASS] ① suppressErrorModalInRun 기본 1 — True
|
||||
[PASS] ① suppressOnlyWhileRunning 기본 1 — True
|
||||
[PASS] ① writeErrorFile 기본 1 — True
|
||||
[PASS] ① includeStack 기본 1 — True
|
||||
[PASS] ① projectileNullTargetGuard 기본 1 — True
|
||||
[PASS] ① projectileGuardLog 기본 0 — False
|
||||
값: errorFileDir=Screenshots_WL/WL813x2 · maxEntriesPerSession=200 · pollSeconds=0.25 · hookSearchTries=40
|
||||
[PASS] ② target null → shooter 대체 · 예외 0 — pos=(1.00, 0.00, 2.30) null누적=1
|
||||
[PASS] ② target·shooter null → 현재 위치 유지 — pos=(7.00, 8.00, 9.00) noShooter=1
|
||||
[PASS] ② target 정상 → 원본 식과 동일 · 카운터 불변 — pos=(-3.00, 1.00, 4.30) 원본=(-3.00, 1.00, 4.30) null누적=2→2
|
||||
[PASS] ② 파괴된 target(가짜 null) 도 == 로 걸림 — pos=(1.00, 0.00, 2.30) null누적=2→3
|
||||
[PASS] ③ 가드 off → 원본 동작(NRE) 재현 = D-35 원인 확정 — NullReferenceException
|
||||
[PASS] ④ logMessageReceived 구독 살아 있음(Info 1줄 · 콘솔 error 0) — Subscribed=True SeenAny=0→1
|
||||
[PASS] ④ Info 는 Error 로 세지 않는다 — Captured=0
|
||||
[PASS] ⑤ 예외 1건 수신 · 모달차단으로 집계 — Captured=1 Suppressed=1
|
||||
[PASS] ⑤ WL 로그 파일 1건 생성 — E:\NerdNavis\WL_wt\gameplay2\Screenshots_WL/WL813x2\errors_161310.txt
|
||||
[PASS] ⑤ 본문 = 문구 + 「모달차단」 — 233byte
|
||||
[PASS] ⑤ 스택 포함 — 포함
|
||||
[PASS] ⑥ 원본 훅에 OnEnable/OnDisable 존재(= enabled 토글로 구독 왕복) — OnEnable=True OnDisable=True
|
||||
[PASS] ⑥ 훅 미발견 시 무해(예외 0 · 상태 불변) — HookFound=False
|
||||
[PASS] ⑥ Apply(true) → 훅 enabled=false (원본 파일 0줄) — enabled=False byUs=True
|
||||
[PASS] ⑥ Apply(false) → 훅 원복 — enabled=True byUs=False
|
||||
[PASS] ⑥ Restore() 안전망 — enabled=True
|
||||
[PASS] ⑦ 런 중에만 차단(에디트 모드 = 런 아님) — IsRunning=False Phase=Idle → False
|
||||
[PASS] ⑦ suppressOnlyWhileRunning=0 → 항상 차단 — True
|
||||
[PASS] ⑦ suppressErrorModalInRun=0 → 원본 모달 — False
|
||||
[PASS] ⑦ ForceState=1 강제 차단 — true
|
||||
[PASS] ⑦ ForceState=-1 강제 해제 — false
|
||||
[PASS] ⑧ GC 0(Tick 200 + TargetPos 200 · 허용 4 KB) — 0 byte
|
||||
[PASS] ⑨ enabled=0 → 차단 판정 false — false
|
||||
[PASS] ⑨ enabled=0 → 훅 무접촉 — hook.enabled=True
|
||||
[PASS] ⑨ RuntimeDisabled → 차단 판정 false — false
|
||||
[PASS] ⑩ 구독 해제 — False
|
||||
[PASS] ⑩ 씬 dirty 불변 — False→False
|
||||
[PASS] ⑩ 디스크 SO 복구 — enabled=1
|
||||
[PASS] ⑩ 임시 훅 잔존 0 — 0
|
||||
RESULT PASS
|
||||
|
|
@ -0,0 +1,373 @@
|
|||
// WL813x2_Probe.cs — #813x2(D-35 NullReference 모달) 에디트 모드 검증 프로브
|
||||
// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 디스크 에셋/씬 무수정(런타임 클론 SO 를 리플렉션으로 주입 · 임시 오브젝트 즉시 파괴)
|
||||
//
|
||||
// 상주 에디터: unity command run_script --project-path <wt> --file AgentScripts/WL813x2_Probe.cs --entry WL813x2_Probe.RunAll
|
||||
// 결과: Console + AgentScripts/WL813x2_PROBE.txt(커밋) + Screenshots_WL/WL813x2/probe_<HHmmss>.txt (RESULT PASS / RESULT FAIL n)
|
||||
//
|
||||
// 검사(발주서 WL-813x2 §1-2/§1-3/§1-4)
|
||||
// ① 에셋 실측: 디스크 WLErrorGuardSettings 10값 · Enabled
|
||||
// ② 원인 재현 + 가드: WLProjectileStart.TargetPos — target null → shooter 대체 · 둘 다 null → 현재 유지 · target 정상 → 원본과 동일 식
|
||||
// ③ C8(투사체): projectileNullTargetGuard=0 → 원본 그대로 NullReferenceException (= D-35 원인 재현)
|
||||
// ④ 구독 왕복: Subscribe() 후 Debug.Log(Info) 1줄 → SeenAnyCount +1 (콘솔 error 0 — 일부러 에러를 내지 않는다)
|
||||
// ⑤ 가짜 예외 → 모달 0 · 로그 파일 1건(스택 포함 · 「모달차단」 표기)
|
||||
// ⑥ 훅 토글: 임시 ErrorLogHookManager → Apply(true) enabled=false · Apply(false) 원복 · OnEnable/OnDisable 존재
|
||||
// ⑦ 스위치 판정: 런 중에만/항상/off 3분기
|
||||
// ⑧ GC 0: Tick 200회 + TargetPos 200회
|
||||
// ⑨ C8(전체): enabled=0 → 판정 false · 훅 무접촉
|
||||
// ⑩ 정리(구독 해제 · 임시 오브젝트 파괴 · 캐시 복구 · 씬 dirty 0)
|
||||
//
|
||||
// 에디트 모드 한계: ErrorLogHookManager 는 비활성 오브젝트에 붙인다(Awake 의 DontDestroyOnLoad 가 에디트 모드에서 돌지 않게).
|
||||
// 따라서 enabled=false 가 실제로 OnDisable → logMessageReceived 구독 해제를 부르는 것은 **Play 필요 = 「미확인」**.
|
||||
// (Unity 계약상 활성 오브젝트의 컴포넌트 enabled 토글은 OnDisable/OnEnable 을 부른다 — 메서드 존재는 ⑥ 에서 실측한다.)
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
using WL.Combat.Diagnostics;
|
||||
|
||||
public static class WL813x2_Probe
|
||||
{
|
||||
const string OutDir = "Screenshots_WL/WL813x2";
|
||||
const string OutCommit = "AgentScripts/WL813x2_PROBE.txt";
|
||||
const BindingFlags SNP = BindingFlags.NonPublic | BindingFlags.Static;
|
||||
|
||||
static WLErrorGuardSettings s_clone;
|
||||
static GameObject s_tmpHookGo, s_tmpActorGo;
|
||||
|
||||
public static object RunAll()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("# WL813x2 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 += ProjGuard(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ② 예외 " + ex); }
|
||||
try { fail += ProjC8(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ③ 예외 " + ex); }
|
||||
try { fail += Subscribe(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ④ 예외 " + ex); }
|
||||
try { fail += FakeException(sb); }catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⑤ 예외 " + ex); }
|
||||
try { fail += HookToggle(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⑥ 예외 " + ex); }
|
||||
try { fail += SwitchLogic(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 += Cleanup(sb, dirty0); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⑩ 예외 " + ex); }
|
||||
|
||||
sb.AppendLine(fail == 0 ? "RESULT PASS" : "RESULT FAIL " + fail);
|
||||
var s = sb.ToString();
|
||||
Debug.Log(s);
|
||||
try
|
||||
{
|
||||
string root = Path.GetDirectoryName(Application.dataPath);
|
||||
Directory.CreateDirectory(Path.Combine(root, OutDir));
|
||||
File.WriteAllText(Path.Combine(root, OutDir + "/probe_" + DateTime.Now.ToString("HHmmss") + ".txt"), s);
|
||||
File.WriteAllText(Path.Combine(root, OutCommit), s);
|
||||
}
|
||||
catch (Exception ex) { Debug.Log("[WL813x2] 프로브 파일 기록 실패 " + ex.Message); }
|
||||
return s;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────── 유틸
|
||||
static int Chk(StringBuilder sb, bool ok, string label, string got)
|
||||
{
|
||||
sb.AppendLine((ok ? "[PASS] " : "[FAIL] ") + label + " — " + got);
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
/// <summary>디스크 에셋을 건드리지 않고 클론 SO 를 캐시에 주입한다.</summary>
|
||||
static WLErrorGuardSettings Inject()
|
||||
{
|
||||
var src = WLErrorGuardSettings.Instance;
|
||||
if (src == null) return null;
|
||||
if (s_clone == null) s_clone = UnityEngine.Object.Instantiate(src);
|
||||
var t = typeof(WLErrorGuardSettings);
|
||||
t.GetField("s_cached", SNP).SetValue(null, s_clone);
|
||||
t.GetField("s_lookupDone", SNP).SetValue(null, true);
|
||||
return s_clone;
|
||||
}
|
||||
|
||||
static void RestoreCache()
|
||||
{
|
||||
var t = typeof(WLErrorGuardSettings);
|
||||
t.GetField("s_cached", SNP).SetValue(null, null);
|
||||
t.GetField("s_lookupDone", SNP).SetValue(null, false);
|
||||
WLErrorGuardSettings.RuntimeDisabled = false;
|
||||
RunErrorModalGuard.ForceState = 0;
|
||||
}
|
||||
|
||||
static Actor NewActor(Vector3 pos)
|
||||
{
|
||||
if (s_tmpActorGo == null)
|
||||
{
|
||||
s_tmpActorGo = new GameObject("[WL813x2] probe actor root");
|
||||
s_tmpActorGo.hideFlags = HideFlags.HideAndDontSave;
|
||||
s_tmpActorGo.SetActive(false); // Awake 를 돌리지 않는다
|
||||
}
|
||||
var go = new GameObject("a");
|
||||
go.hideFlags = HideFlags.HideAndDontSave;
|
||||
go.transform.SetParent(s_tmpActorGo.transform);
|
||||
go.transform.position = pos;
|
||||
return go.AddComponent<Actor>();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────── ① 에셋
|
||||
static int Asset(StringBuilder sb)
|
||||
{
|
||||
int f = 0;
|
||||
var cfg = WLErrorGuardSettings.Instance;
|
||||
f += Chk(sb, cfg != null, "① 에셋 로드", cfg != null ? "WL/WLErrorGuardSettings 존재" : "null(Resources 경로 확인)");
|
||||
if (cfg == null) return f;
|
||||
f += Chk(sb, WLErrorGuardSettings.Enabled, "① Enabled", "enabled=" + cfg.enabled + " RuntimeDisabled=" + WLErrorGuardSettings.RuntimeDisabled);
|
||||
f += Chk(sb, cfg.suppressErrorModalInRun, "① suppressErrorModalInRun 기본 1", cfg.suppressErrorModalInRun.ToString());
|
||||
f += Chk(sb, cfg.suppressOnlyWhileRunning, "① suppressOnlyWhileRunning 기본 1", cfg.suppressOnlyWhileRunning.ToString());
|
||||
f += Chk(sb, cfg.writeErrorFile, "① writeErrorFile 기본 1", cfg.writeErrorFile.ToString());
|
||||
f += Chk(sb, cfg.includeStack, "① includeStack 기본 1", cfg.includeStack.ToString());
|
||||
f += Chk(sb, cfg.projectileNullTargetGuard, "① projectileNullTargetGuard 기본 1", cfg.projectileNullTargetGuard.ToString());
|
||||
f += Chk(sb, !cfg.projectileGuardLog, "① projectileGuardLog 기본 0", cfg.projectileGuardLog.ToString());
|
||||
sb.AppendLine(" 값: errorFileDir=" + cfg.errorFileDir + " · maxEntriesPerSession=" + cfg.maxEntriesPerSession +
|
||||
" · pollSeconds=" + cfg.pollSeconds + " · hookSearchTries=" + cfg.hookSearchTries);
|
||||
return f;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────── ② 투사체 가드
|
||||
static int ProjGuard(StringBuilder sb)
|
||||
{
|
||||
int f = 0;
|
||||
Inject();
|
||||
WLProjectileStart.ResetCounters();
|
||||
|
||||
var cur = new Vector3(7f, 8f, 9f);
|
||||
|
||||
// (a) target null · shooter 살아 있음 → shooter 위치
|
||||
var shooter = NewActor(new Vector3(1f, 0f, 2f));
|
||||
var d1 = new ProjectileData { shooter = shooter, target = null, eStartPos = eProjectileStartPos.Target };
|
||||
Vector3 p1 = WLProjectileStart.TargetPos(d1, cur);
|
||||
f += Chk(sb, p1 == shooter.Get_CenterPositionFoward() && WLProjectileStart.NullTargetCount == 1,
|
||||
"② target null → shooter 대체 · 예외 0", "pos=" + p1 + " null누적=" + WLProjectileStart.NullTargetCount);
|
||||
|
||||
// (b) target·shooter 모두 null → 현재 위치 유지
|
||||
var d2 = new ProjectileData { shooter = null, target = null, eStartPos = eProjectileStartPos.TargetCenter };
|
||||
Vector3 p2 = WLProjectileStart.TargetPos(d2, cur);
|
||||
f += Chk(sb, p2 == cur && WLProjectileStart.NoShooterCount == 1,
|
||||
"② target·shooter null → 현재 위치 유지", "pos=" + p2 + " noShooter=" + WLProjectileStart.NoShooterCount);
|
||||
|
||||
// (c) target 정상 → 원본과 완전히 동일한 식
|
||||
var target = NewActor(new Vector3(-3f, 1f, 4f));
|
||||
var d3 = new ProjectileData { shooter = shooter, target = target, eStartPos = eProjectileStartPos.Target };
|
||||
int n3 = WLProjectileStart.NullTargetCount; // (a)+(b) 로 2
|
||||
Vector3 p3 = WLProjectileStart.TargetPos(d3, cur);
|
||||
f += Chk(sb, p3 == target.Get_CenterPositionFoward() && WLProjectileStart.NullTargetCount == n3,
|
||||
"② target 정상 → 원본 식과 동일 · 카운터 불변", "pos=" + p3 + " 원본=" + target.Get_CenterPositionFoward() + " null누적=" + n3 + "→" + WLProjectileStart.NullTargetCount);
|
||||
|
||||
// (d) 파괴된 Actor(가짜 null · 813y) 도 잡는가
|
||||
var doomed = NewActor(new Vector3(5f, 0f, 5f));
|
||||
var d4 = new ProjectileData { shooter = shooter, target = doomed, eStartPos = eProjectileStartPos.Target };
|
||||
int n4 = WLProjectileStart.NullTargetCount;
|
||||
UnityEngine.Object.DestroyImmediate(doomed.gameObject);
|
||||
Vector3 p4 = WLProjectileStart.TargetPos(d4, cur);
|
||||
f += Chk(sb, p4 == shooter.Get_CenterPositionFoward() && WLProjectileStart.NullTargetCount == n4 + 1,
|
||||
"② 파괴된 target(가짜 null) 도 == 로 걸림", "pos=" + p4 + " null누적=" + n4 + "→" + WLProjectileStart.NullTargetCount);
|
||||
return f;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────── ③ C8(투사체) = D-35 원인 재현
|
||||
static int ProjC8(StringBuilder sb)
|
||||
{
|
||||
int f = 0;
|
||||
var cfg = Inject();
|
||||
if (cfg == null) return Chk(sb, false, "③ C8", "SO 없음");
|
||||
cfg.projectileNullTargetGuard = false;
|
||||
bool threw = false;
|
||||
string kind = "";
|
||||
try { WLProjectileStart.TargetPos(new ProjectileData { shooter = null, target = null }, Vector3.zero); }
|
||||
catch (NullReferenceException) { threw = true; kind = "NullReferenceException"; }
|
||||
catch (Exception ex) { threw = true; kind = ex.GetType().Name; }
|
||||
cfg.projectileNullTargetGuard = true;
|
||||
f += Chk(sb, threw && kind == "NullReferenceException",
|
||||
"③ 가드 off → 원본 동작(NRE) 재현 = D-35 원인 확정", kind == "" ? "예외 없음" : kind);
|
||||
return f;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────── ④ 구독 왕복
|
||||
static int Subscribe(StringBuilder sb)
|
||||
{
|
||||
int f = 0;
|
||||
Inject();
|
||||
RunErrorModalGuard.ResetCounters();
|
||||
RunErrorModalGuard.Subscribe();
|
||||
int before = RunErrorModalGuard.SeenAnyCount;
|
||||
Debug.Log("[WL813x2] 프로브 구독 확인용 정보 로그(에러 아님)");
|
||||
f += Chk(sb, RunErrorModalGuard.Subscribed && RunErrorModalGuard.SeenAnyCount > before,
|
||||
"④ logMessageReceived 구독 살아 있음(Info 1줄 · 콘솔 error 0)",
|
||||
"Subscribed=" + RunErrorModalGuard.Subscribed + " SeenAny=" + before + "→" + RunErrorModalGuard.SeenAnyCount);
|
||||
f += Chk(sb, RunErrorModalGuard.CapturedCount == 0, "④ Info 는 Error 로 세지 않는다", "Captured=" + RunErrorModalGuard.CapturedCount);
|
||||
return f;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────── ⑤ 가짜 예외 → 모달 0 · 파일 1건
|
||||
static int FakeException(StringBuilder sb)
|
||||
{
|
||||
int f = 0;
|
||||
var cfg = Inject();
|
||||
RunErrorModalGuard.ResetCounters();
|
||||
|
||||
// 실제 콘솔 에러를 내지 않고(=콘솔 error 0 유지) 핸들러 본체를 그대로 호출한다.
|
||||
var onLog = typeof(RunErrorModalGuard).GetMethod("OnLog", SNP);
|
||||
if (onLog == null) return Chk(sb, false, "⑤ OnLog 리플렉션", "메서드 없음");
|
||||
|
||||
// 훅이 내려간 상태(런 중)로 가정 — 공개 정적 필드에 직접 세팅
|
||||
typeof(RunErrorModalGuard).GetField("HookDisabledByUs").SetValue(null, true);
|
||||
|
||||
string msg = "NullReferenceException: Object reference not set to an instance of an object";
|
||||
string stk = " at ProjectileBase.Set (ProjectileData _data) in Assets/Script/Character/Projectile/ProjectileBase.cs:233";
|
||||
onLog.Invoke(null, new object[] { msg, stk, LogType.Exception });
|
||||
|
||||
bool wrote = RunErrorModalGuard.WrittenCount == 1 && !string.IsNullOrEmpty(RunErrorModalGuard.LastFilePath) && File.Exists(RunErrorModalGuard.LastFilePath);
|
||||
string body = wrote ? File.ReadAllText(RunErrorModalGuard.LastFilePath) : "";
|
||||
f += Chk(sb, RunErrorModalGuard.CapturedCount == 1 && RunErrorModalGuard.SuppressedCount == 1,
|
||||
"⑤ 예외 1건 수신 · 모달차단으로 집계", "Captured=" + RunErrorModalGuard.CapturedCount + " Suppressed=" + RunErrorModalGuard.SuppressedCount);
|
||||
f += Chk(sb, wrote, "⑤ WL 로그 파일 1건 생성", wrote ? RunErrorModalGuard.LastFilePath : "미생성");
|
||||
f += Chk(sb, body.Contains(msg) && body.Contains("모달차단"), "⑤ 본문 = 문구 + 「모달차단」", wrote ? body.Length + "byte" : "-");
|
||||
f += Chk(sb, cfg == null || !cfg.includeStack || body.Contains("ProjectileBase.cs:233"), "⑤ 스택 포함", wrote ? "포함" : "-");
|
||||
|
||||
typeof(RunErrorModalGuard).GetField("HookDisabledByUs").SetValue(null, false);
|
||||
return f;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────── ⑥ 훅 토글
|
||||
static int HookToggle(StringBuilder sb)
|
||||
{
|
||||
int f = 0;
|
||||
Inject();
|
||||
var cfg = WLErrorGuardSettings.Instance;
|
||||
|
||||
// 비활성 오브젝트에 붙인다(Awake/DontDestroyOnLoad 가 에디트 모드에서 돌지 않게).
|
||||
s_tmpHookGo = new GameObject("[WL813x2] probe ErrorLogHookManager");
|
||||
s_tmpHookGo.hideFlags = HideFlags.HideAndDontSave;
|
||||
s_tmpHookGo.SetActive(false);
|
||||
var hook = s_tmpHookGo.AddComponent<ErrorLogHookManager>();
|
||||
|
||||
var t = typeof(ErrorLogHookManager);
|
||||
bool hasOnEnable = t.GetMethod("OnEnable", BindingFlags.NonPublic | BindingFlags.Instance) != null;
|
||||
bool hasOnDisable = t.GetMethod("OnDisable", BindingFlags.NonPublic | BindingFlags.Instance) != null;
|
||||
f += Chk(sb, hasOnEnable && hasOnDisable,
|
||||
"⑥ 원본 훅에 OnEnable/OnDisable 존재(= enabled 토글로 구독 왕복)", "OnEnable=" + hasOnEnable + " OnDisable=" + hasOnDisable);
|
||||
|
||||
// 훅이 아예 없을 때 무해한가(탐색 실패 경로)
|
||||
RunErrorModalGuard.ResetHookCache();
|
||||
RunErrorModalGuard.SetHookForTest(null);
|
||||
RunErrorModalGuard.Apply(true, cfg);
|
||||
f += Chk(sb, !RunErrorModalGuard.HookFound && !RunErrorModalGuard.HookDisabledByUs,
|
||||
"⑥ 훅 미발견 시 무해(예외 0 · 상태 불변)", "HookFound=" + RunErrorModalGuard.HookFound);
|
||||
|
||||
// 임시 훅은 HideAndDontSave 라 FindFirstObjectByType 이 못 찾는다 → 직접 주입해 토글만 실측
|
||||
RunErrorModalGuard.SetHookForTest(hook);
|
||||
RunErrorModalGuard.Apply(true, cfg);
|
||||
f += Chk(sb, !hook.enabled && RunErrorModalGuard.HookDisabledByUs,
|
||||
"⑥ Apply(true) → 훅 enabled=false (원본 파일 0줄)", "enabled=" + hook.enabled + " byUs=" + RunErrorModalGuard.HookDisabledByUs);
|
||||
|
||||
RunErrorModalGuard.Apply(false, cfg);
|
||||
f += Chk(sb, hook.enabled && !RunErrorModalGuard.HookDisabledByUs,
|
||||
"⑥ Apply(false) → 훅 원복", "enabled=" + hook.enabled + " byUs=" + RunErrorModalGuard.HookDisabledByUs);
|
||||
|
||||
RunErrorModalGuard.Apply(true, cfg);
|
||||
RunErrorModalGuard.Restore();
|
||||
f += Chk(sb, hook.enabled && !RunErrorModalGuard.HookDisabledByUs, "⑥ Restore() 안전망", "enabled=" + hook.enabled);
|
||||
return f;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────── ⑦ 스위치 판정
|
||||
static int SwitchLogic(StringBuilder sb)
|
||||
{
|
||||
int f = 0;
|
||||
var cfg = Inject();
|
||||
if (cfg == null) return Chk(sb, false, "⑦ 스위치", "SO 없음");
|
||||
|
||||
cfg.suppressErrorModalInRun = true; cfg.suppressOnlyWhileRunning = true;
|
||||
bool running = WL.Combat.Run.RunDirector.IsRunning;
|
||||
f += Chk(sb, RunErrorModalGuard.ShouldSuppress() == running,
|
||||
"⑦ 런 중에만 차단(에디트 모드 = 런 아님)", "IsRunning=" + running + " Phase=" + WL.Combat.Run.RunDirector.Phase + " → " + RunErrorModalGuard.ShouldSuppress());
|
||||
|
||||
cfg.suppressOnlyWhileRunning = false;
|
||||
f += Chk(sb, RunErrorModalGuard.ShouldSuppress(), "⑦ suppressOnlyWhileRunning=0 → 항상 차단", RunErrorModalGuard.ShouldSuppress().ToString());
|
||||
|
||||
cfg.suppressErrorModalInRun = false;
|
||||
f += Chk(sb, !RunErrorModalGuard.ShouldSuppress(), "⑦ suppressErrorModalInRun=0 → 원본 모달", RunErrorModalGuard.ShouldSuppress().ToString());
|
||||
|
||||
cfg.suppressErrorModalInRun = true; cfg.suppressOnlyWhileRunning = true;
|
||||
RunErrorModalGuard.ForceState = 1;
|
||||
f += Chk(sb, RunErrorModalGuard.ShouldSuppress(), "⑦ ForceState=1 강제 차단", "true");
|
||||
RunErrorModalGuard.ForceState = -1;
|
||||
f += Chk(sb, !RunErrorModalGuard.ShouldSuppress(), "⑦ ForceState=-1 강제 해제", "false");
|
||||
RunErrorModalGuard.ForceState = 0;
|
||||
return f;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────── ⑧ GC 0
|
||||
static int Gc(StringBuilder sb)
|
||||
{
|
||||
int f = 0;
|
||||
var cfg = Inject();
|
||||
var cur = new Vector3(1f, 2f, 3f);
|
||||
var shooter = NewActor(Vector3.zero);
|
||||
var d = new ProjectileData { shooter = shooter, target = null, eStartPos = eProjectileStartPos.Target };
|
||||
|
||||
for (int i = 0; i < 20; i++) { RunErrorModalGuard.Tick(0.001f); WLProjectileStart.TargetPos(d, cur); } // 워밍업
|
||||
GC.Collect(); GC.WaitForPendingFinalizers();
|
||||
long m0 = GC.GetTotalMemory(false);
|
||||
for (int i = 0; i < 200; i++) { RunErrorModalGuard.Tick(0.001f); WLProjectileStart.TargetPos(d, cur); }
|
||||
long m1 = GC.GetTotalMemory(false);
|
||||
long d1 = m1 - m0;
|
||||
f += Chk(sb, d1 <= 4096, "⑧ GC 0(Tick 200 + TargetPos 200 · 허용 4 KB)", d1 + " byte");
|
||||
return f;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────── ⑨ C8 전체
|
||||
static int C8(StringBuilder sb)
|
||||
{
|
||||
int f = 0;
|
||||
var cfg = Inject();
|
||||
if (cfg == null) return Chk(sb, false, "⑨ C8", "SO 없음");
|
||||
|
||||
cfg.enabled = false;
|
||||
f += Chk(sb, !RunErrorModalGuard.ShouldSuppress(), "⑨ enabled=0 → 차단 판정 false", "false");
|
||||
|
||||
var hook = s_tmpHookGo != null ? s_tmpHookGo.GetComponent<ErrorLogHookManager>() : null;
|
||||
if (hook != null)
|
||||
{
|
||||
hook.enabled = true;
|
||||
RunErrorModalGuard.Tick(999f);
|
||||
f += Chk(sb, hook.enabled && !RunErrorModalGuard.HookDisabledByUs, "⑨ enabled=0 → 훅 무접촉", "hook.enabled=" + hook.enabled);
|
||||
}
|
||||
|
||||
WLErrorGuardSettings.RuntimeDisabled = true;
|
||||
cfg.enabled = true;
|
||||
f += Chk(sb, !RunErrorModalGuard.ShouldSuppress(), "⑨ RuntimeDisabled → 차단 판정 false", "false");
|
||||
WLErrorGuardSettings.RuntimeDisabled = false;
|
||||
return f;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────── ⑩ 정리
|
||||
static int Cleanup(StringBuilder sb, bool dirty0)
|
||||
{
|
||||
int f = 0;
|
||||
RunErrorModalGuard.Unsubscribe();
|
||||
RunErrorModalGuard.ResetHookCache();
|
||||
RunErrorModalGuard.ResetCounters();
|
||||
WLProjectileStart.ResetCounters();
|
||||
|
||||
if (s_tmpHookGo != null) { UnityEngine.Object.DestroyImmediate(s_tmpHookGo); s_tmpHookGo = null; }
|
||||
if (s_tmpActorGo != null) { UnityEngine.Object.DestroyImmediate(s_tmpActorGo); s_tmpActorGo = null; }
|
||||
if (s_clone != null) { UnityEngine.Object.DestroyImmediate(s_clone); s_clone = null; }
|
||||
RestoreCache();
|
||||
|
||||
bool dirty = UnityEngine.SceneManagement.SceneManager.GetActiveScene().isDirty;
|
||||
f += Chk(sb, !RunErrorModalGuard.Subscribed, "⑩ 구독 해제", RunErrorModalGuard.Subscribed.ToString());
|
||||
f += Chk(sb, dirty == dirty0, "⑩ 씬 dirty 불변", dirty0 + "→" + dirty);
|
||||
f += Chk(sb, WLErrorGuardSettings.Instance != null && WLErrorGuardSettings.Instance.enabled, "⑩ 디스크 SO 복구", "enabled=1");
|
||||
var live = UnityEngine.Object.FindObjectsByType<ErrorLogHookManager>(FindObjectsInactive.Include, FindObjectsSortMode.None);
|
||||
f += Chk(sb, live.Length == 0, "⑩ 임시 훅 잔존 0", live.Length.ToString());
|
||||
return f;
|
||||
}
|
||||
}
|
||||
|
|
@ -230,8 +230,8 @@ public class ProjectileBase : MonoBehaviour
|
|||
{
|
||||
case eProjectileStartPos.Shooter: transform.position = _data.shooter.Get_CenterPositionFoward(); break;
|
||||
case eProjectileStartPos.ShooterCenter: transform.position = _data.shooter.Get_CenterPositionFoward(); break;
|
||||
default: transform.position = _data.target.Get_CenterPositionFoward(); break;
|
||||
case eProjectileStartPos.TargetCenter: transform.position = _data.target.Get_CenterPositionFoward(); break;
|
||||
default: transform.position = WL.Combat.Diagnostics.WLProjectileStart.TargetPos(_data, transform.position); break; // WL-813x2 D-35 가드(2줄)
|
||||
case eProjectileStartPos.TargetCenter: transform.position = WL.Combat.Diagnostics.WLProjectileStart.TargetPos(_data, transform.position); break; // WL-813x2 D-35 가드(2줄)
|
||||
case eProjectileStartPos.Custom: transform.position = _data.v3_StartPos; break;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a476951b5ee34959adf0b5b7c36b3ba5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RunErrorModalGuard.cs — 런 중 인게임 에러 모달 차단 + WL 에러 로그 파일 (Q7 D-35)
|
||||
//
|
||||
// PD 지시 #813 · 발주서 WL-813x2 §1-3 (2026-09-09)
|
||||
//
|
||||
// ■ 사슬(813x·813z 실측)
|
||||
// Unity Error/Exception → `Assets/Script/Util/ErrorLogHookManager.cs:26 HandleLog`
|
||||
// → `:33 Popup.Ins.Set(ePopupType.One, 174, null, null, logString)` = 화면 중앙 파란 모달.
|
||||
// Q7 R1 t≈181 s(보스 HP 54 %)에 이 모달이 보스전을 덮었다(캡처 R1_14).
|
||||
//
|
||||
// ■ 왜 "앞단 구독"만으로는 못 막나 — 실측
|
||||
// `Application.logMessageReceived` 는 **취소·소비가 불가능한 멀티캐스트 델리게이트**다.
|
||||
// 우리가 먼저 구독해도(SubsystemRegistration 등) 원본 핸들러는 그대로 뒤이어 호출된다.
|
||||
// → 유일하게 원본 파일 0 줄로 막는 방법 = **훅 컴포넌트를 내려서 그쪽이 스스로 구독을 끊게 하는 것**.
|
||||
// `ErrorLogHookManager.OnDisable()` 이 `Application.logMessageReceived -= HandleLog` 를 부른다(:21-24).
|
||||
// 런이 끝나면 `enabled = true` → `OnEnable()` 이 다시 구독한다. 원본 무수정 · 가역.
|
||||
//
|
||||
// ■ 대신 우리가 같은 이벤트를 구독해 파일로 남긴다(모달 대신 로그).
|
||||
// Screenshots_WL/WL813x2/errors_<HHmmss>.txt (never_commit 경로 · 스택 포함)
|
||||
//
|
||||
// 🔴 이 파일은 절대 Debug.LogError/LogException 을 쓰지 않는다(813z 규칙 — 그 자체가 모달이 된다).
|
||||
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
namespace WL.Combat.Diagnostics
|
||||
{
|
||||
/// <summary>런 중 원본 에러 모달을 내리고, 대신 WL 로그 파일에 남긴다.</summary>
|
||||
public static class RunErrorModalGuard
|
||||
{
|
||||
// ─────────────────────────────────────────── 실측 카운터(프로브가 읽는다)
|
||||
/// <summary>우리 핸들러가 받은 모든 로그 건수(타입 무관 · 구독이 살아 있는지의 증거).</summary>
|
||||
public static int SeenAnyCount;
|
||||
/// <summary>우리 핸들러가 받은 Error/Exception/Assert 건수.</summary>
|
||||
public static int CapturedCount;
|
||||
/// <summary>그중 훅이 내려가 있어 모달이 뜨지 않은 건수.</summary>
|
||||
public static int SuppressedCount;
|
||||
/// <summary>파일에 실제로 기록한 건수.</summary>
|
||||
public static int WrittenCount;
|
||||
/// <summary>우리가 훅을 내려둔 상태인가.</summary>
|
||||
public static bool HookDisabledByUs;
|
||||
/// <summary>훅 컴포넌트를 찾았는가.</summary>
|
||||
public static bool HookFound { get { return s_hook != null; } }
|
||||
/// <summary>훅 컴포넌트의 현재 enabled(못 찾았으면 false).</summary>
|
||||
public static bool HookEnabled { get { return s_hook != null && s_hook.enabled; } }
|
||||
/// <summary>이번 세션의 로그 파일 경로(아직 안 만들었으면 "").</summary>
|
||||
public static string LastFilePath = "";
|
||||
/// <summary>마지막으로 받은 로그 본문(진단용).</summary>
|
||||
public static string LastLog = "";
|
||||
/// <summary>구독 상태.</summary>
|
||||
public static bool Subscribed { get { return s_subscribed; } }
|
||||
|
||||
/// <summary>프로브·A/B 전용 강제 상태. 0 = SO 판정 · 1 = 무조건 차단 · -1 = 무조건 해제.</summary>
|
||||
public static int ForceState;
|
||||
|
||||
// ─────────────────────────────────────────── 내부
|
||||
static bool s_subscribed;
|
||||
static ErrorLogHookManager s_hook;
|
||||
static int s_searchTries;
|
||||
static float s_pollLeft;
|
||||
static readonly StringBuilder s_sb = new StringBuilder(512);
|
||||
|
||||
static WLErrorGuardSettings Cfg { get { return WLErrorGuardSettings.Instance; } }
|
||||
|
||||
// ─────────────────────────────────────────── 부팅
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Boot()
|
||||
{
|
||||
if (!WLErrorGuardSettings.Enabled) return; // 꺼져 있으면 구독 0 · 오브젝트 0(C8)
|
||||
Subscribe();
|
||||
RunErrorModalGuardRunner.Ensure();
|
||||
}
|
||||
|
||||
/// <summary>로그 이벤트 구독(중복 안전).</summary>
|
||||
public static void Subscribe()
|
||||
{
|
||||
if (s_subscribed) return;
|
||||
Application.logMessageReceived += OnLog;
|
||||
s_subscribed = true;
|
||||
}
|
||||
|
||||
/// <summary>구독 해제 + 훅 원복.</summary>
|
||||
public static void Unsubscribe()
|
||||
{
|
||||
if (s_subscribed)
|
||||
{
|
||||
Application.logMessageReceived -= OnLog;
|
||||
s_subscribed = false;
|
||||
}
|
||||
Restore();
|
||||
}
|
||||
|
||||
/// <summary>테스트 초기화(프로브 전용).</summary>
|
||||
public static void ResetCounters()
|
||||
{
|
||||
SeenAnyCount = CapturedCount = SuppressedCount = WrittenCount = 0;
|
||||
LastFilePath = "";
|
||||
LastLog = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 테스트 전용: 훅 인스턴스를 직접 주입한다.
|
||||
/// 에디트 모드 프로브의 임시 훅은 HideAndDontSave 라 FindFirstObjectByType 이 찾지 못하기 때문이다
|
||||
/// (실물 훅은 `ErrorLogHookManager.cs:8` 이 hideFlags 없이 만드는 평범한 GameObject 라 탐색된다).
|
||||
/// </summary>
|
||||
public static void SetHookForTest(ErrorLogHookManager hook) { s_hook = hook; s_searchTries = 0; }
|
||||
|
||||
/// <summary>훅 캐시·탐색 횟수까지 비운다(프로브 전용 · 임시 훅을 갈아끼울 때).</summary>
|
||||
public static void ResetHookCache()
|
||||
{
|
||||
Restore();
|
||||
s_hook = null;
|
||||
s_searchTries = 0;
|
||||
s_pollLeft = 0f;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────── 판정
|
||||
/// <summary>지금 모달을 막아야 하는가.</summary>
|
||||
public static bool ShouldSuppress()
|
||||
{
|
||||
if (ForceState > 0) return true;
|
||||
if (ForceState < 0) return false;
|
||||
var cfg = Cfg;
|
||||
if (cfg == null || !cfg.enabled || WLErrorGuardSettings.RuntimeDisabled) return false;
|
||||
if (!cfg.suppressErrorModalInRun) return false;
|
||||
if (!cfg.suppressOnlyWhileRunning) return true;
|
||||
return WL.Combat.Run.RunDirector.IsRunning;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────── 틱(러너가 부른다 · 프로브도 직접 부를 수 있다)
|
||||
public static void Tick(float unscaledDt)
|
||||
{
|
||||
var cfg = Cfg;
|
||||
if (cfg == null || !cfg.enabled || WLErrorGuardSettings.RuntimeDisabled) { Restore(); return; }
|
||||
|
||||
s_pollLeft -= unscaledDt;
|
||||
if (s_pollLeft > 0f) return;
|
||||
s_pollLeft = cfg.pollSeconds > 0.01f ? cfg.pollSeconds : 0.25f;
|
||||
|
||||
Apply(ShouldSuppress(), cfg);
|
||||
}
|
||||
|
||||
/// <summary>훅 상태를 목표 상태로 맞춘다(바뀔 때만 손댄다 · GC 0).</summary>
|
||||
public static void Apply(bool suppress, WLErrorGuardSettings cfg)
|
||||
{
|
||||
var hook = FindHook(cfg);
|
||||
if (hook == null) return;
|
||||
|
||||
if (suppress)
|
||||
{
|
||||
if (hook.enabled) { hook.enabled = false; HookDisabledByUs = true; } // OnDisable 이 구독 해제
|
||||
}
|
||||
else if (HookDisabledByUs)
|
||||
{
|
||||
if (!hook.enabled) hook.enabled = true; // OnEnable 이 재구독
|
||||
HookDisabledByUs = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>우리가 내려둔 훅을 원상복구한다.</summary>
|
||||
public static void Restore()
|
||||
{
|
||||
if (!HookDisabledByUs) return;
|
||||
if (s_hook != null && !s_hook.enabled) s_hook.enabled = true;
|
||||
HookDisabledByUs = false;
|
||||
}
|
||||
|
||||
static ErrorLogHookManager FindHook(WLErrorGuardSettings cfg)
|
||||
{
|
||||
if (s_hook != null) return s_hook;
|
||||
int max = cfg != null && cfg.hookSearchTries > 0 ? cfg.hookSearchTries : 40;
|
||||
if (s_searchTries >= max) return null; // 탐색 폭주 방지(GC 0)
|
||||
s_searchTries++;
|
||||
s_hook = UnityEngine.Object.FindFirstObjectByType<ErrorLogHookManager>(FindObjectsInactive.Include);
|
||||
return s_hook;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────── 로그 수신
|
||||
static void OnLog(string logString, string stackTrace, LogType type)
|
||||
{
|
||||
SeenAnyCount++;
|
||||
if (type != LogType.Error && type != LogType.Exception && type != LogType.Assert) return;
|
||||
|
||||
var cfg = Cfg;
|
||||
if (cfg == null || !cfg.enabled) return;
|
||||
|
||||
CapturedCount++;
|
||||
bool suppressed = HookDisabledByUs;
|
||||
if (suppressed) SuppressedCount++;
|
||||
LastLog = logString;
|
||||
|
||||
if (!cfg.writeErrorFile) return;
|
||||
if (cfg.maxEntriesPerSession > 0 && WrittenCount >= cfg.maxEntriesPerSession) return;
|
||||
WriteEntry(cfg, type, logString, stackTrace, suppressed);
|
||||
}
|
||||
|
||||
static void WriteEntry(WLErrorGuardSettings cfg, LogType type, string logString, string stackTrace, bool suppressed)
|
||||
{
|
||||
try
|
||||
{
|
||||
string dir = LogDir(cfg);
|
||||
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
||||
if (string.IsNullOrEmpty(LastFilePath))
|
||||
LastFilePath = Path.Combine(dir, "errors_" + DateTime.Now.ToString("HHmmss") + ".txt");
|
||||
|
||||
s_sb.Length = 0;
|
||||
s_sb.Append(DateTime.Now.ToString("HH:mm:ss")).Append(" [").Append(type.ToString()).Append("] ")
|
||||
.Append(suppressed ? "모달차단" : "모달표시")
|
||||
.Append(" · 런 ").Append(WL.Combat.Run.RunDirector.Phase.ToString())
|
||||
.Append(" t=").Append(WL.Combat.Run.RunDirector.Elapsed.ToString("F0")).Append("s")
|
||||
.Append('\n').Append(logString).Append('\n');
|
||||
if (cfg.includeStack && !string.IsNullOrEmpty(stackTrace))
|
||||
{
|
||||
s_sb.Append(stackTrace);
|
||||
if (stackTrace[stackTrace.Length - 1] != '\n') s_sb.Append('\n');
|
||||
}
|
||||
s_sb.Append("--------\n");
|
||||
|
||||
File.AppendAllText(LastFilePath, s_sb.ToString());
|
||||
WrittenCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastLog = "[WL813x2] 로그 파일 기록 실패: " + ex.Message; // 🔴 Debug.LogError 금지(813z)
|
||||
}
|
||||
}
|
||||
|
||||
static string LogDir(WLErrorGuardSettings cfg)
|
||||
{
|
||||
string sub = cfg != null && !string.IsNullOrEmpty(cfg.errorFileDir) ? cfg.errorFileDir : "Screenshots_WL/WL813x2";
|
||||
string root = Application.isEditor ? Path.GetDirectoryName(Application.dataPath) : Application.persistentDataPath;
|
||||
return string.IsNullOrEmpty(root) ? sub : Path.Combine(root, sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: aee587fedfbe4ea8a96f8292c9411432
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RunErrorModalGuardRunner.cs — RunErrorModalGuard 의 시간 축(숨김 GameObject 1개 · 코루틴 0)
|
||||
//
|
||||
// PD 지시 #813 · 발주서 WL-813x2 §1-3 (2026-09-09)
|
||||
//
|
||||
// 새 Manager/Singleton 을 만들지 않는다 — RunDirectorRunner · BossArenaRunner · SurvivalRunner 와 같은 방식.
|
||||
// HideAndDontSave 라 씬 전환에도 살아남고 씬·프리팹에 저장되지 않는다.
|
||||
// WLErrorGuardSettings.Enabled 가 false 면 Boot 이 이 오브젝트를 아예 만들지 않는다(C8).
|
||||
//
|
||||
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
namespace WL.Combat.Diagnostics
|
||||
{
|
||||
/// <summary>훅 상태를 폴링 주기마다 목표 상태로 맞춘다(바뀔 때만 손댄다).</summary>
|
||||
internal sealed class RunErrorModalGuardRunner : MonoBehaviour
|
||||
{
|
||||
static RunErrorModalGuardRunner s_runner;
|
||||
|
||||
/// <summary>런너 1개 보장(플레이 모드 전용).</summary>
|
||||
internal static void Ensure()
|
||||
{
|
||||
if (!Application.isPlaying || s_runner != null) return;
|
||||
var go = new GameObject("[WL813x2] RunErrorModalGuardRunner");
|
||||
go.hideFlags = HideFlags.HideAndDontSave;
|
||||
s_runner = go.AddComponent<RunErrorModalGuardRunner>();
|
||||
}
|
||||
|
||||
/// <summary>런너가 살아 있는가(프로브가 읽는다).</summary>
|
||||
internal static bool Exists { get { return s_runner != null; } }
|
||||
|
||||
void Update() { RunErrorModalGuard.Tick(Time.unscaledDeltaTime); }
|
||||
|
||||
void OnApplicationQuit() { RunErrorModalGuard.Unsubscribe(); }
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (!ReferenceEquals(s_runner, this)) return;
|
||||
s_runner = null;
|
||||
RunErrorModalGuard.Restore(); // 훅을 내려둔 채로 사라지지 않는다
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 997e388261c6402eb362f26006630780
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// WLErrorGuardSettings.cs — 인게임 에러 모달 차단 · 투사체 null 타깃 가드 값의 단일 출처(SOT · C45)
|
||||
//
|
||||
// PD 지시 #813 · 발주서 WL-813x2 §1-2/§1-3 (2026-09-09) · 근거 = Q7 D-35
|
||||
//
|
||||
// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLErrorGuardSettings.asset
|
||||
// → Resources.Load<WLErrorGuardSettings>("WL/WLErrorGuardSettings") 로 1회 로드 후 캐시(기존 WL 설정 SO 관례).
|
||||
// 에셋이 없거나 enabled = false 면 Enabled == false → 훅 무접촉 · 투사체 가드 무동작 = 원본 동작 100%(C8 롤백 1순위).
|
||||
//
|
||||
// 🔴 발주서는 이 값들을 `WLCombatCoreSettings.asset` 에 넣으라고 했으나, 그 클래스는
|
||||
// `Assets/WL/Combat/Core/**` 에 있고 완료 기준 ⓑ 가 **Core 0 줄**을 요구한다.
|
||||
// → 충돌을 피해 같은 Resources/WL 폴더에 **신규 SO** 로 분리했다(보고서에 기재).
|
||||
//
|
||||
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(Actor/ProjectileBase 가 Assembly-CSharp 에 있다).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
namespace WL.Combat.Diagnostics
|
||||
{
|
||||
[CreateAssetMenu(fileName = "WLErrorGuardSettings", menuName = "WL/Error Guard Settings", order = 360)]
|
||||
public sealed class WLErrorGuardSettings : ScriptableObject
|
||||
{
|
||||
public const string ResourcesPath = "WL/WLErrorGuardSettings";
|
||||
|
||||
static WLErrorGuardSettings s_cached;
|
||||
static bool s_lookupDone;
|
||||
|
||||
/// <summary>설정 에셋(1회 로드 후 캐시). 없으면 null.</summary>
|
||||
public static WLErrorGuardSettings Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!s_lookupDone)
|
||||
{
|
||||
s_cached = Resources.Load<WLErrorGuardSettings>(ResourcesPath);
|
||||
s_lookupDone = true;
|
||||
}
|
||||
return s_cached;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>진단·A/B 전용 런타임 스위치. Play 종료 시 도메인 리로드로 자동 false.</summary>
|
||||
public static bool RuntimeDisabled;
|
||||
|
||||
/// <summary>기능이 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off.</summary>
|
||||
public static bool Enabled
|
||||
{
|
||||
get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; }
|
||||
}
|
||||
|
||||
/// <summary>테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다.</summary>
|
||||
public static void ClearCache() { s_cached = null; s_lookupDone = false; }
|
||||
|
||||
// ─────────────────────────────────────────── 전체 스위치
|
||||
[Header("전체 스위치")]
|
||||
[Tooltip("끄면 에러 훅 무접촉 · 투사체 가드 무동작 = 원본 100%. 롤백 1순위.")]
|
||||
public bool enabled = true;
|
||||
|
||||
// ─────────────────────────────────────────── 시연용 모달 차단 (§1-3)
|
||||
[Header("시연용 에러 모달 차단 (RunErrorModalGuard · Q7 D-35)")]
|
||||
[Tooltip("런 중 Unity Error/Exception 이 나도 원본 ErrorLogHookManager 의 인게임 파란 모달을 띄우지 않는다. " +
|
||||
"구현 = 훅 컴포넌트를 런 동안만 enabled=false 로 내려 OnDisable 이 Application.logMessageReceived 구독을 스스로 해제하게 한다" +
|
||||
"(원본 파일 0 줄 수정). 런이 끝나면 다시 enabled=true 로 되돌린다.")]
|
||||
public bool suppressErrorModalInRun = true;
|
||||
|
||||
[Tooltip("1 = 런 중(RunDirector.IsRunning)에만 차단 · 0 = Play 내내 차단. 시연 중 로비 오류까지 막으려면 0.")]
|
||||
public bool suppressOnlyWhileRunning = true;
|
||||
|
||||
[Tooltip("차단한(그리고 차단하지 않은) Error/Exception 을 파일로 남긴다. 끄면 카운터만 센다.")]
|
||||
public bool writeErrorFile = true;
|
||||
|
||||
[Tooltip("로그 파일 폴더(프로젝트 루트 기준 상대 · 에디터). 빌드에서는 persistentDataPath 아래에 같은 이름으로 만든다.")]
|
||||
public string errorFileDir = "Screenshots_WL/WL813x2";
|
||||
|
||||
[Tooltip("스택트레이스도 같이 남긴다(발생원 추적용).")]
|
||||
public bool includeStack = true;
|
||||
|
||||
[Tooltip("한 세션에 남길 최대 건수(로그 폭주 방지). 초과분은 카운터만 올라간다.")]
|
||||
public int maxEntriesPerSession = 200;
|
||||
|
||||
[Tooltip("훅 상태 폴링 주기(초 · unscaled). 이 주기마다 런 진행 상태만 비교하고 바뀔 때만 손댄다(GC 0).")]
|
||||
public float pollSeconds = 0.25f;
|
||||
|
||||
[Tooltip("훅 컴포넌트를 못 찾았을 때의 최대 재탐색 횟수(폴링 기준). 초과하면 탐색을 멈춘다(GC 0).")]
|
||||
public int hookSearchTries = 40;
|
||||
|
||||
// ─────────────────────────────────────────── 투사체 null 타깃 가드 (§1-2)
|
||||
[Header("투사체 null 타깃 가드 (WLProjectileStart · D-35 원인)")]
|
||||
[Tooltip("ProjectileBase.Set 의 시작 위치 스위치에서 target 이 null 이면 shooter 위치로 대체한다. " +
|
||||
"끄면 원본 그대로(= target 을 그대로 역참조 → NullReferenceException).")]
|
||||
public bool projectileNullTargetGuard = true;
|
||||
|
||||
[Tooltip("가드가 실제로 null 을 잡았을 때 Debug.Log 1줄을 남긴다(Error 아님 = 모달 유발 0). 시연 빌드에서는 0 권장.")]
|
||||
public bool projectileGuardLog = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 16c3198115a3448896d0d174a589ef5b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// WLProjectileStart.cs — 투사체 시작 위치 null 타깃 가드 (Q7 D-35 원인 지점)
|
||||
//
|
||||
// PD 지시 #813 · 발주서 WL-813x2 §1-2 (2026-09-09)
|
||||
//
|
||||
// ■ 실측 원인 (메인 에디터 Editor.log · Q7 R1 15:08 구간 · R1_13/R1_14 캡처 사이)
|
||||
// NullReferenceException: Object reference not set to an instance of an object
|
||||
// at ProjectileBase.Set (ProjectileData _data) [0x0026d] in Assets/Script/Character/Projectile/ProjectileBase.cs:233
|
||||
// at Select_BodyonFire.Set (ProjectileData _data) in .../SelectSkill/Select_BodyonFire.cs:12
|
||||
// at InGameInfo+<>c__DisplayClass31_0.<Load_Skill>b__0 (AsyncOperationHandle`1) in Assets/Script/Info/InGameInfo.cs:339
|
||||
// at DelegateList`1[T].Invoke (Addressables · DelayedActionManager.LateUpdate)
|
||||
// 2회째(15:23 R2b 구간)는 같은 줄에서 `SelectFairyBase.Set` 경유.
|
||||
//
|
||||
// ■ 왜 null 인가
|
||||
// `InGameInfo.Load_Skill`(:333-340) 은 선택스킬 프리팹이 풀에 없을 때만 도는 **최초 1회 비동기 로드**다.
|
||||
// Addressables 콜백이 다음 LateUpdate 로 밀리는 사이에 `_ptd.target`(=발사 시점의 몹)이 사라지면
|
||||
// 콜백이 부르는 `ProjectileBase.Set` 안에서 target 이 null 이 된다.
|
||||
// `Set` 은 :184 에서 `isNullTarget` 을 이미 계산해 :213 · :238 은 가드하지만,
|
||||
// :229-236 의 시작 위치 switch 는 **`default:`(=None/Target)와 `TargetCenter` 를 가드하지 않는다**.
|
||||
// → 존 클리어 카드(813o)·레벨업으로 런 도중 선택스킬이 새로 붙는 WL 런에서 특히 잘 걸린다.
|
||||
//
|
||||
// ■ 가드
|
||||
// target 이 (Unity 오버로드 ==) null 이면 shooter 위치로 대체한다. shooter 마저 null 이면 현재 위치 유지.
|
||||
// target 이 살아 있으면 **원본과 완전히 동일한 식**이다.
|
||||
// SO `projectileNullTargetGuard = 0` 이면 원본 동작 그대로(=역참조)로 되돌린다(C8).
|
||||
//
|
||||
// 🔴 `??` · `?.` 를 쓰지 않는다 — UnityEngine.Object 의 가짜 null 을 못 거른다(813y 교훈).
|
||||
// 🔴 Debug.LogError 금지(813z) — 진단 로그는 Debug.Log 1줄뿐이고 기본 off.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
namespace WL.Combat.Diagnostics
|
||||
{
|
||||
/// <summary>ProjectileBase.Set 의 target 기준 시작 위치 계산(원본 2줄 훅이 부른다).</summary>
|
||||
public static class WLProjectileStart
|
||||
{
|
||||
/// <summary>가드가 null target 을 잡은 횟수(프로브·QA 가 읽는다).</summary>
|
||||
public static int NullTargetCount;
|
||||
/// <summary>그중 shooter 로도 대체하지 못해 현재 위치를 유지한 횟수.</summary>
|
||||
public static int NoShooterCount;
|
||||
/// <summary>마지막으로 잡은 스킬(진단용).</summary>
|
||||
public static string LastSkill = "";
|
||||
|
||||
/// <summary>카운터 초기화(프로브 전용).</summary>
|
||||
public static void ResetCounters() { NullTargetCount = NoShooterCount = 0; LastSkill = ""; }
|
||||
|
||||
/// <summary>
|
||||
/// target 기준 시작 위치. target 이 null 이면 shooter → current 순으로 대체한다.
|
||||
/// </summary>
|
||||
/// <param name="data">발사 데이터(원본 `_data` 그대로).</param>
|
||||
/// <param name="current">대체 실패 시 유지할 현재 위치.</param>
|
||||
public static Vector3 TargetPos(ProjectileData data, Vector3 current)
|
||||
{
|
||||
if (data == null) return current;
|
||||
|
||||
var cfg = WLErrorGuardSettings.Instance;
|
||||
bool guard = cfg == null || (cfg.enabled && !WLErrorGuardSettings.RuntimeDisabled && cfg.projectileNullTargetGuard);
|
||||
|
||||
// C8: 가드 off 면 원본 그대로 역참조한다(= 원본 동작 100%).
|
||||
if (!guard) return data.target.Get_CenterPositionFoward();
|
||||
|
||||
if (!(data.target == null)) return data.target.Get_CenterPositionFoward(); // Unity 오버로드 == (가짜 null 포함)
|
||||
|
||||
NullTargetCount++;
|
||||
LastSkill = data.m_Skill.ToString();
|
||||
|
||||
if (!(data.shooter == null))
|
||||
{
|
||||
if (cfg != null && cfg.projectileGuardLog)
|
||||
Debug.Log("[WL813x2] 투사체 타깃 소실 — shooter 위치로 대체 · skill=" + LastSkill + " · 누적 " + NullTargetCount);
|
||||
return data.shooter.Get_CenterPositionFoward();
|
||||
}
|
||||
|
||||
NoShooterCount++;
|
||||
if (cfg != null && cfg.projectileGuardLog)
|
||||
Debug.Log("[WL813x2] 투사체 타깃·발사자 모두 소실 — 현재 위치 유지 · skill=" + LastSkill);
|
||||
return current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 64e8eee9cd1c468cb004d5c2de97f866
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
%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: 16c3198115a3448896d0d174a589ef5b, type: 3}
|
||||
m_Name: WLErrorGuardSettings
|
||||
m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Diagnostics.WLErrorGuardSettings
|
||||
enabled: 1
|
||||
suppressErrorModalInRun: 1
|
||||
suppressOnlyWhileRunning: 1
|
||||
writeErrorFile: 1
|
||||
errorFileDir: Screenshots_WL/WL813x2
|
||||
includeStack: 1
|
||||
maxEntriesPerSession: 200
|
||||
pollSeconds: 0.25
|
||||
hookSearchTries: 40
|
||||
projectileNullTargetGuard: 1
|
||||
projectileGuardLog: 0
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 74f0513e7aef4c0ea85c1cd5aa57d7d4
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Loading…
Reference in New Issue