에러 0개로 틀리는 법
DOTS 로 넘어오면서 새로 생긴 버그의 종류가 있습니다. 컴파일이 통과하고, 예외도 안 나고, 로그도 깨끗한데 동작이 틀린 것 입니다.
소스 제너레이터가 쿼리를 만들어 주고, 잡이 워커에서 돌고, 베이킹이 별도 단계라 “내가 쓴 코드” 와 “실제로 실행되는 것” 사이에 층이 여럿 끼어 있어서 그렇습니다.
한 프로젝트에서 나온 것들을 모았습니다.
| 사건 | 증상 | 원인 |
|---|---|---|
WithPresent 누락 |
다친 적 0마리, 에러 0개 | 쿼리 기본 규칙이 “켜진 것만” 이라 살아 있는 적이 통째로 빠졌습니다 |
| 해시가 두 개 | 이웃을 하나도 못 찾음 | 빌드와 조회가 다른 키를 씀 |
| 잡 미스케줄 | 회피가 아예 안 돎 | 잡을 정의만 하고 OnUpdate 에서 스케줄을 안 함 |
CreateSingleton 누락 |
큐는 있는데 아무도 못 찾음 | 소비자의 if (IsEmpty) return; 가드가 조용히 삼킴 |
Range 와 StopDistance 불일치 |
적이 사거리 밖에 서서 멀뚱히 봄 | 두 파일에 걸친 모순 |
| 부분 갱신 누락 | 죽은 플레이어를 계속 때림 | new 로 덮어써서 IsDead 가 매 프레임 리셋 |
| 가드 순서 | 런타임 데모까지 통과 | 경직 체크가 거리 체크 뒤에 있어 붙어 있는 적은 타이머가 안 깎임 |
normalize(0) |
엔티티가 렌더에서 사라짐 | NaN 이 Position 에 들어가면 영구 복구 불가 |
몇 개는 따로 볼 만합니다.
WithPresent 누락
데미지를 적용하는 잡을 다 짜고 실행했는데 다친 적이 0마리 였습니다.
partial struct DamageApplyJob : IJobEntity
{
void Execute(ref Health health, EnabledRefRW<DeadTag> dead,
ref DynamicBuffer<DamageEvent> buffer) { ... }
}
EnabledRefRW<DeadTag> 를 시그니처에 넣으면 DeadTag 가 쿼리에 들어갑니다.
그리고 쿼리 기본 규칙은 “켜진 것만 매칭” 입니다.
적은 전부 DeadTag 가 꺼진 채로 시작합니다.
그러니 살아 있는 적이 통째로 쿼리에서 빠졌습니다. 죽은 적만 데미지를 받고 있었습니다.
[WithPresent(typeof(DeadTag))] // 켜짐 여부를 무시하고 존재만 봅니다
partial struct DamageApplyJob : IJobEntity { ... }
한 줄입니다. 그런데 이걸 찾기까지 오래 걸린 이유는, “아무 일도 안 일어난다” 가 증상의 전부 였기 때문입니다. 잡이 도는지, 쿼리가 비었는지, 데미지가 0인지를 하나씩 찍어 봐야 했습니다.
가드 순서
가장 고약했던 것입니다. 화면에서는 정확히 맞아 보였습니다.
경직을 넣으면서 이동 잡을 이렇게 썼습니다.
if (distance < StopDistance) return; // 거리 체크가 먼저
if (stun.Remaining > 0f) { stun.Remaining -= dt; return; } // 여기 도달을 못 함
적들은 플레이어에 붙어서 StopDistance 안에 뭉쳐 있습니다.
그 적들은 첫 줄에서 나가 버리니 경직 타이머가 안 깎이고 얼어붙습니다.
그런데 넉백이 이걸 가려 줍니다.
넉백이 적을 StopDistance 밖으로 밀어내면 다음 프레임에는 거리 체크를 통과하고,
그때부터 경직이 정상으로 돕니다.
그래서 광역기 데모가 “밀려났다 잠깐 멈췄다 다시 온다” 로 정확히 보입니다. 로직은 틀렸는데요.
if (stun.Remaining > 0f) { stun.Remaining -= dt; return; } // 위치와 무관하게 벽시계처럼
if (distance < StopDistance) return;
두 시스템이 Stun 이라는 데이터로 계약을 맺은 것인데,
무엇을 공유하느냐뿐 아니라 “언제 확인하느냐” 까지 맞아야 계약이 성립합니다.
격리하지 않으면 검증이 아니다
위의 가드 순서 버그가 알려 준 것이 하나 더 있습니다. 관측값이 다른 원인으로도 설명되면, 그 관측은 아무것도 증명하지 않습니다.
넉백을 검증할 때 같은 문제를 만났습니다.
시스템을 다 켜 둔 채로 재니 넉백이 +1.24 만큼만 움직인 것으로 나왔습니다.
설정한 값은 5인데요.
원인은 넉백과 이동이 같은 Position 을 두고 싸우고 있어서 였습니다.
그래서 MovementSystem 과 SpatialHashSystem 을 런타임에 정지시키고 다시 쟀습니다.
정지 확인: 3회 샘플이 소수점까지 동일 (8.08 / 0.03 / 15.39)
발사 전: 평균 8.08 최소 0.03 최대 15.39
발사 후: 평균 13.08 최소 0.03 최대 20.39
↑ +5.00 ↑ +5.00
평균도 최대도 소수점까지 +5.00 입니다. 설정값과 정확히 일치합니다.
1만 마리 전원이 자기 방향으로 정확히 5씩 밀렸다는 뜻입니다.
그리고 최소가 0.03 에서 안 변한 것이 NaN 가드가 실제로 일했다는 증거입니다.
중심에 0.03만큼 붙어 있던 적이 실제로 있었고, 가드가 없었으면 그 하나가 NaN 이 됐을 것입니다.
경직도 같은 식으로 격리했습니다. KnockbackScale = 0 으로 놓고 쐈습니다.
넉백이 안 밀어 주니 가드 순서가 틀렸으면 경직 걸린 적이 1만이 안 나옵니다.
고친 뒤에 재니 전원 10,000 이 경직에 걸렸고, 같은 프레임에 동시에 풀렸습니다.
테스트 설계도 틀린다
넉백만 떼어내려고 처음에는 Damage = 0 으로 쐈습니다.
그런데 아무 일도 안 일어났습니다.
total > 0 가드에 막힌 것이었습니다. “안 맞았으면 안 민다” 는 정상 동작입니다.
가드가 옳았고 테스트가 틀렸습니다. Damage = 1 로 바꾸니 됐습니다.
버그를 찾을 때 의심하는 순서가 코드, 데이터, 그리고 실험 설계 입니다. 세 번째를 안 의심하면 멀쩡한 코드를 계속 뜯게 됩니다.
무엇으로 잡나
여덟 건 전부 컴파일러가 못 잡았고, 대부분 예외도 안 났습니다. 잡은 방법은 하나뿐이었습니다. 숫자로 확인할 수 있는 형태를 만들어 두는 것.
- 점유 셀 개수를 세서 해시 분산을 확인
- 죽인 수와 킬 카운터를 대조해서 누락과 중복을 확인
- 공격 한 번의 총 데미지가
대상 수 × 데미지와 맞는지 확인 - 경직 걸린 적 수가 전체와 같은지 확인
- 경쟁하는 시스템을 꺼서 오차를 0으로 만든 뒤 확인
“돌아가는 것 같다” 와 “정확히 몇 개” 사이의 거리가 이 프로젝트에서 제일 컸습니다.