# Tornado/K3D x64 송출 운영 가이드

## 안전 기준

기본 모드는 `DryRun`입니다. `DryRun`은 COM 객체를 만들거나 Tornado 출력에 명령을 보내지 않고 WebView 메시지, 큐, 상태 및 오류 표시 흐름만 검증합니다. 현재 방송 PROGRAM(PGM)에 연결된 Tornado 프로세스는 항상 **안전하지 않은 대상**으로 취급합니다. 프로세스 이름이나 실행 여부만으로 테스트 대상이라고 판단하지 않습니다.

이 저장소의 작업과 자동 검증은 실제 PGM 출력 또는 라이브 `TAKE IN`을 허가하지 않습니다. `Test` 검증은 PGM과 분리된 전용 테스트 인스턴스, 테스트 출력 채널 및 허용 목록에 든 테스트 씬을 모두 확인한 뒤에만 수행합니다. `Live`는 운영 책임자의 명시적인 회차별 허가 없이는 사용하지 않습니다.

이 문서의 adapter·builder·DryRun 완료 표현은 화면/계약 구현 범위다. 운영 DB-W, 외부 asset readiness, 기존 데이터 복원과 장면별 실제 PGM은 별도 증거가 있어야 한다. 현재 미적용·적용 중·외부자산 필요 항목은 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다.

## 확인된 x64 COM 등록

현재 개발 장비에서 확인할 등록 기준은 다음과 같습니다.

| 항목 | 기대값 |
|---|---|
| 레지스트리 뷰 | `HKLM\SOFTWARE\Classes`의 `Registry64`; 관련 HKCU override 없음 |
| TypeLib GUID | `{2B7F2D64-3A8D-401C-BE73-5C0747BA342C}` |
| TypeLib 버전/대상 | `1.0` / `0\win64` |
| KAEngine CLSID | `{D756CDBE-AA31-42B2-9CC7-018753CA61BF}` |
| ProgID | `K3DAsyncEngine.KAEngine.1` |
| KAEventHandler CLSID / ProgID | `{39828C77-EFF0-4E59-979B-8673C028C718}` / `K3DAsyncEngine.KAEventHandler.1` |
| ThreadingModel | `Apartment` |
| DLL PE 대상 | TypeLib, KAEngine, KAEventHandler 모두 `AMD64` |

[Inspect-K3DRegistration.ps1](../scripts/Inspect-K3DRegistration.ps1)은 64비트 HKLM 등록에서 GUID, 버전과 `win64` TypeLib을 확인합니다. KAEngine과 KAEventHandler 각각의 CLSID→ProgID 및 ProgID→CLSID 양방향 매핑, `Apartment` 모델, `InprocServer32` 존재와 AMD64 PE 헤더도 검사합니다. HKCU 64비트 `Software\Classes`에 같은 TypeLib GUID, CLSID 또는 ProgID override가 하나라도 있으면 fail-closed합니다. 레지스트리와 파일을 읽기만 하며 COM을 활성화하거나 등록을 변경하지 않습니다.

`MBN_STOCK_WEBVIEW.PlayoutSmoke --probe`는 런타임과 같은 검사기를 통해 KAEngine과 KAEventHandler 등록을 함께 확인합니다. 이 probe도 COM 객체를 생성하지 않습니다.

```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
  -File .\scripts\Inspect-K3DRegistration.ps1
```

검사가 실패하면 x86 등록으로 대체하거나 DLL을 앱 폴더에 복사하지 않습니다. 벤더가 제공한 x64 설치 프로그램과 라이선스 절차로 장비 상태를 복구한 뒤 다시 검사합니다.

## 런타임 참조 방식

앱은 빌드 시점의 `Interop.K3DAsyncEngineLib.dll`을 참조하거나 패키징하지 않습니다. 런타임에는 HKLM Registry64의 TypeLib·KAEngine·KAEventHandler 세 등록이 모두 같은 `DLL\x64\Release\K3DAsyncEngine.dll`을 가리키고 HKCU override가 없을 때만, 그 벤더 루트의 정확한 `Bin\x64\C#\Interop.K3DAsyncEngineLib.dll` 하나를 사용합니다. 상위 폴더 검색이나 `obj`/`artifacts` fallback은 없습니다. 두 파일과 모든 상위 경로는 reparse point가 아니어야 하며 x64 PE인지 확인합니다. COM 객체와 호출 세부 사항은 `IPlayoutEngine` 구현 안에 격리합니다.

등록된 네이티브 `K3DAsyncEngine.dll`과 설치된 C# Interop은 모두 로드 전에 운영자가 승인한 SHA-256과 일치해야 합니다. 현재 프로세스 환경의 `MBN_STOCK_K3D_NATIVE_SHA256`과 `MBN_STOCK_K3D_INTEROP_SHA256`에 각각 공백 없는 64자리 16진수만 허용하며, 일반 Test/Live 어댑터와 PGM 연결 전용 진단이 같은 두 핀을 사용합니다. 먼저 벤더 배포 해시, 신뢰된 설치 매체 또는 관리자 검수로 **두 파일을 독립적으로 승인한 뒤** 그 정확한 파일들을 대상으로 다음 값을 설정합니다.

```powershell
$approvedNative = '<vendor-or-admin-approved-x64-native-file>'
$approvedInterop = '<vendor-or-admin-approved-x64-interop-file>'
$nativeHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $approvedNative).Hash
$interopHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $approvedInterop).Hash
if ($nativeHash -notmatch '^[0-9A-Fa-f]{64}$' -or
    $interopHash -notmatch '^[0-9A-Fa-f]{64}$') { throw 'Invalid SHA-256.' }
$env:MBN_STOCK_K3D_NATIVE_SHA256 = $nativeHash
$env:MBN_STOCK_K3D_INTEROP_SHA256 = $interopHash
```

검수하지 않은 현재 설치 파일의 해시를 계산해 그대로 승인하는 것은 독립적인 신뢰 확인이 아닙니다. 파일 업데이트 후 어느 핀이든 바꾸는 행위는 새 바이너리를 신뢰한다는 명시적 운영 결정이므로 변경 이력과 승인 근거를 남기고, 위 `$env:`를 설정한 같은 셸에서 앱 또는 진단을 시작합니다. 핀과 설치 경로는 결과 JSON이나 로그에 출력하지 않습니다. 핀이 없거나 형식·해시·경로·PE·Interop 메타데이터 검사가 하나라도 실패하면 `installed-interop-metadata-unavailable`로 COM 활성화 전에 종료합니다. 검사를 통과한 두 파일은 프로세스 수명 동안 쓰기·교체 공유를 허용하지 않는 읽기 핸들로 유지합니다.

이 이중 핀과 장기 파일 잠금은 개발 워크스테이션에서 우발적 교체 및 검사-사용 시점(TOCTOU) 교체를 완화하는 방어입니다. 운영 장비에서는 이것만으로 충분하지 않으며, 벤더 설치 루트 ACL을 `Administrators`/`SYSTEM`만 쓰기 가능하고 일반 `Users`는 읽기·실행만 가능하도록 배포 단계에서 구성해야 합니다. 또한 서명된 벤더 패키지 또는 관리자가 별도로 승인한 배포물을 사용해야 합니다. 앱과 진단은 ACL을 수정하거나 취약한 설치를 자동으로 신뢰하지 않습니다.

타입 정보를 조사해야 할 때만 [Generate-K3DInterop.ps1](../scripts/Generate-K3DInterop.ps1)을 선택적으로 사용합니다. 생성 전 Inspect와 같은 HKLM 양방향 매핑·두 COM 서버 AMD64·HKCU override 부재 검사를 통과해야 합니다. 그 뒤 알려진 Windows SDK의 x64 `TlbImp.exe` 위치를 선택하거나 명시적으로 받은 x64 경로를 검증하고 `/machine:X64`로 생성합니다. 입력 TypeLib과 도구 및 결과물 모두 AMD64인지 확인하며, 결과는 Git에서 제외된 다음 위치에만 씁니다.

```text
artifacts\K3DInterop\Interop.K3DAsyncEngineLib.dll
```

실행 전 계획만 확인:

```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
  -File .\scripts\Generate-K3DInterop.ps1 -WhatIf
```

진단용 Interop 생성:

```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
  -File .\scripts\Generate-K3DInterop.ps1
```

SDK가 기본 위치에 없다면 x64 SDK의 `TlbImp.exe` 절대 경로를 `-TlbImpPath`에 지정합니다. 생성물은 타입 조사용일 뿐 프로젝트 참조나 MSIX 콘텐츠로 추가하지 않습니다. 런타임 resolver도 이 `artifacts` 생성물을 찾거나 로드하지 않습니다. 이 스크립트도 COM 활성화와 등록 변경은 하지 않습니다.

## 로컬 설정

런타임 설정 기본 경로는 다음과 같습니다.

```text
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\playout.local.json
```

[playout.example.json](../Config/playout.example.json)을 구조 참고용으로 사용합니다. 예시는 `DryRun`, 외부 씬 루트와 출력 채널 미지정, 빈 씬 허용 목록, 라이브 신뢰 플래그 해제 상태이므로 실제 출력에 사용할 수 없습니다. 테스트 장비의 호스트, 채널, 창 제목 패턴, 외부 씬 루트와 허용할 씬 이름은 로컬 파일에만 기록하고 Git, 로그 또는 지원 첨부파일에 넣지 않습니다.

| 속성 | 의미 |
|---|---|
| `mode` | `Disabled`, `DryRun`, `Test`, `Live` 중 하나 |
| `host`, `port` | 테스트 또는 운영 승인을 받은 KTAP endpoint |
| `tcpMode`, `clientPort` | `KTAPConnect`의 전송 모드와 로컬 client port 인자. Test/Live는 유실 방지를 위해 `tcpMode: 1`만 허용 |
| `sceneDirectory` | Test/Live에서 사용하는 외부 `.t2s` 루트의 절대 경로. `null`은 안전한 미설정 상태 |
| `outputChannel` | 확인된 전용 출력 채널. `null`은 안전한 미설정 상태 |
| `layoutIndex` | 씬 플레이어 layout 위치 |
| `legacySceneFadeDuration` | 원본 `ComboDi.SelectedIndex`에 대응하는 fade. 기본값 6, 허용 범위 0~60 |
| `legacySceneBackgroundKind` | trusted 공통 배경 `None`, `Texture`, `Video`; Web은 임의 경로를 보내지 않고 F2/F3가 native picker/toggle을 호출한다. 현재 picker는 `sceneDirectory` 내부만 허용하므로 원본의 sibling `배경` root와는 아직 동등하지 않음 |
| `legacySceneBackgroundAssetPath` | `sceneDirectory` 아래의 상대 asset. 경로 탈출·reparse·누락 파일은 DryRun에서도 거부 |
| `legacySceneBackgroundVideoLoopCount`, `legacySceneBackgroundVideoLoopInfinite` | 공통 video 배경의 bounded loop 설정 |
| `testProcessWindowTitlePattern` | 전용 테스트 인스턴스만 식별하는 창 제목 패턴 |
| `testSceneAllowlist` | 실제 COM을 사용하는 `Test`와 `Live` 모두에서 허용한 scene name(code)의 폐쇄형 목록. 경로나 확장자는 넣지 않음(기존 설정 호환을 위해 이름 유지) |
| `trustedLiveOutputEnabled` | 운영자가 로컬 파일에서만 설정하는 라이브 1차 게이트 |
| `queueCapacity` | 직렬 STA 명령 큐의 최대 대기 항목 수 |
| `*TimeoutMilliseconds` | 연결, 작업 및 해제 제한 시간 |
| `processPollIntervalMilliseconds` | `Tornado2` 접두사 프로세스 감시 주기 |
| `reconnect*` | 재연결 지연, 최대 횟수 및 활성화 여부 |
| `maximumAutomaticRefreshesPerTakeIn` | trusted local 자동 갱신 상한. `null`은 원본의 연속 갱신, `0`은 자동 갱신 비활성화, 양수는 TAKE IN/playlist NEXT로 시작한 epoch당 성공 dispatch 수 상한. 마지막 refresh의 `OnScenePlayed`까지 drain된 뒤 `CAPPED`로 보고 |

JSON보다 다음 환경 변수가 우선합니다.

```text
MBN_STOCK_PLAYOUT_MODE
MBN_STOCK_PLAYOUT_HOST
MBN_STOCK_PLAYOUT_PORT
MBN_STOCK_PLAYOUT_TCP_MODE
MBN_STOCK_PLAYOUT_CLIENT_PORT
MBN_STOCK_PLAYOUT_SCENE_DIRECTORY
MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL
MBN_STOCK_PLAYOUT_LAYOUT_INDEX
MBN_STOCK_PLAYOUT_LEGACY_FADE_DURATION
MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND
MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET
MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT
MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE
MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN
MBN_STOCK_PLAYOUT_QUEUE_CAPACITY
MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS
MBN_STOCK_PLAYOUT_OPERATION_TIMEOUT_MS
MBN_STOCK_PLAYOUT_DISCONNECT_TIMEOUT_MS
MBN_STOCK_PLAYOUT_PROCESS_POLL_INTERVAL_MS
MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS
MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS
MBN_STOCK_PLAYOUT_RECONNECT_ENABLED
```

`testSceneAllowlist`, `trustedLiveOutputEnabled`, `maximumAutomaticRefreshesPerTakeIn`은 환경 변수로 변경할 수 없으며 로컬 설정 파일에서만 관리합니다. 이름은 기존 설정 호환을 위해 유지하지만 allowlist는 Test뿐 아니라 Live의 PREPARE, TAKE IN 재검사, NEXT와 timer refresh에도 적용되고 비어 있으면 실제 모드를 시작하지 않습니다. 자동 갱신 상한은 Web payload나 환경 변수로 늘릴 수 없고 COM dispatch 경계에서 다시 검사합니다. 공통 background/fade도 Web payload가 아니라 이 trusted 프로세스 설정 경계에서만 결정합니다. `PlayoutSceneCompositionFactory`는 첫 DryRun 또는 실제 PREPARE 전에 background asset의 상대 경로, 허용 확장자, 존재 여부와 reparse ancestry를 검사하며 실제 경로는 Web status/preview에 노출하지 않습니다. `SceneDirectory`는 Test/Live에서 존재하는 비-reparse 외부 디렉터리여야 하며, 엔진은 상대 `.t2s` 파일을 정규화해 이 루트 밖으로 나가는 경로를 거부합니다. scene file의 basename과 scene name 및 allowlist 항목도 서로 일치해야 합니다. 설정 파일은 실행 계정만 읽을 수 있도록 ACL을 제한합니다. 라이선스 키나 인증정보를 이 파일에 기록하지 않습니다.

### KTAP 포트와 Network Monitoring 판정

K3DAsyncEngine 매뉴얼의 `KTAPConnect(bTCP, HostAddress, nHostPort, nClientPort, handler)`에서 Test/Live는 `tcpMode: 1`(TCP)만 허용하므로 로컬 JSON의 `port`는 격리 Test Tornado의 `Tools > Option > Control > Network Server > TCP Port`와 정확히 같아야 합니다. `clientPort`는 UDP일 때만 의미가 있고 `TAP TCP Port`/`TAP UDP Port`는 이 연결의 host port가 아닙니다. 매뉴얼 18쪽의 설명과 26쪽 및 192쪽 예시에 표시된 포트 숫자가 서로 다르므로 `30001`/`30002`를 추정하거나 원본·예제 값을 복사하지 않습니다. 해당 회차 Test 인스턴스 화면의 설정값이 유일한 기준입니다. `playout.example.json`의 `30001`도 DryRun 구조 예시일 뿐 새 Test endpoint의 검증값이 아닙니다.

Tornado2의 `View > Network Monitoring Window`에서 `[R]`은 서버가 클라이언트 요청을 받은 기록, `[S]`는 서버가 응답을 보낸 기록이며 `TCPSession`은 TCP 세션 수입니다(매뉴얼 26~27쪽). 프로세스 감지, COM 등록 probe 또는 COM 객체 생성만으로는 이 기록이 생기지 않습니다. 기본 앱과 `--dry-run`, `--probe`, `--test-plan`은 KTAP를 호출하지 않으므로 빈 모니터가 정상입니다.

상태의 `accepted-unconfirmed`는 `KTAPConnect`가 SDK 성공값 `1`을 반환했다는 뜻일 뿐입니다. 매뉴얼 41쪽의 `OnHello` 콜백이나 실제 `[R]`/`[S]`를 자동 확인했다는 뜻이 아닙니다. connect-only `--pgm-connect-diagnostic`은 렌더 API 표면을 제거한 별도 binding이라 callback을 관찰하지 않고 `ktapHelloObserved`를 `null`로 보고합니다. 일반 `IPlayoutEngine` 경로는 검증된 282-method `DynamicK3dEventHandler`로 `OnHello`와 lifecycle callback을 수신하지만, callback 미수신 상태를 성공으로 추정하지 않으며 Network Monitoring은 계속 사람이 대조합니다. `lastKtapConnectState`는 현재 연결 상태가 아니라 가장 최근 KTAP dispatch 시도의 증거이며, 화면은 `Connected`/`Faulted` 같은 현재 상태와 분리해 표시합니다. 따라서 격리 `--test-connect`가 성공했는데도 같은 시각의 `[R]`/`[S]`가 전혀 없다면 `--test-sequence`로 진행하지 말고 mode/config 파일, 실제 Network Server TCP Port와 안전 게이트 거부 여부를 먼저 확인합니다.

### PGM 네트워크 연결 전용 진단

Tornado2의 `PGM` 창은 본 프로그램이 보낸 KTAP 송출 명령을 표시하는 렌더 출력 창입니다. 창 제목만으로 실제 방송 라우팅 여부를 판정할 수 없으므로 기존 Test 게이트에서 PGM을 허용하지 않습니다. 대신 운영자가 Network Monitoring 창을 먼저 열고 승인한 회차에만 다음 별도 명령으로 네트워크 세션 왕복을 확인합니다.

```powershell
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke `
  -c Debug -p:Platform=x64 -- `
  --pgm-connect-diagnostic `
  --i-understand-this-will-contact-current-pgm-tornado-via-ktap-connect-and-disconnect-only `
  --host 127.0.0.1 `
  --port <Tornado-Network-Server-TCP-Port> `
  --expected-pgm-window-title PGM
```

이 명령은 설정 파일과 `MBN_STOCK_PLAYOUT_*` 환경 override를 받지 않으며 host는 숫자형 loopback만 허용합니다. 정확히 하나의 `Tornado2*` 프로세스, 지정한 PGM 창 제목, 프로세스 세대와 해당 TCP LISTEN 소유권을 COM 전·KTAP 직전·Disconnect 직전에 반복 확인합니다. 자동 재연결과 재시도는 없고 연결·해제 timeout은 각각 3초로 고정됩니다.

COM 경로에는 `KTAPConnect`와 성공 후 최대 한 번의 `Disconnect`만 있으며 `GetScenePlayer[OnChannel]`, `LoadScene`, `Prepare`, `Play`, `PlayDirect`, `CutOut`, `Stop`, `StopAll`을 호출할 수 있는 API 표면이 없습니다. 따라서 명시적인 장면/레이어 출력 명령은 발생하지 않습니다. 다만 PGM에 TCP 제어 세션을 추가하므로 완전한 무영향을 보장하는 명령은 아니며, timeout이나 대상 변경 후에는 반복하지 않습니다.

성공 JSON의 `lastKtapConnectState: "accepted-unconfirmed"`, `completed: true`, `outcomeUnknown: false`, `renderCommandSurfaceExposed: false`, `renderCommandAttempted: false`를 확인하고 같은 시각의 Network Monitoring `[R] HELLO`와 `[S] SUCCESS HELLO`를 사람이 확인합니다. `GetScenePlayer`를 호출하지 않으므로 이 진단의 성공은 장면 송출 준비 완료를 의미하지 않습니다.

x64 SDK의 네이티브 DLL과 정식 Interop은 위의 Registry64 단일 경로·reparse ancestry·AMD64 PE·운영자별 SHA-256 핀을 모두 통과해야 합니다. 두 파일을 쓰기/교체할 수 없게 연 핸들은 COM 활성화와 사용을 포함한 프로세스 수명 동안 유지합니다. Interop 로드 후에는 assembly/TypeLib 버전, COM import GUID와 허용 메서드 서명도 확인합니다. vendor DLL은 Git 또는 MSIX에 복사하지 않습니다.

### 실제 PGM 컷 시퀀스 검증

2026-07-10에 방송 운영자의 명시적 승인 아래 현재 Tornado2 PGM과 다음 고정 시퀀스의 실제 왕복을 완료했습니다.

최초 목표의 실제 호출 검증은 격리 Test 인스턴스를 전제로 했지만, 이후 운영자가 현재 PGM을 이번 회차의 검증 대상으로 명시적으로 지정하고 실제 출력을 승인했습니다. 이에 따라 아래 고정 테스트 컷의 성공 회차를 이번 목표의 일회성 대체 검증으로 사용합니다. 이는 PGM을 일반 Test 인스턴스로 인정한다는 뜻이 아니며, 앱의 기본 `DryRun`, Test 대상 격리 및 Live 이중 승인 조건은 그대로 유지됩니다. 별도 Test 환경의 MSIX WebView→COM 검증은 운영 배포 전 후속 검증입니다.

```text
Connect → Prepare(5001) → Play → 5초 관찰
        → Prepare(5006) → Play → 5초 관찰
        → StopAll → 5초 관찰 → Disconnect
```

이 검증은 일반 `Test`/`Live` 설정을 완화하지 않는 별도 `--pgm-cuts-sequence` 경로입니다. 호출자가 장면 code나 관찰 시간을 바꿀 수 없고 숫자형 loopback, 정확한 PGM 창 제목, 단일 Tornado2 프로세스와 그 프로세스의 LISTEN 소유권을 각 SDK 명령과 관찰 구간 전후에 다시 검사합니다. `MBN_STOCK_PLAYOUT_*` 및 .NET startup hook/profiler 주입 환경 변수가 있으면 시작하지 않으며 자동 재연결과 자동 재생은 항상 꺼집니다. 사용한 컷은 다음 두 파일과 SHA-256으로 고정됩니다.

| 컷 | 승인 SHA-256 |
|---|---|
| `5001.t2s` | `99CE3B689A42D8C42BEB09A86FA10C2D7C1AEF4F50D324D81276C1A1E4C4D8A7` |
| `5006.t2s` | `25CD0AE931F51E4E3B84CE3E6FD21A40DB85464F157A23CC3511D63B336D8757` |

재검증이 승인된 경우에는 임의의 `dotnet run` 대신 [Invoke-PgmCutsSequenceEvidence.ps1](../scripts/Invoke-PgmCutsSequenceEvidence.ps1)을 사용합니다. 먼저 x64 빌드 산출물과 두 컷의 변경이 없는지 독립적으로 검토하고, vendor/관리자가 승인한 네이티브·Interop 해시와 검토한 runner 산출물 해시를 인수로 전달합니다. `Approved*Sha256` 값은 실행 시점의 파일을 단순 계산해 곧바로 승인한 값으로 사용하지 않습니다. 실제 vendor 설치 경로와 컷 루트는 로컬 승인 기록에만 보관합니다.

```powershell
$smoke = '<reviewed-x64-runner>\MBN_STOCK_WEBVIEW.PlayoutSmoke.dll'
$cuts = '<approved-local-cut-root>'

powershell -NoProfile -ExecutionPolicy Bypass `
  -File .\scripts\Invoke-PgmCutsSequenceEvidence.ps1 `
  -SmokeDll $smoke `
  -SceneRoot $cuts `
  -ApprovedNativeSha256 '<vendor-approved-native-sha256>' `
  -ApprovedInteropSha256 '<vendor-approved-interop-sha256>' `
  -ApprovedSmokeSha256 '<reviewed-smoke-sha256>' `
  -ApprovedPlayoutSha256 '<reviewed-playout-sha256>' `
  -ApprovedCoreSha256 '<reviewed-core-sha256>' `
  -ApprovedDepsSha256 '<reviewed-deps-sha256>' `
  -ApprovedRuntimeConfigSha256 '<reviewed-runtimeconfig-sha256>' `
  -ApprovedWindowsSdkSha256 '<reviewed-windows-sdk-sha256>' `
  -ApprovedWinRtSha256 '<reviewed-winrt-sha256>' `
  -ExpectedPgmWindowTitle PGM `
  -Port <verified-ktap-listener-port> `
  -IUnderstandPgmWillRenderCuts
```

스크립트는 실행 전에 PGM과 Network Monitoring 창을 같은 화면에 배치해 기준 프레임 네 장을 남기고, 실행 중 약 500ms 간격으로 두 창을 함께 캡처합니다. runner와 관련 managed 산출물을 승인 해시로 확인해 읽기 잠금을 유지하고, runner 내부도 네이티브·Interop 및 컷 파일의 해시와 안전한 로컬 경로를 다시 확인합니다. 종료 후에는 세 `observation-start` 표식, 각 관찰 시간이 4,900ms 이상인지, 단계별 성공 순서, 안전 상태, runner 종료 code와 최소 프레임 수를 검증해 `manifest.json`에 각 파일·프레임의 해시와 함께 기록합니다. 창 위치와 topmost 상태는 마지막에 원래대로 복원합니다.

성공 회차의 terminal 결과는 `connect`, `prepare-first`, `take-in`, `next`, `take-out`, `disconnect`가 모두 `Success`였고 `completed: true`, `outcomeUnknown: false`, `renderCommandAttempted: true`, `disconnectAttempted: true`, `quarantineAttempted: false`였습니다. 실제 관찰 구간은 각각 `5051ms`, `5052ms`, `5093ms`였습니다. 캡처에서 PGM은 먼저 5001 화면, 다음으로 5006 화면을 표시했고 `StopAll` 뒤 마지막 관찰 구간에는 검은 화면으로 돌아왔습니다. 같은 캡처의 Network Monitoring에는 시간 순서대로 `HELLO`, `LOAD_SCENE`, `SCENE_PREPARE`, `PLAY`, 두 번째 `LOAD_SCENE`, `SCENE_PREPARE`, `PLAY`, `STOPAL`, `BYE` 요청/응답 기록이 남았습니다. `STOPAL`은 모니터에 표시된 KTAP 명령 표기입니다.

성공 증거는 이 장비의 Git 제외 경로 `artifacts/pgm-evidence/20260710_155214_success/manifest.json`에 보존했으며 SHA-256은 `2B3AA4F9B4AC4FD00613BE708D00BCDAA6687135EC06B8228F95F27482B230BB`입니다. 이 로컬 증거는 Git/MSIX에 포함되지 않습니다. 장기 보관할 때는 승인된 증거 저장소로 디렉터리 전체를 복사한 뒤 이 manifest 해시와 내부 프레임 해시를 다시 대조합니다. 해당 manifest는 runner/evidence 종료 code `0`, `terminalValidated: true`, 39개 프레임, 빈 `captureErrors`와 `validationErrors`도 기록합니다.

첫 회차는 `Connect: Success` 뒤 출력 전 `prepare-first: Rejected`로 중단되고 안전한 `Disconnect: Success`만 수행했습니다. 원인은 사전 검사에서 상대 cue를 절대 경로로 한 번 resolve한 뒤 그 이미 resolve된 cue를 `TornadoPlayoutEngine`에 전달해 엔진이 두 번째 resolve에서 거부한 것이었습니다. 수정 후 절대 경로는 승인 자산 검사와 파일 lease에만 사용하고, 엔진에는 고정 상대 cue인 `5001.t2s`와 `5006.t2s`를 전달합니다. 상대 cue와 검증용 절대 자산 경로가 분리되는 회귀 테스트도 추가했습니다. 이 회차는 `Play` 전에 결과가 명확히 거부되고 같은 대상의 Disconnect 성공까지 확인됐기 때문에 원인 수정 후 새 회차를 진행할 수 있었습니다. timeout, `OutcomeUnknown`, 대상 교체 또는 출력 결과가 불명확한 경우에는 자동·수동으로 반복하지 않고 quarantine 뒤 PGM 상태를 사람이 먼저 확인합니다.

PGM 전용 시퀀스의 `outputChannel`은 의도적으로 비워 원본 `MainForm` 연결과 같은 `GetScenePlayer()`를 사용합니다. 검증되지 않은 임의 채널을 추정해 `GetScenePlayerOnChannel()`을 호출하지 않기 위함입니다. TAKE OUT은 원본의 현재 운영 경로와 같이 `TakeOut(All)`을 `StopAll()`로 매핑합니다. 과거 `CutOut(10)`보다 현재 재생기의 모든 레이어를 정리해 최종 PGM이 검은 화면으로 돌아오는 동작과 일치합니다. 현재 35개 scene builder, 34개 도달 runtime, PageN과 실제 데이터 mutation은 포팅·자동/DB 검증을 마쳤지만, 이 과거 고정 runner는 현재 WebView의 fresh TAKE IN, Page NEXT, timer refresh와 실제 화면 동등성을 검증한 회차가 아니므로 완료 증거로 사용하지 않습니다.

## 모드와 안전 게이트

`Disabled`는 모든 송출 명령을 거부하는 운영 롤백 모드입니다. `DryRun`은 COM 없이 WebView 동작을 성공 결과로 모의합니다. `Test`와 `Live`만 등록된 COM을 사용할 수 있습니다.

`Test` 전환 전 다음 조건을 모두 사람이 확인합니다.

1. Tornado 인스턴스가 현재 PGM과 물리적·논리적으로 분리되어 있고 Test의 `host`가 로컬 loopback IP literal(`127.0.0.1` 또는 `::1`)입니다. `localhost`를 포함한 호스트 이름은 허용하지 않습니다.
2. 전용 테스트 창 제목에 독립된 `TEST` 토큰(예: `Tornado2 TEST`)이 있고 `testProcessWindowTitlePattern`이 그 인스턴스 하나만 식별합니다. 빈 제목이나 일반 `Tornado2` 제목은 허용하지 않습니다.
3. `outputChannel`이 라우터/엔진에서 테스트 출력임을 확인했습니다.
4. `sceneDirectory`가 승인된 테스트 자산 루트이고 사용할 scene name이 `testSceneAllowlist`에 있으며 운영 씬이 아닙니다.
5. 같은 플레이리스트로 `DryRun`의 `PREPARE`, `TAKE IN`, `NEXT`, `TAKE OUT` 메시지와 화면 상태를 먼저 확인했습니다.

프로세스 감시는 이름이 `Tornado2`로 시작하는 모든 프로세스를 대소문자 구분 없이 찾습니다. 이는 가용성 신호일 뿐 송출 권한이 아닙니다. Test에서는 Tornado2 프로세스가 정확히 하나이고 그 창 제목이 독립된 `TEST` 토큰과 설정 규칙에 모두 일치해야 합니다. `PGM`/`PROGRAM`, 빈 제목, 일반 `Tornado2` 제목, TEST 표식 없는 제목 또는 여러 인스턴스가 모호하면 COM을 활성화하지 않습니다. 즉 현재 방송 PGM이 실행 중인 장비에서는 창 제목이 순간적으로 바뀌더라도 Test 연결을 거부합니다.

Test에서 로컬 프로세스/창 제목 검사를 원격 KTAP endpoint의 신원 증명으로 사용하지 않습니다. 따라서 Test 모드는 loopback endpoint만 허용하며, 원격 host는 아래의 Live 이중 승인을 모두 통과한 경우에만 사용할 수 있습니다.

`Live`에는 별도의 이중 승인이 필요합니다.

1. 로컬 JSON의 `trustedLiveOutputEnabled`를 명시적으로 `true`로 설정합니다.
2. 그 회차에만 프로세스 환경 변수 `MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT`을 정확히 `I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH`로 설정합니다.

둘 중 하나라도 없으면 라이브 출력을 거부해야 합니다. 이 값은 사용자/시스템 영구 환경 변수로 저장하지 않으며 앱 종료 후 제거합니다. 위에서 완료한 PGM 컷 검증은 승인된 한 회차에만 사용할 수 있는 별도 고정 runner를 통한 것이며, 일반 앱의 `Live`를 자동 승인하거나 이 이중 게이트를 완화하지 않습니다.

## 안전한 스모크 절차

먼저 `mode`가 `DryRun`이고 라이브 환경 변수가 없는지 확인합니다.

```powershell
Remove-Item Env:MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT -ErrorAction SilentlyContinue
powershell -NoProfile -ExecutionPolicy Bypass `
  -File .\scripts\Inspect-K3DRegistration.ps1
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke `
  -c Debug -p:Platform=x64 -- --probe
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke `
  -c Debug -p:Platform=x64 -- --dry-run
dotnet test .\MBN_STOCK_WEBVIEW.sln -c Debug -p:Platform=x64
dotnet test .\MBN_STOCK_WEBVIEW.sln -c Release -p:Platform=x64
powershell -NoProfile -ExecutionPolicy Bypass `
  -File .\scripts\Test-WebPlayout.ps1
```

`PlayoutSmoke --probe`의 기본 출력은 등록 준비 여부·issue 이름, Tornado2 프로세스 수와 PROGRAM 감지 여부만 제공합니다. 벤더 DLL 전체 경로, 프로세스 ID/이름 및 창 제목은 출력하거나 로그에 남기지 않습니다. 정확한 로컬 등록 경로가 필요한 관리자는 `Inspect-K3DRegistration.ps1` 결과를 해당 장비에서만 확인하고 지원 첨부파일에 포함하지 않습니다.

### 격리 Test 단계별 CLI

실제 Test 검증은 아래 세 단계를 순서대로 사용합니다. 세 명령 모두 절대 경로의 일반 로컬 JSON 파일과 `--acknowledge-isolated-non-program-test-output` 승인이 필요합니다. `MBN_STOCK_PLAYOUT_*` 환경 변수가 하나라도 존재하면 파일과 환경의 합성 결과가 달라지는 것을 막기 위해 엔진 생성 전에 거부합니다. JSON은 반드시 `mode: "Test"`, `trustedLiveOutputEnabled: false`여야 하며 기존 Test 안전 게이트를 그대로 통과해야 합니다.

이 장비의 Tornado2 설정은 실행 파일별 프로필이 아니라 현재 사용자 공용 `HKCU\Software\Visual Research Inc\Tornado2\Option\Control`에 저장됩니다. 설치된 Tornado2 매뉴얼 286쪽도 프로그램 실행 시 이 Network Server 설정의 자동 시작을 설명하며 별도 명령행 프로필이나 다중 인스턴스 절차를 제시하지 않습니다. 따라서 `C:\Tornado2`에 함께 설치된 다른 버전 EXE를 PGM 옆에서 실행해 TEST로 간주하지 않습니다. 동일 포트, 동일 출력 보드와 동일 사용자 설정을 공유할 수 있습니다.

권장 구성은 별도 TEST 장비 또는 독립 VM/Windows 사용자 프로필에서 출력 보드를 preview 전용 또는 `None`으로 두고, 그 환경에 Tornado2를 정확히 하나만 실행하는 것입니다. 같은 장비를 사용해야 한다면 방송 운영자가 승인한 정비 시간에 PGM을 정상 종료하고 Tornado UI에서 Network Server와 출력 채널을 TEST 전용으로 구성하며, 창 제목에 `TEST` 식별자가 나타나는 공식 운영 절차까지 확인한 뒤 진행합니다. 실행 중 PGM의 레지스트리나 옵션을 자동 변경하거나 다른 버전 EXE를 병렬 실행하지 않습니다.

먼저 씬 파일과 설정만 검사합니다. 이 단계는 엔진·STA·COM을 생성하지 않으며 실행 중 프로세스의 적격성도 아직 판단하지 않습니다. 승인된 Test 씬 후보인 `5001.t2s`와 `5006.t2s`를 사용할 때 로컬 JSON의 외부 `sceneDirectory` 및 `testSceneAllowlist`에는 각각 basename `5001`, `5006`을 설정합니다. 실제 절대 경로는 로컬 JSON에만 두고 저장소 문서나 예제 설정에 기록하지 않습니다.

```powershell
$config = [IO.Path]::GetFullPath(
  "$env:LOCALAPPDATA\MBN_STOCK_WEBVIEW\Config\playout.test.local.json")

dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke `
  -c Debug -p:Platform=x64 -- `
  --test-plan `
  --acknowledge-isolated-non-program-test-output `
  --config $config `
  --prepare 5001 `
  --next 5006 `
  --observe-ms 5000
```

자산 검사가 끝나면 COM을 만들기 전, 즉 `--test-connect` 직전에 다음 읽기 전용 점검으로 프로세스, 현재 사용자 Network Server 설정과 LISTEN 소유권을 함께 확인합니다. `ExpectedTestWindowTitle`은 실제 창 제목과 대소문자만 무시하고 정확히 같아야 하며 독립된 `TEST` 토큰이 필요합니다. 결과에는 PID, IP 주소와 실제 창 제목을 기록하지 않습니다.

```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
  -File .\scripts\Inspect-TornadoTestIsolation.ps1 `
  -ExpectedTcpPort <Test-Tornado-Network-Server-TCP-Port> `
  -ExpectedTestWindowTitle 'Tornado2 TEST'
```

`ready: true`인 경우에만 `--test-connect`로 진행합니다. 이 점검은 COM과 `KTAPConnect`를 호출하지 않으므로 Network Monitoring 기록이 없어야 합니다. `program-window-detected`, `network-address-not-loopback`, `listener-address-not-loopback`, `listener-not-owned-by-test-instance` 중 하나라도 있으면 현재 실행 환경은 격리 TEST가 아닙니다. 레지스트리 설정뿐 아니라 실제 LISTEN endpoint도 모두 loopback이어야 하며 wildcard(`0.0.0.0`, `::`) 리스너는 거부합니다.

별도 Test Tornado만 정확히 하나 실행되고 PGM/PROGRAM 창이 전혀 없는 것을 사람이 다시 확인한 뒤 연결만 검증합니다. 이 명령은 씬이나 출력 상태를 변경하지 않고 `Connect → Disconnect`만 요청합니다.

명령 전에 Network Monitoring을 열고 `TCPSession`과 로그 시각을 기준선으로 기록합니다. `--test-plan`에는 변화가 없어야 합니다. `--test-connect`는 짧게 `Connect → Disconnect`하므로 세션 수가 곧 원래 값으로 돌아갈 수 있습니다. 일시적인 세션 수만 보지 말고 같은 시각의 `[R]`/`[S]` 양방향 기록을 확인합니다. `[R]`만 있고 `[S]`가 없거나 양쪽 모두 없으면 명령을 반복하지 말고 중단합니다. JSON의 `completed: true`, `outcomeUnknown: false`, `lastKtapConnectState: "accepted-unconfirmed"`와 운영자가 본 모니터 기록을 함께 확인한 뒤에만 5001→5006 시퀀스로 진행합니다.

```powershell
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke `
  -c Debug -p:Platform=x64 -- `
  --test-connect `
  --acknowledge-isolated-non-program-test-output `
  --config $config
```

마지막으로 격리된 Test 모니터를 관찰하면서 실제 시퀀스를 실행합니다. `--observe-ms`는 `TAKE IN`, `NEXT`, `TAKE OUT` 뒤에 각각 적용되는 필수 관찰 시간이며 1,000~30,000ms만 허용합니다.

```powershell
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke `
  -c Debug -p:Platform=x64 -- `
  --test-sequence `
  --acknowledge-isolated-non-program-test-output `
  --config $config `
  --prepare 5001 `
  --next 5006 `
  --observe-ms 5000
```

시퀀스는 `Connect → Prepare(5001) → TakeIn → 관찰 → Next(5006) → 관찰 → TakeOut(All) → 관찰 → Disconnect` 순서입니다. K3D의 Play/Stop 완료는 비동기 이벤트이므로 마지막 관찰 창이 끝나기 전에는 Disconnect하지 않습니다. 자동 재연결은 CLI가 강제로 비활성화합니다. 성공한 `TakeIn` 뒤 관찰 취소처럼 결과가 확정된 중단이면 `TakeOut(All)`을 한 번만 정리 단계로 요청한 뒤, 정리가 성공한 경우에만 `Disconnect`합니다. 이미 실행 결과가 불명확하거나 `TakeOut`이 어떤 비성공 결과라도 반환하면 출력이 남아 있을 수 있으므로 추가 출력 명령과 SDK `Disconnect`를 보내지 않습니다. 이때 `QuarantineAsync`가 같은 STA에서 제어 메서드 호출 없이 로컬 COM 참조만 해제한 다음 bounded 어댑터 폐기를 수행합니다. quarantine 자체를 완료하지 못하면 의도하지 않은 Disconnect보다 로컬 누수를 택해 일반 Dispose도 생략합니다. 어느 경우든 격리 모니터에서 최종 Test 출력 상태를 사람이 확인해야 합니다.

현재 런타임은 vendor의 282-method event handler에서 `OnScenePlayed`, `OnCutOut`, `OnStopAll`을 managed callback queue로 수신합니다. `OnScenePlayed` 성공 뒤에만 retired scene을 unload하고, pending Play callback이 있으면 TAKE OUT 이외의 mutation 명령을 fail-closed 차단합니다. `CutOut`/`StopAll` completion counter는 dispatch 직전에 증가하고 동기 실패 시 원복되며, 성공한 stop/cut callback은 중단된 pending Play accounting도 취소합니다. lifecycle callback이 pending이거나 실패·overflow가 발생하면 Disconnect나 조기 unload를 하지 않고 session을 abandon/quarantine하여 결과 불명확 상태를 보존합니다.

JSON 결과는 단계별 operation/result code와 `connectRequestIssued`, nullable `comActivationAttempted`, `lastKtapConnectState`, `ktapConnectAttempted`, nullable `ktapConnectAccepted`, nullable `ktapHelloObserved`, nullable `networkMonitoringRecordExpected`, `networkMonitoringCheckRequired`, nullable `networkMonitoringVerified`, `outputMayBeActive`, quarantine 시도·완료 여부를 제공합니다. `connectRequestIssued`는 엔진 API 요청일 뿐 KTAP 통신 증거가 아니며, `comActivationAttempted`도 COM 활성화 추정값일 뿐입니다. `lastKtapConnectState`는 `not-attempted`, `attempted`, `accepted-unconfirmed`, `failed` 중 하나입니다. `networkMonitoringRecordExpected`는 성공값을 받은 경우 `true`, dispatch가 없으면 `false`, local reflection/COM 실패 또는 timeout으로 서버 도달을 예측할 수 없으면 `null`입니다. `networkMonitoringCheckRequired`는 KTAP dispatch 경로에 들어간 모든 경우 `true`이며, `networkMonitoringVerified`는 앱이 Tornado2 UI를 판독하지 않으므로 항상 `null`입니다. 운영자가 직접 `[R]`/`[S]`를 확인해야 합니다. `outputMayBeActive: true`이면 자동 정리를 성공으로 확인하지 못했으므로 사람이 격리 출력을 확인해야 합니다. 특히 `Unavailable`, 취소, timeout은 연결 전 거부와 연결 도중 안전 게이트 변화가 같은 결과 code가 될 수 있으므로 추측하지 않습니다. 로컬 경로, 씬 code, PID, 창 제목, HRESULT 및 엔진 원문 오류는 출력하지 않습니다. `--test-plan`의 `runtimeProcessGateChecked: false`는 자산 계획만 검증했다는 뜻이며 실제 연결 가능성을 증명하지 않습니다.

연결 timeout은 COM 활성화와 KTAP dispatch 사이의 원자적 게이트를 닫습니다. timeout이 먼저 게이트를 닫으면 늦게 끝난 STA 작업도 `KTAPConnect`에 진입할 수 없고 `lastKtapConnectState: "not-attempted"`로 남습니다. dispatch가 먼저 게이트를 획득한 경우에는 `attempted` 이상으로 보고하고 `networkMonitoringCheckRequired: true`로 남겨 운영자 확인을 요구합니다.

현재 장비처럼 PGM Tornado가 실행 중이거나 loopback 포트를 PGM이 소유한 상태에서는 `--test-connect`와 `--test-sequence`를 실행하지 않습니다. 기존 Test 엔진도 exactly-one Tornado2, non-PGM 제목 정규식, loopback literal, Test outputChannel, 외부 non-reparse scene root와 allowlist를 연결 전 및 각 명령 전에 재검사합니다.

CLI용 Test JSON은 위처럼 앱 기본 경로인 `playout.local.json`과 다른 파일명을 사용합니다. 기본 경로에 Test 설정을 두면 일반 앱 시작 시 자동 연결을 요청할 수 있으므로, 운영자가 의도적으로 앱 UI Test 모드를 검증하는 회차 외에는 사용하지 않습니다.

앱을 실행한 뒤 다음을 확인합니다.

1. 시작 상태가 `DRY RUN`으로 보이고 앱이 COM 또는 Tornado 없이 종료되지 않습니다.
2. WebView의 `PREPARE`, `TAKE IN`, `NEXT`, `TAKE OUT`이 native bridge를 통과해 명확한 결과를 표시합니다.
3. 잘못된 설정 또는 엔진 부재가 앱 종료가 아니라 연결 상태와 안전한 오류 메시지로 표시됩니다.
4. x64 MSIX를 설치해도 같은 dry-run 흐름이 동작합니다.

최종 서명 Release x64 MSIX를 설치한 package context에서 실제 Oracle/MariaDB를 읽는 `DryRun` 검증을 완료했습니다. Web catalog 35개(34개 runtime route 보유), DB 상태 2/2 정상, alias `5001`/`N5001`, fade 6, mutation preview와 asset 경로 비노출을 확인했습니다. 34개 runtime route가 모두 외부 asset까지 준비됐다는 뜻은 아니다. 이어 `5001 PREPARE → fresh TAKE IN → callback drain 뒤 full 2초 cooldown의 자동 refresh → 5074 playlist NEXT → 같은 entry/scene의 Page NEXT`를 수행했고, 5074 `page 1/20 → page 2/20` 전환과 Page NEXT 뒤 refresh 정지를 확인했습니다. 5·6·12개 단위, 최대 20페이지, partial/마지막 page의 clear·hide, `isLastPage`, `END OF PLAYLIST`와 NEXT 비활성 경계는 원본에서 도출해 고정한 자동 테스트로 검증했습니다. TAKE OUT 뒤에는 scene/refresh가 정리되고 playlist 편집 잠금이 해제되었습니다. 전 과정의 안전 배지는 `DRY RUN · PROGRAM 차단`이었고 COM/KTAP/Tornado2 연결은 발생하지 않았습니다.

WebView 상태 wire는 `Disconnected`, `Connecting`, `Connected`, `Reconnecting`, `Faulted`, `OutcomeUnknown` 등 native connection state를 별도로 전달합니다. Web catalog는 35개 builder와 도달 가능한 45개 alias, row별 `enabled`를 보존하고 PREPARE 성공 뒤 playlist snapshot을 freeze합니다. pending command, `OutcomeUnknown`과 timeout quarantine 중에도 snapshot 편집 잠금을 유지합니다. native status의 current entry/builder/page size/current rows/last-page와 bounded preview가 authoritative 값이며 asset path는 preview에서 숨깁니다. refresh active/next/last-success/fault도 전달하고 refresh fault는 TAKE OUT 외 mutation 명령을 막습니다. 성공한 TAKE OUT 뒤에는 전용 refresh error marker만 reset하며 다른 unknown latch를 지우지 않습니다. `OutcomeUnknown`과 timeout은 `retryable: false`이며 오류 창을 닫아도 native 잠금은 유지됩니다. 브라우저 응답 제한 시간은 검증된 native operation timeout에 5초 전달 여유를 더해 사용하며, 상관 응답이 오지 않으면 strict `ParseTimeoutQuarantine` 요청을 native에 보냅니다. MainWindow는 await 전에 process-lifetime latch를 세우고 vendor session을 quarantine하므로 WebView reload로도 해제되지 않습니다. 명령 진행 중 trusted navigation, reload 또는 WebView2 process failure로 JavaScript 상관관계가 사라지는 경우도 같은 native latch를 먼저 세웁니다. 늦은 응답이나 UI 재시도로 같은 명령을 다시 보내지 않으며 native 명령이 끝나면 authoritative status를 다시 게시합니다. NEXT는 원본의 `m_TakeIn` 조건처럼 on-air 장면이 있을 때만 native와 Web 양쪽에서 허용됩니다. Test on-air 배지는 실제 PROGRAM과 구분해 `TEST ON AIR`로 표시합니다.

On-air 표식이 남은 상태에서는 프로세스 감시, 연결 해제, 앱 종료 및 세션 재활용 경로도 SDK `Disconnect`를 호출하지 않습니다. 중앙 `ReleaseSessionAsync` 방어가 세션을 quarantine/abandon하고 결과를 불명확 상태로 승격하므로, 운영자는 먼저 성공한 `TAKE OUT`을 확인한 다음 정상 종료해야 합니다.

### 화면/계약 구현과 운영 동등성 경계

- PList 기존 한국어 `DC_TITLE`/`LIST_TEXT` 2,213행은 실제 read-only DB에서 무손상 대조했다. KRX·해외종목·GraphE fresh proof를 합친 `selectionMapped=1,603`/차단 610은 확인했지만 scene data·asset·PREPARE·PGM 성공은 별도다. 실제 manual named save write→reload는 미검증이다.
- ThemeA/EList의 expected identity와 응답 상관 계약은 구현됐지만 KRX/NXT·동명이름·코드 경계의 실제 create/replace/delete read-back은 검증되지 않았다.
- 원본 `종목비교.dat`는 signed package에서 8쌍 one-time import와 재시작 marker를 확인했다. FSell/VI는 격리 fixture의 marker-last와 production opened-handle read를 통과했지만, 현재 운영 profile의 기존 동일 파일을 덮어쓰거나 marker를 새로 만들지 않았다.
- `s5006`의 `Video\큐브배경.vrv`와 `s6001` 해외지수 국가 영상 13개는 현재 승인 Cuts에 없으며, alias-only `Missing=0`은 asset-ready 증거가 아니다.
- GraphE·PList/AList·ThemeA·EList 운영 DB-W와 `5001`/`5074` 밖의 실제 PGM은 미검증이다. 아래 Round H 기록은 승인된 두 scene의 역사적 증거로만 보존한다.

승인 컷의 과거 load/play 경로와 현재 WebView 기반 Scene/PageN 데이터 동등성은 서로 다른 검증 범위입니다. 복합 mutation·페이지 계산·fresh TAKE IN·Page NEXT·timer refresh는 구현 및 자동/실데이터 검증을 마쳤으며 [원본 Tornado 송출 흐름 분석](LEGACY_PLAYOUT_ANALYSIS.md)과 [35개 Scene 매트릭스](SCENE_EQUIVALENCE.md)에 기록합니다. 첫 Live WebView 회차 `MBNWEB-20260711-A`의 DB 실패와 Round G의 refresh starvation은 모두 결과를 추정하거나 반복하지 않고 안전하게 종료했습니다. 수정된 Round H에서는 승인된 5001/5074 범위의 실제 TAKE IN, 자동 refresh, playlist/Page NEXT와 TAKE OUT을 Network Monitoring과 PGM에서 함께 완료했습니다. 이 결과를 나머지 scene의 실제 PGM 검증으로 확대하지 않습니다.

일반 앱과 정규 Test 경로의 실제 COM 스모크는 별도 테스트 인스턴스와 테스트 씬이 준비된 때에만 진행합니다. 위 안전 게이트를 독립적으로 재확인하고 `mode`를 `Test`로 바꾼 뒤 테스트 모니터에서 `PREPARE → fresh TAKE IN → Page/playlist NEXT → timer refresh → TAKE OUT` 결과를 관찰합니다. 의도하지 않은 PGM/운영 출력 변화가 보이면 추가 명령을 중단합니다. native 결과가 명확하고 Gate A에 포함된 경우에만 TAKE OUT을 한 번 요청하며, timeout·`OutcomeUnknown`·`WEB_TIMEOUT`·refresh fault라면 앱 종료나 반대 명령으로 자동 롤백하지 않고 session을 quarantine한 채 운영자가 실제 출력 상태를 먼저 확인합니다.

### 2026-07-11 Round G/H 실제 PGM 검증

Round G `MBNWEB-20260711-G`는 5001 TAKE IN과 PGM 표시까지 성공했지만, 자동 refresh가 승인된 1회 예산을 넘어 연속 실행되는 `POST_TAKE_IN_AUTOMATIC_REFRESH_BUDGET_STARVATION` 경계에서 실패했습니다. 성공한 transaction/prepare 39회와 PLAY/callback 38회가 있었고 native FAILURE/ERROR는 0이었지만, `refreshLastSuccessAt`이 갱신되는 동안 `playCompletionPending`이 계속 이어져 관찰 대기가 timeout됐습니다. 5074 NEXT는 보내지 않았고 retry는 0회였습니다. 5001 출력이 명확하고 `OutcomeUnknown=false`였으므로 승인된 비상 TAKE OUT 1회로 STOPALL, 5001 UNLOAD, PGM black과 disconnect를 확인했습니다.

Round G의 immutable evidence SHA-256은 계획 `E8A6B05E28130665A73CC882A70CD85F023EBF6C59795EC3A56671BE06B06B9A`, 회차 결과 `9B573EBB0F5465164298CA96099E1853DE6E6C00B3D375FB81FC954BED3464A0`, manifest `A0AA8E95CB7F0BE52E21438094D1FFCDA53245C9E33CDC5A659D513EEF19D1EC`입니다.

수정된 refresh는 periodic tick backlog를 따라잡지 않습니다. 직전 `OnScenePlayed` callback을 lifecycle queue에서 drain한 뒤 full one-shot cooldown을 새로 시작합니다. 5001의 initial cooldown은 2초이고 성공한 refresh 뒤 recurring cooldown은 3초입니다. 따라서 실제 dispatch cadence는 callback/drain 시간과 cooldown의 합으로 원본보다 의도적으로 길지만, recurring refresh 자체는 유지되며 동시에 하나만 실행됩니다. operator NEXT는 refresh epoch를 먼저 stop하고 callback pending이면 DB 대기나 scene query 전에 `PLAY_CALLBACK_PENDING`으로 즉시 반환합니다. 이 요청을 자동 반복하지 않으며 command gate가 바로 풀리므로 TAKE OUT을 사용할 수 있습니다. callback이 완료된 cooldown 구간에서는 NEXT가 정상 실행됩니다. cancellation generation과 refresh status를 한 lock에서 원자적으로 교체·중지·갱신·완료하므로 Stop 뒤 늦은 delay/status write나 이전 세대의 success/fault/finally가 현재 상태를 덮어쓸 수 없습니다.

Round H `MBNWEB-20260711-H`는 source `fa0eae7b61f57f134dfd30ebf1e59e9cc2cccfc5`(`fa0eae7`), 계획 SHA-256 `55C7755C2F874B4B012239117D4AB717BBCE194E7DCC8DA9B0E40D094FF8CA19`, 회차 결과 SHA-256 `AB70E61C86909F16FE407AAEEF0A5E668F8A4614375448E860C7BE47D687FA0D`, 증적 manifest SHA-256 `CD4EFD0CF64D9C905D7ABF732296E5A80DD4DE362DC7C88FD0D6967846955494`, 서명 유효한 Release x64 MSIX SHA-256 `D51E8CB637860D9F0377AEE6FF691D7D954BA0FF7E30B1AB65443DA153BC5429`에 고정했습니다. 실제 PGM에서 5001 삼성전자, 5074 page 1, 5074 page 2를 순서대로 확인하고 TAKE OUT 뒤 black을 확인했습니다. `OutcomeUnknown=false`, retry 0이었습니다.

Network Monitoring 최종 증분은 HELLO 1/1, 5001/5074 LOAD 각각 1/1, 5001 PREPARE 3/3, 5074 PREPARE 2/2, PLAY 4/4와 `OnScenePlayed` callback 4회, STOPALL 1/1, 5001/5074 UNLOAD 각각 1/1, FAILURE/ERROR 0/0이었습니다. BYE는 request 1과 별도 success marker 0이었지만 계획의 허용 범위 0~1 안이고, callback drain 뒤 앱 disconnected 상태와 최종 PGM black을 함께 확인해 정상 종료로 판정했습니다.

사전 검증은 Core 790, Infrastructure 65, Playout 374개 테스트를 Debug/Release x64에서 각각 모두 통과했고(각 구성 합계 1,229), 실제 Oracle/MariaDB read-only query 55건, Visual Studio 2026 Debug/Release x64와 package build, MSIX 서명·설치·package context 감사를 통과했습니다. 같은 패키지의 DryRun에서도 `5001 PREPARE → TAKE IN → 자동 refresh 정확히 1회 → 5074 page 1 → page 2 → TAKE OUT`과 최종 IDLE, DB 2/2 정상, KTAP 미호출을 확인했습니다.

Round H cleanup은 승인된 TAKE OUT 1회와 두 scene unload, disconnect, 앱·진단 listener 종료, 회차 전용 Live 설정과 승인 환경 제거, PGM/Network Monitoring 창 상태 복원으로 끝났습니다. 정상 회차라 추정 rollback은 실행하지 않았습니다. 장애 rollback은 기본 `DryRun`/`Disabled`로 복귀하거나 조직 절차로 직전 승인 패키지를 복원하는 범위이며, vendor DLL·COM 등록·라이선스·실제 자산은 수정하거나 저장소에 넣지 않습니다. 실제 Live PGM 검증 범위는 허용된 5001/5074뿐이고, 35개 scene 전체 완료 근거는 자동 테스트·55-query 실데이터 smoke·매트릭스입니다.

패키지 스모크에서는 벤더 x64 COM이 장비에 정식 등록되어 있어야 합니다. MSIX에 벤더 DLL을 복사해 활성화 오류를 우회하지 않습니다. 패키지 컨텍스트에서 COM 활성화가 막히면 `DryRun` 또는 `Disabled`를 유지하고 HRESULT와 등록 검사 결과만 보고합니다.

## 장애 및 롤백

가장 빠른 롤백은 로컬 설정을 다음처럼 바꾸고 앱을 재시작하는 것입니다.

```json
{
  "mode": "Disabled"
}
```

그 다음 회차별 라이브 환경 변수를 제거하고 테스트/운영 라우팅 상태를 사람이 확인합니다. 필요하면 조직 배포 절차로 직전 서명 MSIX로 되돌립니다. COM 등록 해제, 벤더 설치 폴더 삭제 또는 DLL 교체는 앱 롤백 절차가 아니므로 수행하지 않습니다. 진단용 `artifacts\K3DInterop`을 삭제해도 런타임에는 영향이 없습니다.

연결 장애나 Tornado 재시작은 화면의 `Disconnected`, `Reconnecting`, `Faulted` 또는 `OutcomeUnknown` 상태로 판단합니다. 재연결 횟수를 모두 소진하거나 결과가 불명확한 송출 명령이 있으면 자동 재송출하지 않고 `Disabled`로 전환한 뒤 운영자가 테스트 출력 상태를 확인합니다.

## 저장소 반입 금지

다음 항목은 Git과 MSIX에 넣지 않습니다.

- 벤더 COM DLL 및 생성한 Interop DLL
- Tornado/K3D 라이선스 파일, 키 또는 인증서
- 실제 `.t2s` 씬, 이미지, 영상 및 방송 자산
- 운영 호스트, 채널, 창 제목 및 로컬 허용 목록이 든 설정
- 실제 출력 캡처나 비밀정보가 포함된 진단 로그

벤더 바이너리와 자산은 승인된 설치·배포 위치에서 관리하고, 앱 저장소에는 COM 중립 인터페이스와 안전한 설정 예시만 유지합니다.
