< 진행 상황으로

게시 2026-08-06

The Clock That Ran at Half Speed: From a Note-Jumping Report to a One-Line Default

원문 Markdown

범위: 7583fde(v0.0.132)부터 e341c8f(v0.0.134)까지

주요 변경 사항

사용자가 보고한 증상은 한 줄이었습니다. "노트와 BGA가 음악과 어긋나서 튄다."

이 한 줄이 지목할 수 있는 층은 셋입니다. 음악이 잘못 흐르거나, 게스트의 시간이 잘못 흐르거나, 렌더가 늦거나. 네 개의 Task가 이 셋을 차례로 갈랐고, 도중에 우리가 여러 Task에 걸쳐 "게임이 멈춘다"고 불러 온 현상이 사실은 우리 계측 도구였다는 것이 밝혀졌습니다. 그리고 최종 수정은 이미 저장소 안에 있었습니다 — 새로 만든 기구는 없고, 환경 변수 하나의 기본값을 뒤집었습니다.

flowchart LR
    S["증상<br/>노트·BGA가 음악과<br/>어긋나 튄다"] --> A["Task 421~423<br/>음악 위치는 <b>정확</b><br/>오차 0, 역행 0"]
    A --> B["Task 430<br/>게스트 시계 = 실시간의 <b>51%</b><br/>28.3초에 13.9초 뒤처짐"]
    B --> C["Task 431<br/>손실의 <b>93.9%</b>가<br/>Glide 게이트 블록 중 발생"]
    C --> D["게이트 안에는 안전점이 없음<br/>= 늦은 것이 아니라 <b>전달 불가</b>"]
    D --> E["Task 432<br/>bool을 개수로<br/>전달률 <b>99.98%</b>"]
    style A fill:#1e8449,color:#fff
    style E fill:#1e8449,color:#fff

1. 음악은 무죄였고, 기대값 자체가 틀려 있었다

가장 먼저 의심한 것은 CD 오디오 위치 보고입니다. 게임은 MSCDEX에게 "지금 몇 번째 섹터를 재생 중이냐"를 물어 노트 타이밍을 맞추므로, 그 답이 흔들리면 정확히 이 증상이 됩니다.

계측을 설계할 때 한 가지를 먼저 정했습니다. 표본을 오디오 worker가 아니라 poll 스레드에서 뜨는 것입니다. 굶주린 worker는 자기 굶주림을 보고할 수 없기 때문입니다. 그리고 위치가 파생되는 모든 값 — worker 반복 횟수, underrun — 을 같은 행에 실었습니다.

결과는 명확했습니다. gameplay 구간의 같은 generation 연속 표본 1,440개에서:

지표 값
평균 delta_lba 8.21
기대값 8.21
범위 7~9 (이상치 0)
역행 0

그런데 이 표에는 함정이 하나 있었습니다. 설계와 가이드에 적어 둔 기대값이 "100 ms당 7~8"이었습니다. GetTickCount의 분해능 때문에 실제 표본 간격은 109.5 ms이고, CD-DA가 초당 75 섹터이므로 정확한 기대값은 75 × 0.1095 = 8.21입니다. 관측값 8.21은 정확히 맞는 값인데, 잘못 적어 둔 기준으로는 "약간 빠름"으로 읽힐 뻔했습니다.

명령 trace는 별개의 결함을 하나 잡았습니다. 게스트가 stop을 16 ms마다 부르는 구간이 있었는데, 우리 Stop()이 매번 paused를 was_playing으로부터 재계산해 두 번째 Stop이 첫 Stop이 세운 pause를 지우고 있었습니다. 멱등화 후 paused는 1로 유지되고 generation은 62에서 5로 줄었습니다.

2. "정지"의 정체 — 게임이 아니라 우리 감시였습니다

이 조사에서 가장 큰 소득은 예정에 없던 것이었습니다.

여러 Task에 걸쳐 "게임이 플레이 직전에 멈춘다"고 기록해 왔습니다. 게스트 코드를 직접 읽어 그 지점을 특정했습니다.

0x0302C588:  call 0x0303F3F4        ; return [0x0328FA18]
0x0302C58D:  cmp  eax, 100
0x0302C590:  jl   0x0302C588        ; 100틱까지 대기

그 카운터를 올리는 곳은 INT 8 타이머 ISR입니다.

0x0303F1A3:  call [0x0328FA08]      ; 이전 핸들러 체인
0x0303F1A9:  inc  [0x0328FA18]      ; 게임이 기다리는 카운터
0x0303F1AF:  inc  [0x0328FA14]

게임은 멈춘 것이 아니라 정상적으로 100틱(약 1.4초)을 기다리고 있었습니다. 그리고 그 대기 동안에는 예외도, single step도, AOT 경계도 발생하지 않습니다. 우리 PollThreadUntilExit의 1초 무진행 감시(live_telemetry_snapshot.cpp, quiet_timeout_milliseconds = 1000)에게는 그것이 곧 "죽은 실행"이었습니다.

REPIU_EXECUTION_TIMEOUT_MS=0은 INFINITE로 해석되어 그 감시를 끕니다. 같은 빌드로 A/B를 돌렸습니다.

조건 실행 시간 프레임 도달 지점
감시 켬 9.5초 363 곡 선택
감시 끔 174초 8,023 gameplay (b15.mcf, b14~16.ski, rage.con)

정정 두 개를 함께 남겼습니다. 여러 Task에서 "정지 지점"으로 불러 온 0x030F2786은 사실 STI(fb c3 = STI; RET)이며, 대기 중 가장 자주 트랩되는 특권 명령일 뿐입니다. 그리고 single-step hotspot census에 이 루프가 나오지 않은 이유는, 그 census가 single-step 경계만 담는데 루프는 AOT 캐시 안에서 돌기 때문입니다. 루프를 찾은 것은 예외와 무관한 5 ms 표본, guest position census였습니다.

3. 누적 한 줄로는 아무것도 판정할 수 없습니다

감시를 끄고 얻은 174초 실행의 종료 요약에 새 후보가 있었습니다.

timer tick delivery due/injected/coalesced/dropped: 41531/39830/1677/24

1,677회 coalesced — 여러 due 틱을 한 번으로 합쳐 전달했다는 뜻입니다. 게스트 시간이 느리게 흐르다 몰아서 따라잡으면 정확히 "튀는" 증상이 됩니다.

그런데 이 한 줄은 판정에 쓸 수 없습니다. 1,677 / 41,531 = 4.04%는 174초 전체 평균이라, 그 손실이 부팅에 몰렸는지 attract에 몰렸는지 gameplay에 있는지 말하지 않습니다. 게다가 기존 trace를 재집계해 보니 게스트 메인 루프는 7,200 polls / 119.968 s = 60.0160 Hz(오차 0.03%)로 실시간에 붙어 있었습니다. 4% 손실이 균등하다면 57.6 Hz여야 하므로, 누적 수치와 루프 주파수가 서로 충돌합니다.

그래서 Task 430은 틱 손실을 시간축에 올렸습니다. CD 위치 census 표본에 timer_ticks_due·timer_ticks_injected 델타와 파생 열 tick_lag_ms를 추가했습니다. 동작은 바뀌지 않습니다 — 이미 유지되던 카운터를 원자 읽기 2회로 읽을 뿐입니다.

계측이 도는지만 보려던 스모크가 곧바로 답의 절반을 줬습니다.

구간 due injected 전달률
0~30초 6,671 1,670 25.0%
30~35초 1,179 989 83.9%
35~40초 1,208 1,191 98.6%
40~45초 1,105 531 48.1%
전체 10,163 4,382 43.1%

전달률이 25%에서 98.6%까지 요동치고, 누적 평균 43.1%는 그 어느 구간의 값도 아닙니다. 종료 요약 한 줄로 이 축을 판단하던 것이 왜 위험했는지가 그대로 보입니다.

설계 판단 하나도 측정으로 확인됐습니다. tick_lag_ms에 240 Hz를 상수로 박지 않았는데, 스모크 첫 1초의 ticks_due는 표본당 1~2로 약 18.2 Hz였습니다. 게스트가 아직 PIT divisor를 바꾸기 전의 DOS 기본 BIOS 틱 주파수입니다. 240을 상수로 썼다면 부팅 구간 지연이 13배 과대 계산됐을 것입니다.

판정은 사용자 실행으로 했습니다. 증상이 실제로 보인 47초 실행(pumpit1, census 427 표본)의 본곡 구간입니다.

지표 값 사전 등록 기준
음악 LBA 20,553 → 22,678 = 74.97 LBA/s 정확(오차 0.04%)
injected/due 약 51% ≤ 96% → 확정
tick_lag_ms 8,810 → 22,666 (+13,856 ms) 단조 증가·> 1,000 ms → 확정

28.3초 동안 음악은 28.3초 흘렀는데 게스트 시계는 14.5초만 흘렀습니다.

시계열이 아니면 보이지 않았을 구조도 같이 나왔습니다. 같은 실행에서 프리뷰 구간은 전달률 약 100% 이고 tick_lag_ms가 오히려 감소(9,006 → 8,793)하는데, 본곡 구간만 51% 입니다. 누적 51.7% 한 줄은 두 구간 중 어느 것도 설명하지 않습니다.

여기서 제 예상이 틀렸다는 것도 기록해 둡니다. 측정 전에 "게스트 루프가 60.016 Hz로 안정적이므로 이 후보는 기각될 것"이라고 적었는데, 그 60 Hz는 다른 실행(pumpit3, 전달률 95.9%)의 값이었습니다. 서로 다른 실행의 수치를 한 결론에 묶은 것이 오류입니다. 판정표를 측정 전에 고정해 둔 덕분에 예상과 무관하게 데이터가 판정했습니다.

4. 세는 것에서 귀속하는 것으로

"틱이 사라진다"까지는 알았지만, 그것만으로는 고칠 곳을 모릅니다. 주입이 거부되는 것인지 도달하지 못하는 것인지가 갈리지 않습니다.

Task 431은 그 갈림을 세는 대신 귀속했습니다. 틱이 due로 잡히는 그 순간 게스트 스레드가 Glide 게이트 안에 있었는지를 기록합니다.

sequenceDiagram
    participant P as poll 스레드
    participant G as 게스트 스레드
    participant H as 호스트 스레드 (GL)
    G->>H: InvokeOnHostThread(grDrawTriangle …)
    activate H
    G-->>G: host_command_cv_.wait() — 게스트 코드 정지
    P->>P: PIT 240 Hz — 틱 due
    Note over P: 안전점(캐시 내 INT3 967개)이<br/>하나도 밟히지 않음
    P->>P: 틱 due (2회째, 3회째 …)
    Note over P: bool 하나에 합쳐짐<br/>→ coalesced
    H-->>G: 완료 통지
    deactivate H
    G->>G: 안전점 도달 — 딱 1개만 주입

사용자 실행(pumpit1, 33초, census 297표본)의 본곡 구간 판정입니다.

지표 값 사전 등록 기준
음악 74.98 LBA/s 정확(오차 0.03%)
전달률 50.6% Task 430의 51%와 일치
coalesced_in_gate / coalesced 2,544 / 2,710 = 93.9% ≥ 80% → 확정
tick_lag_ms 3,898 → 15,263(+11,365 ms) 게스트 시계 = 실시간의 50.1%

결정적인 것은 deferred = 0입니다. 주입이 IF=0이나 비게스트 EIP 때문에 미뤄진 적이 한 번도 없습니다. 즉 틱은 거부당한 것이 아니라, InjectPendingInterrupts에 도달할 기회 자체가 없었습니다. 게이트 안에서는 게스트 코드가 한 줄도 실행되지 않으므로 안전점을 밟을 수 없고, 그동안 due는 240 Hz로 계속 쌓입니다. 그 틱들은 늦은 것이 아니라 전달 불가능했습니다.

검산 둘도 성립했습니다. safe_point_traps / injected가 2,765 대 2,781로 0.99 — "기회 = 안전점"이라는 전제를 확인합니다. 그리고 본곡 구간 trap이 초당 121.4회(240 아님)로, 기회가 실제로 모자란다는 것과 모순이 없습니다.

검증 중에 제 계측의 결함도 하나 잡혔습니다. 첫 스모크에서 in_gate > (due − injected)인 행이 404행 중 47행 나왔습니다. 카운터 버그가 아니라 제가 고른 분모가 틀렸습니다 — 구간별로 due − injected는 그 구간의 coalesced와 같지 않습니다. 이번 구간의 주입이 직전 구간에 armed된 틱을 소비할 수 있으므로 뺄셈이 분모를 과소평가합니다. ticks_coalesced를 별도 열로 싣고 분모를 그것으로 바꾸자 위반 행은 0이 됐습니다.

교훈: 파생 비율은 분자와 분모를 같은 정의로 함께 실어야 합니다. 한쪽만 싣고 나머지를 독자가 빼서 만들게 하면, 그 뺄셈이 성립하는지는 아무도 검산하지 않습니다.

5. 고칠 것은 이미 코드에 있었습니다

손실이 게이트 창에 집중돼 있다는 것을 알았으므로, 저는 게이트 경계에서 밀린 틱을 배출하는 새 기구를 만들 준비를 했습니다. 직접 디스패치 thunk가 게스트 ESP를 프레임 으로 돌려주지 않는다는 것, 즉 에뮬레이터에서 가장 뜨거운 경로를 뜯어야 한다는 것까지 확인했습니다.

그러고 나서야 환경 변수 하나를 켜 봤습니다.

REPIU_TIMER_TICK_BACKLOG는 Task 366이 이미 만들어 두고 opt-in으로 남겨 둔 bounded backlog입니다. 하는 일은 정확히 하나 — 전달을 나르던 bool을 개수로 바꿉니다.

    if (!backlog_enabled)
    {
        // Stage one accounting for the shipping behaviour: arming delivery
        // publishes a single boolean, so one owed tick becomes the pending
        // injection and the rest are gone.
        const std::uint32_t retained = already_pending ? 0U : 1U;
        counters->coalesced_total.fetch_add(due - retained, …);
        …
        return;
    }

    // Stage two: keep the owed ticks, bounded.
    const std::uint32_t room = kWin32TimerTickBacklogCapacity > backlog
        ? kWin32TimerTickBacklogCapacity - backlog : 0U;
    const std::uint32_t accepted = std::min(due, room);

주입 쪽은 아직 밀린 틱이 있으면 전달을 armed 상태로 유지해, 안전점 하나당 인터럽트 하나씩 배출합니다(게스트 스택에 몰아넣지 않습니다).

사용자가 REPIU_TIMER_TICK_BACKLOG=1로 gameplay를 하고 증상 해소를 확인했습니다. 같은 실행의 본곡 구간 64.53초·표본 591개입니다.

지표 OFF ON
전달률 injected/due 50.6% 99.98%
coalesced / coalesced_in_gate 2,710 / 2,544 0 / 0
tick_lag_ms 증가 +11,365 ms −11 ms
음악 74.98 LBA/s 75.00 LBA/s
안전점 trap 121.4/초 240.2/초
max_backlog 1 12 (상한 64)

traps/초 = 240.2가 핵심입니다 — 틱당 정확히 1회이고 과잉 trap이 없습니다.

그렇다면 Task 366은 왜 이것을 opt-in으로 남겼을까요? 366은 같은 스위치로 프레임 −16.4% 를 재고 "속도를 기대하고 켜지 말 것"이라고 적었습니다. 그런데 366은 그 비용의 기전까지 스스로 특정해 두었습니다 — 비싼 것은 주입이 아니라 safe point가 상시 armed로 유지되는 것이고, 그것은 backlog가 상한에 고착될 때만 일어납니다.

366 당시 현재
max_backlog 64 (상한 고착) 12
상한 초과 폐기 1,029~1,076 27~64
safe-point trap +20.1% 틱당 1회(과잉 없음)

그 사이 Tasks 414(tick당 포트 읽기 200→2)·415·417(세대 실패 0)·419(프레임 +27.7%)가 실행 속도를 올려 backlog가 게이트 사이에 비워집니다. 366의 결론은 그 시점의 기록으로 유지하고, 두 문서 머리말에 무효 범위를 적었습니다. REPIU_TIMER_TICK_BACKLOG=0은 회귀 대조군으로 남깁니다.

기본값 전환의 대가는 프레임 −1.6%(2,042 대 2,076)로, 앞선 짝 A/B의 −1.2%와 일관되며 실행 간 편차와 구분되지 않습니다. 근거로 주장하는 것은 "비용 0"이 아니라 "−16.4%가 아니다"까지입니다. 본곡 구간의 짝 프레임 측정은 미측정으로 남겼습니다 — 위 A/B는 전부 attract 구간이고, 게이트 점유가 가장 높은 구간의 대가는 별도로 재야 합니다.

이 범위의 다른 커밋

이 사슬과 독립적으로 두 가지가 같은 범위에 들어 있습니다.

  • 78366f8 (v0.0.133) — 실행 backend를 legacy와 dynamic 둘로 줄였습니다. aot와 aot-dynamic은 제거 시점에 이미 pumpit3에서 이미지 생성에 실패하고 있었고, 옛 이름은 별칭 없이 거부합니다.
  • 2038829 · 3b5073c — OpenWatcom 샘플 baseline을 v0.0.59에서 v0.0.133으로 갱신하고, 통과 판정에 완주 요구를 추가했습니다.

piu_1st 실행 로그 — 현재 진행 지점과 blocker

수정 전, 증상이 보인 사용자 실행(pumpit1, 33초)의 종료 요약입니다.

timer tick delivery due/injected/coalesced/dropped/deferred: 7523/4024/3475/24/0
timer tick in-gate    due/coalesced/coalesced-share:          4872/3213/92%
AOT timer safe-point  trap/injected/deferred:                 4010/3988/22

세 줄이 함께 읽혀야 뜻이 나옵니다. deferred = 0이므로 거부는 없고, coalesced 3,475 중 3,213(92%)이 게이트 안에서 났으며, 안전점 trap 4,010은 33초에 걸쳐 초당 121회로 240 Hz의 절반입니다.

수정 후 같은 계측은 coalesced = 0, 전달률 99.98%, max_backlog = 12를 보고합니다.

현재 진행 지점: REPIU_EXECUTION_TIMEOUT_MS=0으로 감시를 끄면 piu_1st는 174초· 8,023프레임으로 gameplay까지 진행하며, 노트·BGA 점프 축은 닫혔습니다.

현재 blocker 두 가지:

  1. 무진행 감시가 아직 그대로입니다. 정상 실행을 1초 만에 죽이므로 프레임 기반 측정이 전부 왜곡됩니다. 진행 판정에 HLE 활동(MSCDEX·DOS 서비스)을 포함시키거나 틱 대기를 진행으로 인정해야 합니다. 그때까지 측정은 REPIU_EXECUTION_TIMEOUT_MS=0
    • 하니스 시간 제한으로 우회합니다.
  2. 3D 모델이 깨져 보입니다. 이 범위 직후 6ab688a(v0.0.135)에서 원인이 Glide 정점 깊이(ooz)를 디코더가 읽지 않아 모든 정점이 z=0으로 나가던 것으로 확인됐습니다. 다음 글의 주제입니다.

sample test 결과

scripts/test_openwatcom_samples.ps1의 baseline을 v0.0.59에서 v0.0.133으로 갱신했습니다. 갱신 직전 비교에서 회귀 0건, 신규 통과 6건이었으므로 안전한 갱신입니다.

항목 0.0.59 0.0.133
Total 819 819
BuildPassed / BuildSkipped 793 / 26 793 / 26
RunPassed 529 535
RunPassRate 66.7% 67.5%
OverallPassRate 64.6% 65.3%

신규 통과 6건은 b_keybrd.c, chainint.c, getdate.c, gettime.c, getvect.c, setvect.c로 인터럽트·날짜·시각 계열입니다. 이번 타이머 작업이 아니라 그 사이 74개 버전 동안 쌓인 INT 21h·INT 8 계열 작업의 결과입니다.

xychart-beta
    title "OpenWatcom Sample Cumulative Results"
    x-axis ["0.0.1", "0.0.5", "0.0.9", "0.0.15", "0.0.34", "0.0.59", "0.0.133"]
    y-axis "Samples" 0 --> 850
    line "Total" [819, 819, 819, 819, 819, 819, 819]
    line "Build Passed" [788, 793, 793, 793, 793, 793, 793]
    line "Run Passed" [419, 473, 473, 522, 523, 529, 535]
    line "Build Skipped" [0, 26, 26, 26, 26, 26, 26]

한 가지 주의를 함께 적습니다. 위 535는 옛 판정 기준으로 측정된 값입니다. 3b5073c가 판정에 완주 요구를 추가했기 때문입니다.

# 이전 — timeout도 통과로 집계됩니다
$runPassed = $run.ExitCode -eq 0 -and
             $run.Output -match "Win32 minimal execution exception caught: false"

# 이후
$runPassed = $run.ExitCode -eq 0 -and
             $run.Output -match "Win32 minimal execution exception caught: false" -and
             $run.Output -match "Win32 minimal execution returned: true" -and
             $run.Output -notmatch "minimal execution attempt timed out"

게스트가 멈춘 채 로더의 기본 1,000 ms timeout이 끝나면 예외는 잡히지 않고 프로세스는 0 으로 종료합니다. 옛 판정식은 그 둘만 봤으므로 timeout이 통과가 됐습니다. 검증 표본 8개 중 4개가 위양성이었습니다. 강화된 기준의 첫 실행은 회귀를 보고할 수 있으며, 그것은 코드 회귀가 아니라 측정 정정입니다.

커밋

내용 커밋
CD 위치 census · MSCDEX 명령 trace · Stop() 멱등화 · 감시 정체 규명 (Tasks 421~423) 7583fde
실행 backend를 legacy/dynamic으로 통합 (Tasks 424~427) 78366f8
OpenWatcom 샘플 baseline 갱신 (Task 428) 2038829
샘플 통과 기준에 완주 요구 추가 (Task 429) 3b5073c
틱 시계열 · 게이트 귀속 · backlog 기본값 (Tasks 430~432) e341c8f

교훈

환경 변수 하나로 되는 것을 먼저 시도할 것. 저는 새 배출 기구의 설계를 마치고, 가장 뜨거운 경로를 뜯어야 한다는 것까지 확인한 뒤에야 backlog A/B를 돌렸습니다. 순서가 정확히 거꾸로였습니다.

원인과 결과를 뒤집어 읽으면 해법을 스스로 지웁니다. 저는 "주입 기회가 초당 120회뿐이므로 backlog로는 고칠 수 없다"고 단정했습니다. 그 120회는 독립적인 상한이 아니라 bool의 결과였습니다. 개수를 보존하자 240회가 됐습니다.

옛 측정의 유효 범위를 의심할 것. Task 366의 −16.4%를 그대로 받아들였다면 이 축은 닫히지 않았습니다. 재검토가 가능했던 이유는 366이 결론만이 아니라 비용의 기전까지 적어 두었기 때문입니다. 결론만 적혀 있었다면 다시 시험할 대상이 없었습니다.

누적 한 줄은 판정 근거가 아닙니다. 같은 실행 안에서 전달률이 25%와 98.6%를 오갔고, 프리뷰 구간과 본곡 구간이 100%와 51%로 갈렸습니다. 어떤 평균도 그 구조를 설명하지 않습니다.

판정표를 측정 전에 고정할 것. 이번에도 제 예상(기각)이 틀렸고, 미리 써 둔 기준이 데이터에게 판정을 맡겼습니다.

"멈췄다"를 의심할 것. 여러 Task에 걸쳐 게임의 정지로 기록해 온 것이 우리 계측 도구의 1초 타임아웃이었습니다.

사용된 기술 스택

8254 PIT 채널 0과 INT 8

DOS 게임의 시간 기준은 Intel 8253/8254 Programmable Interval Timer의 채널 0입니다. 입력 클록은 약 1.193 MHz(우리 구현은 kInputClockHz = 1193280)이고, 채널 0의 16비트 divisor가 IRQ 0의 주기를 정합니다. IRQ 0은 실모드 INT 08h로 들어옵니다.

divisor 주파수 용도
65,536 (0) 18.2065 Hz DOS/BIOS 기본값
4,972 240 Hz PIU가 재프로그램하는 값

PIU.EXE가 실제로 쓰는 값은 로그로 확인됩니다.

[repiu-pit] channel=0 divisor=4972 frequency=240.000000Hz generation=2

게임은 부팅 직후 divisor를 낮춰 240 Hz로 올리고, 자기 INT 08h 핸들러에서 이전 핸들러를 체인한 뒤 자체 카운터를 증가시킵니다. §2에서 본 inc [0x0328FA18]이 그것입니다. 그래서 이 프로젝트에서 "게스트의 시간"은 곧 우리가 INT 8을 몇 번 주입했는가입니다.

우리 구현은 PitChannel0이 포트 0x40/0x43 쓰기를 관찰해 generation + divisor 원자 스냅샷을 발행하고, PitIrqSchedule::Poll이 경과 시간과 1,193,280 / divisor 비율로 due 틱 수를 계산합니다. BDA의 BIOS 틱 카운트 (0x0000:0x046C)는 게스트가 divisor를 바꾸든 말든 기본 divisor 65,536 기준으로 따로 갱신합니다 — 그 주소를 읽는 코드가 기대하는 것은 18.2 Hz이기 때문입니다.

flowchart LR
    A["게스트: out 0x43/0x40<br/>divisor 쓰기"] --> B["PitChannel0<br/>스냅샷 발행(generation)"]
    B --> C["PitIrqSchedule::Poll<br/>경과 ns → due 틱 수"]
    C --> D["RecordTimerTicksDue<br/>backlog에 누적"]
    D --> E["안전점 도달 시<br/>InjectPendingInterrupts"]
    E --> F["게스트 스택에 프레임 구성<br/>INT 08h 벡터로 점프"]

PitIrqSchedule이 divisor generation을 들고 있는 것이 중요합니다. 게스트가 주기를 바꾸는 순간 epoch를 다시 잡지 않으면, 18.2 Hz로 흐르던 시간이 240 Hz 기준으로 재해석돼 수천 틱이 한꺼번에 due가 됩니다.

인터럽트 주입 안전점

원본 코드를 그대로 실행하면서 인터럽트를 넣으려면, 아무 지점에나 넣을 수는 없습니다. 명령 중간이나 우리 HLE 경계 안에서 게스트 스택에 프레임을 쌓으면 상태가 깨집니다. 그래서 주입은 세 조건을 모두 만족하는 지점에서만 일어납니다.

  1. EIP가 게스트 코드이거나 AOT 캐시 안일 것
  2. EFLAGS.IF가 서 있을 것 (게스트가 인터럽트를 허용한 상태일 것)
  3. INT 8 벡터가 설치돼 있을 것

동적 번역 backend에서는 캐시 안에 심어 둔 INT3 안전점 967개가 그 지점입니다. armed 상태에서 게스트가 그중 하나를 밟으면 예외가 우리에게 오고, 거기서 프레임을 구성해 벡터로 점프합니다. 1과 2에서 걸린 경우는 deferred로 세는데, 이것은 손실이 아니라 지연입니다 — pending이 살아 있으므로 다음 기회에 전달됩니다.

이번 조사에서 deferred = 0이 결정적이었던 이유가 여기 있습니다. 거부가 0이라는 것은 남은 원인이 "기회 자체가 오지 않았다" 하나뿐임을 뜻합니다.

Glide 게이트와 스레드 rendezvous

OpenGL 컨텍스트는 호스트 스레드가 소유하므로, 게스트가 부른 Glide 함수는 InvokeOnHostThread로 호스트 스레드에 건네지고 게스트 스레드는 host_command_cv_에서 대기합니다. 이 창 안에서는 게스트 코드가 한 줄도 실행되지 않습니다. 안전점은 게스트 코드가 밟아야 하는 것이므로, 게이트 시간이 길수록 주입 기회는 구조적으로 줄어듭니다.

이 게이트가 guest-run 시간의 54~55% 를 차지한다는 것은 Task 418이 이미 측정해 두었고, Task 431의 due_in_gate/due 56.5%가 그 값과 맞물립니다. 서로 다른 두 계측이 같은 그림을 그린 것이 이번 귀속의 교차 검산이었습니다.

CD-DA 위치와 MSCDEX

CD 오디오는 초당 75 섹터(프레임) 재생이 규격이고, 위치는 LBA로 보고됩니다. 리듬 게임은 이 값을 노트 타이밍의 기준으로 삼으므로, HLE가 돌려주는 위치의 정확도가 곧 게임 판정의 정확도입니다.

우리는 실제 재생 진행을 호스트 오디오 백엔드에서 가져와 LBA로 환산해 MSCDEX IOCTL 12(위치 질의)에 답합니다. §1의 delta_lba 검증은 이 환산이 시간축에서 드리프트하지 않는지를 보는 것입니다. 75 프레임/초 × 0.1095초 = 8.21이라는 기대값이 바로 이 규격에서 나옵니다.

계측 방법론 — 사전 등록한 판정표

이번 범위에서 반복해 쓴 절차입니다.

  1. 설계 문서에 측정 전에 판정 기준을 쓴다(예: injected/due ≤ 96%이면 확정).
  2. 그 다음에 예상을 적는다. 예상은 판정에 관여하지 않는다.
  3. 계측은 전부 환경 변수 opt-in으로 만들고 동작을 바꾸지 않는다.
  4. 판정은 증상이 실제로 보인 실행에만 적용한다.

Task 430에서 제 예상이 틀렸을 때 이 절차가 값을 했습니다. 예상을 먼저 세우고 기준을 나중에 썼다면, 51%를 보고도 "루프는 60 Hz니까 괜찮다"로 읽었을 수 있습니다.

이번에 쓴 계측은 다음과 같습니다.

변수 목적
REPIU_CD_AUDIO_POSITION_CENSUS poll 스레드 위치 표본 + 틱 델타 + tick_lag_ms
REPIU_MSCDEX_COMMAND_TRACE 명령·인자·우리가 준 응답·그 순간 위치
REPIU_TIMER_TICK_BACKLOG 밀린 틱 보존(Task 432에서 기본 ON, =0이 대조군)
REPIU_EXECUTION_TIMEOUT_MS 무진행 감시(0 = INFINITE, 감시 끔)

참고