# Tornado/K3D x64 송출 운영 가이드 ## 안전 기준 기본 모드는 `DryRun`입니다. `DryRun`은 COM 객체를 만들거나 Tornado 출력에 명령을 보내지 않고 WebView 메시지, 큐, 상태 및 오류 표시 흐름만 검증합니다. 현재 방송 PROGRAM(PGM)에 연결된 Tornado 프로세스는 항상 **안전하지 않은 대상**으로 취급합니다. 프로세스 이름이나 실행 여부만으로 테스트 대상이라고 판단하지 않습니다. 이 저장소의 작업과 자동 검증은 실제 PGM 출력 또는 라이브 `TAKE IN`을 허가하지 않습니다. `Test` 검증은 PGM과 분리된 전용 테스트 인스턴스, 테스트 출력 채널 및 허용 목록에 든 테스트 씬을 모두 확인한 뒤에만 수행합니다. `Live`는 운영 책임자의 명시적인 회차별 허가 없이는 사용하지 않습니다. ## 확인된 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 = '' $approvedInterop = '' $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 위치 | | `testProcessWindowTitlePattern` | 전용 테스트 인스턴스만 식별하는 창 제목 패턴 | | `testSceneAllowlist` | `Test`에서 허용한 테스트 scene name(code) 목록. 경로나 확장자는 넣지 않음 | | `trustedLiveOutputEnabled` | 운영자가 로컬 파일에서만 설정하는 라이브 1차 게이트 | | `queueCapacity` | 직렬 STA 명령 큐의 최대 대기 항목 수 | | `*TimeoutMilliseconds` | 연결, 작업 및 해제 제한 시간 | | `processPollIntervalMilliseconds` | `Tornado2` 접두사 프로세스 감시 주기 | | `reconnect*` | 재연결 지연, 최대 횟수 및 활성화 여부 | 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_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`는 환경 변수로 변경할 수 없으며 로컬 설정 파일에서만 관리합니다. `SceneDirectory`는 Test/Live에서 존재하는 비-reparse 외부 디렉터리여야 하며, 엔진은 상대 `.t2s` 파일을 정규화해 이 루트 밖으로 나가는 경로를 거부합니다. scene file의 basename과 scene name 및 Test 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]`를 자동 확인했다는 뜻이 아닙니다. 현재 late-bound 어댑터는 282개 메서드 `IKAEventHandler` ABI를 안전하게 패키징하는 검증된 전략이 없어 `ktapHelloObserved`를 `null`로 보고합니다. `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 ` --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 = '\MBN_STOCK_WEBVIEW.PlayoutSmoke.dll' $cuts = '' powershell -NoProfile -ExecutionPolicy Bypass ` -File .\scripts\Invoke-PgmCutsSequenceEvidence.ps1 ` -SmokeDll $smoke ` -SceneRoot $cuts ` -ApprovedNativeSha256 '' ` -ApprovedInteropSha256 '' ` -ApprovedSmokeSha256 '' ` -ApprovedPlayoutSha256 '' ` -ApprovedCoreSha256 '' ` -ApprovedDepsSha256 '' ` -ApprovedRuntimeConfigSha256 '' ` -ApprovedWindowsSdkSha256 '' ` -ApprovedWinRtSha256 '' ` -ExpectedPgmWindowTitle PGM ` -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이 검은 화면으로 돌아오는 동작과 일치합니다. 이 검증은 두 승인 컷의 load/prepare/play/stop 경로를 증명하지만, 아직 포팅되지 않은 35개 scene builder와 `PageN` 데이터 표현의 동등성을 증명하지는 않습니다. ## 모드와 안전 게이트 `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 ` -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를 managed callback으로 아직 소비하지 않습니다. 따라서 `Play`, `CutOut`, `StopAll`의 COM 반환 직후 Scene을 `Unload`하지 않고 prepared/current/retired 참조를 연결 종료까지 보존합니다. 이는 두 장면만 쓰는 bounded 검증에서 조기 Unload로 화면 전환을 끊는 위험을 피하기 위한 동작입니다. 장시간 운영에서 Scene을 누적하지 않으려면 `OnScenePlayed`/`OnCutOut`/`OnStopAll` 성공 콜백 뒤에만 unload queue를 비우는 handler 포팅이 선행되어야 합니다. 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 흐름이 동작합니다. WebView 상태 wire는 `Disconnected`, `Connecting`, `Connected`, `Reconnecting`, `Faulted`, `OutcomeUnknown` 등 native connection state를 별도로 전달합니다. `OutcomeUnknown`과 timeout은 `retryable: false`이며 오류 창을 닫아도 native 잠금은 앱 재시작 전까지 유지됩니다. 브라우저 응답 제한 시간은 고정 15초가 아니라 검증된 native operation timeout에 5초 전달 여유를 더해 사용하고, 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`을 확인한 다음 정상 종료해야 합니다. 승인 컷의 load/play 경로와 원본 Scene/PageN 데이터 표현력은 서로 다른 검증 범위입니다. 현재 지원 범위와 아직 포팅되지 않은 복합 mutation·페이지 갱신은 [원본 Tornado 송출 흐름 분석](LEGACY_PLAYOUT_ANALYSIS.md)을 기준으로 판단합니다. 일반 앱과 정규 Test 경로의 향후 실제 COM 스모크는 별도 테스트 인스턴스와 테스트 씬이 준비된 때에만 진행합니다. 위 안전 게이트를 독립적으로 재확인하고 `mode`를 `Test`로 바꾼 뒤 테스트 모니터에서 `PREPARE → TAKE IN → NEXT → TAKE OUT` 결과를 관찰합니다. PGM/운영 출력에 변화가 보이면 즉시 앱을 종료하고 롤백합니다. 명령 timeout 뒤 결과가 불명확하면 명령을 자동 또는 수동으로 반복하지 말고 테스트 출력 상태를 먼저 확인합니다. 패키지 스모크에서는 벤더 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 중립 인터페이스와 안전한 설정 예시만 유지합니다.