﻿#!/usr/bin/env python3
"""POG422 fixture UART â†” Ethernet bridge with microsecond event timing.

Any fixture position (1â€“4) â€” same daisy-chain routing on every Pi:
  UART origin        â†’ fan out to every carrier-up ETH port (both legs when both cabled)
  ETH ingress on X   â†’ peer port(s) + UART inject (unless --eth-relay-only)

Pos 3/4 bench also supports legacy single-interface br0 via --interface br0.

Bridging model (daisy-chain â€” directional eth0/eth1, default):
  UART/POG422-bus originated on this node â†’ broadcast on every live ETH port
  ETH ingress on one port â†’ relay to peer port(s) + UART inject queue
  Suppress only local AF_PACKET loopback / outgoing pkttype (not gameplay filter)

Legacy br0 mode (--interface br0): single merged socket; use run_bridge_jun11_br0_legacy.sh only.

ETHâ†’UART inject is queued when SLV-IN is inactive (bus released).

Status 0x0D synth (--status-0d-synth + --status-0d-block-all):
  Every ETH frame still injects to the local T340+ POG422 â€” x0, x9, polls, all games.
  The ONLY inject exception is Master 0x0D status-close from ETH (blocked; local synth
  0x0D substitutes on x1). Synth round-state (pair local x1 with x9 byte 17) is a
  separate parse/memory policy â€” it must never suppress ethâ†’UART inject.
  data/packets.raw   â€” every complete HS UART window (full copper) + ethâ†’UART inject
  data/wide_rx.bin   â€” always-on wide tee: [data][flags]â€¦ (flag bit 0x10 = cached HS)
  data/inject_echo.jsonl â€” bus RX echo + uartd inject forensics (sent/echo/wide)
  data/events.jsonl, data/fixture_stats.json

Usage:
  ./hat_fixture_bridge.sh
  python3 pog422_bridge.py --fixture-pos 4 --interface br0
"""

from __future__ import annotations

import argparse
import errno
import os
import select
import sys
import threading
import time
import types
from collections import deque
from dataclasses import dataclass
from typing import Any

import serial

from pog422_uart_capture import (
    UartCaptureHandler,
    resolve_uartd_enabled,
    resolve_uartd_inject_enabled,
)

from lab_gameplay_synth import (
    config_from_env as lab_gameplay_config_from_env,
    merge_cli_config as lab_gameplay_merge_cli,
    try_start_lab_gameplay_synth,
)

from pog422_core import (
    DirectionalEthRouter,
    EventLog,
    FIXTURE_PARSER_BUILD,
    FIXTURE_PARSER_BUILD_PROD,
    FixtureIdentity,
    HandshakeCaptureState,
    KENO_RESULT_MSG,
    KENO_RESULT_BALL_OFFSET,
    SOH,
    COMMAND_WIRE_LEN,
    TOUCHEASY_KENO_GAME_ID,
    add_common_cli,
    build_eth_header,
    UartOriginTracker,
    channel_uses_raw_hs_window,
    clear_capture_files,
    create_eth_socket,
    eth_forward_ok,
    frame_marshal_fields,
    fixture_eth_tag_payload,
    TX_FIFO_COUNT_REG,
    xr_get_reg_ch,
    xr_b142x_channel,
    probe_hat_session,
    fixture_eth_untag_payload,
    resolve_dev_light,
    bridge_uses_eth_channel_tag,
    resolve_bridge_channels,
    resolve_fixture_pos,
    handshake_capture_tick,
    arm_post_x9_inject_grace,
    normalize_window,
    extract_valid_local_x1_ack_frames,
    MRT_RISK_LATE_0D_MS,
    MRT_HAND_REOPEN_MIN_AGE_MS,
    MRT_RISK_MISSING_X1_MS,
    MRT_LOCAL_X1_INJECT_HOLD_MS,
    MRT_X1_INFER_NO_X9_RETRY_MS,
    MRT_X1_INFER_AFTER_X9_RETRY_MS,
    POST_X9_ORPHAN_RX_MS,
    MRT_HUB_X9_RETRY_MIN_GAP_MS,
    MRT_HUB_X9_RETRY_MAX_GAP_MS,
    STATUS_0D_PYTHON_FALLBACK_DEFER_MS,
    STATUS_0D_PYTHON_FALLBACK_DEFER_CGS_MS,
    LOCAL_GAME_TXN_MAX_MS,
    INJECT_WAIT_S_GAMEPLAY,
    INJECT_WAIT_S_DEFAULT,
    INJECT_SLOW_LOG_US,
    ETH_FANOUT_LOG_MIN_PORTS,
    EDGE_TID_UNICAST,
    BANK_PROGRESSIVE_BROADCAST_TID,
    TRANSACTION_DEFER_MAX_QUEUE,
    TRUNK_ROUND_STALE_MS,
    INJECT_DEFER_LOG_MIN_INTERVAL_MS,
    ois_raw_forward_ok,
    open_serial,
    split_packets,
    build_status_0d_from_round,
    build_status_0d_from_template,
    build_status_0d_minimal,
    build_bingo_jpot_synth,
    build_triple7s_jpot_synth,
    build_keno_jpot_synth,
    build_poker_jpot_grant_synth,
    build_poker_royal_flush_synth,
    build_spinjack_flushjack_synth,
    build_superpick_result_synth,
    build_touch6_result_synth,
    BLACKJACK_RESULT_MSG,
    BINGO_GAME_ID,
    BINGO_RESULT_MSG,
    TRIPLE7S_GAME_ID,
    SLOT_WAGER_MSG,
    SLOT_RESULT_MSG,
    slot_grid_wire_from_result,
    bingo_calls_from_result,
    DRAW_POKER_GAME_ID,
    SPINJACK_GAME_ID,
    TOUCH6_GAME_ID,
    TOUCH6_RESULT_BALL_OFFSET,
    TOUCH6_RESULT_MSG,
    touch6_count_jpot_cards,
    SUPERPICK_GAME_ID,
    SUPERPICK_RESULT_BALL_OFFSET,
    SUPERPICK_RESULT_MSG,
    SUPERPICK_WAGER_MSG,
    superpick_count_jpot_cards,
    keno_display_ball_from_wire,
    POKER_GRANT_MSG,
    POKER_RESULT_MSG,
    progressive_cents_to_dollars,
    progressive_progwin_cents_be,
    progressive_progdsp_cents_be,
    resolve_lab_progwin_cents,
    u32_le_at,
    is_gameplay_ack,
    is_gameplay_request,
    is_bank_progressive_jackpot_frame,
    bank_progressive_jackpot_winner_tid,
    is_bank_jpot_x1_priority_ack,
    is_gameplay_close_leg,
    inject_priority_class,
    INJECT_PRIORITY_BANK_JPOT_X1,
    INJECT_PRIORITY_NORMAL,
    INJECT_PRIORITY_P0_CLOSE,
    INJECT_PRIORITY_SCR,
    INJECT_PRIORITY_STOM,
    BANK_JPOT_X1_PRIORITY_HOLD_MS,
    BANK_JPOT_X1_MAX_REINJECT,
    BANK_JPOT_X1_SEAL_MS,
    BANK_JPOT_EMPTY_CLOSE_BLOCK_MS,
    BANK_JPOT_RESULT_X9_STALE_MS,
    SCR_COALESCE_MS,
    is_gameplay_response,
    is_progressive_broadcast,
    is_skill_jpot_grant,
    is_scr_msg,
    is_stom_comm_priority,
    eth_only_ports_unmapped,
    inject_echo_bytes_match,
    peer_uart_bytes_after_inject,
    pog422_scr_diag_fields,
    SCR_MSG,
    SCR_REPLY,
    ProgressiveCsvWriter,
    resolve_progressives_csv_path,
    eth_inject_tid_blacklist_add,
    local_slave_tid_uart_learn_ok,
    note_status_0d_global_counter,
    status_0d_close_counter_from_x9,
    status_0d_close_counter_skill_grant,
    status_synth_x1_trigger_ok,
    parse_packet_meta,
    packet_bank_event_fields,
    resolve_local_bank_number,
    post_x9_grace_active,
    post_x9_local_grace_active,
    recv_eth_l2_frame,
    resolve_channel,
    resolve_eth_port_names,
    resolve_output_dir,
    resolve_serial_port,
    BUS_CHANNELS,
    set_rx,
    set_tx,
    slv_in_active,
    slv_in_active_cached,
    status_close_dedupe_key,
    trim_to_last_eop,
    uart_air_time_us,
    uart_echo_false_glued_wager,
    uart_window_lead_is_response_echo,
)

from current_game_ipc import CurrentGamePublisher, current_game_ipc_mode, map_wire_game_to_index
from machine_status_ipc import (
    maybe_refresh_machine_status,
    note_progressive_hit,
    resolve_machine_status_path,
)
from early_win_phase1 import Phase1ProgressiveTrap, build_phase1_trap_from_env
from early_win_phase2_slots import (
    Phase2SlotTrap,
    build_phase2_slot_trap_from_env,
    resolve_slot_credit_cents,
    resolve_slot_threshold_cents,
)
from early_win_phase2_keno import (
    Phase2KenoTrap,
    resolve_keno_credit_cents,
    resolve_keno_threshold_cents,
)

try:
    from pog422_uart_client import (
        DaemonHandshake,
        INJECT_ERR_HS_BUSY,
        POG422_UARTD_VERSION,
        UART_PORT_DEAD_ERRNOS,
        UartDaemonClient,
        UartdIpcDesyncError,
        default_socket_path,
        restart_config_from_env,
    )
except ImportError:
    INJECT_ERR_HS_BUSY = errno.EBUSY
    UART_PORT_DEAD_ERRNOS = frozenset(  # type: ignore[misc, assignment]
        {errno.ENODEV, errno.EIO, errno.ENXIO, errno.ENOENT, errno.EBADF}
    )
    POG422_UARTD_VERSION = ""  # type: ignore[misc, assignment]
    UartDaemonClient = None  # type: ignore[misc, assignment]
    DaemonHandshake = None  # type: ignore[misc, assignment]
    UartdIpcDesyncError = ConnectionError  # type: ignore[misc, assignment]
    default_socket_path = lambda pos: f"/tmp/pog422_uart_{pos}.sock"  # type: ignore[misc, assignment]
    restart_config_from_env = lambda **_k: None  # type: ignore[misc, assignment]

try:
    from pog_edge_bank_wlan import maybe_publish_bank_wlan, start_bank_wlan_for_bridge, stop_bank_wlan
except ImportError:  # pragma: no cover
    def maybe_publish_bank_wlan(bridge, pkt, meta) -> None:  # type: ignore[misc]
        return None

    def start_bank_wlan_for_bridge(bridge, stop):  # type: ignore[misc]
        return None, None

    def stop_bank_wlan() -> None:  # type: ignore[misc]
        return None

try:
    from pog_edge_enrollment import start_enrollment_hub_for_bridge, start_spoke_enroll_for_bridge
except ImportError:  # pragma: no cover
    def start_enrollment_hub_for_bridge(bridge, stop, router=None):  # type: ignore[misc]
        return None

    def start_spoke_enroll_for_bridge(bridge, stop):  # type: ignore[misc]
        return None

try:
    from pog_edge_identity import identity_as_session_fields, latch_cabinet_identity
except ImportError:  # pragma: no cover
    def latch_cabinet_identity(*_a, **_k):  # type: ignore[misc]
        return None

    def identity_as_session_fields(_ident):  # type: ignore[misc]
        return {}


@dataclass
class BridgeTiming:
    # EG 2.2.1.5: pre-TX settle eliminated (DE on â†’ immediate bulk). Kept for CLI/log compat.
    pre_tx_us: float = 0.0
    # EG 2.2.1.12: post_tx + post_air_margin = wire margin on hybrid DE deadline (not Master reply).
    post_tx_us: float = 0.0
    post_air_margin_us: float = 400.0
    flush_rx_before_tx: bool = False
    loop_sleep_us: float = 100.0
    wait_hs_inactive_before_tx: bool = True
    post_hs_inactive_us: float = 150.0
    hs_inactive_wait_ms: float = 80.0


# Skip UARTâ†’ETH wager relay when x9 for same TID+seq was injected recently.
X9_INJECT_WAGER_ETH_TX_DEDUPE_MS = 200.0
# Master may relay the same x9 outcome several times on ETH; inject once so T340+ gets
# a clean single 79 and can answer with 71 (pos2 seq=7 triple-inject failure).
X9_RESPONSE_ETH_INJECT_DEDUPE_MS = 500.0
# Bank progressive jackpot frames never use this window (Master retries must re-inject).
# While bank progressive close is armed: tighten non-jpot x9 eth inject dedupe further.
X9_RESPONSE_ETH_INJECT_DEDUPE_BANK_JPOT_MS = 2000.0
# Lab Keno: Master UART may emit duplicate native 0x49 before slave 0x41 â€” synth once.
LAB_KENO_JPOT_SYNTH_DEDUPE_MS = 2000.0


@dataclass
class PendingInject:
    frame: bytes
    queued_perf_ns: int
    msg: str
    tid: str | None
    source: str = "eth_to_uart"
    strict_slv_idle: bool = False
    marshal_crc32: str = ""


@dataclass
class TrunkRoundState:
    """Open gameplay round on trunk (x0 witnessed â†’ 0D close)."""

    game: str | None
    seq: int | None
    opened_perf_ns: int
    x9_injected: bool = False
    x1_seen: bool = False
    closed: bool = False


# Per-terminal round key: (slave_tid_hex, game_id_hex)
RoundKey = tuple[str, str]

# EG 3.0.1.29: inject source of truth = per-spoke deques; copper is one arbiter.
SPOKE_SOFT = "__soft__"  # progressive 0A / tid-less soft
SPOKE_P0 = "__p0__"  # status_0d_synth close path


@dataclass
class TidTxFsm:
    """Per-TID game transaction (star slave open round). Wire TID is always known;
    this tracks which copper inject legs are required for that transaction.
    """

    # idle | await_x9 | await_x1 | await_0d
    phase: str = "idle"
    game: str | None = None
    seq: int | None = None
    opened_perf_ns: int = 0


class UartBridge:
    """Serial + handshake capture with separate TX lock for ETHâ†’UART replies."""

    def __init__(
        self,
        ser: serial.Serial | None,
        channel,
        handshake,
        event_log: EventLog,
        timing: BridgeTiming,
        *,
        use_uartd: bool = False,
        use_uartd_inject: bool = False,
        uartd_client: UartDaemonClient | None = None,
        inject_port: str | None = None,
        inject_active_low: bool = True,
        suppress_uart_echo: bool = False,
        uart_origin_hold_ms: float = 2000.0,
        status_0d_first_only: bool = False,
        status_0d_block_all: bool = False,
        status_0d_synth: bool = False,
        status_0d_first_passthrough: bool = False,
        status_0d_synth_from_x9: bool = False,
        status_0d_synth_loose_counter: bool = False,
        status_0d_dedupe_ms: float = 5000.0,
        lab_keno_jpot_synth: bool = False,
        lab_keno_jpot_game: str = TOUCHEASY_KENO_GAME_ID,
        lab_keno_jpot_amount: int | None = None,
        lab_keno_jpot_tid: str | None = None,
        lab_poker_royal_synth: bool = False,
        lab_poker_game: str = DRAW_POKER_GAME_ID,
        lab_poker_jpot_amount: int | None = None,
        lab_poker_tid: str | None = None,
        lab_spinjack_flushjack_synth: bool = False,
        lab_spinjack_game: str = SPINJACK_GAME_ID,
        lab_spinjack_tid: str | None = None,
        lab_touch6_jpot_synth: bool = False,
        lab_touch6_jpot_game: str = TOUCH6_GAME_ID,
        lab_touch6_jpot_amount: int | None = None,
        lab_touch6_jpot_tid: str | None = None,
        lab_superpick_jpot_synth: bool = False,
        lab_superpick_jpot_game: str = SUPERPICK_GAME_ID,
        lab_superpick_jpot_amount: int | None = None,
        lab_superpick_jpot_tid: str | None = None,
        lab_bingo_jpot_synth: bool = False,
        lab_bingo_jpot_game: str = BINGO_GAME_ID,
        lab_bingo_jpot_amount: int | None = None,
        lab_bingo_jpot_tid: str | None = None,
        lab_triple7s_jpot_synth: bool = False,
        lab_triple7s_jpot_game: str = TRIPLE7S_GAME_ID,
        lab_triple7s_jpot_tid: str | None = None,
        lab_withhold_status_0d: bool = False,
        lab_withhold_status_0d_count: int = 1,
        progressive_csv: ProgressiveCsvWriter | None = None,
        early_win_phase1: Phase1ProgressiveTrap | None = None,
        early_win_slots: Phase2SlotTrap | None = None,
        early_win_keno: Phase2KenoTrap | None = None,
        scr_diag: bool = False,
        scr_master_native_only: bool = False,
    ):
        self.ser = ser
        self.channel = channel
        self.handshake = handshake
        self.event_log = event_log
        self.timing = timing
        self.use_uartd = use_uartd
        self.use_uartd_inject = use_uartd_inject
        self.uartd = uartd_client
        self._inject_port = inject_port
        self._inject_active_low = inject_active_low
        self._inject_ipc_paused = False
        self.state = HandshakeCaptureState()
        self.uart_capture: UartCaptureHandler | None = None
        self._ac_armed_frame: bytes | None = None
        self._ac_armed_meta = None
        # EG 3.0.2.2: FIFO arm list so late AUTO_CLOSE_DONE still matches the
        # correct x9 when inject() returns before 0D completes.
        self._ac_arm_q: list[tuple[bytes, Any]] = []
        if use_uartd and uartd_client is not None:
            self.uart_capture = UartCaptureHandler(
                uartd_client,
                channel,
                event_log,
                self.state,
                on_auto_close_done=self._on_uartd_auto_close_done_ipc,
                on_x1_ack_frame=self._on_uartd_x1_ack_frame_ipc,
            )
        self.suppress_uart_echo = suppress_uart_echo
        self.uart_origin = UartOriginTracker(hold_ms=uart_origin_hold_ms)
        self.status_0d_first_only = status_0d_first_only
        self.status_0d_block_all = status_0d_block_all
        self.status_0d_synth = status_0d_synth
        self.status_0d_first_passthrough = status_0d_first_passthrough
        self.status_0d_synth_from_x9 = status_0d_synth_from_x9
        self.status_0d_synth_loose_counter = status_0d_synth_loose_counter
        self.status_0d_dedupe_ms = status_0d_dedupe_ms
        self.lab_keno_jpot_synth = lab_keno_jpot_synth
        self.lab_keno_jpot_game = lab_keno_jpot_game.upper()
        self.lab_keno_jpot_amount = lab_keno_jpot_amount
        self.lab_keno_jpot_tid = lab_keno_jpot_tid.upper() if lab_keno_jpot_tid else None
        self._lab_keno_synth_recent: dict[tuple[str, int | None], int] = {}
        self.lab_poker_royal_synth = lab_poker_royal_synth
        self.lab_poker_game = lab_poker_game.upper()
        self.lab_poker_jpot_amount = lab_poker_jpot_amount
        self.lab_poker_tid = lab_poker_tid.upper() if lab_poker_tid else None
        self.lab_spinjack_flushjack_synth = lab_spinjack_flushjack_synth
        self.lab_spinjack_game = lab_spinjack_game.upper()
        self.lab_spinjack_tid = lab_spinjack_tid.upper() if lab_spinjack_tid else None
        self.lab_touch6_jpot_synth = lab_touch6_jpot_synth
        self.lab_touch6_jpot_game = lab_touch6_jpot_game.upper()
        self.lab_touch6_jpot_amount = lab_touch6_jpot_amount
        self.lab_touch6_jpot_tid = lab_touch6_jpot_tid.upper() if lab_touch6_jpot_tid else None
        self._lab_touch6_last_wager: dict[str, bytes] = {}
        self.lab_superpick_jpot_synth = lab_superpick_jpot_synth
        self.lab_superpick_jpot_game = lab_superpick_jpot_game.upper()
        self.lab_superpick_jpot_amount = lab_superpick_jpot_amount
        self.lab_superpick_jpot_tid = lab_superpick_jpot_tid.upper() if lab_superpick_jpot_tid else None
        self._lab_superpick_last_wager: dict[str, bytes] = {}
        self.lab_bingo_jpot_synth = lab_bingo_jpot_synth
        self.lab_bingo_jpot_game = lab_bingo_jpot_game.upper()
        self.lab_bingo_jpot_amount = lab_bingo_jpot_amount
        self.lab_bingo_jpot_tid = lab_bingo_jpot_tid.upper() if lab_bingo_jpot_tid else None
        self.lab_triple7s_jpot_synth = lab_triple7s_jpot_synth
        self.lab_triple7s_jpot_game = lab_triple7s_jpot_game.upper()
        self.lab_triple7s_jpot_tid = (
            lab_triple7s_jpot_tid.upper() if lab_triple7s_jpot_tid else None
        )
        self.lab_withhold_status_0d = bool(lab_withhold_status_0d)
        self._lab_withhold_status_0d_remaining = (
            max(0, int(lab_withhold_status_0d_count))
            if self.lab_withhold_status_0d
            else 0
        )
        self._lab_withhold_status_0d_rks: set[RoundKey] = set()
        self.progressive_csv = progressive_csv
        self.early_win_phase1 = early_win_phase1
        self.early_win_slots = early_win_slots
        self.early_win_keno = early_win_keno
        self.scr_diag = scr_diag
        self.scr_master_native_only = scr_master_native_only
        self._scr_open_perf_ns: int | None = None
        self._scr_open_tid: str | None = None
        # EG 4.1.0.2: same-TID SCR coalesce + 0A defer while awaiting Master 0B.
        self._scr_inj_ns: dict[str, int] = {}
        self._scr_reply_ns: dict[str, int] = {}
        self._scr_eth_tx_ns: dict[str, int] = {}
        self._status_0d_seen: dict[tuple[str, int], int] = {}
        self._status_0d_cache: dict[tuple[str, int], bytes] = {}
        self._status_0d_template: dict[str, bytes] = {}
        self._status_0d_high_counter: dict[str, int] = {}
        self._status_0d_x9_hint: dict[RoundKey, dict] = {}
        self._status_0d_last_wager: dict[RoundKey, bytes] = {}
        self._status_0d_last_x9: dict[RoundKey, bytes] = {}
        # Per-round x9 provenance: "uart" = Master native outcome (no synth 0D);
        # "eth" = Slave path (eth→UART inject) — synth 0D after local x1.
        self._status_0d_x9_origin: dict[RoundKey, str] = {}
        self._status_0d_bank_tid_tail: bytes | None = None
        self._global_close_counter: int | None = None
        self._local_slave_tid: str | None = None
        # Local-TID → DyzineFrame curr_index (current-game.json).
        # Always construct publisher: continuous publish still gated by
        # ETHERGOLD_CURRENT_GAME_IPC, but Heltec boot-arm force-publish works even
        # when mode=off (gap-fill after POG-CC flash + next local play).
        self._current_game_publisher: CurrentGamePublisher = CurrentGamePublisher()
        event_log.add_packet_complete_hook(self._on_packet_complete_current_game)
        event_log.emit(
            "current_game_ipc_enabled",
            mode=current_game_ipc_mode(),
            boot_arm_gap_fill=True,
        )
        # True until local T340+ originates native Master 0x0D on UARTâ†’ETH.
        self._relay_eth_synth_enabled: bool = True
        self._eth_inject_tid_blacklist: set[str] = set()
        self._eth_inject_tid_blacklist_order: list[str] = []
        self._status_0d_x1_pending: set[RoundKey] = set()
        self._status_0d_await_passthrough_logged: set[RoundKey] = set()
        self._status_0d_synth_sent: set[tuple[RoundKey, int]] = set()
        # EG 3.0.1.43+: with uartd, deal 0D is C-only unless auto_close_miss arms this set.
        self._status_0d_python_fallback_rks: set[RoundKey] = set()
        # EG 3.0.1.44: deferred P0 after miss (wait brief for late AUTO_CLOSE_DONE).
        self._status_0d_python_fallback_ready_ns: dict[RoundKey, int] = {}
        self._status_0d_skill_grant_seen: set[RoundKey] = set()
        self._status_0d_skill_round_active: set[RoundKey] = set()
        self._status_0d_skill_close_done: set[RoundKey] = set()
        # Latest local play_sequence per (tid, game) for close-wait supersede (>N).
        self._local_round_latest_seq: dict[RoundKey, int] = {}
        self._tx_lock = threading.Lock()
        self._pending: deque[PendingInject] = deque()
        self._transaction_deferred: deque[PendingInject] = deque()
        self._deferred_0a_latest: bytes | None = None
        # EG 3.0.1.29: per-spoke inject buffers (source of truth); _pending is arbiter view.
        self._spoke_queues: dict[str, deque[PendingInject]] = {}
        self._tid_tx: dict[str, TidTxFsm] = {}
        # Winner TID → perf expiry after bank progressive issue.
        # EG 3.0.1.35/36: exclusive arm clears only on *copper* x1 proof. eth_rx
        # releases fanout (CGS) only. Post-copper seal holds last x1 for
        # BANK_JPOT_X1_SEAL_MS (250 ms — Master progressive abort race).
        self._bank_jpot_await_x1: dict[str, int] = {}
        self._bank_jpot_x1_last_frame: dict[str, bytes] = {}
        self._bank_jpot_x1_reinject_count: dict[str, int] = {}
        self._bank_jpot_x1_last_echo_len: dict[str, int] = {}
        self._bank_jpot_x1_last_post_de_len: dict[str, int] = {}
        self._bank_jpot_x1_last_echo_match: dict[str, str] = {}
        # eth_rx of winner x1 seen while still awaiting copper seal (not exclusive-clear).
        self._bank_jpot_x1_eth_seen: set[str] = set()
        # Copper-sealed TID → perf_ns until which progressive retries reinject x1.
        self._bank_jpot_x1_seal_until: dict[str, int] = {}
        # EG 3.0.1.31: full multiport eth of bank progressive holds until Master has x1.
        self._bank_jpot_defer_fanout_frame: dict[str, bytes] = {}
        self._bank_jpot_fanout_released: set[str] = set()
        # EG 3.0.1.38: seal only after winner progressive award X9; drop pre-arm x1.
        self._bank_jpot_arm_ns: dict[str, int] = {}
        self._bank_jpot_award_x9_ok: set[str] = set()
        self._bank_jpot_x1_eth_queued_ns: dict[str, int] = {}
        # tid → last gameplay/skill result x9 inject or copper time (perf_ns).
        self._tid_last_result_x9_ns: dict[str, int] = {}
        # tid → block progressive award inject until (empty x1-without-result).
        self._tid_empty_close_block_until: dict[str, int] = {}
        self._eth_router: DirectionalEthRouter | None = None
        self._eth_dual_channel: bool = False
        self._trunk_rounds: dict[str, TrunkRoundState] = {}
        self._last_hs_inactive_perf_ns: int | None = None
        # Dual bridge: OIS defer inject while machine post-x9 grace is active.
        self._grace_gate_machine: UartBridge | None = None
        self._recent_x9_inject_ns: dict[tuple[str, int | None], int] = {}
        # Local x9 â†’ x1 â†’ closing 0x0D timing diagnostics (true MRT = x1 without timely 0x0D).
        self._mrt_risk_await_x1: dict[RoundKey, int] = {}
        self._mrt_risk_await_0d: dict[RoundKey, int] = {}
        self._mrt_risk_logged_x1: set[RoundKey] = set()
        self._mrt_risk_logged_0d: set[RoundKey] = set()
        # Master re-sent same-seq x9 while we await local x1 (true miss â€” do not infer).
        self._mrt_risk_x9_retry_seen: set[RoundKey] = set()
        # EG 3.0.1.55: once per await arm for hand-open infer-skip log (no spam).
        self._mrt_risk_infer_hand_open_logged: set[RoundKey] = set()
        # UARTâ†’ETH gameplay response (x9) last TX time for same-seq retry tagging.
        self._uart_x9_eth_tx_ns: dict[tuple[str, int, int], int] = {}
        # play_sequence already closed (synth/native 0x0D) â€” skip re-arm on dup eth x9.
        self._mrt_risk_close_done_seq: dict[RoundKey, int] = {}
        # When close-done was stamped (gate same-seq reopen — EG 3.0.1.48+).
        self._mrt_risk_close_done_ns: dict[RoundKey, int] = {}
        # "ok" | "recovery" — recovery reopens immediately (miss/late_0d latch).
        self._mrt_risk_close_quality: dict[RoundKey, str] = {}
        # Pending quality for next mark_seq_closed (auto_close_miss → recovery).
        self._mrt_risk_close_quality_pending: dict[RoundKey, str] = {}
        # Last local ethâ†’UART x9 inject forensics (echo / post-DE / peer) for MRT diagnose.
        self._mrt_risk_x9_diag: dict[RoundKey, dict[str, Any]] = {}
        # EG 3.0.2.12: hand gen — local x0 restart / miss abandon orphans late 41/0D.
        self._local_hand_gen: dict[RoundKey, int] = {}
        self._local_hand_x9_gen: dict[RoundKey, int] = {}
        # Soft bus hold for non-local ethâ†’UART after local x9 (not a round abandon).
        self._local_x1_inject_hold_until_ns: int | None = None
        # EG 4.0.0.60: after auto_close_miss with good self-echo (delivery OK,
        # harvest empty), allow same-seq local x9 retry / one active reinject.
        self._empty_harvest_x9_retry_ok: set[RoundKey] = set()
        self._empty_harvest_x9_reinjected: set[RoundKey] = set()
        # EG 4.0.0.71 diag: spoke orphan RX after empty peel; hub Master 89 retry watch.
        self._orphan_rx_until_ns: int | None = None
        self._orphan_rx_armed_ns: int | None = None
        self._orphan_rx_rk: RoundKey | None = None
        self._orphan_rx_seq: int | None = None
        self._orphan_rx_hits: int = 0
        self._orphan_rx_x1_hits: int = 0
        self._hub_last_master_x9_ns: dict[str, int] = {}
        self._hub_last_master_x9_seq: dict[str, int | None] = {}
        self._hub_master_x9_streak: dict[str, int] = {}
        # EG 3.0.1.56: rate-limit bank_jpot_x1_priority_lift log.
        self._bank_jpot_lift_log_ns: int | None = None
        # EG 3.0.2.6: copper + inject stamps to prove Master phase (A) vs local (B).
        self._hand_phase_t40_ns: int | None = None
        self._hand_phase_t49_ns: int | None = None
        self._hand_phase_tid: str | None = None
        self._hand_phase_seq: int | None = None
        self._hand_phase_hub40_sw: dict[str, Any] | None = None
        # EG 3.0.1.2: after gameplay inject timeout+abort, brief hold before requeue
        # barge while HS still high / prior inject draining (pos5 17:37 stacked 79).
        self._hand_phase_t40_origin: str | None = None
        self._inject_abort_cooldown_until_ns: int | None = None
        self._inject_defer_log_ns: dict[str, int] = {}
        self._transaction_defer_log_ns: dict[str, int] = {}
        self._relay_synth_skip_log_ns: dict[str, int] = {}
        # Local x1 synth retries when _tx_lock is held (never block capture_tick on inject).
        self._synth_retry_rks: list[RoundKey] = []
        # Set by patch_event_log_for_eth_forward_* â€” UARTâ†’ETH for inject-window peer TxD.
        self.forward_uart_origin_window: Any = None
        event_log.add_packet_complete_hook(self._on_packet_complete_trunk_round)
        event_log.add_packet_complete_hook(self._on_packet_complete_machine_status)
        if progressive_csv is not None:
            event_log.add_packet_complete_hook(self._on_packet_complete_progressive_csv)
        if status_0d_synth:
            event_log.add_packet_complete_hook(self._on_packet_complete_status_synth)
            event_log.add_packet_complete_hook(self._on_packet_complete_mrt_x1_diag)
        if scr_diag:
            event_log.add_packet_complete_hook(self._on_packet_complete_scr_diag)
        # EG 4.1.0.2: always track SCR 0B for coalesce (diag optional).
        event_log.add_packet_complete_hook(self._on_packet_complete_scr_coalesce)
        if lab_touch6_jpot_synth:
            event_log.add_packet_complete_hook(self._on_packet_complete_lab_touch6_wager)
        if lab_superpick_jpot_synth:
            event_log.add_packet_complete_hook(self._on_packet_complete_lab_superpick_wager)
        if (
            early_win_phase1 is not None
            or early_win_slots is not None
            or early_win_keno is not None
        ):
            event_log.add_packet_complete_hook(self._on_packet_complete_early_win)

    def _on_packet_complete_current_game(
        self,
        meta,
        pkt: bytes,
        packets_in_window: int,
        *,
        uart_first_byte: int | None = None,
        window_us: float | None = None,
        byte_span_us: float | None = None,
    ) -> None:
        """Publish DyzineFrame game index from local-TID gameplay (compare / write)."""
        pub = self._current_game_publisher
        if pub is None or meta is None:
            return
        if not self._meta_is_local_terminal(meta):
            return
        if not (
            is_gameplay_request(meta.msg_type) or is_gameplay_response(meta.msg_type)
        ):
            return
        if not meta.game_id_hex:
            return
        result = pub.observe(
            game_id_hex=meta.game_id_hex,
            tid_hex=meta.tid_hex,
            source="local_tid_gameplay",
        )
        if result.action in ("off", "skip_unchanged"):
            return
        if result.action == "skip_unmapped":
            self.event_log.emit(
                "current_game_unmapped",
                game=result.game_id_hex,
                tid=result.tid_hex,
                mode=result.mode,
            )
            return
        if result.action == "would_publish":
            self.event_log.emit(
                "current_game_would_publish",
                game_index=result.game_index,
                game=result.game_id_hex,
                tid=result.tid_hex,
                mode=result.mode,
            )
            return
        if result.action == "published":
            self.event_log.emit(
                "current_game_published",
                game_index=result.game_index,
                game=result.game_id_hex,
                tid=result.tid_hex,
                mode=result.mode,
                force=bool(result.force),
                detail=result.detail,
            )
            return
        if result.action == "error":
            self.event_log.emit(
                "current_game_publish_error",
                game_index=result.game_index,
                game=result.game_id_hex,
                tid=result.tid_hex,
                detail=result.detail,
                force=bool(result.force),
            )

    def _early_win_phase1_inspect(self, frame: bytes, *, source: str) -> None:
        if self.early_win_phase1 is None or channel_uses_raw_hs_window(self.channel):
            return
        self.early_win_phase1.inspect(
            frame, source=source, local_wire_tid=self._local_slave_tid
        )

    def _early_win_slot_inspect(self, frame: bytes, *, source: str) -> None:
        if self.early_win_slots is None or channel_uses_raw_hs_window(self.channel):
            return
        self.early_win_slots.inspect(
            frame, source=source, local_wire_tid=self._local_slave_tid
        )

    def _early_win_keno_inspect(self, frame: bytes, *, source: str) -> None:
        if self.early_win_keno is None or channel_uses_raw_hs_window(self.channel):
            return
        self.early_win_keno.inspect(
            frame, source=source, local_wire_tid=self._local_slave_tid
        )

    def _early_win_inspect(self, frame: bytes, *, source: str) -> None:
        self._early_win_phase1_inspect(frame, source=source)
        self._early_win_slot_inspect(frame, source=source)
        self._early_win_keno_inspect(frame, source=source)

    def _on_packet_complete_early_win(
        self,
        meta,
        pkt: bytes,
        packets_in_window: int,
        *,
        uart_first_byte: int | None = None,
        window_us: float | None = None,
        byte_span_us: float | None = None,
    ) -> None:
        self._early_win_inspect(pkt, source="uart")

    def _on_packet_complete_lab_touch6_wager(
        self,
        meta,
        pkt: bytes,
        packets_in_window: int,
        *,
        uart_first_byte: int | None = None,
        window_us: float | None = None,
        byte_span_us: float | None = None,
    ) -> None:
        if meta.msg_type != 0x20:
            return
        self._lab_remember_touch6_wager(pkt, meta)

    def _lab_remember_touch6_wager(self, frame: bytes, meta) -> None:
        if not self.lab_touch6_jpot_synth:
            return
        if meta.msg_type != 0x20:
            return
        if not self._lab_game_tid_ok(
            meta, game=self.lab_touch6_jpot_game, tid=self.lab_touch6_jpot_tid
        ):
            return
        self._lab_touch6_last_wager[meta.tid_hex] = bytes(frame)
        self.event_log.emit(
            "lab_touch6_wager_store",
            game=meta.game_id_hex,
            tid=meta.tid_hex,
            len=len(frame),
        )

    def _lab_game_tid_ok(self, meta, *, game: str, tid: str | None) -> bool:
        if not meta.game_id_hex or meta.game_id_hex.upper() != game.upper():
            return False
        if tid and meta.tid_hex != tid.upper():
            return False
        return True

    def transform_uart_egress_frame(self, frame: bytes) -> bytes:
        """Replace Master gameplay x9 on POG422 before UARTâ†’ETH fan-out (lab hooks)."""
        frame = self._lab_synth_superpick_uart_egress(frame)
        frame = self._lab_synth_touch6_uart_egress(frame)
        frame = self._lab_synth_bingo_jpot_uart_egress(frame)
        frame = self._lab_synth_triple7s_jpot_uart_egress(frame)
        frame = self._lab_synth_keno_jpot_uart_egress(frame)
        frame = self._lab_synth_poker_royal_uart_egress(frame)
        frame = self._lab_synth_poker_jpot_grant_uart_egress(frame)
        frame = self._lab_synth_spinjack_flushjack_uart_egress(frame)
        self._early_win_inspect(frame, source="uart_egress")
        return frame

    def _lab_synth_touch6_uart_egress(self, frame: bytes) -> bytes:
        """Replace Master Touch 6 x9: forced balls 1â€“6 + wager-derived progressive."""
        if not self.lab_touch6_jpot_synth:
            return frame
        meta = parse_packet_meta(frame)
        if meta.msg_type != TOUCH6_RESULT_MSG:
            return frame
        if not self._lab_game_tid_ok(
            meta, game=self.lab_touch6_jpot_game, tid=self.lab_touch6_jpot_tid
        ):
            return frame
        wager = self._lab_touch6_last_wager.get(meta.tid_hex)
        synth = build_touch6_result_synth(
            frame,
            jpot_amount=self.lab_touch6_jpot_amount,
            wager=wager,
        )
        if synth is None:
            self.event_log.emit(
                "lab_touch6_jpot_skip",
                reason="build_failed",
                hook="uart_egress",
                game=meta.game_id_hex,
                tid=meta.tid_hex,
                len=len(frame),
                wager_len=len(wager) if wager else 0,
            )
            return frame
        jpot_cards = touch6_count_jpot_cards(wager, tuple(synth[TOUCH6_RESULT_BALL_OFFSET : TOUCH6_RESULT_BALL_OFFSET + 6]))
        progwin_cents = progressive_progwin_cents_be(synth)
        self.event_log.emit(
            "lab_touch6_jpot_synth",
            hook="uart_egress",
            game=meta.game_id_hex,
            tid=meta.tid_hex,
            len=len(synth),
            jpot_cards=jpot_cards,
            activewins=synth[8],
            progwin_cents=progwin_cents,
            progwin_dollars=progressive_cents_to_dollars(progwin_cents),
            progdsp_cents=progressive_progdsp_cents_be(synth),
            balls=",".join(str(b) for b in synth[TOUCH6_RESULT_BALL_OFFSET : TOUCH6_RESULT_BALL_OFFSET + 6]),
            wager_stored=wager is not None,
            master_len=len(frame),
        )
        return synth

    def _on_packet_complete_lab_superpick_wager(
        self,
        meta,
        pkt: bytes,
        packets_in_window: int,
        *,
        uart_first_byte: int | None = None,
        window_us: float | None = None,
        byte_span_us: float | None = None,
    ) -> None:
        if meta.msg_type != SUPERPICK_WAGER_MSG:
            return
        self._lab_remember_superpick_wager(pkt, meta)

    def _lab_remember_superpick_wager(self, frame: bytes, meta) -> None:
        if not self.lab_superpick_jpot_synth:
            return
        if meta.msg_type != SUPERPICK_WAGER_MSG:
            return
        if not self._lab_game_tid_ok(
            meta, game=self.lab_superpick_jpot_game, tid=self.lab_superpick_jpot_tid
        ):
            return
        self._lab_superpick_last_wager[meta.tid_hex] = bytes(frame)
        self.event_log.emit(
            "lab_superpick_wager_store",
            game=meta.game_id_hex,
            tid=meta.tid_hex,
            len=len(frame),
        )

    def _lab_synth_superpick_uart_egress(self, frame: bytes) -> bytes:
        """Replace Master SuperPick x9: forced all-zero draw + wager-derived progressive."""
        if not self.lab_superpick_jpot_synth:
            return frame
        meta = parse_packet_meta(frame)
        if meta.msg_type != SUPERPICK_RESULT_MSG:
            return frame
        if not self._lab_game_tid_ok(
            meta, game=self.lab_superpick_jpot_game, tid=self.lab_superpick_jpot_tid
        ):
            return frame
        wager = self._lab_superpick_last_wager.get(meta.tid_hex)
        synth = build_superpick_result_synth(
            frame,
            jpot_amount=self.lab_superpick_jpot_amount,
            wager=wager,
        )
        if synth is None:
            self.event_log.emit(
                "lab_superpick_jpot_skip",
                reason="build_failed",
                hook="uart_egress",
                game=meta.game_id_hex,
                tid=meta.tid_hex,
                len=len(frame),
                wager_len=len(wager) if wager else 0,
            )
            return frame
        jpot_cards = superpick_count_jpot_cards(
            wager,
            tuple(synth[SUPERPICK_RESULT_BALL_OFFSET : SUPERPICK_RESULT_BALL_OFFSET + 6]),
        )
        progwin_cents = progressive_progwin_cents_be(synth)
        self.event_log.emit(
            "lab_superpick_jpot_synth",
            hook="uart_egress",
            game=meta.game_id_hex,
            tid=meta.tid_hex,
            len=len(synth),
            jpot_cards=jpot_cards,
            activewins=synth[8],
            progwin_cents=progwin_cents,
            progwin_dollars=progressive_cents_to_dollars(progwin_cents),
            progdsp_cents=progressive_progdsp_cents_be(synth),
            balls=",".join(str(b) for b in synth[SUPERPICK_RESULT_BALL_OFFSET : SUPERPICK_RESULT_BALL_OFFSET + 6]),
            wager_stored=wager is not None,
            master_len=len(frame),
        )
        return synth

    def _lab_synth_bingo_jpot_uart_egress(self, frame: bytes) -> bytes:
        """Replace Master Bingo 0x39 with coverall calls + forced progwinval."""
        if not self.lab_bingo_jpot_synth:
            return frame
        meta = parse_packet_meta(frame)
        if meta.msg_type != BINGO_RESULT_MSG:
            return frame
        if not self._lab_game_tid_ok(
            meta, game=self.lab_bingo_jpot_game, tid=self.lab_bingo_jpot_tid
        ):
            return frame
        synth = build_bingo_jpot_synth(
            frame,
            jpot_amount=self.lab_bingo_jpot_amount,
        )
        if synth is None:
            self.event_log.emit(
                "lab_bingo_jpot_skip",
                reason="build_failed",
                hook="uart_egress",
                game=meta.game_id_hex,
                tid=meta.tid_hex,
                len=len(frame),
            )
            return frame
        calls = bingo_calls_from_result(synth)
        progwin_cents = progressive_progwin_cents_be(synth)
        self.event_log.emit(
            "lab_bingo_jpot_synth",
            hook="uart_egress",
            game=meta.game_id_hex,
            tid=meta.tid_hex,
            len=len(synth),
            active_wins=synth[8],
            progwin_cents=progwin_cents,
            progwin_dollars=progressive_cents_to_dollars(progwin_cents),
            calls=len(calls),
            coverall_prefix=",".join(str(n) for n in calls[:24]),
            master_len=len(frame),
        )
        return synth

    def _lab_synth_triple7s_jpot_uart_egress(self, frame: bytes) -> bytes:
        """Replace Master Triple 7's 0x89 with 3Ã— TRIPLE7 on payline 1."""
        if not self.lab_triple7s_jpot_synth:
            return frame
        meta = parse_packet_meta(frame)
        if meta.msg_type != SLOT_RESULT_MSG:
            return frame
        if not self._lab_game_tid_ok(
            meta, game=self.lab_triple7s_jpot_game, tid=self.lab_triple7s_jpot_tid
        ):
            return frame
        synth = build_triple7s_jpot_synth(frame)
        if synth is None:
            self.event_log.emit(
                "lab_triple7s_jpot_skip",
                reason="build_failed",
                hook="uart_egress",
                game=meta.game_id_hex,
                tid=meta.tid_hex,
                len=len(frame),
            )
            return frame
        grid = slot_grid_wire_from_result(synth)
        self.event_log.emit(
            "lab_triple7s_jpot_synth",
            hook="uart_egress",
            game=meta.game_id_hex,
            tid=meta.tid_hex,
            len=len(synth),
            payline1=",".join(f"{b:02X}" for b in (grid or ())[3:6]),
            grid=",".join(f"{b:02X}" for b in (grid or ())),
            master_len=len(frame),
        )
        return synth

    def _lab_synth_keno_jpot_uart_egress(self, frame: bytes) -> bytes:
        """Replace Master Keno x9 captured on POG422 before UARTâ†’ETH fan-out."""
        if not self.lab_keno_jpot_synth:
            return frame
        meta = parse_packet_meta(frame)
        if meta.msg_type != KENO_RESULT_MSG:
            return frame
        if not self._lab_game_tid_ok(
            meta, game=self.lab_keno_jpot_game, tid=self.lab_keno_jpot_tid
        ):
            return frame
        tid_key = meta.tid_hex.upper() if meta.tid_hex else ""
        dedupe_key = (tid_key, meta.play_sequence)
        now_ns = time.perf_counter_ns()
        last_ns = self._lab_keno_synth_recent.get(dedupe_key)
        if last_ns is not None:
            age_ms = (now_ns - last_ns) / 1_000_000.0
            if age_ms < LAB_KENO_JPOT_SYNTH_DEDUPE_MS:
                self.event_log.emit(
                    "lab_keno_jpot_skip",
                    reason="duplicate_master_x49",
                    hook="uart_egress",
                    game=meta.game_id_hex,
                    tid=meta.tid_hex,
                    play_sequence=meta.play_sequence,
                    age_ms=round(age_ms, 1),
                )
                return b""
        existing_progwin = progressive_progwin_cents_be(frame)
        if existing_progwin:
            self.event_log.emit(
                "lab_keno_jpot_skip",
                reason="master_already_jpot",
                hook="uart_egress",
                game=meta.game_id_hex,
                tid=meta.tid_hex,
                progwin_cents=existing_progwin,
                progwin_dollars=progressive_cents_to_dollars(existing_progwin),
            )
            return frame
        synth = build_keno_jpot_synth(
            frame,
            jpot_amount=self.lab_keno_jpot_amount,
        )
        if synth is None:
            self.event_log.emit(
                "lab_keno_jpot_skip",
                reason="build_failed",
                hook="uart_egress",
                game=meta.game_id_hex,
                tid=meta.tid_hex,
                len=len(frame),
            )
            return frame
        progwin_cents = progressive_progwin_cents_be(synth)
        self.event_log.emit(
            "lab_keno_jpot_synth",
            hook="uart_egress",
            game=meta.game_id_hex,
            tid=meta.tid_hex,
            len=len(synth),
            progwin_cents=progwin_cents,
            progwin_dollars=progressive_cents_to_dollars(progwin_cents),
            progdsp_cents=progressive_progdsp_cents_be(synth),
            progwin_le=u32_le_at(synth, 13),
            activewins=synth[8],
            balls="1-20",
            ball_bytes=",".join(
                f"{b:02X}"
                for b in synth[KENO_RESULT_BALL_OFFSET : KENO_RESULT_BALL_OFFSET + 20]
            ),
            display_balls=",".join(
                str(b)
                for b in synth[KENO_RESULT_BALL_OFFSET : KENO_RESULT_BALL_OFFSET + 20]
            ),
            master_len=len(frame),
        )
        self._lab_keno_synth_recent[dedupe_key] = now_ns
        return synth

    def _lab_synth_poker_royal_uart_egress(self, frame: bytes) -> bytes:
        """Replace Draw Poker 0x59 deal with royal flush; keep unique 10-card payload."""
        if not self.lab_poker_royal_synth:
            return frame
        meta = parse_packet_meta(frame)
        if meta.msg_type != POKER_RESULT_MSG:
            return frame
        if not self._lab_game_tid_ok(
            meta, game=self.lab_poker_game, tid=self.lab_poker_tid
        ):
            return frame
        synth = build_poker_royal_flush_synth(frame)
        if synth is None:
            self.event_log.emit(
                "lab_poker_royal_skip",
                reason="build_failed",
                hook="uart_egress",
                game=meta.game_id_hex,
                tid=meta.tid_hex,
                len=len(frame),
            )
            return frame
        master_draw = tuple(frame[26:31])
        synth_draw = tuple(synth[26:31])
        draw_deduped = master_draw != synth_draw
        self.event_log.emit(
            "lab_poker_royal_synth",
            hook="uart_egress",
            game=meta.game_id_hex,
            tid=meta.tid_hex,
            len=len(synth),
            cards="AH,KH,QH,JH,10H",
            draw_deduped=draw_deduped,
            deal_bytes=",".join(
                f"{b:02X}"
                for b in synth[21:26]
            ),
            draw_bytes=",".join(
                f"{b:02X}"
                for b in synth[26:31]
            ),
            master_len=len(frame),
        )
        return synth

    def _lab_synth_poker_jpot_grant_uart_egress(self, frame: bytes) -> bytes:
        """Force progressive progwin on Draw Poker 0x5A after royal + STAND."""
        if not self.lab_poker_royal_synth:
            return frame
        meta = parse_packet_meta(frame)
        if meta.msg_type != POKER_GRANT_MSG:
            return frame
        if not self._lab_game_tid_ok(
            meta, game=self.lab_poker_game, tid=self.lab_poker_tid
        ):
            return frame
        existing_progwin = progressive_progwin_cents_be(frame)
        if existing_progwin:
            self.event_log.emit(
                "lab_poker_jpot_skip",
                reason="master_already_jpot",
                hook="uart_egress",
                game=meta.game_id_hex,
                tid=meta.tid_hex,
                progwin_cents=existing_progwin,
                progwin_dollars=progressive_cents_to_dollars(existing_progwin),
            )
            return frame
        synth = build_poker_jpot_grant_synth(
            frame,
            jpot_amount=self.lab_poker_jpot_amount,
        )
        if synth is None:
            self.event_log.emit(
                "lab_poker_jpot_skip",
                reason="build_failed",
                hook="uart_egress",
                game=meta.game_id_hex,
                tid=meta.tid_hex,
                len=len(frame),
            )
            return frame
        progwin_cents = progressive_progwin_cents_be(synth)
        self.event_log.emit(
            "lab_poker_jpot_synth",
            hook="uart_egress",
            game=meta.game_id_hex,
            tid=meta.tid_hex,
            len=len(synth),
            progwin_cents=progwin_cents,
            progwin_dollars=progressive_cents_to_dollars(progwin_cents),
            progdsp_cents=progressive_progdsp_cents_be(synth),
            master_len=len(frame),
        )
        return synth

    def _lab_synth_spinjack_flushjack_uart_egress(self, frame: bytes) -> bytes:
        """Replace SpinJack 0x79 player cards with suited ace + ten-value (flushjack)."""
        if not self.lab_spinjack_flushjack_synth:
            return frame
        meta = parse_packet_meta(frame)
        if meta.msg_type != BLACKJACK_RESULT_MSG:
            return frame
        if not self._lab_game_tid_ok(
            meta, game=self.lab_spinjack_game, tid=self.lab_spinjack_tid
        ):
            return frame
        synth = build_spinjack_flushjack_synth(frame)
        if synth is None:
            self.event_log.emit(
                "lab_spinjack_flushjack_skip",
                reason="build_failed",
                hook="uart_egress",
                game=meta.game_id_hex,
                tid=meta.tid_hex,
                len=len(frame),
            )
            return frame
        master_shoe = tuple(frame[25:39])
        synth_shoe = tuple(synth[25:39])
        shoe_deduped = master_shoe != synth_shoe
        self.event_log.emit(
            "lab_spinjack_flushjack_synth",
            hook="uart_egress",
            game=meta.game_id_hex,
            tid=meta.tid_hex,
            len=len(synth),
            cards="AH,KH",
            shoe_deduped=shoe_deduped,
            table_bytes=",".join(f"{b:02X}" for b in synth[21:25]),
            shoe_bytes=",".join(f"{b:02X}" for b in synth[25:39]),
            master_len=len(frame),
        )
        return synth

    def _inject_engine_name(self) -> str:
        return "uartd" if self.use_uartd_inject else "python"

    def _sync_hs_inactive_from_capture(self) -> None:
        if self.uart_capture is not None and self.uart_capture.last_hs_inactive_perf_ns is not None:
            self._last_hs_inactive_perf_ns = self.uart_capture.last_hs_inactive_perf_ns

    def _inject_pre_tx_us(self) -> float:
        """Always 0 â€” C ignores pre_tx_us; DE assert then immediate INJECT_TX."""
        return 0.0

    def _inject_post_hold_us(self, air_us: float) -> float:
        """Wire margin after air_us for hybrid DE deadline (EG 2.2.1.12)."""
        del air_us
        margin = float(self.timing.post_tx_us) + float(self.timing.post_air_margin_us)
        return margin if margin > 0.0 else 400.0

    def capture_tick(self) -> None:
        if self._inject_ipc_paused:
            return
        if self.uart_capture is not None:
            # EG 3.0.1.3: heal dead/zombie uartd before drain (field CGS guard).
            uartd = self.uartd
            if uartd is not None and not uartd.ipc_connected():
                if uartd.ensure_alive(reason="capture_tick"):
                    self.event_log.emit(
                        "uartd_auto_reinit",
                        detail=getattr(uartd, "_last_ensure_detail", ""),
                        where="capture_tick",
                    )
            self.uart_capture.drain()
            self._sync_hs_inactive_from_capture()
        else:
            was_active = self.state.handshake_active
            handshake_capture_tick(
                self.ser,
                self.channel,
                self.handshake,
                self.event_log,
                self.state,
            )
            if was_active and not self.state.handshake_active:
                self._last_hs_inactive_perf_ns = time.perf_counter_ns()
        self._mrt_risk_tick()
        self._drain_deferred_python_close_fallback()
        self._drain_synth_retries()
        self._process_pending_injects()
        pcs = self.progressive_csv
        if pcs is not None:
            try:
                pcs.snapshot_tick()
            except Exception:
                pass

    def _local_round_has_open_close_wait(self, rk: RoundKey) -> bool:
        if rk in self._mrt_risk_await_x1 or rk in self._mrt_risk_await_0d:
            return True
        if rk in self._status_0d_x1_pending:
            return True
        if any(key[0] == rk for key in self._status_0d_synth_sent):
            return True
        if rk in self._status_0d_skill_round_active or rk in self._status_0d_skill_grant_seen:
            return True
        st = self._trunk_rounds.get(rk[0])
        if st is None or st.closed:
            return False
        if st.game is not None and st.game.upper() != rk[1].upper():
            return False
        return True

    def _clear_local_round_close_wait(
        self,
        rk: RoundKey,
        *,
        new_seq: int | None,
    ) -> None:
        """Drop await-x1 / await-0D / pending synth / skill / stale trunk for rk."""
        self._mrt_risk_clear_await_x1(rk)
        self._mrt_risk_await_0d.pop(rk, None)
        self._mrt_risk_logged_0d.discard(rk)
        self._mrt_risk_x9_diag.pop(rk, None)
        self._status_0d_x1_pending.discard(rk)
        self._status_0d_await_passthrough_logged.discard(rk)
        self._status_0d_python_fallback_rks.discard(rk)
        self._status_0d_python_fallback_ready_ns.pop(rk, None)
        self._status_0d_x9_origin.pop(rk, None)
        self._clear_skill_round_state(rk)
        for synth_key in list(self._status_0d_synth_sent):
            if synth_key[0] == rk:
                self._status_0d_synth_sent.discard(synth_key)
        st = self._trunk_rounds.get(rk[0])
        if st is None or st.closed:
            return
        if st.game is not None and st.game.upper() != rk[1].upper():
            return
        trunk_seq = st.seq
        # Close older/unknown trunk; keep trunk already opened for new_seq.
        if (
            trunk_seq is None
            or new_seq is None
            or self._play_seq_is_newer(new_seq, trunk_seq)
        ):
            self._trunk_round_close(rk[0], rk[1])

    @staticmethod
    def _play_seq_is_newer(new_seq: int, old_seq: int) -> bool:
        """True when new_seq is a later local play than old_seq.

        Prefers any change other than equality: strict increases (gaps ok) and
        decreases (wire-byte wrap/reset â€” soak 7â†’0 under ``hint < latest``
        caused false ``superseded_seq`` and late 0x0D). Same seq = retry.
        """
        return new_seq != old_seq

    def _supersede_local_round_if_newer(
        self,
        rk: RoundKey,
        new_seq: int | None,
        *,
        reason: str,
        trigger_msg: str | None = None,
    ) -> bool:
        """Abandon open local close-wait when play_sequence advances or is missing.

        Same sequence leaves the open wait alone. Missing sequence is latest-wins.
        A lower sequence is wrap/reset (newer), not a stale retry.
        """
        old_seq = self._local_round_latest_seq.get(rk)
        wait_open = self._local_round_has_open_close_wait(rk)
        should = False
        if new_seq is None:
            should = wait_open
        elif old_seq is None:
            should = wait_open
        elif self._play_seq_is_newer(new_seq, old_seq):
            should = True
        if should:
            self._clear_local_round_close_wait(rk, new_seq=new_seq)
            self.event_log.emit(
                "local_round_superseded",
                tid=rk[0],
                game=rk[1],
                old_seq=old_seq,
                new_seq=new_seq,
                reason=reason,
                msg=trigger_msg,
            )
        if new_seq is not None and (
            old_seq is None or self._play_seq_is_newer(new_seq, old_seq)
        ):
            self._local_round_latest_seq[rk] = new_seq
        elif should and new_seq is None:
            self._local_round_latest_seq.pop(rk, None)
        return should

    def _mrt_risk_mark_seq_closed(
        self,
        rk: RoundKey,
        seq: int | None = None,
        *,
        quality: str | None = None,
    ) -> None:
        """Record play_sequence for which closing 0x0D already completed.

        quality ``ok`` (default): good uartd/normal close — reopen only after
        MRT_HAND_REOPEN_MIN_AGE_MS. ``recovery``: miss/fallback/late_0d — glass
        may re-wager same seq immediately (continuous-MRT latch of soak 1436).
        """
        if seq is None:
            hint = self._status_0d_x9_hint.get(rk) or {}
            seq = hint.get("play_sequence")
        if seq is None:
            seq = self._local_round_latest_seq.get(rk)
        if seq is not None:
            self._mrt_risk_close_done_seq[rk] = int(seq)
            self._mrt_risk_close_done_ns[rk] = time.perf_counter_ns()
            if quality is None:
                quality = self._mrt_risk_close_quality_pending.pop(rk, "ok")
            else:
                self._mrt_risk_close_quality_pending.pop(rk, None)
            q = str(quality or "ok").lower()
            if q not in ("ok", "recovery"):
                q = "ok"
            self._mrt_risk_close_quality[rk] = q

    def _mrt_risk_seq_already_closed(self, meta) -> bool:
        """True when this meta's play_sequence already got a closing 0x0D."""
        rk = self._round_key(meta)
        if rk is None or meta is None or meta.play_sequence is None:
            return False
        closed = self._mrt_risk_close_done_seq.get(rk)
        return closed is not None and int(meta.play_sequence) == int(closed)

    def _local_hand_gen_get(self, rk: RoundKey) -> int:
        return int(self._local_hand_gen.get(rk, 0))

    def _local_hand_invalidate_x9(self, rk: RoundKey, *, reason: str) -> int:
        """Bump hand gen and unbind prior x9 so stale 41/0D cannot seal a new txn."""
        gen = self._local_hand_gen_get(rk) + 1
        self._local_hand_gen[rk] = gen
        prev_x9 = self._local_hand_x9_gen.pop(rk, None)
        self.event_log.emit(
            "local_hand_gen_bump",
            tid=rk[0],
            game=rk[1],
            hand_gen=gen,
            prev_x9_gen=prev_x9,
            reason=reason,
        )
        return gen

    def _local_hand_bind_x9(self, rk: RoundKey) -> int:
        """Stamp active local x9 with current hand gen (seal only while matched)."""
        gen = self._local_hand_gen_get(rk)
        self._local_hand_x9_gen[rk] = gen
        return gen

    def _local_hand_x9_current(self, rk: RoundKey) -> bool:
        bound = self._local_hand_x9_gen.get(rk)
        if bound is None:
            return False
        return int(bound) == self._local_hand_gen_get(rk)

    def _force_close_local_trunk_same_seq(
        self,
        rk: RoundKey,
        new_seq: int | None,
    ) -> bool:
        """Close open trunk when same-seq restart (clear-wait keeps trunk on equal seq)."""
        st = self._trunk_rounds.get(rk[0])
        if st is None or st.closed:
            return False
        if st.game is not None and st.game.upper() != rk[1].upper():
            return False
        if (
            new_seq is not None
            and st.seq is not None
            and int(st.seq) != int(new_seq)
        ):
            return False
        self._trunk_round_close(rk[0], rk[1])
        return True

    def _local_round_has_mid_deal_close_state(self, rk: RoundKey) -> bool:
        """Await/pending seal only — not bare open trunk (note_request already opened it)."""
        if rk in self._mrt_risk_await_x1 or rk in self._mrt_risk_await_0d:
            return True
        if rk in self._status_0d_x1_pending:
            return True
        if any(key[0] == rk for key in self._status_0d_synth_sent):
            return True
        if rk in self._status_0d_skill_round_active or rk in self._status_0d_skill_grant_seen:
            return True
        return False

    def _mrt_risk_txn_restart_on_local_wager(self, meta) -> bool:
        """Full transaction restart when local slave issues a new gameplay request.

        Product: hard MRT clear restarts the whole hand; the next local x0 (often
        same play_sequence) must abandon half-finished await/close and orphan any
        late 41/0D from the dead attempt. Supersede only clears on *newer* seq;
        close-done reopen only after a closed mark — neither covered mid-await
        empty-harvest / post-timeout same-seq re-Play (pos5 MRT thrash soak).

        Does not force-close trunk: packet-complete already opened/refreshed the
        trunk for this x0; killing it would drop local exclusive on every wager.

        EG 4.0.0.58: once local x1 is known for this seq (await_0d / x1_pending),
        do **not** restart on another same-seq x0. Slot Pi-HS early peel arms
        close, then a duplicate 80/40 PC ~1 ms later was clearing await and
        orphaning the seal → glass MRT. T340 will not re-wager until 0D; that
        second PC is not a new game.
        """
        if meta is None or not is_gameplay_request(meta.msg_type):
            return False
        if not self._meta_is_local_for_round_state(meta):
            return False
        rk = self._round_key(meta)
        if rk is None:
            return False
        seq = meta.play_sequence
        x1_close_active = (
            rk in self._mrt_risk_await_0d or rk in self._status_0d_x1_pending
        )
        if (
            x1_close_active
            and seq is not None
            and self._local_round_latest_seq.get(rk) == int(seq)
        ):
            self.event_log.emit(
                "mrt_risk_txn_restart_skip",
                tid=rk[0],
                game=rk[1],
                play_sequence=seq,
                reason="x1_close_active",
                msg=meta.msg_type_hex,
                await_0d=rk in self._mrt_risk_await_0d,
                x1_pending=rk in self._status_0d_x1_pending,
            )
            return False
        mid_deal = self._local_round_has_mid_deal_close_state(rk)
        had_bound_x9 = self._local_hand_x9_current(rk)
        # Always orphan prior x9 seal across a new local x0 (including same seq).
        gen = self._local_hand_invalidate_x9(rk, reason="local_wager_txn_restart")
        cleared_wait = False
        if mid_deal:
            # Same-seq supersede is a no-op; still drop await/x1/0D pending.
            self._clear_local_round_close_wait(rk, new_seq=seq)
            cleared_wait = True
        reopened = self._mrt_risk_reopen_hand_on_local_wager(meta)
        if not (cleared_wait or reopened or had_bound_x9):
            # First wager of a cold hand — gen bump only (no prior mid-deal state).
            return False
        self.event_log.emit(
            "mrt_risk_hand_txn_restart",
            tid=rk[0],
            game=rk[1],
            play_sequence=seq,
            hand_gen=gen,
            cleared_open_wait=cleared_wait,
            close_reopened=reopened,
            had_bound_x9=had_bound_x9,
            msg=meta.msg_type_hex,
        )
        return True

    def _mrt_risk_reopen_hand_on_local_wager(self, meta) -> bool:
        """Same-seq glass re-wager may clear close-done (quality + settle).

        EG 3.0.1.47 (too broad): any same-seq 40 cleared close-done → 1513 late_0d.
        EG 3.0.1.48: all closes needed 2500 ms — restored continuous MRT (latch).
        EG 3.0.1.49:
          - recovery close (auto_close_miss / late_0d): reopen immediately
          - ok close: settle MRT_HAND_REOPEN_MIN_AGE_MS (blocks CGS ~1 ms)
          - never reopen while open close-wait
        """
        if meta is None or not is_gameplay_request(meta.msg_type):
            return False
        if meta.play_sequence is None:
            return False
        if not self._meta_is_local_for_round_state(meta):
            return False
        rk = self._round_key(meta)
        if rk is None:
            return False
        closed = self._mrt_risk_close_done_seq.get(rk)
        if closed is None or int(closed) != int(meta.play_sequence):
            return False
        # Bare open trunk is expected after this x0 note_request — only mid seal blocks.
        if self._local_round_has_mid_deal_close_state(rk):
            self.event_log.emit(
                "mrt_risk_hand_reopen_skip",
                tid=rk[0],
                game=rk[1],
                play_sequence=int(meta.play_sequence),
                closed_seq=int(closed),
                reason="open_close_wait",
                msg=meta.msg_type_hex,
            )
            return False
        quality = self._mrt_risk_close_quality.get(rk) or "ok"
        closed_ns = self._mrt_risk_close_done_ns.get(rk)
        age_ms = None
        if closed_ns is not None:
            age_ms = (time.perf_counter_ns() - closed_ns) / 1_000_000.0
        if quality != "recovery":
            if age_ms is not None and age_ms < MRT_HAND_REOPEN_MIN_AGE_MS:
                self.event_log.emit(
                    "mrt_risk_hand_reopen_skip",
                    tid=rk[0],
                    game=rk[1],
                    play_sequence=int(meta.play_sequence),
                    closed_seq=int(closed),
                    reason="too_soon_after_close",
                    quality=quality,
                    age_ms=round(age_ms, 1),
                    min_age_ms=MRT_HAND_REOPEN_MIN_AGE_MS,
                    msg=meta.msg_type_hex,
                )
                return False
        del self._mrt_risk_close_done_seq[rk]
        self._mrt_risk_close_done_ns.pop(rk, None)
        self._mrt_risk_close_quality.pop(rk, None)
        self._mrt_risk_close_quality_pending.pop(rk, None)
        # Drop half-lived close state so the new hand can arm cleanly.
        self._clear_local_round_close_wait(rk, new_seq=int(meta.play_sequence))
        self.event_log.emit(
            "mrt_risk_hand_reopen",
            tid=rk[0],
            game=rk[1],
            play_sequence=int(meta.play_sequence),
            closed_seq=int(closed),
            age_ms=None if age_ms is None else round(age_ms, 1),
            quality=quality,
            reason=(
                "same_seq_local_wager_recovery"
                if quality == "recovery"
                else "same_seq_local_wager_settled"
            ),
            msg=meta.msg_type_hex,
        )
        return True

    def _mrt_risk_arm_after_local_x9(self, meta) -> str | None:
        """Arm await-x1 after local ethâ†’UART x9.

        Returns ``None`` when armed, else an arm-skip / not-applicable reason.
        """
        if not self.status_0d_synth or not self._meta_is_local_terminal(meta):
            return "not_local_or_synth_off"
        if not is_gameplay_response(meta.msg_type):
            return "not_gameplay_response"
        rk = self._round_key(meta)
        if rk is None:
            return "no_round_key"
        self._supersede_local_round_if_newer(
            rk,
            meta.play_sequence,
            reason="local_x9_arm",
            trigger_msg=meta.msg_type_hex,
        )
        seq = meta.play_sequence
        closed_seq = self._mrt_risk_close_done_seq.get(rk)
        # Duplicate ethâ†’UART x9 after synth/native 0x0D for this seq must not
        # re-arm await_x1 (soak: false x9_no_local_x1 @400ms while copper 71+0D done).
        if seq is not None and closed_seq is not None and int(seq) == closed_seq:
            self.event_log.emit(
                "mrt_risk_arm_skip",
                tid=rk[0],
                game=rk[1],
                msg=meta.msg_type_hex,
                reason="seq_already_closed",
                play_sequence=seq,
            )
            return "seq_already_closed"
        # Local x1 already seen â€” stay on await-0D; do not regress to await-x1.
        if rk in self._mrt_risk_await_0d:
            self.event_log.emit(
                "mrt_risk_arm_skip",
                tid=rk[0],
                game=rk[1],
                msg=meta.msg_type_hex,
                reason="await_0d_active",
                play_sequence=seq,
            )
            return "await_0d_active"
        # Already armed for this seq — Master (or eth) re-sent x9. Note retry and
        # EG 3.0.1.55: refresh await_x1 clock so missing_x1 timeout is not burned
        # by the failed first peel while we re-inject Master re-49.
        if (
            rk in self._mrt_risk_await_x1
            and seq is not None
            and self._local_round_latest_seq.get(rk) == int(seq)
        ):
            now = time.perf_counter_ns()
            self._mrt_risk_x9_retry_seen.add(rk)
            self._mrt_risk_await_x1[rk] = now
            self._local_hand_bind_x9(rk)
            self.event_log.emit(
                "mrt_risk_arm_skip",
                tid=rk[0],
                game=rk[1],
                msg=meta.msg_type_hex,
                reason="await_x1_same_seq",
                play_sequence=seq,
                await_clock_refreshed=True,
            )
            return "await_x1_same_seq"
        now = time.perf_counter_ns()
        self._mrt_risk_await_x1[rk] = now
        self._mrt_risk_x9_retry_seen.discard(rk)
        self._mrt_risk_infer_hand_open_logged.discard(rk)
        self._mrt_risk_await_0d.pop(rk, None)
        self._mrt_risk_logged_x1.discard(rk)
        self._mrt_risk_logged_0d.discard(rk)
        self._local_hand_bind_x9(rk)
        hold_ns = int(MRT_LOCAL_X1_INJECT_HOLD_MS * 1_000_000)
        until = now + hold_ns
        prev = self._local_x1_inject_hold_until_ns
        if prev is None or until > prev:
            self._local_x1_inject_hold_until_ns = until
        return None

    def _mrt_risk_emit_x9_inject_diag(
        self,
        meta,
        *,
        frame: bytes,
        echo: bytes,
        post_de: bytes,
        peer: bytes,
        peer_msgs: list[str],
        arm_skip: str | None,
        attempt: int | None = None,
    ) -> None:
        """Compact local x9 inject forensics for 79â†’71 miss / late-ACK soaks."""
        if not self.status_0d_synth or not self._meta_is_local_terminal(meta):
            return
        if not is_gameplay_response(meta.msg_type):
            return
        rk = self._round_key(meta)
        if rk is None:
            return
        echo = echo or b""
        post_de = post_de or b""
        peer = peer or b""
        echo_ok, echo_how = inject_echo_bytes_match(frame, echo)
        post_ok, post_how = inject_echo_bytes_match(frame, post_de)
        armed_ns = self._mrt_risk_await_x1.get(rk)
        diag: dict[str, Any] = {
            "armed_ns": armed_ns,
            "arm_skip": arm_skip,
            "attempt": attempt,
            "play_sequence": meta.play_sequence,
            "msg": meta.msg_type_hex,
            "echo_len": len(echo),
            "echo_match": echo_how if echo else "none",
            "echo_ok": bool(echo_ok),
            # Retain bytes for pending_expire miss → empty-harvest reinject (no
            # echo on late AUTO_CLOSE_DONE IPC).
            "echo": bytes(echo) if echo else b"",
            "post_de_len": len(post_de),
            "post_de_match": post_how if post_de else "none",
            "post_de_ok": bool(post_ok),
            "post_de": bytes(post_de) if post_de else b"",
            "peer_len": len(peer),
            "peer_msgs": ",".join(peer_msgs) if peer_msgs else "",
            "first_peer_byte": f"{peer[0]:02X}" if peer else None,
        }
        # Keep prior armed_ns if same-seq retry did not re-arm.
        prev = self._mrt_risk_x9_diag.get(rk)
        if armed_ns is None and prev is not None and prev.get("armed_ns") is not None:
            if prev.get("play_sequence") == meta.play_sequence:
                diag["armed_ns"] = prev["armed_ns"]
        self._mrt_risk_x9_diag[rk] = diag
        self.event_log.emit(
            "mrt_x9_inject_diag",
            tid=rk[0],
            game=rk[1],
            msg=meta.msg_type_hex,
            play_sequence=meta.play_sequence,
            armed=arm_skip is None,
            arm_skip=arm_skip,
            attempt=attempt,
            echo_len=diag["echo_len"],
            echo_match=diag["echo_match"],
            post_de_len=diag["post_de_len"],
            post_de_match=diag["post_de_match"],
            peer_len=diag["peer_len"],
            peer_msgs=diag["peer_msgs"] or None,
            first_peer_byte=diag["first_peer_byte"],
        )

    def _should_defer_same_seq_x9_await_x1_peel(self, meta) -> bool:
        """Hold Master's same-seq eth→UART x9 while local x1 peel / x9 already on bus.

        EG 3.0.1.17: after await_x1, ~120 ms eth retry must not barge mid-peel.
        EG 3.0.1.51: also hold once this seq's x9 was already injected (soaked 1634 —
        second eth 49 during first inject stacked load / harvest CGS 40).
        EG 4.0.0.60: after empty-harvest miss with good self-echo, *do* let the
        same-seq x9 through — peel already finished empty; Master retry / reinject
        is the recovery path (59 porch alone left glass MRT).
        """
        if not self.status_0d_synth or not self._meta_is_local_terminal(meta):
            return False
        if not is_gameplay_response(meta.msg_type):
            return False
        rk = self._round_key(meta)
        if rk is None:
            return False
        if rk in self._empty_harvest_x9_retry_ok:
            return False
        seq = meta.play_sequence
        if seq is None:
            return False
        if self._local_round_latest_seq.get(rk) != int(seq):
            return False
        if rk in self._mrt_risk_await_x1:
            return True
        st = self._trunk_rounds.get(rk[0].upper())
        if st is not None and not st.closed and st.x9_injected:
            if st.seq is None or int(st.seq) == int(seq):
                return True
        return False

    def _clear_empty_harvest_x9_retry(self, rk: RoundKey) -> None:
        self._empty_harvest_x9_retry_ok.discard(rk)
        self._empty_harvest_x9_reinjected.discard(rk)

    def _lift_empty_harvest_x9_from_transaction_deferred(self, rk: RoundKey) -> int:
        """Promote deferred same-round local x9 after empty-harvest arm."""
        if rk is None or not self._transaction_deferred:
            return 0
        keep: deque[PendingInject] = deque()
        lifted: list[PendingInject] = []
        while self._transaction_deferred:
            stashed = self._transaction_deferred.popleft()
            try:
                meta = parse_packet_meta(stashed.frame)
            except Exception:
                keep.append(stashed)
                continue
            st_rk = self._round_key(meta)
            if (
                st_rk == rk
                and is_gameplay_response(meta.msg_type)
                and self._meta_is_local_terminal(meta)
            ):
                lifted.append(stashed)
            else:
                keep.append(stashed)
        self._transaction_deferred = keep
        # Oldest first onto copper front.
        for stashed in reversed(lifted):
            self._enqueue_pending(stashed, front=True)
        if lifted:
            self.event_log.emit(
                "mrt_x9_empty_harvest_lift_deferred",
                tid=rk[0],
                game=rk[1],
                lifted=len(lifted),
            )
        return len(lifted)

    def _queue_empty_harvest_x9_reinject(
        self,
        rk: RoundKey,
        meta,
        frame: bytes,
    ) -> bool:
        """One active reinject of the missed local x9 (good echo, empty harvest)."""
        if rk is None or not frame:
            return False
        if rk in self._empty_harvest_x9_reinjected:
            return False
        if not is_gameplay_response(
            meta.msg_type if meta is not None else parse_packet_meta(frame).msg_type
        ):
            return False
        self._empty_harvest_x9_reinjected.add(rk)
        msg_label = (
            meta.msg_type_hex
            if meta is not None and getattr(meta, "msg_type_hex", None)
            else f"{frame[1]:02X}"
            if len(frame) >= 2
            else None
        )
        self._enqueue_pending(
            PendingInject(
                frame=bytes(frame),
                queued_perf_ns=time.perf_counter_ns(),
                msg=msg_label or "??",
                tid=rk[0],
                source="empty_harvest_x9_reinject",
                strict_slv_idle=False,
            ),
            front=True,
        )
        self.event_log.emit(
            "mrt_x9_empty_harvest_reinject",
            tid=rk[0],
            game=rk[1],
            msg=msg_label,
            play_sequence=(meta.play_sequence if meta is not None else None),
            len=len(frame),
        )
        return True

    def _local_x1_inject_hold_active(self, now_ns: int | None = None) -> bool:
        """True during brief post-local-x9 hold of non-local ethâ†’UART."""
        until = self._local_x1_inject_hold_until_ns
        if until is None:
            return False
        if now_ns is None:
            now_ns = time.perf_counter_ns()
        if now_ns >= until:
            self._local_x1_inject_hold_until_ns = None
            return False
        return True

    def _mrt_risk_clear_await_x1(self, rk: RoundKey) -> None:
        self._mrt_risk_await_x1.pop(rk, None)
        self._mrt_risk_logged_x1.discard(rk)
        self._mrt_risk_x9_retry_seen.discard(rk)
        self._mrt_risk_infer_hand_open_logged.discard(rk)
        self._clear_empty_harvest_x9_retry(rk)
        if not self._mrt_risk_await_x1:
            self._local_x1_inject_hold_until_ns = None

    def _mrt_risk_abandon_await_x1(
        self,
        rk: RoundKey,
        *,
        reason: str,
        wait_ms: float | None = None,
    ) -> None:
        """End missing-x1 wait: clear soft hold state, close local trunk, drop deferred."""
        armed_ns = self._mrt_risk_await_x1.pop(rk, None)
        self._mrt_risk_x9_retry_seen.discard(rk)
        self._mrt_risk_infer_hand_open_logged.discard(rk)
        self._clear_empty_harvest_x9_retry(rk)
        if armed_ns is None:
            return
        if not self._mrt_risk_await_x1:
            self._local_x1_inject_hold_until_ns = None
        if wait_ms is None:
            wait_ms = round(
                (time.perf_counter_ns() - armed_ns) / 1_000_000.0,
                1,
            )
        self.event_log.emit(
            "await_local_x1_timeout",
            tid=rk[0],
            game=rk[1],
            reason=reason,
            wait_ms=wait_ms,
            bound_ms=MRT_RISK_MISSING_X1_MS,
            hold_ms=MRT_LOCAL_X1_INJECT_HOLD_MS,
        )
        # Orphan late 41/0D still in flight for this dead attempt.
        self._local_hand_invalidate_x9(rk, reason="await_local_x1_timeout")
        # Drop stashed foreign first (do not flush-storm into _pending), then close trunk.
        if not self._local_close_pending():
            self._abandon_transaction_deferred(reason="await_local_x1_timeout")
        self._trunk_round_close(rk[0], rk[1])

    def _mrt_risk_note_local_x1(
        self,
        rk: RoundKey,
        *,
        trigger: str | None = None,
        msg: str | None = None,
    ) -> None:
        # Foreign bus x1 must never arm await_0d â€” sticky non-local keys lock the
        # EG 2.2.2.31 P0 gate and starve Master (CGS Master not responding).
        if self._local_slave_tid and rk[0].upper() != self._local_slave_tid.upper():
            return
        armed_ns = self._mrt_risk_await_x1.pop(rk, None)
        self._mrt_risk_x9_retry_seen.discard(rk)
        late_after_miss = rk in self._mrt_risk_logged_x1
        self._mrt_risk_logged_x1.discard(rk)
        if not self._mrt_risk_await_x1:
            self._local_x1_inject_hold_until_ns = None
        now = time.perf_counter_ns()
        self._mrt_risk_await_0d[rk] = now
        self._mrt_risk_logged_0d.discard(rk)
        diag = self._mrt_risk_x9_diag.get(rk)
        base_ns = armed_ns
        if base_ns is None and diag is not None:
            base_ns = diag.get("armed_ns")
        if base_ns is not None and self.status_0d_synth:
            self.event_log.emit(
                "mrt_x1_after_x9",
                tid=rk[0],
                game=rk[1],
                msg=msg,
                trigger=trigger,
                x9_to_x1_ms=round((now - int(base_ns)) / 1_000_000.0, 1),
                late_after_miss=late_after_miss,
                play_sequence=(diag.get("play_sequence") if diag else None),
                echo_len=(diag.get("echo_len") if diag else None),
                echo_match=(diag.get("echo_match") if diag else None),
                post_de_len=(diag.get("post_de_len") if diag else None),
                post_de_match=(diag.get("post_de_match") if diag else None),
                peer_msgs=(diag.get("peer_msgs") or None) if diag else None,
            )

    def _mrt_risk_infer_x1_no_x9_retry(
        self,
        rk: RoundKey,
        *,
        wait_ms: float,
        bound_ms: float | None = None,
        after_x9_retry: bool = False,
    ) -> None:
        """EG 3.0.1.55: disabled — empty harvest leaves hand open.

        Historical path assumed Master heard local x1 without a harvestable ACK and
        synth-closed. That closed glass while real 41/Master re-49 still arrived
        (soak 1941). Keep the log for soak metrics; do not mark x1 or sched 0D.
        """
        if rk not in self._mrt_risk_await_x1:
            return
        if rk in self._mrt_risk_infer_hand_open_logged:
            return
        self._mrt_risk_infer_hand_open_logged.add(rk)
        bound = (
            float(bound_ms)
            if bound_ms is not None
            else (
                MRT_X1_INFER_AFTER_X9_RETRY_MS
                if after_x9_retry
                else MRT_X1_INFER_NO_X9_RETRY_MS
            )
        )
        diag = self._mrt_risk_x9_diag.get(rk) or {}
        self.event_log.emit(
            "mrt_x1_infer_skipped_hand_open",
            tid=rk[0],
            game=rk[1],
            wait_ms=wait_ms,
            bound_ms=bound,
            after_x9_retry=after_x9_retry,
            play_sequence=diag.get("play_sequence"),
            echo_len=diag.get("echo_len"),
            echo_match=diag.get("echo_match"),
            post_de_len=diag.get("post_de_len"),
            post_de_match=diag.get("post_de_match"),
            peer_msgs=diag.get("peer_msgs") or None,
            reason="empty_harvest_hand_open",
        )
        return

    def _status_0d_close_done_for_round(self, rk: RoundKey) -> bool:
        """True after uartd auto-close or Python synth for this round (deal or skill)."""
        if rk in self._status_0d_skill_close_done:
            return True
        return any(sk[0] == rk for sk in self._status_0d_synth_sent)

    def _python_may_deal_synth_close(self, rk: RoundKey) -> bool:
        """Deal-path 0D: Python only without uartd, or after deferred miss arm."""
        if not self.use_uartd_inject:
            return True
        if rk not in self._status_0d_python_fallback_rks:
            return False
        ready_ns = self._status_0d_python_fallback_ready_ns.get(rk)
        if ready_ns is not None and time.perf_counter_ns() < ready_ns:
            return False
        return True

    def _arm_python_deal_close_fallback(
        self,
        rk: RoundKey,
        *,
        reason: str,
        defer_ms: float | None = None,
        **fields: Any,
    ) -> None:
        """Legacy Python deal 0D after miss — EG 3.0.1.55 no longer arms on empty harvest.

        Retained for unit/compat and non-uartd; product path uses
        ``_hand_open_after_auto_close_miss`` instead of Python 0D on NO_X1.
        """
        if not self.status_0d_synth or not self.use_uartd_inject:
            return
        if self._status_0d_close_done_for_round(rk):
            return
        self._status_0d_python_fallback_rks.add(rk)
        self._status_0d_x1_pending.add(rk)
        wait = (
            STATUS_0D_PYTHON_FALLBACK_DEFER_MS
            if defer_ms is None
            else float(defer_ms)
        )
        if wait > 0:
            ready = time.perf_counter_ns() + int(wait * 1_000_000)
            self._status_0d_python_fallback_ready_ns[rk] = ready
        else:
            self._status_0d_python_fallback_ready_ns.pop(rk, None)
            if rk not in self._synth_retry_rks:
                self._synth_retry_rks.append(rk)
        # Next 0D note is recovery — allow same-seq glass re-wager immediately.
        self._mrt_risk_close_quality_pending[rk] = "recovery"
        self.event_log.emit(
            "status_0d_python_fallback_arm",
            tid=rk[0],
            game=rk[1],
            reason=reason,
            defer_ms=round(wait, 1),
            **fields,
        )

    def _hand_open_after_auto_close_miss(
        self,
        rk: RoundKey,
        meta,
        *,
        cgs_40: bool = False,
        frame: bytes | None = None,
        echo_ok: bool = False,
        **fields: Any,
    ) -> None:
        """Empty uartd x1 harvest: leave deal open for Master re-49 + real 41.

        EG 3.0.1.55 — do not mark seq closed, invent x1, or Python-synth 0D.
        EG 4.0.0.60 — when self-echo proved delivery (`echo_ok`), arm same-seq
        x9 retry through the exclusive peel gate and reinject once / lift
        deferred Master 89 (59 miss: retries stuck behind local_game_txn_exclusive).
        """
        if rk is None:
            return
        # Cancel any prior miss-defer Python arm for this round.
        self._status_0d_python_fallback_rks.discard(rk)
        self._status_0d_python_fallback_ready_ns.pop(rk, None)
        # Allow Master re-inject of same-seq Keno 49 (soft age dedupe).
        seq = None
        if meta is not None:
            seq = meta.play_sequence
            if meta.tid_hex:
                self._recent_x9_inject_ns.pop(
                    (meta.tid_hex.upper(), seq), None
                )
        self._recent_x9_inject_ns.pop((rk[0].upper(), seq), None)
        self.event_log.emit(
            "status_0d_auto_close_miss_hand_open",
            tid=rk[0],
            game=rk[1],
            reason="empty_harvest_hand_open",
            cgs_40=bool(cgs_40),
            play_sequence=seq,
            echo_ok=bool(echo_ok),
            **fields,
        )
        if echo_ok and not cgs_40:
            self._empty_harvest_x9_retry_ok.add(rk)
            if not self._empty_harvest_x9_reinject_enabled():
                self.event_log.emit(
                    "mrt_x9_empty_harvest_retry_armed",
                    tid=rk[0],
                    game=rk[1],
                    play_sequence=seq,
                    via="gate_only_no_reinject",
                    lifted=0,
                )
                return
            lifted = self._lift_empty_harvest_x9_from_transaction_deferred(rk)
            x9 = frame or self._status_0d_last_x9.get(rk)
            if lifted == 0 and x9:
                self._queue_empty_harvest_x9_reinject(rk, meta, bytes(x9))
            elif lifted:
                self.event_log.emit(
                    "mrt_x9_empty_harvest_retry_armed",
                    tid=rk[0],
                    game=rk[1],
                    play_sequence=seq,
                    via="lift_deferred",
                    lifted=lifted,
                )
            else:
                self.event_log.emit(
                    "mrt_x9_empty_harvest_retry_armed",
                    tid=rk[0],
                    game=rk[1],
                    play_sequence=seq,
                    via="gate_only",
                    lifted=0,
                )

    def _drain_deferred_python_close_fallback(self) -> None:
        """Promote deferred NO_X1 Python P0 once defer elapses (or cancel if closed)."""
        if not self._status_0d_python_fallback_ready_ns:
            return
        now = time.perf_counter_ns()
        for rk in list(self._status_0d_python_fallback_ready_ns):
            ready = self._status_0d_python_fallback_ready_ns.get(rk)
            if ready is None or now < ready:
                continue
            self._status_0d_python_fallback_ready_ns.pop(rk, None)
            if self._status_0d_close_done_for_round(rk):
                self._status_0d_python_fallback_rks.discard(rk)
                self._status_0d_x1_pending.discard(rk)
                self.event_log.emit(
                    "status_0d_python_fallback_cancel",
                    tid=rk[0],
                    game=rk[1],
                    reason="already_closed",
                )
                continue
            if rk not in self._status_0d_python_fallback_rks:
                continue
            if rk not in self._synth_retry_rks:
                self._synth_retry_rks.append(rk)
            self.event_log.emit(
                "status_0d_python_fallback_ready",
                tid=rk[0],
                game=rk[1],
            )

    def _schedule_synth_attempt(
        self,
        rk: RoundKey,
        *,
        synth_defer: list[RoundKey] | None = None,
    ) -> None:
        """Queue or run synth. Always build the 0D frame when possible.

        If ``_tx_lock`` is held, ``_dispatch_status_synth_frame`` appendlefts
        the frame onto ``_pending`` (P0) — never wait for eth→UART inject DONE
        before arming close (MRT 12:13: queue must exist even if HS blocks TX).

        EG 3.0.1.5 / 3.0.1.43: with uartd inject, C owns hard-RT deal close —
        never queue Python unless auto_close_miss armed fallback for this rk.
        """
        if not self._python_may_deal_synth_close(rk):
            return
        if synth_defer is not None:
            if rk not in synth_defer:
                synth_defer.append(rk)
            return
        self._try_synth_pending_for_round(rk)

    def _drain_synth_retries(self) -> None:
        if not self._synth_retry_rks or self._tx_lock.locked():
            return
        pending = list(self._synth_retry_rks)
        self._synth_retry_rks.clear()
        for rk in pending:
            self._try_synth_pending_for_round(rk)

    def _set_x9_origin(self, rk: RoundKey, origin: str) -> None:
        """Record whether this round's x9 was UART-origin (Master) or eth-injected (Slave).

        EG 4.1.0.26: once marked ``eth``, do not flip to ``uart``. Eth→UART inject
        of Master's x9 always appears on local SLV-IN; treating that echo as
        Master-native made ``_skip_synth_master_uart_x9`` refuse synth after a
        real local x1 (pos4 glass MRT 2026-09-15 ~17:10 EDT).
        """
        if origin not in ("uart", "eth"):
            return
        prev = self._status_0d_x9_origin.get(rk)
        if origin == "uart" and prev == "eth":
            self.event_log.emit(
                "status_0d_x9_origin_keep",
                tid=rk[0],
                game=rk[1],
                origin=prev,
                rejected=origin,
                reason="eth_sticky",
            )
            return
        self._status_0d_x9_origin[rk] = origin
        if prev != origin:
            self.event_log.emit(
                "status_0d_x9_origin",
                tid=rk[0],
                game=rk[1],
                origin=origin,
                prev=prev,
            )

    def _x9_is_uart_origin(self, rk: RoundKey) -> bool:
        return self._status_0d_x9_origin.get(rk) == "uart"

    def _lab_withhold_close_for_round(
        self,
        rk: RoundKey | None,
        *,
        where: str,
        **fields: Any,
    ) -> bool:
        """LAB: True = do not synth/auto-close 0D for this round (consumes one credit)."""
        if rk is None or not self.lab_withhold_status_0d:
            return False
        if rk in self._lab_withhold_status_0d_rks:
            return True
        if self._lab_withhold_status_0d_remaining <= 0:
            return False
        self._lab_withhold_status_0d_remaining -= 1
        self._lab_withhold_status_0d_rks.add(rk)
        self.event_log.emit(
            "lab_withhold_status_0d",
            tid=rk[0],
            game=rk[1],
            where=where,
            remaining=self._lab_withhold_status_0d_remaining,
            **fields,
        )
        if self._lab_withhold_status_0d_remaining == 0:
            self.event_log.emit(
                "lab_withhold_status_0d_exhausted",
                tid=rk[0],
                game=rk[1],
                where=where,
            )
        return True

    def _skip_synth_master_uart_x9(self, rk: RoundKey, *, where: str) -> bool:
        """True when Master native path: UART-origin x9 ⇒ never synth 0D.

        Local POG422 closes with native 0x0D. Only eth-injected x9 (Slave) needs synth.
        """
        if not self._x9_is_uart_origin(rk):
            return False
        self.event_log.emit(
            "status_0d_synth_skip",
            reason="master_uart_x9_native_0d",
            game=rk[1],
            tid=rk[0],
            where=where,
        )
        self._mrt_risk_abandon_await_0d(rk, reason="master_uart_x9_native_0d")
        return True

    def _mark_local_x1_pending(
        self,
        rk: RoundKey,
        *,
        trigger: str,
        msg: str | None = None,
        synth_defer: list[RoundKey] | None = None,
        harvest_path: str | None = None,
        **fields: Any,
    ) -> bool:
        """Record local x1 and guarantee a synth attempt once it is safe to inject."""
        if self._local_slave_tid and rk[0].upper() != self._local_slave_tid.upper():
            return False
        # EG 3.0.2.12: discard late x1 from a superseded/abandoned hand.
        if not self._local_hand_x9_current(rk):
            self.event_log.emit(
                "status_0d_synth_skip",
                reason="stale_hand_gen",
                msg=msg,
                game=rk[1],
                tid=rk[0],
                trigger=trigger,
                hand_gen=self._local_hand_gen_get(rk),
                x9_gen=self._local_hand_x9_gen.get(rk),
            )
            return False
        # Master: UART-origin x9 ⇒ native 0D; do not arm synth / await_0d.
        if self._skip_synth_master_uart_x9(rk, where=f"x1_pending:{trigger}"):
            return False
        if self._lab_withhold_close_for_round(
            rk,
            where=f"x1_pending:{trigger}",
            msg=msg,
            trigger=trigger,
        ):
            self.event_log.emit(
                "status_0d_synth_skip",
                reason="lab_withhold_status_0d",
                msg=msg,
                game=rk[1],
                tid=rk[0],
                trigger=trigger,
            )
            return False
        # EG 3.0.1.43: after uartd auto_close (or Python synth), never re-arm /
        # release synth_sent — that second path fired a duplicate copper 0D.
        if self._status_0d_close_done_for_round(rk):
            self.event_log.emit(
                "status_0d_synth_skip",
                reason="already_closed",
                msg=msg,
                game=rk[1],
                tid=rk[0],
                trigger=trigger,
                close_phase=self._round_close_phase(rk),
            )
            return False
        self._status_0d_x1_pending.add(rk)
        # Only Python (non-uartd) may clear synth_sent for counter-wrap resend.
        # With uartd, clearing after auto_done re-enabled a second Python inject.
        if not self.use_uartd_inject:
            self._release_synth_sent_for_x9_counter(rk)
        path = harvest_path or {
            "peer_x1_in_echo": "post_de_peel",
            "peer_x1_early_bus": "inject_echo_early",
            "no_x9_retry": "infer",
            "solo_x1": "uart_window",
            "last_in_window_x1": "uart_window",
            "local_x1_glued": "uart_window",
        }.get(trigger, "uart_window")
        try:
            self._mrt_risk_note_local_x1(rk, trigger=trigger, msg=msg)
            self.event_log.emit(
                "x1_harvest_path",
                tid=rk[0],
                game=rk[1],
                msg=msg,
                trigger=trigger,
                source=path,
                **{k: v for k, v in fields.items() if k in ("peer_msgs", "echo_len", "play_sequence", "packets_in_window")},
            )
            self.event_log.emit(
                "status_0d_synth_trigger",
                msg=msg,
                game=rk[1],
                tid=rk[0],
                trigger=trigger,
                harvest_path=path,
                **fields,
            )
        finally:
            # EG 3.0.1.50: late local x1 during miss-defer — stop waiting pure NO_X1
            # timer and close now with x1 knowledge (soak 1634: 41 @ +212 ms).
            if rk in self._status_0d_python_fallback_ready_ns:
                self._status_0d_python_fallback_ready_ns.pop(rk, None)
                self.event_log.emit(
                    "status_0d_python_fallback_late_x1",
                    tid=rk[0],
                    game=rk[1],
                    trigger=trigger,
                    msg=msg,
                    harvest_path=path,
                )
            # EG 3.0.1.5 / 3.0.1.43: uartd owns hard-RT 0D unless fallback armed.
            if self.use_uartd_inject and rk not in self._status_0d_python_fallback_rks:
                self.event_log.emit(
                    "status_0d_synth_skip",
                    reason="uartd_owns_close",
                    msg=msg,
                    game=rk[1],
                    tid=rk[0],
                    trigger=trigger,
                    harvest_path=path,
                )
            else:
                self._schedule_synth_attempt(rk, synth_defer=synth_defer)
        return True

    def _mrt_risk_abandon_await_0d(self, rk: RoundKey, *, reason: str) -> None:
        """Drop close-wait for rk so P0 defer cannot stick forever (synth fail / timeout)."""
        had = rk in self._mrt_risk_await_0d or rk in self._status_0d_x1_pending
        self._mrt_risk_await_0d.pop(rk, None)
        self._mrt_risk_logged_0d.discard(rk)
        self._status_0d_x1_pending.discard(rk)
        self._status_0d_await_passthrough_logged.discard(rk)
        self._status_0d_python_fallback_rks.discard(rk)
        self._status_0d_python_fallback_ready_ns.pop(rk, None)
        while rk in self._synth_retry_rks:
            self._synth_retry_rks.remove(rk)
        # Clear synth_sent so a later re-arm is not stuck on already_sent.
        for synth_key in list(self._status_0d_synth_sent):
            if synth_key[0] == rk:
                self._status_0d_synth_sent.discard(synth_key)
        if not had:
            return
        self.event_log.emit(
            "mrt_risk_await_0d_abandon",
            tid=rk[0],
            game=rk[1],
            reason=reason,
        )
        if not self._local_close_pending():
            self._flush_transaction_deferred()
            self.event_log.try_rotate_debug_logs()

    def _emit_inject_deferred_rate_limited(
        self,
        *,
        reason: str,
        now_ns: int,
        **fields: Any,
    ) -> None:
        """Hot-path safe: at most one defer log per reason per interval."""
        min_ns = int(INJECT_DEFER_LOG_MIN_INTERVAL_MS * 1_000_000)
        last = self._inject_defer_log_ns.get(reason)
        if last is not None and (now_ns - last) < min_ns:
            return
        self._inject_defer_log_ns[reason] = now_ns
        self.event_log.emit("inject_deferred", reason=reason, **fields)

    def _emit_transaction_defer_rate_limited(
        self,
        *,
        reason: str,
        now_ns: int,
        **fields: Any,
    ) -> None:
        """Hot-path safe: at most one transaction_defer log per reason per interval."""
        min_ns = int(INJECT_DEFER_LOG_MIN_INTERVAL_MS * 1_000_000)
        last = self._transaction_defer_log_ns.get(reason)
        if last is not None and (now_ns - last) < min_ns:
            return
        self._transaction_defer_log_ns[reason] = now_ns
        self.event_log.emit("transaction_defer", reason=reason, **fields)

    def _emit_relay_synth_skip_rate_limited(
        self,
        *,
        reason: str,
        now_ns: int,
        **fields: Any,
    ) -> None:
        """Hot-path safe: at most one same-game relay skip log per reason per interval."""
        min_ns = int(INJECT_DEFER_LOG_MIN_INTERVAL_MS * 1_000_000)
        last = self._relay_synth_skip_log_ns.get(reason)
        if last is not None and (now_ns - last) < min_ns:
            return
        self._relay_synth_skip_log_ns[reason] = now_ns
        self.event_log.emit("status_0d_relay_synth_skip", reason=reason, **fields)

    def _mrt_risk_note_closing_0d(self, rk: RoundKey | None) -> None:
        if rk is None:
            return
        self._mrt_risk_await_0d.pop(rk, None)
        self._mrt_risk_logged_0d.discard(rk)
        self._status_0d_x1_pending.discard(rk)
        self._status_0d_await_passthrough_logged.discard(rk)
        self._mrt_risk_clear_await_x1(rk)
        self._mrt_risk_mark_seq_closed(rk)
        self._mrt_risk_x9_diag.pop(rk, None)
        # Good close: drop x9 seal so the next local x0 is a cold hand, not a
        # false mrt_risk_hand_txn_restart (Pi HS 89/81 porch soaks).
        self._local_hand_x9_gen.pop(rk, None)
        # Synth 0D done â€” release stashed P1 traffic even if trunk was already closed.
        if not self._local_close_pending():
            self._flush_transaction_deferred()
            self.event_log.try_rotate_debug_logs()

    def _mrt_risk_tick(self) -> None:
        if not self.status_0d_synth:
            return
        now = time.perf_counter_ns()
        # Evict foreign await_0d keys (solo-x1 arm bug / stale) so P0 gate stays local-only.
        if self._local_slave_tid:
            local_u = self._local_slave_tid.upper()
            for rk in list(self._mrt_risk_await_0d):
                if rk[0].upper() != local_u:
                    self._mrt_risk_abandon_await_0d(rk, reason="non_local_await_0d")
        miss_ns = int(MRT_RISK_MISSING_X1_MS * 1_000_000)
        late_ns = int(MRT_RISK_LATE_0D_MS * 1_000_000)
        infer_ns = int(MRT_X1_INFER_NO_X9_RETRY_MS * 1_000_000)
        infer_after_retry_ns = int(MRT_X1_INFER_AFTER_X9_RETRY_MS * 1_000_000)
        # Harvest-miss close: disabled EG 3.0.1.55 (see _mrt_risk_infer_x1_no_x9_retry).
        # Still call so soaks emit mrt_x1_infer_skipped_hand_open while hand stays open.
        for rk, armed_ns in list(self._mrt_risk_await_x1.items()):
            if rk in self._mrt_risk_logged_x1:
                continue
            if rk in self._mrt_risk_x9_retry_seen:
                if now - armed_ns < infer_after_retry_ns:
                    continue
                wait_ms = round((now - armed_ns) / 1_000_000.0, 1)
                self._mrt_risk_infer_x1_no_x9_retry(
                    rk,
                    wait_ms=wait_ms,
                    after_x9_retry=True,
                )
                continue
            if now - armed_ns < infer_ns:
                continue
            wait_ms = round((now - armed_ns) / 1_000_000.0, 1)
            self._mrt_risk_infer_x1_no_x9_retry(rk, wait_ms=wait_ms)
        for rk, armed_ns in list(self._mrt_risk_await_x1.items()):
            if rk in self._mrt_risk_logged_x1:
                continue
            if now - armed_ns < miss_ns:
                continue
            wait_ms = round((now - armed_ns) / 1_000_000.0, 1)
            self._mrt_risk_logged_x1.add(rk)
            # Not true MRT â€” slave never ACKed x9 with x1 (may re-wager with x0).
            diag = self._mrt_risk_x9_diag.get(rk) or {}
            self.event_log.emit(
                "x9_no_local_x1",
                tid=rk[0],
                game=rk[1],
                wait_ms=wait_ms,
                bound_ms=MRT_RISK_MISSING_X1_MS,
                play_sequence=diag.get("play_sequence"),
                echo_len=diag.get("echo_len"),
                echo_match=diag.get("echo_match"),
                post_de_len=diag.get("post_de_len"),
                post_de_match=diag.get("post_de_match"),
                peer_len=diag.get("peer_len"),
                peer_msgs=diag.get("peer_msgs") or None,
                first_peer_byte=diag.get("first_peer_byte"),
                x9_retry_seen=rk in self._mrt_risk_x9_retry_seen,
            )
            # Real gate cleanup: clear await_x1 / local trunk / deferred foreign.
            # Keep logged_x1 so a late machine x1 can still arm await_0d.
            self._mrt_risk_abandon_await_x1(
                rk,
                reason="missing_x1_timeout",
                wait_ms=wait_ms,
            )
        self._prune_stale_trunk_rounds(now)
        # Heal / retry: await_0d must keep trying synth every tick (covers
        # await_passthrough until x9 hint arrives, and note-without-schedule races).
        # EG 3.0.1.5: uartd owns close — do not re-queue Python synth.
        for rk in list(self._mrt_risk_await_0d):
            if rk not in self._status_0d_x1_pending:
                self._status_0d_x1_pending.add(rk)
            if not self.use_uartd_inject:
                self._schedule_synth_attempt(rk)
        for rk, x1_ns in list(self._mrt_risk_await_0d.items()):
            if rk in self._mrt_risk_logged_0d:
                continue
            if now - x1_ns < late_ns:
                continue
            self._mrt_risk_logged_0d.add(rk)
            # True MRT class: local x1 seen, closing 0x0D not completed in time.
            queued_0d = self._status_0d_synth_queued_for(rk)
            mid_flight = any(sk[0] == rk for sk in self._status_0d_synth_sent)
            self.event_log.emit(
                "mrt_risk_late_0d_after_x1",
                tid=rk[0],
                game=rk[1],
                wait_ms=round((now - x1_ns) / 1_000_000.0, 1),
                bound_ms=MRT_RISK_LATE_0D_MS,
                close_phase=self._round_close_phase(rk),
                queued_0d=queued_0d,
                mid_flight=mid_flight,
            )
            # Glass will re-Play same seq after product MRT — do not latch ok-close.
            self._mrt_risk_close_quality[rk] = "recovery"
            self._mrt_risk_close_quality_pending[rk] = "recovery"
            # EG 2.2.2.69: never abandon while P0 0D is queued or mid-flight —
            # late_0d used to clear pending behind _tx_lock/HS (MRT hole).
            if queued_0d or mid_flight:
                self.event_log.emit(
                    "mrt_risk_late_0d_keep_pending",
                    tid=rk[0],
                    game=rk[1],
                    reason="queued_or_inflight_0d",
                    close_phase=self._round_close_phase(rk),
                )
                continue
            # Abandon sticky wait so P0 defer cannot starve Master SCR/STOM.
            self._mrt_risk_abandon_await_0d(rk, reason="late_0d_timeout")

    def _prune_stale_trunk_rounds(self, now_ns: int | None = None) -> None:
        """Abandon open trunk rounds that never closed (overnight gate stick)."""
        if channel_uses_raw_hs_window(self.channel):
            return
        if now_ns is None:
            now_ns = time.perf_counter_ns()
        stale_ns = int(TRUNK_ROUND_STALE_MS * 1_000_000)
        local_u = self._local_slave_tid.upper() if self._local_slave_tid else None
        for tid, st in list(self._trunk_rounds.items()):
            if st.closed:
                continue
            age_ns = now_ns - st.opened_perf_ns
            stale_this = stale_ns
            if local_u and tid == local_u:
                stale_this = int(LOCAL_GAME_TXN_MAX_MS * 1_000_000)
            if age_ns < stale_this:
                continue
            # Local close path owns its trunk until await_x1 / await_0d abandon.
            if local_u and tid == local_u:
                if any(rk[0].upper() == local_u for rk in self._mrt_risk_await_x1):
                    continue
                if any(rk[0].upper() == local_u for rk in self._mrt_risk_await_0d):
                    continue
                if any(
                    p.source == "status_0d_synth"
                    and p.tid
                    and p.tid.upper() == local_u
                    for p in self._pending
                ):
                    continue
            age_ms = round(age_ns / 1_000_000.0, 1)
            self.event_log.emit(
                "trunk_round_stale_abandon",
                tid=tid,
                game=st.game,
                age_ms=age_ms,
                bound_ms=(
                    LOCAL_GAME_TXN_MAX_MS
                    if local_u and tid == local_u
                    else TRUNK_ROUND_STALE_MS
                ),
                x9_injected=st.x9_injected,
                x1_seen=st.x1_seen,
            )
            self._trunk_round_close(tid, st.game)

    def _prune_status_0d_dedupe(self, now_ns: int) -> None:
        if not self._status_0d_seen:
            return
        ttl_ns = int(self.status_0d_dedupe_ms * 1_000_000)
        cutoff = now_ns - ttl_ns
        self._status_0d_seen = {
            key: ts for key, ts in self._status_0d_seen.items() if ts >= cutoff
        }

    def _evict_superseded_status_cache(self, game_id: str, counter: int) -> None:
        stale = [k for k in self._status_0d_cache if k[0] == game_id and k[1] < counter]
        for key in stale:
            self._status_0d_cache.pop(key, None)
            self.event_log.emit(
                "status_0d_cache_evict",
                game=key[0],
                counter=key[1],
                reason="superseded",
            )

    def _note_global_close_counter(self, counter: int | None) -> None:
        if counter is None:
            return
        self._global_close_counter = note_status_0d_global_counter(
            self._global_close_counter,
            counter,
        )

    def _cache_status_0d(self, frame: bytes, meta) -> None:
        key = status_close_dedupe_key(meta)
        if key is None:
            return
        self._note_global_close_counter(key[1])
        self._evict_superseded_status_cache(key[0], key[1])
        self._status_0d_cache[key] = frame
        self._status_0d_template[key[0]] = frame
        self._status_0d_high_counter[key[0]] = max(
            self._status_0d_high_counter.get(key[0], 0),
            key[1],
        )
        self.event_log.emit(
            "status_0d_cached",
            game=key[0],
            counter=key[1],
            len=len(frame),
        )
        self._try_synth_pending_for_game(key[0])

    def _round_key(self, meta) -> RoundKey | None:
        if not meta.game_id_hex or not meta.tid_hex:
            return None
        return (meta.tid_hex.upper(), meta.game_id_hex.upper())

    def _local_round_key_for_game(self, game_id_hex: str) -> RoundKey | None:
        if not self._local_slave_tid or not game_id_hex:
            return None
        return (self._local_slave_tid.upper(), game_id_hex.upper())

    def _needs_relay_eth_synth(self, meta) -> bool:
        """ETH inject carries another terminal's TID â€” rekey status synth to learned local."""
        if not self.status_0d_synth or not self._relay_eth_synth_enabled:
            return False
        if not self._local_slave_tid or not meta.tid_hex:
            return False
        return meta.tid_hex.upper() != self._local_slave_tid.upper()

    def _local_round_has_state(self, rk: RoundKey) -> bool:
        """True when the local seat is already tracking this game round."""
        if self._local_round_has_open_close_wait(rk):
            return True
        if rk in self._status_0d_last_wager or rk in self._status_0d_last_x9:
            return True
        if rk in self._local_round_latest_seq:
            return True
        return any(key[0] == rk for key in self._status_0d_synth_sent)

    def _note_native_uart_status_close(self) -> None:
        """Local T340+ originated Master status close â€” native close, not relay synth."""
        if not self._relay_eth_synth_enabled:
            return
        self._relay_eth_synth_enabled = False
        self.event_log.emit("native_uart_status_close")

    def _note_local_slave_tid_from_uart_origin(
        self,
        tid_hex: str | None,
        msg_type: int | None = None,
    ) -> None:
        """First UARTâ†’ETH origin TID for this session (local T340+, not inject echo)."""
        if self._local_slave_tid is not None:
            return
        if not local_slave_tid_uart_learn_ok(
            tid_hex,
            msg_type,
            eth_inject_tid_blacklist=self._eth_inject_tid_blacklist,
        ):
            if (
                tid_hex
                and tid_hex.upper() in self._eth_inject_tid_blacklist
            ):
                self.event_log.emit(
                    "local_slave_tid_learn_skip",
                    reason="eth_inject_blacklist",
                    tid=tid_hex.upper(),
                )
            return
        self._local_slave_tid = tid_hex.upper()
        self.event_log.emit(
            "local_slave_tid_learned",
            tid=self._local_slave_tid,
            source="uart_origin",
            msg=f"{msg_type:02X}" if msg_type is not None else None,
        )

    def _blacklist_eth_inject_tid(self, tid_hex: str | None) -> None:
        """ETH-origin TID about to inject â€” do not learn it from UART echo afterward."""
        eth_inject_tid_blacklist_add(
            self._eth_inject_tid_blacklist,
            self._eth_inject_tid_blacklist_order,
            tid_hex,
        )

    def _sync_ac_armed_from_q(self) -> None:
        """Keep legacy _ac_armed_* as head of FIFO (x1 late IPC labels)."""
        if self._ac_arm_q:
            self._ac_armed_frame, self._ac_armed_meta = self._ac_arm_q[0]
        else:
            self._ac_armed_frame = None
            self._ac_armed_meta = None

    def _push_ac_arm(self, frame: bytes, meta) -> None:
        self._ac_arm_q.append((bytes(frame), meta))
        self._sync_ac_armed_from_q()

    def _pop_ac_arm(self) -> tuple[bytes | None, Any]:
        if not self._ac_arm_q:
            self._sync_ac_armed_from_q()
            return None, None
        frame, meta = self._ac_arm_q.pop(0)
        self._sync_ac_armed_from_q()
        return frame, meta

    def _apply_uartd_auto_close_result(
        self,
        frame: bytes,
        meta,
        ac,
        *,
        inject_echo: bytes = b"",
        inject_post_de: bytes = b"",
        via: str = "inject_result",
    ) -> None:
        """Bookkeeping for C-side auto-close OK or miss (inline or late IPC)."""
        if meta is None:
            return
        if ac is not None and bool(getattr(ac, "ok", False)):
            rk_ac = self._round_key(meta)
            if rk_ac is not None and not self._local_hand_x9_current(rk_ac):
                self.event_log.emit(
                    "status_0d_auto_close_skip",
                    reason="stale_hand_gen",
                    tid=meta.tid_hex,
                    game=meta.game_id_hex,
                    msg=meta.msg_type_hex,
                    play_sequence=meta.play_sequence,
                    hand_gen=self._local_hand_gen_get(rk_ac),
                    x9_gen=self._local_hand_x9_gen.get(rk_ac),
                    via=via,
                )
                return
            self._note_uartd_auto_close(frame, meta, ac)
            return
        if self._mrt_risk_seq_already_closed(meta):
            self.event_log.emit(
                "status_0d_auto_close_skip",
                reason="seq_already_closed",
                tid=meta.tid_hex,
                game=meta.game_id_hex,
                msg=meta.msg_type_hex,
                err=int(getattr(ac, "err", -1) or -1),
                x1_msg=f"{int(getattr(ac, 'x1_msg', 0) or 0):02X}",
                harvest=int(getattr(ac, "harvest", 0) or 0),
                play_sequence=meta.play_sequence,
                via=via,
            )
            return
        self.event_log.emit(
            "status_0d_auto_close_miss",
            tid=meta.tid_hex,
            game=meta.game_id_hex,
            msg=meta.msg_type_hex,
            err=int(getattr(ac, "err", -1) or -1),
            x1_msg=f"{int(getattr(ac, 'x1_msg', 0) or 0):02X}",
            harvest=int(getattr(ac, "harvest", 0) or 0),
            play_sequence=meta.play_sequence,
            via=via,
        )
        miss_rk = self._round_key(meta)
        if miss_rk is None:
            return
        # Late AUTO_CLOSE_DONE / pending_expire has no inject_echo argument — recover
        # self-echo proof from the inject-time mrt_x9 diag (EG 4.0.0.70). Without
        # this, echo_ok stays False and empty-harvest reinject never arms while
        # Master ~125 ms retries pile up behind local_game_txn_exclusive.
        echo_blob = inject_echo or b""
        post_blob = inject_post_de or b""
        echo_ok_source = "inject_arg"
        diag = self._mrt_risk_x9_diag.get(miss_rk) or {}
        if (
            not echo_blob
            and diag.get("play_sequence") == meta.play_sequence
            and isinstance(diag.get("echo"), (bytes, bytearray))
            and diag.get("echo")
        ):
            echo_blob = bytes(diag["echo"])
            echo_ok_source = "x9_diag_echo"
        if (
            not post_blob
            and diag.get("play_sequence") == meta.play_sequence
            and isinstance(diag.get("post_de"), (bytes, bytearray))
            and diag.get("post_de")
        ):
            post_blob = bytes(diag["post_de"])
        peer_peek = peer_uart_bytes_after_inject(
            frame,
            echo_blob,
            post_blob,
        )
        echo_ok, _echo_how = inject_echo_bytes_match(frame, echo_blob)
        if (
            not echo_ok
            and diag.get("play_sequence") == meta.play_sequence
            and bool(diag.get("echo_ok"))
        ):
            # Diag already matched at inject; pending_expire lost the bytes.
            echo_ok = True
            echo_ok_source = "x9_diag_flag"
        cgs_40 = False
        for blob in (peer_peek, echo_blob, post_blob):
            if not blob or cgs_40:
                continue
            try:
                norm = normalize_window(blob) or blob
                for pkt in split_packets(norm):
                    pm = parse_packet_meta(pkt)
                    if is_gameplay_request(pm.msg_type):
                        cgs_40 = True
                        break
            except Exception:
                pass
        if cgs_40:
            self.event_log.emit(
                "status_0d_auto_close_miss_cgs_no_synth",
                tid=meta.tid_hex,
                game=meta.game_id_hex,
                msg=meta.msg_type_hex,
                harvest=int(getattr(ac, "harvest", 0) or 0),
                err=int(getattr(ac, "err", -1) or -1),
                play_sequence=meta.play_sequence,
                reason="peer_gameplay_request_during_x9",
                via=via,
            )
        self._hand_open_after_auto_close_miss(
            miss_rk,
            meta,
            cgs_40=cgs_40,
            frame=frame,
            echo_ok=bool(echo_ok),
            harvest=int(getattr(ac, "harvest", 0) or 0),
            err=int(getattr(ac, "err", -1) or -1),
            msg=meta.msg_type_hex,
            via=via,
            echo_len=len(echo_blob),
            post_de_len=len(post_blob),
            peer_len=len(peer_peek or b""),
            echo_ok_source=echo_ok_source,
        )

    def _note_uartd_auto_close(self, frame: bytes, meta, ac) -> bool:
        """After-the-fact FSM for EG 3.0.1.1 C-side synth 0D (already on copper)."""
        rk = self._round_key(meta)
        if rk is None:
            return False
        counter = int(getattr(ac, "counter", 0)) & 0xFF
        x1_msg = f"{int(getattr(ac, 'x1_msg', 0)):02X}"
        harvest = int(getattr(ac, "harvest", 0))
        synth_key = (rk, counter)
        self._status_0d_synth_sent.add(synth_key)
        self._note_global_close_counter(counter)
        self._status_0d_high_counter[rk[1]] = max(
            self._status_0d_high_counter.get(rk[1], 0),
            counter,
        )
        self._status_0d_x1_pending.discard(rk)
        self._status_0d_python_fallback_rks.discard(rk)
        self._status_0d_python_fallback_ready_ns.pop(rk, None)
        self._clear_empty_harvest_x9_retry(rk)
        while rk in self._synth_retry_rks:
            self._synth_retry_rks.remove(rk)
        self._mrt_risk_await_0d.pop(rk, None)
        # Drop any Python-queued duplicate 0D for this round.
        if self._pending:
            kept = []
            for p in self._pending:
                if (
                    p.source == "status_0d_synth"
                    and p.tid
                    and p.tid.upper() == rk[0]
                ):
                    continue
                kept.append(p)
            self._replace_pending_contents(kept)
        self.event_log.emit(
            "status_0d_synth_done",
            game=rk[1],
            tid=rk[0],
            counter=counter,
            msg="0D",
            source="uartd_auto",
            x1_msg=x1_msg,
            harvest=(
                "echo"
                if harvest == 1
                else "post_de"
                if harvest == 2
                else "hs_window"
                if harvest == 3
                else "immediate_post_x9"
                if harvest == 4
                else "pending_expire"
                if harvest == 5
                else str(harvest)
            ),
            od_total_us=round(int(getattr(ac, "od_total_ns", 0)) / 1000.0, 1),
            play_sequence=meta.play_sequence,
        )
        # Successful uartd auto-close is an ok close (not miss recovery latch).
        self._mrt_risk_close_quality_pending.pop(rk, None)
        self._mrt_risk_note_closing_0d(rk)
        return True

    def _local_x1_ack_frames_for_eth(self, ack: bytes) -> list[bytes]:
        """EG 3.0.1.21: only wire-len-valid local gameplay ACKs may eth-forward."""
        return extract_valid_local_x1_ack_frames(ack, self._local_slave_tid)

    def _on_uartd_x1_ack_frame_ipc(self, ack: bytes) -> None:
        """EG 3.0.1.20+: late X1_ACK_FRAME (pending auto-close outside inject wait)."""
        if not ack or self.forward_uart_origin_window is None:
            return
        frames = self._local_x1_ack_frames_for_eth(ack)
        meta = self._ac_armed_meta
        if not frames:
            self.event_log.emit(
                "inject_x1_ack_early_eth_reject",
                source="auto_close_pending",
                sent_msg=(meta.msg_type_hex if meta is not None else None),
                sent_tid=(meta.tid_hex if meta is not None else None),
                peer_len=len(ack),
                reason="not_wire_len_local_ack",
                via="late_ipc",
            )
            if self._orphan_rx_active():
                self.event_log.emit(
                    "post_x9_orphan_x1_ipc_reject",
                    tid=(meta.tid_hex if meta is not None else None),
                    peer_len=len(ack),
                    hex=self._pkt_hex_csv(ack),
                    watch_tid=(self._orphan_rx_rk[0] if self._orphan_rx_rk else None),
                )
            return
        fwd = b"".join(frames)
        t_fwd0 = time.perf_counter_ns()
        self.forward_uart_origin_window(
            bytearray(fwd),
            uart_first_byte=fwd[0],
        )
        t_fwd1 = time.perf_counter_ns()
        ack_msgs = [f"{pkt[1]:02X}" for pkt in frames]
        self.event_log.emit(
            "inject_x1_ack_early_eth",
            source="auto_close_pending",
            sent_msg=(meta.msg_type_hex if meta is not None else None),
            sent_tid=(meta.tid_hex if meta is not None else None),
            peer_len=len(fwd),
            peer_msgs=",".join(ack_msgs) if ack_msgs else None,
            fwd_us=round((t_fwd1 - t_fwd0) / 1000.0, 1),
            via="late_ipc",
        )
        if self._orphan_rx_active() and self._orphan_rx_armed_ns is not None:
            dt_ms = round(
                (time.perf_counter_ns() - self._orphan_rx_armed_ns) / 1_000_000.0, 1
            )
            self._orphan_rx_x1_hits += 1
            self.event_log.emit(
                "post_x9_orphan_x1_hit",
                tid=(self._orphan_rx_rk[0] if self._orphan_rx_rk else None),
                game=(self._orphan_rx_rk[1] if self._orphan_rx_rk else None),
                play_sequence=self._orphan_rx_seq,
                dt_ms=dt_ms,
                msg=",".join(ack_msgs) if ack_msgs else None,
                len=len(fwd),
                hex=self._pkt_hex_csv(fwd),
                via="x1_ack_ipc",
            )
        emit_peer_gameplay_eth_fwd(
            self,
            fwd,
            source="auto_close_pending",
            sent_msg=(meta.msg_type_hex if meta is not None else None),
            sent_tid=(meta.tid_hex if meta is not None else None),
        )

    def _on_uartd_auto_close_done_ipc(self, ev: dict) -> None:
        """EG 3.0.1.5 / 3.0.2.2 / 4.0.0.63: late AUTO_CLOSE_DONE (OK or miss).

        Success AC is not waited inside inject(). Empty peel is also deferred:
        uartd arms pending and only sends NO_X1 (harvest=5) on pending expire.
        Miss bookkeeping / EG60 reinject run here — not on inject_result.
        """
        from pog422_uart_client import AutoCloseResult

        ac = AutoCloseResult(
            err=int(ev.get("err") or 0),
            x1_msg=int(ev.get("x1_msg") or 0),
            counter=int(ev.get("counter") or 0),
            harvest=int(ev.get("harvest") or 0),
            od_tx_ns=int(ev.get("od_tx_ns") or 0),
            od_total_ns=int(ev.get("od_total_ns") or 0),
        )
        frame, meta = self._pop_ac_arm()
        if frame is None or meta is None:
            self.event_log.emit(
                "status_0d_auto_close_late_orphan",
                err=ev.get("err"),
                harvest=ev.get("harvest"),
                x1_msg=f"{int(ev.get('x1_msg') or 0):02X}",
                ok=bool(ev.get("ok")),
            )
            return
        via = "late_ipc"
        if (not ac.ok) and int(ac.harvest) == 5:
            via = "pending_expire"
        self._apply_uartd_auto_close_result(
            frame,
            meta,
            ac,
            via=via,
        )

    def _meta_is_local_terminal(self, meta) -> bool:
        if not meta.tid_hex or self._local_slave_tid is None:
            return False
        return meta.tid_hex.upper() == self._local_slave_tid

    def _lab_gameplay_note_eth_outcome(self, frame: bytes, meta) -> None:
        """Notify lab seat SM of Master x9 on eth_rx (all game families)."""
        synth = getattr(self, "_lab_gameplay_synth", None)
        if synth is None or meta is None:
            return
        if not is_gameplay_response(meta.msg_type):
            return
        if not self._meta_is_local_terminal(meta):
            return
        if not callable(getattr(synth, "on_master_outcome", None)):
            return
        try:
            synth.on_master_outcome(frame, meta)
        except Exception as exc:
            self.event_log.emit(
                "lab_gameplay_synth_skip",
                reason="eth_outcome_hook_failed",
                tid=meta.tid_hex,
                detail=str(exc),
            )

    def _pending_inject_is_local_terminal(self, pending: PendingInject) -> bool:
        if self._local_slave_tid is None:
            return False
        if pending.tid and pending.tid.upper() == self._local_slave_tid:
            return True
        meta = parse_packet_meta(pending.frame)
        return (
            meta.tid_hex is not None
            and meta.tid_hex.upper() == self._local_slave_tid
        )

    def _status_0d_synth_queued_for(self, rk: RoundKey) -> bool:
        """True if a synth 0D for this round (or untid'd close) is on ``_pending``."""
        tid_u = rk[0].upper()
        return any(
            p.source == "status_0d_synth"
            and (not p.tid or str(p.tid).upper() == tid_u)
            for p in self._pending
        )

    def _round_close_phase(self, rk: RoundKey) -> str:
        """Thin close FSM label for logs: Open → AwaitX1 → Await0D → Closed."""
        if rk in self._mrt_risk_await_0d or rk in self._status_0d_x1_pending:
            return "Await0D"
        if rk in self._mrt_risk_await_x1:
            return "AwaitX1"
        st = self._trunk_rounds.get(rk[0].upper())
        if st is not None and not st.closed:
            return "Open"
        return "Closed"

    def _local_game_txn_exclusive(self) -> bool:
        """EG 3.0.1.51 / 4.1.0.0: local slave x0→0D exclusive — other eth→UART must wait.

        Hard for WiFi star Pi HS Window 1→2: progressive 0A must not inject between
        our 89 self-echo window and Slave 81 HS-IN window (FIFO accounting).

        Hub (EDGE_TOPOLOGY=hub) keeps the narrow “await local 0D only” rule so
        peer spokes are not frozen. Field spokes / bench slaves use full exclusive
        from trunk open (x0) through synth/auto 0D.
        """
        if self._is_hub_spoke_master():
            return self._local_close_pending_0d_only()
        local = self._local_slave_tid
        if not local:
            return self._local_close_pending_0d_only()
        local_u = local.upper()
        if self._trunk_round_active_for_tid(local_u):
            return True
        if any(rk[0].upper() == local_u for rk in self._mrt_risk_await_x1):
            return True
        if any(rk[0].upper() == local_u for rk in self._mrt_risk_await_0d):
            return True
        if any(rk[0].upper() == local_u for rk in self._status_0d_x1_pending):
            return True
        if any(
            p.source == "status_0d_synth"
            and p.tid
            and str(p.tid).upper() == local_u
            for p in self._pending
        ):
            return True
        st = self._tid_tx_get(local_u)
        if st is not None and st.phase != "idle":
            return True
        return False

    def _local_close_pending_0d_only(self) -> bool:
        """True while *local* x1 awaits synth 0x0D or a synth 0D is queued."""
        local = self._local_slave_tid
        if local:
            local_u = local.upper()
            if any(rk[0].upper() == local_u for rk in self._mrt_risk_await_0d):
                return True
        elif self._mrt_risk_await_0d:
            return True
        return any(p.source == "status_0d_synth" for p in self._pending)

    def _local_close_pending(self) -> bool:
        """Exclusive local game window (x0…0D) on spokes; 0D-only on hub Master."""
        return self._local_game_txn_exclusive()

    def _is_hub_spoke_master(self) -> bool:
        """Wlan-star hub: many spoke TIDs share one Master UART — do not freeze peers."""
        return (os.environ.get("EDGE_TOPOLOGY") or "").strip().lower() == "hub"

    def _is_edge_spoke_topology(self) -> bool:
        """EDGE_TOPOLOGY=spoke: seat has a cabinet TID, not Master UART for multi-TID."""
        return (os.environ.get("EDGE_TOPOLOGY") or "").strip().lower() == "spoke"

    def _mrt_x1_orphan_diag_enabled(self) -> bool:
        """EG 4.1.0.0: orphan RX is diag-only (default off). Pi HS Window 2 is authority."""
        raw = (os.environ.get("MRT_X1_ORPHAN_DIAG") or "0").strip().lower()
        return raw not in ("0", "false", "no", "off")

    def _empty_harvest_x9_reinject_enabled(self) -> bool:
        """EG 4.1.0.0: empty-harvest reinject demoted (default off)."""
        raw = (os.environ.get("MRT_EMPTY_HARVEST_REINJECT") or "0").strip().lower()
        return raw not in ("0", "false", "no", "off")

    @staticmethod
    def _pkt_hex_csv(pkt: bytes, *, limit: int = 48) -> str:
        body = pkt[:limit]
        s = ",".join(f"{b:02X}" for b in body)
        if len(pkt) > limit:
            s += ",..."
        return s

    def _arm_post_x9_orphan_rx(
        self,
        meta,
        *,
        peel_src: int,
        post_de_len: int,
        reason: str,
    ) -> None:
        """EG 4.0.0.71: watch copper after empty peel for a late Slave 81."""
        if not self._mrt_x1_orphan_diag_enabled():
            return
        if not self.status_0d_synth or meta is None:
            return
        if not self._meta_is_local_terminal(meta):
            return
        if not is_gameplay_response(meta.msg_type):
            return
        # Only when uartd did not harvest x1 (peel_src=0) or post-DE was empty.
        if int(peel_src) != 0 and int(post_de_len) > 0:
            return
        rk = self._round_key(meta)
        if rk is None:
            return
        now = time.perf_counter_ns()
        self._orphan_rx_rk = rk
        self._orphan_rx_seq = meta.play_sequence
        self._orphan_rx_armed_ns = now
        self._orphan_rx_until_ns = now + int(POST_X9_ORPHAN_RX_MS * 1_000_000)
        self._orphan_rx_hits = 0
        self._orphan_rx_x1_hits = 0
        self.event_log.emit(
            "post_x9_orphan_rx_arm",
            tid=rk[0],
            game=rk[1],
            play_sequence=meta.play_sequence,
            peel_src=int(peel_src),
            post_de_len=int(post_de_len),
            window_ms=POST_X9_ORPHAN_RX_MS,
            reason=reason,
        )

    def _orphan_rx_active(self, now_ns: int | None = None) -> bool:
        until = self._orphan_rx_until_ns
        if until is None or self._orphan_rx_rk is None:
            return False
        if now_ns is None:
            now_ns = time.perf_counter_ns()
        if now_ns >= until:
            self._disarm_post_x9_orphan_rx(reason="window_elapsed", now_ns=now_ns)
            return False
        return True

    def _disarm_post_x9_orphan_rx(
        self,
        *,
        reason: str,
        now_ns: int | None = None,
    ) -> None:
        rk = self._orphan_rx_rk
        if rk is None and self._orphan_rx_until_ns is None:
            return
        armed = self._orphan_rx_armed_ns
        if now_ns is None:
            now_ns = time.perf_counter_ns()
        elapsed_ms = (
            round((now_ns - armed) / 1_000_000.0, 1) if armed is not None else None
        )
        self.event_log.emit(
            "post_x9_orphan_rx_end",
            tid=(rk[0] if rk else None),
            game=(rk[1] if rk else None),
            play_sequence=self._orphan_rx_seq,
            reason=reason,
            elapsed_ms=elapsed_ms,
            hits=self._orphan_rx_hits,
            x1_hits=self._orphan_rx_x1_hits,
        )
        self._orphan_rx_until_ns = None
        self._orphan_rx_armed_ns = None
        self._orphan_rx_rk = None
        self._orphan_rx_seq = None
        self._orphan_rx_hits = 0
        self._orphan_rx_x1_hits = 0

    def _note_post_x9_orphan_packet(self, meta, pkt: bytes) -> None:
        if not self._orphan_rx_active():
            return
        rk = self._orphan_rx_rk
        armed = self._orphan_rx_armed_ns
        if rk is None or armed is None or meta is None:
            return
        now = time.perf_counter_ns()
        dt_ms = round((now - armed) / 1_000_000.0, 1)
        local = bool(
            meta.tid_hex and meta.tid_hex.upper() == rk[0].upper()
        )
        is_x1 = bool(is_gameplay_ack(meta.msg_type) and local)
        self._orphan_rx_hits += 1
        if is_x1:
            self._orphan_rx_x1_hits += 1
        self.event_log.emit(
            "post_x9_orphan_rx",
            tid=meta.tid_hex,
            game=meta.game_id_hex,
            msg=meta.msg_type_hex,
            play_sequence=meta.play_sequence,
            watch_tid=rk[0],
            watch_seq=self._orphan_rx_seq,
            local_tid=local,
            is_x1=is_x1,
            dt_ms=dt_ms,
            len=len(pkt),
            hex=self._pkt_hex_csv(pkt),
        )
        if is_x1:
            self.event_log.emit(
                "post_x9_orphan_x1_hit",
                tid=rk[0],
                game=rk[1],
                play_sequence=self._orphan_rx_seq,
                dt_ms=dt_ms,
                msg=meta.msg_type_hex,
                len=len(pkt),
                hex=self._pkt_hex_csv(pkt),
            )
            # Keep watching briefly for late 0D; do not disarm on first 81.

    def _mrt_hub_note_master_copper_x9(self, meta) -> None:
        """Hub copper Master 89 — tag ~125 ms same-TID retries for soak join."""
        if not self._mrt_x1_orphan_diag_enabled():
            return
        if not self._is_hub_spoke_master():
            return
        if meta is None or not meta.tid_hex:
            return
        if not is_gameplay_response(meta.msg_type):
            return
        tid_u = meta.tid_hex.upper()
        now = time.perf_counter_ns()
        prev = self._hub_last_master_x9_ns.get(tid_u)
        gap_ms = (
            round((now - prev) / 1_000_000.0, 1) if prev is not None else None
        )
        streak = 1
        is_retry = False
        if (
            gap_ms is not None
            and MRT_HUB_X9_RETRY_MIN_GAP_MS <= gap_ms <= MRT_HUB_X9_RETRY_MAX_GAP_MS
        ):
            is_retry = True
            streak = int(self._hub_master_x9_streak.get(tid_u, 1)) + 1
        elif gap_ms is None or gap_ms > MRT_HUB_X9_RETRY_MAX_GAP_MS:
            streak = 1
        else:
            streak = int(self._hub_master_x9_streak.get(tid_u, 1))
        self._hub_last_master_x9_ns[tid_u] = now
        self._hub_last_master_x9_seq[tid_u] = meta.play_sequence
        self._hub_master_x9_streak[tid_u] = streak
        self.event_log.emit(
            "mrt_hub_master_x9",
            tid=tid_u,
            game=meta.game_id_hex,
            msg=meta.msg_type_hex,
            play_sequence=meta.play_sequence,
            gap_ms=gap_ms,
            streak=streak,
            is_retry=is_retry,
        )

    def _mrt_hub_note_spoke_eth_x1(self, meta, *, eth_port: str | None) -> None:
        """Hub ETH 81 from a spoke — gap since last Master copper 89 for that TID."""
        if not self._mrt_x1_orphan_diag_enabled():
            return
        if not self._is_hub_spoke_master():
            return
        if meta is None or not meta.tid_hex:
            return
        if not is_gameplay_ack(meta.msg_type):
            return
        tid_u = meta.tid_hex.upper()
        now = time.perf_counter_ns()
        prev = self._hub_last_master_x9_ns.get(tid_u)
        gap_ms = (
            round((now - prev) / 1_000_000.0, 1) if prev is not None else None
        )
        self.event_log.emit(
            "mrt_hub_spoke_x1",
            tid=tid_u,
            game=meta.game_id_hex,
            msg=meta.msg_type_hex,
            play_sequence=meta.play_sequence,
            eth_port=eth_port,
            gap_ms_since_master_x9=gap_ms,
            master_x9_seq=self._hub_last_master_x9_seq.get(tid_u),
            master_x9_streak=self._hub_master_x9_streak.get(tid_u),
        )

    def _on_packet_complete_mrt_x1_diag(
        self,
        meta,
        pkt: bytes,
        packets_in_window: int,
        *,
        uart_first_byte: int | None = None,
        window_us: float | None = None,
        byte_span_us: float | None = None,
    ) -> None:
        """Spoke orphan RX + hub Master 89 retry crumbs (EG 4.0.0.71)."""
        del packets_in_window, uart_first_byte, window_us, byte_span_us
        if meta is None:
            return
        if self._is_hub_spoke_master():
            self._mrt_hub_note_master_copper_x9(meta)
        if self._is_edge_spoke_topology() or (
            self.status_0d_synth and self._local_slave_tid
        ):
            self._note_post_x9_orphan_packet(meta, pkt)

    def _should_skip_spoke_peer_eth_uart_inject(self, meta, frame: bytes) -> bool:
        """EG 3.0.2.7 / 4.1.0.11: EDGE spoke — do not inject peer game legs onto local copper.

        Hub (EDGE_TOPOLOGY=hub) still injects every spoke x0/x1/x9 that should
        reach Master. Copper multi-drop with no EDGE_TOPOLOGY keeps full eth
        mirror. Progressive 0A / STOM / bank-jpot winner path unchanged.

        EG 4.1.0.11: spoke also skips eth→UART of its **own** gameplay ACK
        (local ``81``). Slave already put that ACK on copper; UART→eth to the
        hub is the progressive path. Reinjection recirculates empty-echo floods.
        """
        if not self._is_edge_spoke_topology():
            return False
        if not self._local_slave_tid:
            return False
        if self._is_hub_spoke_master():
            return False
        if channel_uses_raw_hs_window(self.channel):
            return False
        if self._meta_is_local_terminal(meta):
            # Own x1 must not eth→UART; own x9/0D/etc. still may.
            if is_gameplay_ack(meta.msg_type):
                return True
            return False
        if is_stom_comm_priority(meta.msg_type):
            return False
        if is_progressive_broadcast(meta.msg_type):
            return False
        if is_bank_progressive_jackpot_frame(frame):
            return False
        if is_gameplay_request(meta.msg_type):
            return True
        if is_gameplay_response(meta.msg_type) or is_skill_jpot_grant(meta.msg_type):
            return True
        if is_gameplay_ack(meta.msg_type):
            return True
        if meta.msg_type == 0x0D:
            return True
        return False

    def _transaction_deferred_keep_score(self, stashed: PendingInject) -> int:
        """Higher = keep under queue_cap (EG 3.0.2.7)."""
        try:
            meta = parse_packet_meta(stashed.frame)
        except Exception:
            return 0
        if is_bank_progressive_jackpot_frame(stashed.frame):
            return 100
        if is_stom_comm_priority(meta.msg_type):
            return 95
        try:
            local = self._meta_is_local_terminal(meta)
        except Exception:
            local = False
        if local:
            if is_gameplay_response(meta.msg_type) or is_skill_jpot_grant(
                meta.msg_type
            ):
                return 90
            if is_gameplay_ack(meta.msg_type) or meta.msg_type == 0x0D:
                return 85
            if is_gameplay_request(meta.msg_type):
                return 70
            return 40
        # Foreign / unknown TID — drop first under pressure.
        if (
            is_gameplay_request(meta.msg_type)
            or is_gameplay_response(meta.msg_type)
            or is_skill_jpot_grant(meta.msg_type)
            or is_gameplay_ack(meta.msg_type)
            or meta.msg_type == 0x0D
        ):
            return 5
        return 20

    def _tid_has_open_local_close(self, tid_hex: str | None) -> bool:
        """True when tid is in await_0d / await_x1 (the close we must not barge into)."""
        if not tid_hex:
            return False
        tid_u = tid_hex.upper()
        if any(rk[0].upper() == tid_u for rk in self._mrt_risk_await_0d):
            return True
        if any(rk[0].upper() == tid_u for rk in self._mrt_risk_await_x1):
            return True
        return False

    def _spoke_key_for_pending(self, pending: PendingInject) -> str:
        """Which per-spoke buffer owns this inject."""
        if pending.source == "status_0d_synth":
            return SPOKE_P0
        meta = parse_packet_meta(pending.frame)
        if is_progressive_broadcast(meta.msg_type):
            return SPOKE_SOFT
        tid = (pending.tid or meta.tid_hex or "").strip().upper()
        if not tid or tid == "0000":
            return SPOKE_SOFT
        return tid

    def _tid_tx_get(self, tid: str | None) -> TidTxFsm | None:
        if not tid:
            return None
        return self._tid_tx.get(tid.strip().upper())

    def _tid_tx_set_phase(
        self,
        tid: str | None,
        phase: str,
        *,
        game: str | None = None,
        seq: int | None = None,
        reason: str = "",
    ) -> None:
        if not tid:
            return
        tid_u = tid.strip().upper()
        st = self._tid_tx.get(tid_u)
        if st is None:
            st = TidTxFsm(opened_perf_ns=time.perf_counter_ns())
            self._tid_tx[tid_u] = st
        prev = st.phase
        st.phase = phase
        if game is not None:
            st.game = game
        if seq is not None:
            st.seq = seq
        if phase == "idle":
            st.game = None
            st.seq = None
        if prev != phase:
            self.event_log.emit(
                "tid_tx_fsm",
                tid=tid_u,
                phase=phase,
                prev=prev,
                game=st.game,
                seq=st.seq,
                reason=reason or None,
            )

    def _tid_tx_note_request(self, meta) -> None:
        if meta is None or not meta.tid_hex:
            return
        if not is_gameplay_request(meta.msg_type):
            return
        self._tid_tx_set_phase(
            meta.tid_hex,
            "await_x9",
            game=meta.game_id_hex,
            seq=meta.play_sequence,
            reason="x0",
        )

    def _tid_tx_note_x9_queued_or_done(self, meta) -> None:
        if meta is None or not meta.tid_hex:
            return
        if not (
            is_gameplay_response(meta.msg_type) or is_skill_jpot_grant(meta.msg_type)
        ):
            return
        # Advance toward open close only after request was seen.
        st = self._tid_tx_get(meta.tid_hex)
        if st is None or st.phase in ("idle", "await_x9"):
            self._tid_tx_set_phase(
                meta.tid_hex,
                "await_x1",
                game=meta.game_id_hex,
                seq=meta.play_sequence,
                reason="x9",
            )

    def _tid_tx_note_x1(self, meta) -> None:
        if meta is None or not meta.tid_hex:
            return
        if not is_gameplay_ack(meta.msg_type):
            return
        self._tid_tx_set_phase(
            meta.tid_hex,
            "await_0d",
            game=meta.game_id_hex,
            seq=meta.play_sequence,
            reason="x1",
        )

    def _tid_tx_note_0d(self, meta) -> None:
        tid = (meta.tid_hex if meta is not None else None) or self._local_slave_tid
        if not tid:
            return
        self._tid_tx_set_phase(tid, "idle", reason="0d_close")

    def _arbiter_score(self, pending: PendingInject, spoke_key: str) -> int:
        """Higher score → sooner copper inject (multi-drop one-at-a-time)."""
        now_ns = time.perf_counter_ns()
        meta = parse_packet_meta(pending.frame)
        score = inject_priority_class(
            meta.msg_type,
            source=pending.source,
            tid_hex=meta.tid_hex or pending.tid,
            bank_jpot_x1_armed_until_ns=self._bank_jpot_await_x1,
            now_ns=now_ns,
        )
        # Required leg for this TID's open transaction — beat peer soft traffic.
        tid_u = (meta.tid_hex or pending.tid or "").strip().upper()
        st = self._tid_tx.get(tid_u) if tid_u else None
        if st is not None and st.phase != "idle":
            if st.phase == "await_x9" and (
                is_gameplay_response(meta.msg_type) or is_skill_jpot_grant(meta.msg_type)
            ):
                score += 40
            elif st.phase == "await_x1" and is_gameplay_ack(meta.msg_type):
                score += 45
            elif st.phase == "await_0d" and (
                meta.msg_type == 0x0D or pending.source == "status_0d_synth"
            ):
                score += 50
            # Local machine terminal always prioritizes its own open legs.
            if self._meta_is_local_terminal(meta):
                score += 15
        # Soft progressive 0A (and tid-less) yields to gameplay when scores equal.
        if spoke_key == SPOKE_SOFT or is_progressive_broadcast(meta.msg_type):
            score -= 8
        return score

    def _rebuild_pending_from_spokes(self) -> None:
        """Copper arbiter view: merge per-spoke deques by score, FIFO within score."""
        scored: list[tuple[int, int, PendingInject]] = []
        for key, q in self._spoke_queues.items():
            for p in q:
                scored.append((self._arbiter_score(p, key), p.queued_perf_ns, p))
        scored.sort(key=lambda t: (-t[0], t[1]))
        self._pending = deque(p for _, _, p in scored)

    def _remove_pending_from_spokes(self, pending: PendingInject) -> None:
        for key, q in list(self._spoke_queues.items()):
            if not q:
                continue
            kept = deque(x for x in q if x is not pending)
            if len(kept) != len(q):
                if kept:
                    self._spoke_queues[key] = kept
                else:
                    del self._spoke_queues[key]
                return

    def _enqueue_pending(self, pending: PendingInject, *, front: bool = False) -> None:
        """Place inject on a per-spoke buffer (or soft / P0 lane), rebuild copper view."""
        key = self._spoke_key_for_pending(pending)
        # Progressive 0A: latest-wins on soft lane — one slot, never multi-copy backlog.
        meta = parse_packet_meta(pending.frame)
        if is_progressive_broadcast(meta.msg_type):
            self._deferred_0a_latest = bytes(pending.frame)
            key = SPOKE_SOFT
            # Keep at most one 0A frame in soft queue.
            q = self._spoke_queues.get(key)
            if q:
                q = deque(x for x in q if not is_progressive_broadcast(
                    parse_packet_meta(x.frame).msg_type
                ))
                self._spoke_queues[key] = q
        q = self._spoke_queues.setdefault(key, deque())
        if front:
            q.appendleft(pending)
        else:
            q.append(pending)
        if is_gameplay_request(meta.msg_type):
            self._tid_tx_note_request(meta)
        elif is_gameplay_response(meta.msg_type) or is_skill_jpot_grant(meta.msg_type):
            self._tid_tx_note_x9_queued_or_done(meta)
        self._rebuild_pending_from_spokes()

    def _pop_pending_left(self) -> PendingInject:
        pending = self._pending.popleft()
        self._remove_pending_from_spokes(pending)
        return pending

    def _clear_pending_queues(self) -> None:
        self._spoke_queues.clear()
        self._pending.clear()

    def _replace_pending_contents(self, items: list[PendingInject] | deque) -> None:
        """Rebuild spoke buckets from an ordered keep-list (e.g. bank-jpot cancel)."""
        self._spoke_queues.clear()
        for p in items:
            key = self._spoke_key_for_pending(p)
            self._spoke_queues.setdefault(key, deque()).append(p)
        self._rebuild_pending_from_spokes()

    def _queue_depth(self) -> int:
        return sum(len(q) for q in self._spoke_queues.values())

    def _hub_allows_peer_gameplay_during_local_close(self, meta) -> bool:
        """EG 3.0.0.3+: hub may inject other TIDs' gameplay while one local close is open.

        Soft 0x0A stays deferred. Same-TID legs still wait so synth 0D / x1 harvest
        are not barged. UART remains serialized by _tx_lock + P0 synth promotion.
        Winner bank progressive x1 always allowed (Master must see the close ACK).
        While bank jpot is armed, **any** gameplay x1 / 0x0D still injects
        (EG 3.0.1.33); peer x0/x9/soft still yield so progressive close stays primary.
        """
        if not self._is_hub_spoke_master():
            return False
        if meta is None:
            return False
        if is_stom_comm_priority(meta.msg_type):
            # T340+ CGS / Transfer Master Config must reach Master UART (copper would).
            return True
        if self._meta_is_bank_jpot_x1_priority(meta):
            return True
        if self._bank_jpot_close_armed():
            # Close legs for any transaction never black-hole during bank jpot.
            if is_gameplay_close_leg(meta.msg_type):
                return True
            # EG 3.0.2.3: same soft-open as queue defer — after eth x1/fanout.
            if self._bank_jpot_peer_gameplay_soft_open():
                return (
                    is_gameplay_request(meta.msg_type)
                    or is_gameplay_response(meta.msg_type)
                    or is_gameplay_ack(meta.msg_type)
                    or meta.msg_type == 0x0D
                )
            return False
        if is_progressive_broadcast(meta.msg_type):
            return False
        if not (
            is_gameplay_request(meta.msg_type)
            or is_gameplay_response(meta.msg_type)
            or is_gameplay_ack(meta.msg_type)
            or meta.msg_type == 0x0D
        ):
            return False
        if self._meta_is_local_terminal(meta):
            return True
        # Other spoke TID — allow unless that TID itself has an open close wait.
        return not self._tid_has_open_local_close(meta.tid_hex)

    def _reopen_hand_for_bank_jpot_progressive(self, frame: bytes | None) -> None:
        """EG 3.0.1.42: progressive supersedes empty-close hand so winner can note.

        JP14: prior empty `41` closed play_sequence → auto_close / MRT
        ``seq_already_closed``; progressive inject landed with no seal path.
        """
        if frame is None or not is_bank_progressive_jackpot_frame(frame):
            return
        meta = parse_packet_meta(frame)
        if meta is None:
            return
        tid_u = (meta.tid_hex or "").strip().upper()
        if tid_u:
            self._tid_empty_close_block_until.pop(tid_u, None)
        rk = self._round_key(meta)
        if rk is None:
            return
        seq = meta.play_sequence
        closed = self._mrt_risk_close_done_seq.get(rk)
        if closed is None:
            return
        if seq is not None and int(closed) != int(seq):
            return
        del self._mrt_risk_close_done_seq[rk]
        self._mrt_risk_close_done_ns.pop(rk, None)
        self._mrt_risk_close_quality.pop(rk, None)
        self._mrt_risk_close_quality_pending.pop(rk, None)
        self.event_log.emit(
            "bank_jpot_hand_reopen",
            tid=tid_u or rk[0],
            game=rk[1],
            play_sequence=seq,
            closed_seq=int(closed),
            reason="progressive_supersedes_empty_close",
        )

    def _bank_jpot_x1_may_store_last_frame(self, tid_u: str) -> bool:
        """Only keep winner x1 for reinject/seal after post-arm queue stamp."""
        tid_u = (tid_u or "").strip().upper()
        if not tid_u:
            return False
        arm_ns = self._bank_jpot_arm_ns.get(tid_u)
        if arm_ns is None:
            return False
        q_ns = self._bank_jpot_x1_eth_queued_ns.get(tid_u)
        return q_ns is not None and q_ns >= arm_ns

    def _forget_bank_jpot_x1_track(self, tid_u: str, *, keep_frame: bool = False) -> None:
        if not keep_frame:
            self._bank_jpot_x1_last_frame.pop(tid_u, None)
        self._bank_jpot_x1_reinject_count.pop(tid_u, None)
        self._bank_jpot_x1_last_echo_len.pop(tid_u, None)
        self._bank_jpot_x1_last_post_de_len.pop(tid_u, None)
        self._bank_jpot_x1_last_echo_match.pop(tid_u, None)
        self._bank_jpot_x1_eth_seen.discard(tid_u)
        self._bank_jpot_x1_eth_queued_ns.pop(tid_u, None)

    def _note_tid_result_x9(self, meta, frame: bytes | None = None) -> None:
        """Record a gameplay/skill result for empty-close classification + award ownership."""
        if meta is None or not meta.tid_hex:
            return
        if not (
            is_gameplay_response(meta.msg_type) or is_skill_jpot_grant(meta.msg_type)
        ):
            return
        tid_u = meta.tid_hex.strip().upper()
        now_ns = time.perf_counter_ns()
        self._tid_last_result_x9_ns[tid_u] = now_ns
        self._tid_empty_close_block_until.pop(tid_u, None)
        if frame is not None and is_bank_progressive_jackpot_frame(frame):
            self._note_bank_jpot_award_x9(frame, meta, path="result_x9")

    def _note_tid_x1_empty_close(self, meta) -> None:
        """x1 without recent result x9 → block progressive award inject on this TID."""
        if meta is None or not meta.tid_hex or not is_gameplay_ack(meta.msg_type):
            return
        tid_u = meta.tid_hex.strip().upper()
        now_ns = time.perf_counter_ns()
        last_x9 = self._tid_last_result_x9_ns.get(tid_u)
        stale_ns = int(BANK_JPOT_RESULT_X9_STALE_MS * 1_000_000)
        if last_x9 is not None and (now_ns - last_x9) <= stale_ns:
            return
        block_ns = int(BANK_JPOT_EMPTY_CLOSE_BLOCK_MS * 1_000_000)
        self._tid_empty_close_block_until[tid_u] = now_ns + block_ns
        self.event_log.emit(
            "bank_jpot_empty_close_block",
            tid=tid_u,
            block_ms=BANK_JPOT_EMPTY_CLOSE_BLOCK_MS,
            msg=meta.msg_type_hex,
        )

    def _tid_empty_close_blocks_award(self, tid_u: str, *, now_ns: int | None = None) -> bool:
        tid_u = (tid_u or "").strip().upper()
        if not tid_u:
            return False
        exp = self._tid_empty_close_block_until.get(tid_u)
        if exp is None:
            return False
        if now_ns is None:
            now_ns = time.perf_counter_ns()
        if now_ns > exp:
            self._tid_empty_close_block_until.pop(tid_u, None)
            return False
        return True

    def _note_bank_jpot_award_x9(
        self,
        frame: bytes | None,
        meta,
        *,
        path: str,
    ) -> None:
        """Winner progressive grant owned on this seat (glass-capable path)."""
        if frame is None or not is_bank_progressive_jackpot_frame(frame):
            return
        tid = bank_progressive_jackpot_winner_tid(frame)
        if not tid:
            return
        # Peer multiport replay / non-local *inject* must not count as award.
        # arm_progressive / result_x9 may stamp on hub (winner TID ≠ hub local).
        if path == "inject" and not self._meta_is_local_terminal(meta):
            return
        if tid not in self._bank_jpot_award_x9_ok:
            self._bank_jpot_award_x9_ok.add(tid)
            pm = parse_packet_meta(frame)
            self.event_log.emit(
                "bank_jpot_award_x9_ok",
                tid=tid,
                path=path,
                msg=pm.msg_type_hex,
                progwin_cents=progressive_progwin_cents_be(frame),
            )
            try:
                note_progressive_hit(
                    game_index=map_wire_game_to_index(pm.game_id_hex),
                    game_id_hex=pm.game_id_hex,
                    progwin_cents=progressive_progwin_cents_be(frame),
                    tid=tid,
                    source=f"bank_jpot:{path}",
                )
            except OSError:
                pass
        self._tid_empty_close_block_until.pop(tid, None)
        self._tid_last_result_x9_ns[tid] = time.perf_counter_ns()

    def _purge_pre_arm_bank_jpot_x1(self, tid_u: str) -> int:
        """Drop queued eth→UART x1 for this TID so pre-award empty close cannot seal.

        Soak 2026-08-06_2040 JP3: empty `41` eth_rx ~200 ms *before* progressive
        grant stayed in the inject queue; arm elevated it; inject_echo sealed the
        progressive and reset meters while winner still had no result x9.
        """
        tid_u = (tid_u or "").strip().upper()
        if not tid_u:
            return 0
        purged = 0

        def _is_stale_x1(pending: PendingInject) -> bool:
            if pending.source not in (
                "eth_to_uart",
                "bank_jpot_x1_reinject",
                "edge_fallback_eth_to_uart",
            ):
                return False
            pm = parse_packet_meta(pending.frame)
            if not is_gameplay_ack(pm.msg_type):
                return False
            pt = (pm.tid_hex or pending.tid or "").strip().upper()
            return pt == tid_u

        keep: list[PendingInject] = []
        while self._pending:
            stashed = self._pop_pending_left()
            if _is_stale_x1(stashed):
                purged += 1
            else:
                keep.append(stashed)
        self._replace_pending_contents(keep)
        # Spoke queues mirror _pending via rebuild on reorder; also scrub deferred.
        deferred_keep: list[PendingInject] = []
        while self._transaction_deferred:
            stashed = self._transaction_deferred.popleft()
            if _is_stale_x1(stashed):
                purged += 1
            else:
                deferred_keep.append(stashed)
        self._transaction_deferred.extend(deferred_keep)
        for name, dq in list(self._spoke_queues.items()):
            if not dq:
                continue
            filt = deque(p for p in dq if not _is_stale_x1(p))
            purged += len(dq) - len(filt)
            self._spoke_queues[name] = filt
        self._bank_jpot_x1_last_frame.pop(tid_u, None)
        self._bank_jpot_x1_eth_queued_ns.pop(tid_u, None)
        self._bank_jpot_x1_eth_seen.discard(tid_u)
        if purged:
            self.event_log.emit(
                "bank_jpot_purge_pre_arm_x1",
                tid=tid_u,
                purged=purged,
            )
            self._reorder_pending_by_priority()
        return purged

    def _bank_jpot_post_arm_x1_ok(
        self,
        tid_u: str,
        *,
        evidence: str | None = None,
        now_ns: int | None = None,
    ) -> bool:
        """True when winner x1 evidence is from *after* progressive arm (not empty pre-close)."""
        tid_u = (tid_u or "").strip().upper()
        if not tid_u:
            return False
        arm_ns = self._bank_jpot_arm_ns.get(tid_u)
        if arm_ns is None:
            return False
        evidence_l = (evidence or "").strip().lower()
        # Live copper decode of x1 is inherently "now" and after arm if still armed.
        if evidence_l in ("copper_packet_complete", "inject_glued_0d"):
            return True
        q_ns = self._bank_jpot_x1_eth_queued_ns.get(tid_u)
        if q_ns is not None and q_ns >= arm_ns:
            return True
        return False

    def _prune_bank_jpot_x1_seal(self, *, now_ns: int | None = None) -> None:
        if not self._bank_jpot_x1_seal_until:
            return
        if now_ns is None:
            now_ns = time.perf_counter_ns()
        expired = [t for t, exp in self._bank_jpot_x1_seal_until.items() if now_ns > exp]
        for tid in expired:
            del self._bank_jpot_x1_seal_until[tid]
            if tid not in self._bank_jpot_await_x1:
                self._bank_jpot_x1_last_frame.pop(tid, None)

    def _bank_jpot_tid_sealed(self, tid_u: str, *, now_ns: int | None = None) -> bool:
        self._prune_bank_jpot_x1_seal(now_ns=now_ns)
        if now_ns is None:
            now_ns = time.perf_counter_ns()
        exp = self._bank_jpot_x1_seal_until.get(tid_u)
        return bool(exp is not None and now_ns <= exp)

    def _prune_bank_jpot_x1_priority(self, *, now_ns: int | None = None) -> None:
        if not self._bank_jpot_await_x1:
            return
        if now_ns is None:
            now_ns = time.perf_counter_ns()
        expired = [t for t, exp in self._bank_jpot_await_x1.items() if now_ns > exp]
        for tid in expired:
            self.event_log.emit(
                "bank_jpot_x1_close_miss",
                tid=tid,
                reason="hold_timeout",
                reinjects=int(self._bank_jpot_x1_reinject_count.get(tid, 0)),
                last_echo_len=int(self._bank_jpot_x1_last_echo_len.get(tid, -1)),
                last_post_de_len=int(self._bank_jpot_x1_last_post_de_len.get(tid, -1)),
                hold_ms=BANK_JPOT_X1_PRIORITY_HOLD_MS,
                eth_x1_seen=1 if tid in self._bank_jpot_x1_eth_seen else 0,
            )
            self._clear_bank_jpot_x1_priority(tid, reason="hold_timeout")

    def _bank_jpot_close_armed(self) -> bool:
        """True while at least one bank progressive jackpot close (x1) is primary."""
        self._prune_bank_jpot_x1_priority()
        self._prune_bank_jpot_x1_seal()
        return bool(self._bank_jpot_await_x1)

    def _is_bank_jpot_close_path_frame(
        self,
        meta,
        frame: bytes | None = None,
        *,
        source: str | None = None,
    ) -> bool:
        """Traffic allowed to eth→UART while bank progressive close is primary.

        Still primary: winner x1 + Master progressive issue/retries (head-of-queue
        priority). EG 3.0.1.33: **any** gameplay x1 / 0x0D (incl. status_0d_synth)
        is also allowed — never transaction-deferred for any TID. Soft/0A, peer x0
        and non-progressive x9 still yield during the arm window.
        """
        if source is not None and is_gameplay_close_leg(None, source=source):
            return True
        if meta is not None and is_gameplay_close_leg(meta.msg_type, source=source):
            return True
        if self._meta_is_bank_jpot_x1_priority(meta):
            return True
        if frame is not None and is_bank_progressive_jackpot_frame(frame):
            return True
        return False

    def _arm_bank_jpot_x1_priority(
        self,
        frame: bytes | None,
        *,
        source: str = "unknown",
    ) -> None:
        """After Master bank progressive issue: that winner's x1 is head-of-queue.

        EG 3.0.1.35: copper-sealed TID progressive retries reinject last x1 only
        (no second 30s exclusive arm — dealkiller meter-reset path).

        EG 3.0.1.38: on first arm, purge pre-award eth x1 for this TID and require
        post-arm x1 (plus award x9 ownership) before seal.
        """
        tid = bank_progressive_jackpot_winner_tid(frame)
        if not tid:
            return
        now_ns = time.perf_counter_ns()
        # Post-seal Master progressive re-TX: reinject x1 without re-arming exclusive.
        if self._bank_jpot_tid_sealed(tid, now_ns=now_ns) and tid not in self._bank_jpot_await_x1:
            reinjected = self._queue_bank_jpot_x1_reinject(
                tid, trigger="master_progressive_retry_sealed", allow_sealed=True
            )
            self.event_log.emit(
                "bank_jpot_x1_sealed_retry",
                tid=tid,
                source=source,
                reinjected=1 if reinjected else 0,
                has_frame=1 if self._bank_jpot_x1_last_frame.get(tid) else 0,
            )
            return
        hold_ns = int(BANK_JPOT_X1_PRIORITY_HOLD_MS * 1_000_000)
        was_armed = tid in self._bank_jpot_await_x1
        self._bank_jpot_await_x1[tid] = now_ns + hold_ns
        purged = 0
        if not was_armed:
            self._bank_jpot_x1_reinject_count[tid] = 0
            # New progressive: re-gate eth fanout until Master eth-sees winner x1.
            self._bank_jpot_fanout_released.discard(tid)
            # Fresh progressive supersedes prior seal for this TID.
            self._bank_jpot_x1_seal_until.pop(tid, None)
            self._bank_jpot_arm_ns[tid] = now_ns
            # EG 3.0.2.3: do NOT discard award_x9_ok. Hub result_x9 often stamps
            # milliseconds before arm; discard → seal_hold no_award_x9 + 30s freeze.
            # Fresh progressive opens a new award window — clear empty-close block.
            self._tid_empty_close_block_until.pop(tid, None)
            # EG 3.0.1.42: empty soft-close may have closed this play_seq — reopen.
            self._reopen_hand_for_bank_jpot_progressive(frame)
            meta = parse_packet_meta(frame) if frame else None
            # Progressive arm frame itself is award ownership (hub or local winner).
            if (
                frame is not None
                and meta is not None
                and is_bank_progressive_jackpot_frame(frame)
            ):
                self._note_bank_jpot_award_x9(frame, meta, path="arm_progressive")
            purged = self._purge_pre_arm_bank_jpot_x1(tid)
        else:
            # Re-arm (Master progressive retry): keep empty-close cleared + reopen.
            self._tid_empty_close_block_until.pop(tid, None)
            self._reopen_hand_for_bank_jpot_progressive(frame)
        self._lift_bank_jpot_x1_from_transaction_deferred()
        cancelled = self._cancel_pending_outside_bank_jpot_close()
        self._reorder_pending_by_priority()
        self.event_log.emit(
            "bank_jpot_x1_priority_arm",
            tid=tid,
            source=source,
            hold_ms=BANK_JPOT_X1_PRIORITY_HOLD_MS,
            armed_count=len(self._bank_jpot_await_x1),
            cancelled_pending=cancelled,
            rearm=1 if was_armed else 0,
            eth_x1_seen=1 if tid in self._bank_jpot_x1_eth_seen else 0,
            purged_pre_arm_x1=purged,
        )
        # Master progressive retry while post-arm x1 already on path: reinject.
        if (
            was_armed
            and self._bank_jpot_x1_last_frame.get(tid)
            and tid in self._bank_jpot_award_x9_ok
            and self._bank_jpot_x1_may_store_last_frame(tid)
        ):
            self._queue_bank_jpot_x1_reinject(tid, trigger="master_progressive_retry")

    def bind_eth_router(
        self,
        router: DirectionalEthRouter | None,
        *,
        dual: bool = False,
    ) -> None:
        """Directional eth router handle for deferred bank-jpot fanout release."""
        self._eth_router = router
        self._eth_dual_channel = bool(dual)

    def _bank_jpot_eth_ports_for_issue(
        self,
        router: DirectionalEthRouter,
        frame: bytes,
    ) -> list[str] | None:
        """Winner unicast while awaiting copper x1; multiport flood after seal.

        Until Master has the winner's x1 on **hub copper**, multiport progressive
        fanout races the close/reseed path. Eth_rx of spoke 41 is not enough
        (EG 3.0.2.10 / soak 2026-08-10_0016 $1107.80). SCR is unrelated.
        """
        tid = bank_progressive_jackpot_winner_tid(frame)
        if not tid:
            return None
        if tid in self._bank_jpot_fanout_released:
            return None
        self._bank_jpot_defer_fanout_frame[tid] = bytes(frame)
        port = router.port_for_tid(tid)
        if port:
            self.event_log.emit(
                "bank_jpot_eth_unicast_hold",
                tid=tid,
                eth_port=port,
                reason="await_x1_before_fanout",
                msg=parse_packet_meta(frame).msg_type_hex,
            )
            return [port]
        # Mapped winner path missing: do not invent multi-port flood while awaiting close.
        self.event_log.emit(
            "bank_jpot_eth_unicast_hold",
            tid=tid,
            reason="winner_port_unknown",
            msg=parse_packet_meta(frame).msg_type_hex,
        )
        return []

    def _bank_jpot_fanout_peer_ports(
        self,
        router: "DirectionalEthRouter",
        winner_port: str | None,
    ) -> list[str]:
        """Peer ports for multiport progressive fanout (EG 3.0.2.3).

        Soak ``0202`` hub blasted progressive to **all** EDGE ifaces (port_count=25).
        Prefer TID-mapped spokes; fallback = carrier-up only (never ghost gretaps).
        """
        winner = (winner_port or "").strip()
        mapped: set[str] = set()
        tid_map = getattr(router, "tid_to_port", None) or {}
        for _tid, port in tid_map.items():
            if not port or port not in router.ports:
                continue
            if winner and port == winner:
                continue
            mapped.add(port)
        if mapped:
            return sorted(mapped)
        peers: list[str] = []
        for name in router.port_names():
            if winner and name == winner:
                continue
            try:
                if router.port_has_carrier(name):
                    peers.append(name)
            except Exception:
                continue
        return peers

    def _release_bank_jpot_eth_fanout(
        self,
        tid_hex: str | None,
        *,
        source: str,
    ) -> bool:
        """Multiport flood of stashed progressive once Master copper has winner x1.

        EG 3.0.2.10: only callable from copper seal path (``_bank_jpot_x1_close_ok``),
        not eth_rx alone.
        """
        tid_u = (tid_hex or "").strip().upper()
        if not tid_u:
            return False
        if tid_u in self._bank_jpot_fanout_released:
            return False
        frame = self._bank_jpot_defer_fanout_frame.pop(tid_u, None)
        self._bank_jpot_fanout_released.add(tid_u)
        if not frame:
            self.event_log.emit(
                "bank_jpot_eth_fanout_skip",
                tid=tid_u,
                source=source,
                reason="no_stashed_frame",
            )
            return False
        router = self._eth_router
        if router is None:
            self.event_log.emit(
                "bank_jpot_eth_fanout_skip",
                tid=tid_u,
                source=source,
                reason="no_router",
            )
            return False
        try:
            wire = fixture_eth_tag_payload(
                self.channel, frame, dual=self._eth_dual_channel
            )
            # Peers only — winner already got unicast hold delivery.
            # Re-flooding the winner re-injects progressive + re-arms exclusive close.
            winner_port = router.port_for_tid(tid_u)
            only_ports = self._bank_jpot_fanout_peer_ports(router, winner_port)
            if not only_ports:
                # Winner-only bank (hub + one spoke): unicast already delivered.
                self.event_log.emit(
                    "bank_jpot_eth_fanout_skip",
                    tid=tid_u,
                    source=source,
                    reason="no_peer_ports",
                    winner_port=winner_port or "",
                )
                return True
            sends = router.send_uart_to_eth(wire, only_ports=only_ports)
        except Exception as exc:
            self.event_log.emit(
                "error",
                where="bank_jpot_eth_fanout_release",
                message=str(exc),
                tid=tid_u,
                source=source,
            )
            return False
        meta = parse_packet_meta(frame)
        self.event_log.emit(
            "bank_jpot_eth_fanout_release",
            tid=tid_u,
            source=source,
            port_count=len(sends),
            msg=meta.msg_type_hex,
            len=len(frame),
            exclude_winner=1 if winner_port else 0,
            winner_port=winner_port or "",
        )
        gx = uart_gameplay_eth_tx_extra(self, meta)
        for eth_port, dt_us in sends:
            self.event_log.emit(
                "eth_tx",
                msg=meta.msg_type_hex,
                len=len(frame),
                tid=meta.tid_hex,
                eth_port=eth_port,
                dt_us=round(dt_us, 1),
                bank_jpot_fanout=1,
                **gx,
            )
        return True

    def _maybe_clear_bank_jpot_non_local_after_fanout(self, tid_u: str) -> None:
        """EG 3.0.2.10: retired no-op (was EG 3.0.2.3 eth-only exclusive clear).

        Soak ``2026-08-10_0016`` `$1107.80` 37BC: hub eth_rx of spoke 41 + multiport
        fanout + ``non_local_eth_x1_fanout`` clear ran **before** Master copper got the
        winner's 41 → progressive never reseeds. Product: multiport fanout and exclusive
        clear only after copper seal (``_bank_jpot_x1_close_ok``). Leave signature so
        older call sites and tests stay import-safe.
        """
        _ = tid_u
        return

    def _bank_jpot_peer_gameplay_soft_open(self) -> bool:
        """True when exclusive hold no longer needs to black-hole peer x0/x9.

        EG 3.0.2.10: soft-open only after multiport progressive fanout has released —
        and fanout now waits for **Master copper** x1 (not eth alone). Eth-seen alone
        is not enough; still waiting for copper must keep peer x0 deferred.
        """
        if not self._bank_jpot_await_x1:
            return True
        for tid in self._bank_jpot_await_x1:
            if tid not in self._bank_jpot_fanout_released:
                return False
        return True

    def _note_bank_jpot_eth_x1_seen(
        self,
        meta,
        *,
        source: str,
        frame: bytes | None = None,
    ) -> None:
        """Stamp eth_rx of winner 41; do **not** multiport-flood or clear exclusive.

        EG 3.0.2.10: eth proves the spoke closed; Master reseed needs that 41 eth→UART
        onto hub copper. Fanout release is left to ``_bank_jpot_x1_close_ok``.
        Stamps eth_queued_ns / last_frame so copper seal and reinject are post-arm.
        """
        if not is_gameplay_ack(meta.msg_type):
            return
        tid_u = (meta.tid_hex or "").strip().upper()
        if not tid_u:
            return
        if (
            tid_u not in self._bank_jpot_await_x1
            and tid_u not in self._bank_jpot_defer_fanout_frame
        ):
            return
        if tid_u not in self._bank_jpot_await_x1:
            return
        now = time.perf_counter_ns()
        self._bank_jpot_x1_eth_seen.add(tid_u)
        arm_ns = self._bank_jpot_arm_ns.get(tid_u)
        if arm_ns is not None:
            prev_q = self._bank_jpot_x1_eth_queued_ns.get(tid_u)
            if prev_q is None or prev_q < arm_ns:
                self._bank_jpot_x1_eth_queued_ns[tid_u] = now
        if frame:
            self._bank_jpot_x1_last_frame[tid_u] = bytes(frame)
        self.event_log.emit(
            "bank_jpot_x1_eth_seen",
            tid=tid_u,
            msg=meta.msg_type_hex,
            source=source or "eth_rx_x1",
            sealed=0,
        )

    def _maybe_release_bank_jpot_fanout_on_x1(
        self, meta, *, source: str, frame: bytes | None = None
    ) -> None:
        """Legacy name: eth_rx path only stamps eth_seen (EG 3.0.2.10).

        Multiport flood of the stashed progressive is **not** released here.
        Release only from copper seal: ``_bank_jpot_x1_close_ok`` →
        ``_release_bank_jpot_eth_fanout``.
        """
        self._note_bank_jpot_eth_x1_seen(meta, source=source, frame=frame)

    def _bank_jpot_x1_close_ok(
        self,
        tid_u: str,
        *,
        evidence: str,
        msg: str | None = None,
    ) -> bool:
        """Clear exclusive arm only on *copper* winner-x1 evidence (EG 3.0.1.35).

        ``evidence`` for seal: copper_packet_complete | inject_echo_ok |
        inject_glued_0d. eth_rx is tracked separately (eth_seen only) and **never**
        seals or releases multiport progressive fanout (EG 3.0.2.10).

        EG 3.0.1.38: refuse seal unless winner progressive X9 ownership is known
        *and* the x1 is post-arm (stale empty close cannot reset meters).

        EG 3.0.1.42: award_x9_ok is **required** (not only empty-close block gate);
        pre-arm inject_echo never stores last_frame for reinject.
        """
        tid_u = (tid_u or "").strip().upper()
        if not tid_u or tid_u not in self._bank_jpot_await_x1:
            return False
        # eth_rx must never seal — route should not hit this, but hard-guard.
        evidence_l = (evidence or "").strip().lower()
        if evidence_l in ("eth_rx_x1", "eth_rx") or evidence_l.startswith("eth_rx"):
            self._bank_jpot_x1_eth_seen.add(tid_u)
            # Do NOT multiport-flood from eth (EG 3.0.2.10).
            self.event_log.emit(
                "bank_jpot_x1_eth_seen",
                tid=tid_u,
                msg=msg,
                source=evidence,
                sealed=0,
            )
            return False
        award_ok = tid_u in self._bank_jpot_award_x9_ok
        post_arm = self._bank_jpot_post_arm_x1_ok(tid_u, evidence=evidence_l)
        # EG 3.0.1.42: progressive ownership required before any seal.
        if not award_ok:
            self.event_log.emit(
                "bank_jpot_seal_hold",
                tid=tid_u,
                msg=msg,
                evidence=evidence,
                reason="no_award_x9",
                award_x9=0,
                post_arm_x1=1 if post_arm else 0,
            )
            return False
        # Never seal on pre-arm x1 (empty close still in the inject queue — JP3 steal).
        if not post_arm:
            # Do not keep mid-flight pre-arm empty x1 as reinject fodder (JP14).
            if evidence_l == "inject_echo_ok":
                self._bank_jpot_x1_last_frame.pop(tid_u, None)
            self.event_log.emit(
                "bank_jpot_seal_hold",
                tid=tid_u,
                msg=msg,
                evidence=evidence,
                reason="pre_arm_or_stale_x1",
                award_x9=1,
                post_arm_x1=0,
            )
            return False
        # While local empty-close block is active without progressive award ownership,
        # do not seal (meter must not drop for a hand without result x9).
        if self._tid_empty_close_blocks_award(tid_u) and not award_ok:
            self.event_log.emit(
                "bank_jpot_seal_hold",
                tid=tid_u,
                msg=msg,
                evidence=evidence,
                reason="empty_close_no_award_x9",
                award_x9=0,
                post_arm_x1=1,
            )
            return False
        reinjects = int(self._bank_jpot_x1_reinject_count.get(tid_u, 0))
        last_echo = int(self._bank_jpot_x1_last_echo_len.get(tid_u, -1))
        last_post = int(self._bank_jpot_x1_last_post_de_len.get(tid_u, -1))
        last_match = self._bank_jpot_x1_last_echo_match.get(tid_u)
        self.event_log.emit(
            "bank_jpot_x1_close_ok",
            tid=tid_u,
            msg=msg,
            evidence=evidence,
            reinjects=reinjects,
            last_echo_len=last_echo,
            last_post_de_len=last_post,
            last_echo_match=last_match,
            award_x9=1 if award_ok else 0,
        )
        self._release_bank_jpot_eth_fanout(tid_u, source=evidence)
        # Keep last x1 for Master progressive retries (meter-clear dealkiller path).
        seal_ms = int(BANK_JPOT_X1_SEAL_MS)
        seal_until = time.perf_counter_ns() + seal_ms * 1_000_000
        self._bank_jpot_x1_seal_until[tid_u] = seal_until
        self.event_log.emit(
            "bank_jpot_x1_sealed",
            tid=tid_u,
            evidence=evidence,
            seal_ms=seal_ms,
            has_frame=1 if self._bank_jpot_x1_last_frame.get(tid_u) else 0,
        )
        self._clear_bank_jpot_x1_priority(tid_u, reason=evidence, keep_frame=True)
        return True

    def _cancel_pending_outside_bank_jpot_close(self) -> int:
        """Stash competing eth→UART work so progressive close owns the UART."""
        if not self._pending or not self._bank_jpot_close_armed():
            return 0
        keep: list[PendingInject] = []
        cancelled = 0
        while self._pending:
            stashed = self._pop_pending_left()
            meta = parse_packet_meta(stashed.frame)
            if self._is_bank_jpot_close_path_frame(
                meta, stashed.frame, source=stashed.source
            ):
                keep.append(stashed)
            else:
                self._transaction_deferred.append(stashed)
                cancelled += 1
        self._replace_pending_contents(keep)
        if cancelled:
            self.event_log.emit(
                "bank_jpot_x1_priority_cancel",
                cancelled=cancelled,
                queue_depth=len(self._pending),
                deferred=len(self._transaction_deferred),
            )
        return cancelled

    def _pending_has_bank_jpot_x1(self, tid_u: str) -> bool:
        for p in self._pending:
            if not is_gameplay_ack(parse_packet_meta(p.frame).msg_type):
                continue
            if (p.tid or "").strip().upper() != tid_u:
                continue
            if p.source in (
                "eth_to_uart",
                "bank_jpot_x1_reinject",
                "edge_fallback_eth_to_uart",
            ):
                return True
        return False

    def _queue_bank_jpot_x1_reinject(
        self,
        tid_u: str,
        *,
        trigger: str,
        allow_sealed: bool = False,
    ) -> bool:
        """Re-queue winner x1 eth→UART after empty echo or Master progressive retry.

        Does not clear arm. Cap reinjects with close_miss while armed.
        ``allow_sealed``: post-copper seal progressive retries (no exclusive arm).
        """
        tid_u = (tid_u or "").strip().upper()
        if not tid_u:
            return False
        armed = tid_u in self._bank_jpot_await_x1
        sealed = self._bank_jpot_tid_sealed(tid_u)
        if not armed and not (allow_sealed and sealed):
            return False
        frame = self._bank_jpot_x1_last_frame.get(tid_u)
        if not frame:
            return False
        if self._pending_has_bank_jpot_x1(tid_u):
            return False
        count = int(self._bank_jpot_x1_reinject_count.get(tid_u, 0))
        if count >= int(BANK_JPOT_X1_MAX_REINJECT):
            self.event_log.emit(
                "bank_jpot_x1_close_miss",
                tid=tid_u,
                reason="max_reinject",
                reinjects=count,
                last_echo_len=int(self._bank_jpot_x1_last_echo_len.get(tid_u, -1)),
                last_post_de_len=int(self._bank_jpot_x1_last_post_de_len.get(tid_u, -1)),
                max_reinject=BANK_JPOT_X1_MAX_REINJECT,
                trigger=trigger,
                sealed=1 if sealed else 0,
            )
            if armed:
                self._clear_bank_jpot_x1_priority(tid_u, reason="max_reinject")
            return False
        self._bank_jpot_x1_reinject_count[tid_u] = count + 1
        meta = parse_packet_meta(frame)
        now = time.perf_counter_ns()
        # EG 3.0.1.42: reinject is post-arm by definition — stamp for seal gate.
        self._bank_jpot_x1_eth_queued_ns[tid_u] = now
        marshal = frame_marshal_fields(frame)
        self._enqueue_pending(
            PendingInject(
                frame=bytes(frame),
                queued_perf_ns=now,
                msg=meta.msg_type_hex or "41",
                tid=tid_u,
                source="bank_jpot_x1_reinject",
                strict_slv_idle=False,
                marshal_crc32=marshal["marshal_crc32"],
            ), front=True)
        self._reorder_pending_by_priority()
        self.event_log.emit(
            "bank_jpot_x1_reinject",
            tid=tid_u,
            trigger=trigger,
            reinjects=int(self._bank_jpot_x1_reinject_count[tid_u]),
            pending=len(self._pending),
            frame_len=len(frame),
            sealed=1 if sealed and not armed else 0,
            eth_queued_post_arm=1,
        )
        return True

    def _on_bank_jpot_x1_copper_packet_complete(self, meta) -> None:
        """Copper HS window completed an armed TID's x1 (native decode path)."""
        if not is_gameplay_ack(meta.msg_type):
            return
        tid_u = (meta.tid_hex or "").strip().upper()
        if not tid_u:
            return
        self._bank_jpot_x1_close_ok(
            tid_u,
            evidence="copper_packet_complete",
            msg=meta.msg_type_hex,
        )

    def _note_bank_jpot_x1_inject_forensics(
        self,
        *,
        tid_hex: str | None,
        msg_type: int | None,
        frame: bytes | None,
        inject_echo: bytes | None,
        inject_post_de: bytes | None,
    ) -> None:
        if not is_gameplay_ack(msg_type):
            return
        tid_u = (tid_hex or "").strip().upper()
        if not tid_u:
            return
        if tid_u not in self._bank_jpot_await_x1 and not self._bank_jpot_tid_sealed(tid_u):
            return
        echo = inject_echo or b""
        post = inject_post_de or b""
        self._bank_jpot_x1_last_echo_len[tid_u] = len(echo)
        self._bank_jpot_x1_last_post_de_len[tid_u] = len(post)
        if frame:
            ok, kind = inject_echo_bytes_match(frame, echo)
            if not ok and post:
                ok_p, kind_p = inject_echo_bytes_match(frame, post)
                if ok_p:
                    ok, kind = ok_p, kind_p
            self._bank_jpot_x1_last_echo_match[tid_u] = kind
            # EG 3.0.1.42: only post-arm-queued x1 may be reinjected / seal template.
            if self._bank_jpot_x1_may_store_last_frame(tid_u):
                self._bank_jpot_x1_last_frame[tid_u] = bytes(frame)
            else:
                self._bank_jpot_x1_last_frame.pop(tid_u, None)
        # Master 0D often glues to x1 inject RX in <3 ms (prefix after self).
        peer = peer_uart_bytes_after_inject(frame or b"", echo, post)
        if peer or (frame and len(echo) > len(frame)):
            trail_msgs: list[str] = []
            has_0d = False
            i = 0
            while i + 1 < len(peer):
                if peer[i] != SOH:
                    i += 1
                    continue
                mt = peer[i + 1]
                trail_msgs.append(f"{mt:02X}")
                if mt == 0x0D:
                    has_0d = True
                exp = COMMAND_WIRE_LEN.get(mt)
                if exp and exp > 0:
                    i += exp
                else:
                    i += 1
            self.event_log.emit(
                "bank_jpot_x1_bus_trail",
                tid=tid_u,
                echo_len=len(echo),
                post_de_rx_len=len(post),
                peer_len=len(peer),
                echo_match=self._bank_jpot_x1_last_echo_match.get(tid_u),
                peer_msgs=",".join(trail_msgs) if trail_msgs else None,
                has_status_0d=has_0d,
                peer_prefix=(
                    ",".join(f"{b:02X}" for b in peer[:24]) if peer else None
                ),
            )
            if has_0d:
                # Glued Master 0D after our x1 inject — close the exclusive arm.
                self._bank_jpot_x1_close_ok(
                    tid_u,
                    evidence="inject_glued_0d",
                    msg="0D",
                )

    def _clear_bank_jpot_x1_priority(
        self,
        tid_hex: str | None,
        *,
        reason: str = "manual",
        keep_frame: bool = False,
    ) -> None:
        if not tid_hex:
            return
        tid_u = tid_hex.strip().upper()
        if tid_u not in self._bank_jpot_await_x1:
            return
        reinjects = int(self._bank_jpot_x1_reinject_count.get(tid_u, 0))
        last_echo = int(self._bank_jpot_x1_last_echo_len.get(tid_u, -1))
        last_post = int(self._bank_jpot_x1_last_post_de_len.get(tid_u, -1))
        del self._bank_jpot_await_x1[tid_u]
        # keep_frame for copper seal so Master progressive retries can reinject.
        seal_keep = keep_frame or self._bank_jpot_tid_sealed(tid_u)
        self._forget_bank_jpot_x1_track(tid_u, keep_frame=seal_keep)
        if not seal_keep:
            self._bank_jpot_x1_seal_until.pop(tid_u, None)
        # Do not leave bells un-heard if close timed out without eth_rx/x1 fanout release.
        # EG 3.0.1.38: hold_timeout / max_reinject — drop stashed progressive, do **not**
        # multiport-flood eth injects (CGS thrash + multi-seat early_win storm).
        if tid_u in self._bank_jpot_defer_fanout_frame:
            if reason in ("hold_timeout", "max_reinject"):
                self._bank_jpot_defer_fanout_frame.pop(tid_u, None)
                self.event_log.emit(
                    "bank_jpot_eth_fanout_drop",
                    tid=tid_u,
                    source=f"clear_{reason}",
                    reason="no_flood_on_close_miss",
                )
            else:
                self._release_bank_jpot_eth_fanout(tid_u, source=f"clear_{reason}")
        self._bank_jpot_fanout_released.discard(tid_u)
        if reason not in ("inject_echo_ok", "copper_packet_complete", "inject_glued_0d"):
            self._bank_jpot_arm_ns.pop(tid_u, None)
            # keep award_x9_ok so a late post-arm x1 can still be evaluated on re-arm
        self.event_log.emit(
            "bank_jpot_x1_priority_clear",
            tid=tid_u,
            reason=reason,
            reinjects=reinjects,
            last_echo_len=last_echo,
            last_post_de_len=last_post,
            keep_frame=1 if seal_keep else 0,
        )

    def _meta_is_bank_jpot_x1_priority(self, meta) -> bool:
        if meta is None:
            return False
        self._prune_bank_jpot_x1_priority()
        return is_bank_jpot_x1_priority_ack(
            meta.msg_type,
            meta.tid_hex,
            self._bank_jpot_await_x1,
        )

    def _pending_is_bank_jpot_x1_priority(self, pending: PendingInject) -> bool:
        meta = parse_packet_meta(pending.frame)
        return self._meta_is_bank_jpot_x1_priority(meta)

    def _inject_priority_of(self, pending: PendingInject, *, now_ns: int | None = None) -> int:
        if pending.source == "status_0d_synth":
            return INJECT_PRIORITY_P0_CLOSE
        # EG 3.0.1.42: winner progressive issue rides bank-jpot class while armed.
        if is_bank_progressive_jackpot_frame(pending.frame):
            meta_p = parse_packet_meta(pending.frame)
            tid_p = (meta_p.tid_hex or pending.tid or "").strip().upper()
            if (
                tid_p
                and tid_p in self._bank_jpot_await_x1
                and self._meta_is_local_terminal(meta_p)
            ):
                return INJECT_PRIORITY_BANK_JPOT_X1
        meta = parse_packet_meta(pending.frame)
        return inject_priority_class(
            meta.msg_type,
            source=pending.source,
            tid_hex=meta.tid_hex or pending.tid,
            bank_jpot_x1_armed_until_ns=self._bank_jpot_await_x1,
            now_ns=now_ns,
        )

    def _reorder_pending_by_priority(self) -> None:
        """Copper arbiter: reblend per-spoke queues by score (EG 3.0.1.29)."""
        self._prune_bank_jpot_x1_priority()
        self._rebuild_pending_from_spokes()

    def _lift_bank_jpot_x1_from_transaction_deferred(self) -> None:
        """Promote stashed progressive ACKs / close legs that may inject *now*.

        EG 3.0.1.56: do not lift frames that ``_should_defer_transaction_inject``
        would immediately re-stash (foreign x1 under local exclusive, etc.). That
        bounce every main-loop poll spammed ``bank_jpot_x1_priority_lift`` after
        jackpots and filled the console under ``echo``.
        """
        if not self._transaction_deferred:
            return
        keep: list[PendingInject] = []
        lifted = 0
        for stashed in self._transaction_deferred:
            meta = parse_packet_meta(stashed.frame)
            may_promote = self._meta_is_bank_jpot_x1_priority(meta) or is_gameplay_close_leg(
                meta.msg_type, source=stashed.source
            )
            if not may_promote:
                keep.append(stashed)
                continue
            # About to bounce back into Queue B this same ``_process_pending_injects``.
            if self._should_defer_transaction_inject(meta, stashed.frame):
                keep.append(stashed)
                continue
            self._enqueue_pending(
                PendingInject(
                    frame=stashed.frame,
                    queued_perf_ns=stashed.queued_perf_ns,
                    msg=stashed.msg,
                    tid=stashed.tid,
                    source=(
                        stashed.source
                        if stashed.source == "status_0d_synth"
                        else "eth_to_uart"
                    ),
                    strict_slv_idle=stashed.strict_slv_idle,
                    marshal_crc32=stashed.marshal_crc32,
                )
            )
            lifted += 1
        if not lifted:
            return
        self._transaction_deferred.clear()
        self._transaction_deferred.extend(keep)
        # Rate-limit: thrash used to emit this every poll for tens of seconds.
        now_ns = time.perf_counter_ns()
        min_ns = int(INJECT_DEFER_LOG_MIN_INTERVAL_MS * 1_000_000)
        last = getattr(self, "_bank_jpot_lift_log_ns", None)
        if last is not None and (now_ns - last) < min_ns:
            return
        self._bank_jpot_lift_log_ns = now_ns
        self.event_log.emit(
            "bank_jpot_x1_priority_lift",
            lifted=lifted,
            queue_depth=len(self._pending),
            deferred_remaining=len(self._transaction_deferred),
        )

    def _pending_is_p0_close_priority(self, pending: PendingInject) -> bool:
        """P0/P1: any gameplay x1/0D, bank progressive winner x1, local-terminal."""
        meta = parse_packet_meta(pending.frame)
        if is_gameplay_close_leg(meta.msg_type, source=pending.source):
            return True
        if self._pending_is_bank_jpot_x1_priority(pending):
            return True
        return self._pending_inject_is_local_terminal(pending)

    def _promote_status_0d_synth_pending(self) -> None:
        """Reorder inject queue by priority class (P0 close, bank jpot x1, STOM, FIFO)."""
        self._reorder_pending_by_priority()

    def _any_trunk_round_active(self) -> bool:
        return any(not st.closed for st in self._trunk_rounds.values())

    def _trunk_round_active_for_tid(self, tid_hex: str | None) -> bool:
        if not tid_hex:
            return False
        st = self._trunk_rounds.get(tid_hex.upper())
        return st is not None and not st.closed

    def _trunk_round_note_request(self, meta) -> None:
        if channel_uses_raw_hs_window(self.channel):
            return
        if not meta.tid_hex or not is_gameplay_request(meta.msg_type):
            return
        tid = meta.tid_hex.upper()
        prev = self._trunk_rounds.get(tid)
        was_open = prev is not None and not prev.closed
        self._trunk_rounds[tid] = TrunkRoundState(
            game=meta.game_id_hex,
            seq=meta.play_sequence,
            opened_perf_ns=time.perf_counter_ns(),
        )
        self._tid_tx_note_request(meta)
        # EG 3.0.1.51: local x0 starts exclusive game transaction on this seat.
        if self._meta_is_local_for_round_state(meta) and not was_open:
            self.event_log.emit(
                "local_game_txn_open",
                tid=tid,
                game=meta.game_id_hex,
                play_sequence=meta.play_sequence,
                msg=meta.msg_type_hex,
            )

    def _trunk_round_mark_x9(self, tid_hex: str | None) -> None:
        if not tid_hex:
            return
        st = self._trunk_rounds.get(tid_hex.upper())
        if st is not None and not st.closed:
            st.x9_injected = True

    def _trunk_round_mark_x1(self, tid_hex: str | None) -> None:
        if not tid_hex:
            return
        st = self._trunk_rounds.get(tid_hex.upper())
        if st is not None and not st.closed:
            st.x1_seen = True

    def _trunk_round_close(self, tid_hex: str | None, game_id: str | None = None) -> None:
        if not tid_hex:
            return
        tid = tid_hex.upper()
        st = self._trunk_rounds.get(tid)
        if st is None or st.closed:
            return
        if game_id is not None and st.game and st.game.upper() != game_id.upper():
            return
        st.closed = True
        local_u = self._local_slave_tid.upper() if self._local_slave_tid else None
        if local_u and tid == local_u:
            age_ms = None
            if st.opened_perf_ns:
                age_ms = round(
                    (time.perf_counter_ns() - st.opened_perf_ns) / 1_000_000.0, 1
                )
            self.event_log.emit(
                "local_game_txn_close",
                tid=tid,
                game=st.game,
                play_sequence=st.seq,
                age_ms=age_ms,
                x9_injected=st.x9_injected,
                x1_seen=st.x1_seen,
            )
        self._prune_closed_trunk_rounds()
        self._clear_skill_round_state_for_tid(tid, game_id)
        # Defer flush while synth 0D close is still armed — note_closing_0d flushes.
        if not self._any_trunk_round_active() and not self._local_close_pending():
            self._flush_transaction_deferred()

    def _clear_skill_round_state_for_tid(
        self,
        tid_hex: str | None,
        game_id: str | None = None,
    ) -> None:
        if not tid_hex:
            return
        tid = tid_hex.upper()
        game_key = game_id.upper() if game_id else None
        for rk in list(self._status_0d_skill_round_active):
            if rk[0] != tid:
                continue
            if game_key is not None and rk[1].upper() != game_key:
                continue
            self._status_0d_skill_round_active.discard(rk)
            self._status_0d_skill_grant_seen.discard(rk)
            self._status_0d_skill_close_done.discard(rk)

    def _suppress_post_skill_x1_synth(self, rk: RoundKey) -> bool:
        """Suppress spurious deal-x1 synth only during an open skill grant burst."""
        return (
            rk in self._status_0d_skill_round_active
            or rk in self._status_0d_skill_grant_seen
        )

    def _clear_skill_round_state(self, rk: RoundKey) -> None:
        self._status_0d_skill_round_active.discard(rk)
        self._status_0d_skill_grant_seen.discard(rk)
        self._status_0d_skill_close_done.discard(rk)

    def _trunk_round_close_on_native_status(self, meta) -> None:
        """Master UART status close (0x0D) â€” release game_transaction_gate."""
        if channel_uses_raw_hs_window(self.channel):
            return
        tid = meta.tid_hex.upper() if meta.tid_hex else None
        game = meta.game_id_hex
        if tid:
            self._trunk_round_close(tid, game)
            self.event_log.emit(
                "trunk_round_close_native_status",
                tid=tid,
                game=game,
            )
            return
        # Master 0x0D often has tid=null (master_tid in diag only) â€” close by game.
        closed_tids: list[str] = []
        for round_tid, st in list(self._trunk_rounds.items()):
            if st.closed:
                continue
            if game is not None and st.game and st.game.upper() != game.upper():
                continue
            self._trunk_round_close(round_tid, game)
            closed_tids.append(round_tid)
        if closed_tids:
            self.event_log.emit(
                "trunk_round_close_native_status",
                tid=",".join(closed_tids),
                game=game,
            )

    def _prune_closed_trunk_rounds(self) -> None:
        drop = [tid for tid, st in self._trunk_rounds.items() if st.closed]
        for tid in drop:
            del self._trunk_rounds[tid]

    def _inject_is_leg_for_tid(self, meta, tid: str) -> bool:
        if is_stom_comm_priority(meta.msg_type):
            return True
        st = self._trunk_rounds.get(tid)
        if st is None or st.closed:
            return False
        if is_gameplay_request(meta.msg_type):
            # In-round superseding 0x40+ stay owned by transaction path so they are
            # not held behind game_transaction_gate when a trunk is open for tid.
            return True
        if is_gameplay_response(meta.msg_type):
            return not st.x9_injected
        if is_gameplay_ack(meta.msg_type):
            return True
        if meta.msg_type == 0x0D:
            return not self.status_0d_block_all
        return False

    def _should_defer_transaction_inject(
        self, meta, frame: bytes | None = None
    ) -> bool:
        """True → stash for post-round UART inject (Queue B); never eth→eth discard.

        EG 3.0.1.51 local exclusive: once this seat’s slave x0 opens a game txn,
        keep *all other* eth→UART traffic out until 0D — no “under load” barge.
        Only the transaction legs (local x9 / x1 / 0D / skill on this TID), STOM,
        and bank-jpot close priority may inject.
        """
        if channel_uses_raw_hs_window(self.channel):
            return False
        # Progressive-close ACK to Master never waits behind lab/synth FIFO.
        if self._meta_is_bank_jpot_x1_priority(meta):
            return False
        # T340+ CGS / Transfer / POG_UPDT 0x0A — copper would pass even during jpot.
        # EG 4.1.0.2: soft 0A yields while SCR awaits Master 0B (UART budget).
        if is_progressive_broadcast(meta.msg_type) and self._scr_awaiting_reply():
            return True
        if is_stom_comm_priority(meta.msg_type):
            return False
        # EG 3.0.1.27/33: while bank progressive close is primary, soft traffic and
        # non-close-path frames may wait — but never defer any gameplay x1 / 0x0D,
        # nor winner progressive issue/retries.
        if self._bank_jpot_close_armed():
            if self._is_bank_jpot_close_path_frame(meta, frame):
                return False
            # EG 3.0.2.3: after eth x1 + fanout for armed jpot(s), resume peer x0 —
            # exclusive hold no longer freezes ordinary Master path injects.
            if self._bank_jpot_peer_gameplay_soft_open():
                return False
            return True
        # STOM always (brief Master enrollment / SCR) — not game content load.
        if is_stom_comm_priority(meta.msg_type):
            return False
        # ---- Local game exclusive (x0 open → 0D) ----
        if self._local_game_txn_exclusive():
            if is_progressive_broadcast(meta.msg_type):
                return True
            if self._hub_allows_peer_gameplay_during_local_close(meta):
                return False
            # Foreign TIDs: full fence.
            if not self._meta_is_local_terminal(meta):
                return True
            # Local TID: only transaction legs.
            if is_gameplay_response(meta.msg_type) or is_skill_jpot_grant(meta.msg_type):
                # Same-seq x9 after first inject belongs in peel hold, not queue barge.
                if self._should_defer_same_seq_x9_await_x1_peel(meta):
                    return True
                return False
            if is_gameplay_ack(meta.msg_type):
                return False
            if meta.msg_type == 0x0D:
                return False
            if is_gameplay_request(meta.msg_type):
                # Allow superseding local x0 (CGS) so seq can advance — copper path
                # already uses local_round_superseded; eth must not stall forever.
                return False
            # Soft progressive already returned True; other local soft → defer.
            return True
        # Legacy: P0 close-only window (hub master 0d narrow path uses same helper).
        if self._local_close_pending_0d_only():
            if is_progressive_broadcast(meta.msg_type):
                return True
            if self._hub_allows_peer_gameplay_during_local_close(meta):
                return False
            return not self._meta_is_local_terminal(meta)
        # v16ab local_tid always pass when not exclusive.
        if self._meta_is_local_terminal(meta):
            return False
        if is_progressive_broadcast(meta.msg_type) and self._any_trunk_round_active():
            return True
        # EG 3.0.1.29: soft 0A also defers while any per-TID transaction FSM is open.
        if is_progressive_broadcast(meta.msg_type) and any(
            st.phase != "idle" for st in self._tid_tx.values()
        ):
            return True
        tid = meta.tid_hex.upper() if meta.tid_hex else None
        if tid and self._trunk_round_active_for_tid(tid):
            return not self._inject_is_leg_for_tid(meta, tid)
        # Open FSM without trunk (hub eth-path): only required response/ack/0d legs pass.
        st = self._tid_tx_get(tid)
        if st is not None and st.phase != "idle":
            if is_gameplay_response(meta.msg_type) or is_skill_jpot_grant(meta.msg_type):
                return st.phase not in ("await_x9", "await_x1", "await_0d")
            if is_gameplay_ack(meta.msg_type):
                return st.phase not in ("await_x1", "await_0d")
            if meta.msg_type == 0x0D:
                return st.phase != "await_0d"
            if is_gameplay_request(meta.msg_type):
                return False
            return True
        return False

    def _stash_transaction_deferred(self, pending: PendingInject, meta) -> None:
        now_ns = time.perf_counter_ns()
        if is_progressive_broadcast(meta.msg_type):
            self._deferred_0a_latest = bytes(pending.frame)
            self._emit_transaction_defer_rate_limited(
                reason="0a_latest_wins",
                now_ns=now_ns,
                msg=pending.msg,
                len=len(pending.frame),
                deferred_0a=True,
            )
            return
        if self._local_game_txn_exclusive():
            defer_reason = "local_game_txn_exclusive"
        elif self._local_close_pending_0d_only():
            defer_reason = "await_local_0d"
        else:
            defer_reason = "game_transaction_gate"
        self._transaction_deferred.append(pending)
        dropped = 0
        while len(self._transaction_deferred) > TRANSACTION_DEFER_MAX_QUEUE:
            # EG 3.0.2.7: lowest keep-score first (foreign game before local x9/x1).
            # Tie-break oldest (leftmost).
            victim = 0
            victim_score = self._transaction_deferred_keep_score(
                self._transaction_deferred[0]
            )
            for idx, stashed in enumerate(self._transaction_deferred):
                sc = self._transaction_deferred_keep_score(stashed)
                if sc < victim_score or (sc == victim_score and idx < victim):
                    victim = idx
                    victim_score = sc
            del self._transaction_deferred[victim]
            dropped += 1
        if dropped:
            self._emit_transaction_defer_rate_limited(
                reason="queue_cap",
                now_ns=now_ns,
                msg=pending.msg,
                tid=pending.tid,
                dropped=dropped,
                queue_depth=len(self._transaction_deferred),
                max_queue=TRANSACTION_DEFER_MAX_QUEUE,
            )
            # Also emit abandon once per interval for soak visibility.
            last = self._transaction_defer_log_ns.get("abandon_queue_cap")
            min_ns = int(INJECT_DEFER_LOG_MIN_INTERVAL_MS * 1_000_000)
            if last is None or (now_ns - last) >= min_ns:
                self._transaction_defer_log_ns["abandon_queue_cap"] = now_ns
                self.event_log.emit(
                    "transaction_defer_abandon",
                    reason="queue_cap",
                    dropped=dropped,
                    queue_depth=len(self._transaction_deferred),
                    max_queue=TRANSACTION_DEFER_MAX_QUEUE,
                )
        self._emit_transaction_defer_rate_limited(
            reason=defer_reason,
            now_ns=now_ns,
            msg=pending.msg,
            tid=pending.tid,
            len=len(pending.frame),
            queue_depth=len(self._transaction_deferred),
        )

    def _abandon_transaction_deferred(self, *, reason: str) -> None:
        """Drop stashed foreign frames (failed local wait) â€” do not inject-storm flush."""
        # Bank progressive jackpot x9 is exempt: a late bell is acceptable, a
        # missing one is not, so promote those to _pending instead of dropping.
        kept: list[PendingInject] = []
        dropped = 0
        for stashed in self._transaction_deferred:
            if is_bank_progressive_jackpot_frame(stashed.frame):
                kept.append(stashed)
            else:
                dropped += 1
        had_0a = self._deferred_0a_latest is not None
        self._transaction_deferred.clear()
        self._deferred_0a_latest = None
        for stashed in kept:
            self._enqueue_pending(
                PendingInject(
                    frame=stashed.frame,
                    queued_perf_ns=time.perf_counter_ns(),
                    msg=stashed.msg,
                    tid=stashed.tid,
                    source="bank_jpot_late",
                    marshal_crc32=stashed.marshal_crc32,
                ))
        if not dropped and not had_0a and not kept:
            return
        self.event_log.emit(
            "transaction_defer_abandon",
            reason=reason,
            dropped=dropped,
            dropped_0a=had_0a,
            kept_bank_jpot=len(kept),
            queue_depth=0,
        )

    def _flush_transaction_deferred(self) -> None:
        flushed = 0
        if self._deferred_0a_latest is not None:
            frame = self._deferred_0a_latest
            self._deferred_0a_latest = None
            meta = parse_packet_meta(frame)
            self._enqueue_pending(
                PendingInject(
                    frame=frame,
                    queued_perf_ns=time.perf_counter_ns(),
                    msg=meta.msg_type_hex,
                    tid=meta.tid_hex,
                    source="transaction_deferred",
                    marshal_crc32=frame_marshal_fields(frame)["marshal_crc32"],
                ))
            flushed += 1
            self.event_log.emit(
                "transaction_defer_flush",
                kind="0a_latest",
                msg=meta.msg_type_hex,
                len=len(frame),
            )
        while self._transaction_deferred:
            pending = self._transaction_deferred.popleft()
            self._enqueue_pending(pending)
            flushed += 1
        if flushed:
            self.event_log.emit(
                "transaction_defer_flush",
                kind="queue",
                count=flushed,
                pending_inject_count=len(self._pending),
            )

    def _local_wager_awaiting_local_x9(self, local_rk: RoundKey) -> bool:
        """True when this seat has an open local wager round awaiting its own x9 inject."""
        wager = self._status_0d_last_wager.get(local_rk)
        if wager is None:
            return False
        wager_meta = parse_packet_meta(wager)
        if wager_meta.play_sequence is None:
            return True
        outcome = self._status_0d_last_x9.get(local_rk)
        if outcome is None:
            return True
        outcome_meta = parse_packet_meta(outcome)
        return outcome_meta.play_sequence != wager_meta.play_sequence

    def _meta_is_local_for_round_state(self, meta) -> bool:
        """Synth round state from UART: local T340 once TID known; accept all until then."""
        if not meta.tid_hex:
            return False
        if self._local_slave_tid is None:
            return True
        return meta.tid_hex.upper() == self._local_slave_tid

    def _note_hand_phase_copper(self, meta) -> None:
        """EG 3.0.2.6/7: stamp copper (or eth-anchored) 40→49→41 gaps.

        Emits ``master_phase_diag`` when pairs complete so soaks show whether
        Phase A is Master RTT vs local eth inject wall (hub_40_sw side band).

        Hub often has no copper packet_complete for x0 (eth inject of spoke
        wager) — EG 3.0.2.7 sets t40 via ``_note_hand_phase_eth_x0``.
        """
        try:
            msg = int(meta.msg_type) & 0xFF
        except Exception:
            return
        tid = (getattr(meta, "tid_hex", None) or "").upper()
        try:
            seq = int(getattr(meta, "play_sequence", -1))
        except Exception:
            seq = -1
        now = time.perf_counter_ns()
        if msg == 0x40:
            self._hand_phase_t40_ns = now
            self._hand_phase_t49_ns = None
            self._hand_phase_tid = tid or None
            self._hand_phase_seq = seq if seq >= 0 else None
            self._hand_phase_t40_origin = "copper"
            return
        if msg == 0x49 and self._hand_phase_t40_ns is not None:
            gap_us = int(max(0, (now - self._hand_phase_t40_ns) // 1000))
            self._hand_phase_t49_ns = now
            fields: dict[str, Any] = {
                "segment": "copper_A_40_to_49",
                "gap_us": gap_us,
                "tid": tid or self._hand_phase_tid,
                "play_sequence": seq if seq >= 0 else self._hand_phase_seq,
                "t40_origin": getattr(self, "_hand_phase_t40_origin", None) or "copper",
            }
            hub = self._hand_phase_hub40_sw
            if hub and (
                not tid
                or not hub.get("tid")
                or str(hub.get("tid")).upper() == tid
            ):
                fields["hub_40_inject_rpc_us"] = hub.get("inject_rpc_wait_us")
                fields["hub_40_pre_bulk_us"] = hub.get("pre_bulk_us")
                fields["hub_40_job_us"] = hub.get("uartd_job_to_done_us")
                fields["hub_40_air_us"] = hub.get("air_us")
                fields["hub_40_de_hold_us"] = hub.get("de_hold_us")
                # Residue Master sits above hub eth→UART 40 inject wall.
                fields["master_beyond_hub40_us"] = max(
                    0, gap_us - int(hub.get("inject_rpc_wait_us") or 0)
                )
            self.event_log.emit("master_phase_diag", **fields)
            return
        if msg == 0x41 and self._hand_phase_t49_ns is not None:
            gap_b = int(max(0, (now - self._hand_phase_t49_ns) // 1000))
            full = (
                int(max(0, (now - self._hand_phase_t40_ns) // 1000))
                if self._hand_phase_t40_ns is not None
                else None
            )
            fields = {
                "segment": "copper_B_49_to_41",
                "gap_us": gap_b,
                "tid": tid or self._hand_phase_tid,
                "play_sequence": seq if seq >= 0 else self._hand_phase_seq,
            }
            if full is not None:
                fields["full_40_to_41_us"] = full
            self.event_log.emit("master_phase_diag", **fields)

    def _note_hand_phase_eth_x0(self, meta) -> None:
        """EG 3.0.2.7: hub eth_rx x0 anchors Phase A when copper does not decode x0."""
        if not self._is_hub_spoke_master():
            return
        if not is_gameplay_request(meta.msg_type):
            return
        tid = (getattr(meta, "tid_hex", None) or "").upper()
        try:
            seq = int(getattr(meta, "play_sequence", -1))
        except Exception:
            seq = -1
        self._hand_phase_t40_ns = time.perf_counter_ns()
        self._hand_phase_t49_ns = None
        self._hand_phase_tid = tid or None
        self._hand_phase_seq = seq if seq >= 0 else None
        self._hand_phase_t40_origin = "eth_rx"
        self.event_log.emit(
            "master_phase_diag",
            segment="hub_eth_x0",
            tid=self._hand_phase_tid,
            play_sequence=self._hand_phase_seq,
        )

    def _on_packet_complete_trunk_round(
        self,
        meta,
        pkt: bytes,
        packets_in_window: int,
        *,
        uart_first_byte: int | None = None,
        window_us: float | None = None,
        byte_span_us: float | None = None,
    ) -> None:
        if uart_echo_false_glued_wager(
            meta.msg_type,
            packets_in_window=packets_in_window,
            uart_first_byte=uart_first_byte,
        ):
            return
        try:
            self._note_hand_phase_copper(meta)
        except Exception:
            pass
        if is_gameplay_request(meta.msg_type) and meta.tid_hex:
            self._trunk_round_note_request(meta)
        elif is_gameplay_response(meta.msg_type) or is_skill_jpot_grant(meta.msg_type):
            self._note_tid_result_x9(meta, pkt)
            if is_bank_progressive_jackpot_frame(pkt) and self._meta_is_local_terminal(
                meta
            ):
                self._note_bank_jpot_award_x9(pkt, meta, path="copper")
        elif is_gameplay_ack(meta.msg_type) and meta.tid_hex:
            self._note_tid_x1_empty_close(meta)
            self._trunk_round_mark_x1(meta.tid_hex)
            self._tid_tx_note_x1(meta)
            self._on_bank_jpot_x1_copper_packet_complete(meta)

    def _scr_msg_hex(self, msg_type: int | None) -> str | None:
        if msg_type == SCR_MSG:
            return "03"
        if msg_type == SCR_REPLY:
            return "0B"
        return None

    def _scr_queue_has(self, msg_hex: str, tid_u: str) -> bool:
        want = (msg_hex or "").upper()
        tid_u = (tid_u or "").upper()
        if not want or not tid_u:
            return False

        def _hit(pending: PendingInject) -> bool:
            return (pending.msg or "").upper() == want and (
                pending.tid or ""
            ).upper() == tid_u

        if any(_hit(p) for p in self._pending):
            return True
        for q in self._spoke_queues.values():
            if any(_hit(p) for p in q):
                return True
        return False

    def _scr_prune_coalesce_maps(self, now_ns: int) -> None:
        ttl_ns = int(SCR_COALESCE_MS * 3 * 1_000_000)
        for store in (self._scr_inj_ns, self._scr_reply_ns, self._scr_eth_tx_ns):
            dead = [k for k, ts in store.items() if (now_ns - ts) > ttl_ns]
            for k in dead:
                del store[k]

    def _scr_awaiting_reply(self, now_ns: int | None = None) -> bool:
        """True when any TID has an open SCR 03 awaiting Master 0B."""
        if now_ns is None:
            now_ns = time.perf_counter_ns()
        window_ns = int(SCR_COALESCE_MS * 1_000_000)
        for tid_u, inj_ns in list(self._scr_inj_ns.items()):
            # 0B inject stamps use "0B:TID" — not an open 03 await.
            if ":" in tid_u:
                continue
            if (now_ns - inj_ns) > window_ns:
                continue
            reply_ns = self._scr_reply_ns.get(tid_u)
            if reply_ns is None or reply_ns < inj_ns:
                return True
        if any(
            (p.msg or "").upper() == "03" and (p.tid or "").strip()
            for p in self._pending
        ):
            return True
        for q in self._spoke_queues.values():
            if any(
                (p.msg or "").upper() == "03" and (p.tid or "").strip() for p in q
            ):
                return True
        return False

    def _scr_eth_inject_coalesce_reason(self, meta, *, now_ns: int) -> str | None:
        """Drop duplicate SCR eth→UART (EG 4.1.0.2). None = inject."""
        msg_hex = self._scr_msg_hex(meta.msg_type)
        if msg_hex is None:
            return None
        tid_u = (meta.tid_hex or "").strip().upper()
        if not tid_u:
            return None
        self._scr_prune_coalesce_maps(now_ns)
        window_ns = int(SCR_COALESCE_MS * 1_000_000)
        if self._scr_queue_has(msg_hex, tid_u):
            return "scr_pending"
        if msg_hex == "03":
            reply_ns = self._scr_reply_ns.get(tid_u)
            if reply_ns is not None and (now_ns - reply_ns) <= window_ns:
                return "scr_recent_0b"
            inj_ns = self._scr_inj_ns.get(tid_u)
            if inj_ns is not None and (now_ns - inj_ns) <= window_ns:
                if reply_ns is None or reply_ns < inj_ns:
                    return "scr_inflight"
        else:
            inj_ns = self._scr_inj_ns.get(f"0B:{tid_u}")
            if inj_ns is not None and (now_ns - inj_ns) <= window_ns:
                return "scr_reply_recent"
            if self._scr_queue_has("0B", tid_u):
                return "scr_pending"
        return None

    def _scr_note_inject_done(self, meta, *, now_ns: int | None = None) -> None:
        if not is_scr_msg(meta.msg_type):
            return
        tid_u = (meta.tid_hex or "").strip().upper()
        if not tid_u:
            return
        if now_ns is None:
            now_ns = time.perf_counter_ns()
        if meta.msg_type == SCR_MSG:
            self._scr_inj_ns[tid_u] = now_ns
        elif meta.msg_type == SCR_REPLY:
            self._scr_inj_ns[f"0B:{tid_u}"] = now_ns
            self._scr_reply_ns[tid_u] = now_ns

    def _scr_note_reply_seen(self, meta, *, now_ns: int | None = None) -> None:
        if meta.msg_type != SCR_REPLY:
            return
        tid_u = (meta.tid_hex or "").strip().upper()
        if not tid_u:
            return
        if now_ns is None:
            now_ns = time.perf_counter_ns()
        self._scr_reply_ns[tid_u] = now_ns

    def _scr_uart_eth_coalesce_reason(self, meta, *, now_ns: int) -> str | None:
        """Spoke UART→ETH SCR policy (EG 4.1.0.4).

        Never suppress a Slave copper ``03`` retry while we are still waiting for
        Master ``0B`` — the glass will re-SCR and the star must carry it. Only
        skip eth re-TX when we **already have** a same-TID ``0B`` queued or just
        injected (response is in hand for the local seat).
        """
        if meta.msg_type != SCR_MSG:
            return None
        tid_u = (meta.tid_hex or "").strip().upper()
        if not tid_u:
            return None
        self._scr_prune_coalesce_maps(now_ns)
        # Response already on the inject queue → no need to re-ask Master.
        if self._scr_queue_has("0B", tid_u):
            return "scr_uart_eth_0b_pending"
        window_ns = int(SCR_COALESCE_MS * 1_000_000)
        # 0B inject just completed for this TID → Slave should have the answer.
        reply_inj = self._scr_inj_ns.get(f"0B:{tid_u}")
        if reply_inj is not None and (now_ns - reply_inj) <= window_ns:
            return "scr_uart_eth_0b_delivered"
        return None

    def _scr_note_uart_eth_tx(self, meta, *, now_ns: int | None = None) -> None:
        """No-op retained for call sites; spoke no longer stamps eth_tx for suppress."""
        del meta, now_ns

    def _on_packet_complete_scr_coalesce(
        self,
        meta,
        pkt: bytes,
        packets_in_window: int,
        *,
        uart_first_byte: int | None = None,
        window_us: float | None = None,
        byte_span_us: float | None = None,
    ) -> None:
        del pkt, packets_in_window, uart_first_byte, window_us, byte_span_us
        if meta is None:
            return
        if meta.msg_type == SCR_REPLY:
            self._scr_note_reply_seen(meta)

    def _on_packet_complete_scr_diag(
        self,
        meta,
        pkt: bytes,
        packets_in_window: int,
        *,
        uart_first_byte: int | None = None,
        window_us: float | None = None,
        byte_span_us: float | None = None,
    ) -> None:
        if not self.scr_diag or not is_scr_msg(meta.msg_type):
            return
        origin = "uart_native"
        fields = pog422_scr_diag_fields(pkt, meta)
        fields.update(
            {
                "origin": origin,
                "packets_in_window": packets_in_window,
                "uart_first_byte": (
                    f"{uart_first_byte:02X}" if uart_first_byte is not None else None
                ),
            }
        )
        if window_us is not None:
            fields["window_us"] = window_us
        if byte_span_us is not None:
            fields["byte_span_us"] = byte_span_us
        self.event_log.emit("scr_diag", **fields)
        now_ns = time.perf_counter_ns()
        if meta.msg_type == SCR_MSG:
            self._scr_open_perf_ns = now_ns
            self._scr_open_tid = meta.tid_hex
            self.event_log.emit(
                "scr_round",
                phase="request",
                origin=origin,
                tid=meta.tid_hex,
                game=meta.game_id_hex,
                seq=meta.play_sequence,
                **{k: fields[k] for k in ("wire_len_ok", "checksum_ok", "byte_span_us", "window_us") if k in fields},
            )
        elif meta.msg_type == SCR_REPLY:
            rtt_ms: float | None = None
            if self._scr_open_perf_ns is not None:
                rtt_ms = (now_ns - self._scr_open_perf_ns) / 1_000_000.0
            self.event_log.emit(
                "scr_round",
                phase="reply",
                origin=origin,
                tid=meta.tid_hex or self._scr_open_tid,
                master_tid=meta.master_tid_hex,
                rtt_ms=round(rtt_ms, 2) if rtt_ms is not None else None,
                **{k: fields[k] for k in ("wire_len_ok", "checksum_ok", "byte_span_us", "window_us") if k in fields},
            )
            self._scr_open_perf_ns = None
            self._scr_open_tid = None

    def _try_progressive_csv_update(self, frame: bytes, *, source: str) -> None:
        if self.progressive_csv is None:
            return
        if channel_uses_raw_hs_window(self.channel):
            return
        self.progressive_csv.maybe_update(frame, source=source)

    def _on_packet_complete_progressive_csv(
        self,
        meta,
        pkt: bytes,
        packets_in_window: int,
        *,
        uart_first_byte: int | None = None,
        window_us: float | None = None,
        byte_span_us: float | None = None,
    ) -> None:
        if not is_progressive_broadcast(meta.msg_type):
            return
        self._try_progressive_csv_update(pkt, source="uart")

    def _on_packet_complete_machine_status(
        self,
        meta,
        pkt: bytes,
        packets_in_window: int,
        *,
        uart_first_byte: int | None = None,
        window_us: float | None = None,
        byte_span_us: float | None = None,
    ) -> None:
        """Merge POG-CC telemetry + progressives into status/machine-status.json (~5s)."""
        del meta, pkt, packets_in_window, uart_first_byte, window_us, byte_span_us
        try:
            maybe_refresh_machine_status(min_interval_s=5.0)
        except Exception:
            pass

    def _on_packet_complete_status_synth(
        self,
        meta,
        pkt: bytes,
        packets_in_window: int,
        *,
        uart_first_byte: int | None = None,
        window_us: float | None = None,
        byte_span_us: float | None = None,
    ) -> None:
        if not self.status_0d_synth:
            return
        if uart_echo_false_glued_wager(
            meta.msg_type,
            packets_in_window=packets_in_window,
            uart_first_byte=uart_first_byte,
        ):
            self.event_log.emit(
                "status_0d_synth_skip",
                reason="uart_x9_echo_glued_wager",
                uart_first_byte=f"{uart_first_byte:02X}" if uart_first_byte is not None else None,
                packets_in_window=packets_in_window,
                msg=meta.msg_type_hex,
                tid=meta.tid_hex,
                game=meta.game_id_hex,
            )
            return
        if meta.msg_type == 0x0A and len(pkt) >= 11:
            self._status_0d_bank_tid_tail = pkt[9:11]
        self._remember_gameplay_round_uart(pkt, meta)
        ok, trigger = status_synth_x1_trigger_ok(meta, self._local_slave_tid)
        rk = self._round_key(meta)
        if not ok or rk is None:
            return
        # Solo/last-in-window x1 framing can match foreign TIDs on the shared bus.
        # Only the local terminal may arm await_0d / local synth close.
        if not self._meta_is_local_terminal(meta):
            return
        if self._suppress_post_skill_x1_synth(rk):
            self.event_log.emit(
                "status_0d_synth_skip",
                reason="post_skill_x1_echo",
                msg=meta.msg_type_hex,
                game=rk[1],
                tid=rk[0],
                trigger=trigger,
            )
            return
        # EG 2.2.2.68: always synth 0D after a real local x1 packet -- even when
        # infer already closed the seq (premature 0D is harmless; missing 0D after
        # the slave's 61 is MRT). _mark_local_x1_pending releases synth_sent so
        # a second close can TX.
        self._mark_local_x1_pending(
            rk,
            msg=meta.msg_type_hex,
            trigger=trigger,
            packets_in_window=packets_in_window,
        )

    def _game_has_status_template(self, game_id: str) -> bool:
        return game_id in self._status_0d_template

    def _remember_gameplay_round_uart(self, frame: bytes, meta) -> None:
        """Wager/x9 from local SLV-IN UART capture (this Pi's T340+ only)."""
        if not self.status_0d_synth:
            return
        if not self._meta_is_local_for_round_state(meta):
            return
        rk = self._round_key(meta)
        if rk is None:
            return
        if is_gameplay_request(meta.msg_type):
            self._supersede_local_round_if_newer(
                rk,
                meta.play_sequence,
                reason="local_uart_wager",
                trigger_msg=meta.msg_type_hex,
            )
            # Full txn restart on local x0 (same-seq + drop late 41/0D) — EG 3.0.2.12.
            self._mrt_risk_txn_restart_on_local_wager(meta)
            self._status_0d_last_wager[rk] = bytes(frame)
            if meta.msg_type == 0x50:
                self._clear_skill_round_state(rk)
        elif is_gameplay_response(meta.msg_type):
            self._supersede_local_round_if_newer(
                rk,
                meta.play_sequence,
                reason="local_uart_x9",
                trigger_msg=meta.msg_type_hex,
            )
            self._status_0d_last_x9[rk] = bytes(frame)
            self._note_x9_outcome(frame, meta, rk)
            # Master native outcome — POG422 emits 0D; never synth on this path.
            # If eth inject already marked this round, keep eth (see _set_x9_origin).
            self._set_x9_origin(rk, "uart")
            if rk in self._status_0d_x1_pending and self._x9_is_uart_origin(rk):
                self._skip_synth_master_uart_x9(rk, where="uart_x9_remember")

    def _remember_gameplay_round_eth_inject(
        self,
        frame: bytes,
        meta,
        *,
        synth_defer: list[RoundKey] | None = None,
        skill_synth_defer: list[RoundKey] | None = None,
    ) -> None:
        """Store local terminal x9 at ethâ†’UART inject time; relay synth on slave bridges.

        Every x9 still injects to the bus. Local TID uses UART + inject-store; when a
        slave receives another terminal's gameplay over ETH, rekey round state to the
        fixture local TID and synth status close after relayed x1 ack inject.
        """
        def _queue_synth(rk: RoundKey) -> None:
            # EG 3.0.1.43: never bypass schedule — uartd owns deal close unless
            # auto_close_miss armed Python fallback for this round.
            self._schedule_synth_attempt(rk, synth_defer=synth_defer)

        if not self.status_0d_synth:
            return
        if is_skill_jpot_grant(meta.msg_type):
            rk = self._round_key(meta)
            if rk is None and meta.game_id_hex:
                rk = self._local_round_key_for_game(meta.game_id_hex)
            if rk is not None:
                self._arm_skill_grant_close_synth(rk, meta, skill_synth_defer)
            return
        if self._meta_is_local_terminal(meta):
            if not is_gameplay_response(meta.msg_type):
                return
            rk = self._round_key(meta)
            if rk is None:
                return
            self._supersede_local_round_if_newer(
                rk,
                meta.play_sequence,
                reason="local_eth_x9",
                trigger_msg=meta.msg_type_hex,
            )
            self._status_0d_last_x9[rk] = bytes(frame)
            self._note_x9_outcome(frame, meta, rk)
            # Slave path: eth-injected x9 ⇒ synth 0D after local x1.
            self._set_x9_origin(rk, "eth")
            self._local_hand_bind_x9(rk)
            tid_key = rk[0]
            seq = meta.play_sequence
            self._recent_x9_inject_ns[(tid_key, seq)] = time.perf_counter_ns()
            self.event_log.emit(
                "status_0d_x9_inject_store",
                game=rk[1],
                tid=rk[0],
                msg=meta.msg_type_hex,
                len=len(frame),
            )
            _queue_synth(rk)
            return
        if not self._needs_relay_eth_synth(meta) or not meta.game_id_hex:
            return
        local_rk = self._local_round_key_for_game(meta.game_id_hex)
        if local_rk is None:
            return
        if self._local_round_has_state(local_rk):
            # Expected under multi-seat same-game soak â€” skip is correct; log â‰¤1/s.
            self._emit_relay_synth_skip_rate_limited(
                reason="local_same_game_active",
                now_ns=time.perf_counter_ns(),
                game=local_rk[1],
                tid=local_rk[0],
                msg=meta.msg_type_hex,
                origin_tid=meta.tid_hex,
                play_sequence=meta.play_sequence,
            )
            return
        if is_gameplay_response(meta.msg_type):
            if self._local_wager_awaiting_local_x9(local_rk):
                self.event_log.emit(
                    "status_0d_x9_inject_store_skip",
                    game=local_rk[1],
                    tid=local_rk[0],
                    msg=meta.msg_type_hex,
                    reason="local_wager_in_flight",
                    origin_tid=meta.tid_hex,
                    play_sequence=meta.play_sequence,
                )
                return
            self._status_0d_last_x9[local_rk] = bytes(frame)
            self._note_x9_outcome(frame, meta, local_rk)
            self.event_log.emit(
                "status_0d_x9_inject_store",
                game=local_rk[1],
                tid=local_rk[0],
                msg=meta.msg_type_hex,
                len=len(frame),
                relay=True,
                origin_tid=meta.tid_hex,
            )
            return
        if is_gameplay_ack(meta.msg_type):
            if self._suppress_post_skill_x1_synth(local_rk):
                self.event_log.emit(
                    "status_0d_synth_skip",
                    reason="post_skill_x1_echo",
                    msg=meta.msg_type_hex,
                    game=local_rk[1],
                    tid=local_rk[0],
                    trigger="relay_x1_inject",
                    origin_tid=meta.tid_hex,
                )
                return
            self._mark_local_x1_pending(
                local_rk,
                trigger="relay_x1_inject",
                msg=meta.msg_type_hex,
                synth_defer=synth_defer,
                origin_tid=meta.tid_hex,
            )

    def _x9_outcome_eth_inject_duplicate(
        self, meta, frame: bytes | None = None
    ) -> bool:
        """True when this ETH x9 outcome was already queued or injected for this TID+seq.

        v16aa local_tid_x9_never_eth_inject_dedupe: never skip local-TID x9 outcomes —
        Master poll retries must always re-inject to UART (except Keno 0x49 result dupes).
        EG 3.0.1.32: bank progressive eth injects *are* deduped for non-winners and for a
        winner whose bank-jpot arm already cleared — suppresses repeat-jackpot storms from
        multiport fanout / Master eth retries. Local **winner while still armed** keeps
        never-dedupe so Master progressive retries can re-inject for close.

        EG 3.0.1.37 product law: **local terminal progressive grant** (progwinval>0)
        is **never** soft-deduped. Eth arm order is: dedupe check *before* bank-jpot
        arm, so requiring ``tid in _bank_jpot_await_x1`` still skipped the first award
        inject after a same-TID cash ``0x49`` (soak 2026-08-06_1744: cash inject then
        jpot eth_rx_skip x9_eth_inject_dedupe → meter reset, no glass award).
        """
        if not meta.tid_hex or not is_gameplay_response(meta.msg_type):
            return False
        bank_jpot = frame is not None and is_bank_progressive_jackpot_frame(frame)
        if bank_jpot and self._meta_is_local_terminal(meta):
            # Award path: never soft-dedupe (cash→jpot same TID+seq within 500ms
            # must inject the progressive grant for Jackpot Won UI).
            return False
        # EG 3.0.1.55: empty harvest left await_x1 open — allow Master re-49 for
        # the open local seq (do not soft-dedupe cash result still awaiting 41).
        if (
            not bank_jpot
            and self._meta_is_local_terminal(meta)
            and is_gameplay_response(meta.msg_type)
        ):
            rk_open = self._round_key(meta)
            if (
                rk_open is not None
                and rk_open in self._mrt_risk_await_x1
                and not self._mrt_risk_seq_already_closed(meta)
                and not self._status_0d_close_done_for_round(rk_open)
            ):
                return False
        if bank_jpot:
            # Peer bells / multiport re-flood: age dedupe below.
            pass
        elif meta.msg_type == KENO_RESULT_MSG:
            if not self._meta_is_local_terminal(meta):
                return False
        elif meta.msg_type == 0x79:
            if self._meta_is_local_terminal(meta):
                return False
        else:
            return False
        tid_key = meta.tid_hex.upper()
        seq = meta.play_sequence
        key = (tid_key, seq)
        now_ns = time.perf_counter_ns()
        last_ns = self._recent_x9_inject_ns.get(key)
        if last_ns is not None:
            age_ms = (now_ns - last_ns) / 1_000_000.0
            dedupe_ms = X9_RESPONSE_ETH_INJECT_DEDUPE_MS
            if self._bank_jpot_close_armed() or bank_jpot:
                dedupe_ms = X9_RESPONSE_ETH_INJECT_DEDUPE_BANK_JPOT_MS
            if age_ms < dedupe_ms:
                return True
        for pending in self._pending:
            # Cash and progressive grants share TID+msg+seq; do not treat a
            # non-progressive pending as a duplicate of a progressive grant (local
            # bank_jpot already returned above — this covers peer bank_jpot).
            if bank_jpot and not is_bank_progressive_jackpot_frame(pending.frame):
                continue
            pm = parse_packet_meta(pending.frame)
            if (
                pm.msg_type == meta.msg_type
                and pm.tid_hex
                and pm.tid_hex.upper() == tid_key
                and pm.play_sequence == seq
            ):
                return True
        return False

    def _note_x9_outcome(self, frame: bytes, meta, rk: RoundKey) -> None:
        if not self.status_0d_synth:
            return
        if not is_gameplay_response(meta.msg_type):
            return
        counter_hint, counter_src = status_0d_close_counter_from_x9(
            frame,
            last_global=self._global_close_counter,
            loose_counter=self.status_0d_synth_loose_counter,
        )
        self._status_0d_x9_hint[rk] = {
            "counter_hint": counter_hint,
            "counter_source": counter_src,
            "play_sequence": meta.play_sequence,
            "x9_byte17": frame[17] if len(frame) > 17 else None,
        }
        self.event_log.emit(
            "status_0d_x9_hint",
            game=rk[1],
            tid=rk[0],
            msg=meta.msg_type_hex,
            counter_hint=counter_hint,
            counter_source=counter_src,
            play_sequence=meta.play_sequence,
            x9_byte17=frame[17] if len(frame) > 17 else None,
        )

    def _store_local_x9_for_synth(self, frame: bytes, meta) -> RoundKey | None:
        """Stash local eth→UART x9 *before* inject so early bus harvest can build 0D."""
        if not self.status_0d_synth or not self._meta_is_local_terminal(meta):
            return None
        if not is_gameplay_response(meta.msg_type):
            return None
        rk = self._round_key(meta)
        if rk is None:
            return None
        self._supersede_local_round_if_newer(
            rk,
            meta.play_sequence,
            reason="local_eth_x9_prestore",
            trigger_msg=meta.msg_type_hex,
        )
        self._status_0d_last_x9[rk] = bytes(frame)
        self._note_x9_outcome(frame, meta, rk)
        self._set_x9_origin(rk, "eth")
        # Bind before inject so early bus harvest / inline AUTO_CLOSE seal (3.0.2.12).
        self._local_hand_bind_x9(rk)
        return rk

    def _harvest_x1_from_inject_bus(
        self,
        frame: bytes,
        meta,
        *,
        echo: bytes,
        post_de: bytes,
        early: bool,
        synth_defer: list[RoundKey] | None = None,
    ) -> bool:
        """Peel local x1 from inject echo/post_de and arm 0D (queue even under tx lock)."""
        if (
            not self.status_0d_synth
            or meta is None
            or channel_uses_raw_hs_window(self.channel)
            or not self._meta_is_local_terminal(meta)
            or not is_gameplay_response(meta.msg_type)
        ):
            return False
        rk = self._round_key(meta)
        if rk is None:
            return False
        if rk not in self._status_0d_last_x9:
            self._status_0d_last_x9[rk] = bytes(frame)
            self._note_x9_outcome(frame, meta, rk)
            if rk not in self._status_0d_x9_origin:
                self._set_x9_origin(rk, "eth")
        peer = peer_uart_bytes_after_inject(frame, echo or b"", post_de or b"")
        if not peer:
            return False
        peer_x1_msg: str | None = None
        normalized = normalize_window(peer)
        packets = split_packets(normalized) if normalized else []
        for pkt in packets:
            pm = parse_packet_meta(pkt)
            if is_gameplay_ack(pm.msg_type) and self._meta_is_local_terminal(pm):
                peer_x1_msg = pm.msg_type_hex
                break
        if peer_x1_msg is None:
            return False
        if early and rk in self._status_0d_x1_pending:
            # Already armed from a prior ECHO chunk — still nudge synth queue.
            self._schedule_synth_attempt(rk, synth_defer=synth_defer)
            return True
        trigger = "peer_x1_early_bus" if early else "peer_x1_in_echo"
        path = "inject_echo_early" if early else "post_de_peel"
        if early:
            self.event_log.emit(
                "mrt_peer_x1_in_echo",
                tid=rk[0],
                game=rk[1],
                msg=meta.msg_type_hex,
                x1_msg=peer_x1_msg,
                echo_len=len(echo or b""),
                post_de_len=len(post_de or b""),
                play_sequence=meta.play_sequence,
                early=True,
            )
        self._mark_local_x1_pending(
            rk,
            msg=peer_x1_msg,
            trigger=trigger,
            harvest_path=path,
            echo_len=len(echo or b""),
            play_sequence=meta.play_sequence,
            synth_defer=synth_defer,
        )
        return True

    def _release_synth_sent_for_x9_counter(self, rk: RoundKey) -> None:
        """Allow resend after 8-bit counter wrap â€” stale synth_sent must not block x1."""
        counter_info = self._synth_counter_for_round(rk)
        if counter_info is None:
            return
        counter, _ = counter_info
        self._status_0d_synth_sent.discard((rk, counter))

    def _synth_counter_for_round(
        self,
        rk: RoundKey,
    ) -> tuple[int, str] | None:
        outcome = self._status_0d_last_x9.get(rk)
        if outcome is not None:
            return status_0d_close_counter_from_x9(
                outcome,
                last_global=self._global_close_counter,
                loose_counter=self.status_0d_synth_loose_counter,
            )
        hint = self._status_0d_x9_hint.get(rk)
        if hint is None:
            return None
        src = hint.get("counter_source")
        if src == "x9_byte17" or self.status_0d_synth_loose_counter:
            return int(hint["counter_hint"]) & 0xFF, str(src)
        return None

    def _can_synth_from_x9_byte17(self, rk: RoundKey) -> bool:
        counter_info = self._synth_counter_for_round(rk)
        if counter_info is None:
            return False
        _, counter_src = counter_info
        return self.status_0d_synth_loose_counter or counter_src == "x9_byte17"

    def _build_synth_close_frame(
        self,
        rk: RoundKey,
        counter: int,
    ) -> tuple[bytes, str] | None:
        game_id = rk[1]
        outcome = self._status_0d_last_x9.get(rk)
        wager = self._status_0d_last_wager.get(rk)
        if outcome is not None:
            frame = build_status_0d_from_round(
                wager,
                outcome,
                counter=counter,
                progressive_tail=self._status_0d_bank_tid_tail,
            )
            if frame is not None:
                return frame, "round"
        template = self._status_0d_template.get(game_id)
        if template is not None:
            frame = build_status_0d_from_template(template, counter)
            if frame is not None:
                return frame, "template"
        frame = build_status_0d_minimal(
            game_id,
            counter,
            tid_tail=self._status_0d_bank_tid_tail,
        )
        if frame is None:
            return None
        return frame, "minimal"

    def _try_synth_pending_for_game(self, game_id: str) -> None:
        for rk in list(self._status_0d_x1_pending):
            if rk[1] == game_id:
                self._try_synth_pending_for_round(rk)

    def _arm_skill_grant_close_synth(
        self,
        rk: RoundKey,
        meta,
        skill_synth_defer: list[RoundKey] | None,
    ) -> None:
        if not self.status_0d_synth:
            return
        if rk in self._status_0d_skill_close_done:
            self.event_log.emit(
                "status_0d_synth_skip",
                msg=meta.msg_type_hex,
                game=rk[1],
                tid=rk[0],
                reason="skill_grant_burst_dup",
                trigger="skill_grant_inject",
                origin_tid=meta.tid_hex,
            )
            return
        self._status_0d_skill_grant_seen.add(rk)
        self._status_0d_skill_round_active.add(rk)
        self.event_log.emit(
            "status_0d_synth_trigger",
            msg=meta.msg_type_hex,
            game=rk[1],
            tid=rk[0],
            trigger="skill_grant_inject",
            origin_tid=meta.tid_hex,
        )
        if skill_synth_defer is not None:
            if rk not in skill_synth_defer:
                skill_synth_defer.append(rk)
        else:
            self._try_synth_skill_grant_close_for_round(rk)

    def _try_synth_skill_grant_close_for_round(self, rk: RoundKey) -> None:
        if not self.status_0d_synth:
            return
        if rk not in self._status_0d_skill_grant_seen:
            return
        if self._lab_withhold_close_for_round(rk, where="skill_grant"):
            self._status_0d_skill_grant_seen.discard(rk)
            self.event_log.emit(
                "status_0d_synth_skip",
                reason="lab_withhold_status_0d",
                game=rk[1],
                tid=rk[0],
                trigger="skill_grant",
            )
            return
        game_id = rk[1]
        counter, counter_src = status_0d_close_counter_skill_grant(
            self._global_close_counter
        )
        synth_key = (rk, counter)
        if synth_key in self._status_0d_synth_sent:
            self._status_0d_skill_grant_seen.discard(rk)
            self.event_log.emit(
                "status_0d_synth_skip",
                game=game_id,
                tid=rk[0],
                counter=counter,
                reason="already_sent",
                counter_source=counter_src,
                trigger="skill_grant",
            )
            return
        built = self._build_synth_close_frame(rk, counter)
        if built is None:
            self.event_log.emit(
                "status_0d_synth_skip",
                game=game_id,
                tid=rk[0],
                counter=counter,
                reason="bad_build",
                counter_source=counter_src,
                trigger="skill_grant",
            )
            return
        frame, build_source = built
        self.event_log.emit(
            "status_0d_synth_built",
            game=game_id,
            tid=rk[0],
            counter=counter,
            len=len(frame),
            build_source=build_source,
            counter_source=counter_src,
            trigger="skill_grant",
        )
        self._status_0d_skill_grant_seen.discard(rk)
        self._status_0d_skill_close_done.add(rk)
        self._dispatch_status_synth_frame(
            frame,
            synth_key,
            built=True,
            build_source=build_source,
        )

    def _try_synth_pending_for_round(self, rk: RoundKey) -> None:
        if rk not in self._status_0d_x1_pending:
            return
        # EG 3.0.1.43: with uartd, only Python close after auto_close_miss.
        if not self._python_may_deal_synth_close(rk):
            return
        # Master UART-origin x9 ⇒ native 0D; never inject synth.
        if self._skip_synth_master_uart_x9(rk, where="try_synth"):
            return
        if self._lab_withhold_close_for_round(rk, where="try_synth"):
            self._status_0d_x1_pending.discard(rk)
            self._mrt_risk_await_0d.pop(rk, None)
            self.event_log.emit(
                "status_0d_synth_skip",
                reason="lab_withhold_status_0d",
                game=rk[1],
                tid=rk[0],
                trigger="deal_x1",
            )
            return
        game_id = rk[1]
        latest_seq = self._local_round_latest_seq.get(rk)
        hint_seq = self._status_0d_x9_hint.get(rk, {}).get("play_sequence")
        if (
            latest_seq is not None
            and hint_seq is not None
            and hint_seq != latest_seq
        ):
            # Prefer a local last_x9 that already matches latest (UART x1 raced
            # ahead of eth hint refresh) over abandoning the close.
            outcome = self._status_0d_last_x9.get(rk)
            refreshed = False
            if outcome is not None:
                om = parse_packet_meta(outcome)
                if om.play_sequence == latest_seq:
                    self._note_x9_outcome(outcome, om, rk)
                    hint_seq = latest_seq
                    refreshed = True
            if not refreshed:
                self.event_log.emit(
                    "status_0d_synth_skip",
                    game=game_id,
                    tid=rk[0],
                    counter=0,
                    reason="superseded_seq",
                    play_sequence=hint_seq,
                    latest_seq=latest_seq,
                )
                self._mrt_risk_abandon_await_0d(rk, reason="superseded_seq")
                return
        if (
            self.status_0d_first_passthrough
            and not self._game_has_status_template(game_id)
            and not self._can_synth_from_x9_byte17(rk)
        ):
            if rk not in self._status_0d_await_passthrough_logged:
                self._status_0d_await_passthrough_logged.add(rk)
                self.event_log.emit(
                    "status_0d_synth_skip",
                    game=game_id,
                    tid=rk[0],
                    counter=0,
                    reason="await_passthrough",
                )
            # Keep x1_pending / await_0d â€” tick retries once x9 hint arrives.
            return
        if not self.status_0d_synth_from_x9:
            self.event_log.emit(
                "status_0d_synth_skip",
                game=game_id,
                tid=rk[0],
                counter=0,
                reason="x9_synth_disabled",
            )
            self._mrt_risk_abandon_await_0d(rk, reason="x9_synth_disabled")
            return
        counter_info = self._synth_counter_for_round(rk)
        if counter_info is None:
            self.event_log.emit(
                "status_0d_synth_skip",
                game=game_id,
                tid=rk[0],
                counter=0,
                reason="no_x9",
            )
            self._mrt_risk_abandon_await_0d(rk, reason="no_x9")
            return
        counter, counter_src = counter_info
        synth_key = (rk, counter)
        if synth_key in self._status_0d_synth_sent:
            # Queued behind tx_lock/HS — leave pending; do not abandon mid-flight.
            if rk in self._mrt_risk_await_0d or self._status_0d_synth_queued_for(rk):
                return
            self.event_log.emit(
                "status_0d_synth_skip",
                game=game_id,
                tid=rk[0],
                counter=counter,
                reason="already_sent",
                counter_source=counter_src,
                trigger="deal_x1",
                close_phase=self._round_close_phase(rk),
            )
            self._mrt_risk_abandon_await_0d(rk, reason="already_sent")
            return
        outcome = self._status_0d_last_x9.get(rk)
        if (
            not self.status_0d_synth_loose_counter
            and counter_src != "x9_byte17"
        ):
            self.event_log.emit(
                "status_0d_synth_skip",
                game=game_id,
                tid=rk[0],
                counter=counter,
                reason="x9_byte17_missing",
                counter_source=counter_src,
                x9_byte17=(
                    outcome[17]
                    if outcome is not None and len(outcome) > 17
                    else self._status_0d_x9_hint.get(rk, {}).get("x9_byte17")
                ),
            )
            self._mrt_risk_abandon_await_0d(rk, reason="x9_byte17_missing")
            return
        built = self._build_synth_close_frame(rk, counter)
        if built is None:
            self.event_log.emit(
                "status_0d_synth_skip",
                game=game_id,
                tid=rk[0],
                counter=counter,
                reason="bad_build",
                counter_source=counter_src,
            )
            self._mrt_risk_abandon_await_0d(rk, reason="bad_build")
            return
        frame, build_source = built
        synth_key = (rk, counter)
        template = self._status_0d_template.get(game_id)
        self.event_log.emit(
            "status_0d_synth_built",
            game=game_id,
            tid=rk[0],
            counter=counter,
            len=len(frame),
            build_source=build_source,
            counter_source=counter_src,
            template_counter=template[8] if template is not None else None,
            x9_byte17=(
                outcome[17]
                if outcome is not None and len(outcome) > 17
                else self._status_0d_x9_hint.get(rk, {}).get("x9_byte17")
            ),
            play_sequence=(
                parse_packet_meta(outcome).play_sequence
                if outcome is not None
                else self._status_0d_x9_hint.get(rk, {}).get("play_sequence")
            ),
            loose_counter=self.status_0d_synth_loose_counter,
        )
        self._dispatch_status_synth_frame(
            frame,
            synth_key,
            built=True,
            build_source=build_source,
        )

    def _inject_hs_ready(
        self,
        now_ns: int,
        *,
        queued_perf_ns: int,
        strict_slv_idle: bool = False,
        emit_timeout: bool = False,
        timeout_msg: str | None = None,
        timeout_tid: str | None = None,
        p0_close: bool = False,
    ) -> bool:
        """Policy gate before calling inject.

        EG 2.1: with uartd INJECT, C ``require_idle`` owns live SLV-IN wait.
        Python only applies post-HS grace / post-x9 policy (callers), not a
        tight SLV poll around TX.

        ``p0_close`` (synth 0D): skip post-HS settle — queue/TX must not wait
        on post_hs_inactive_us after x1 is known.
        """
        if not self.timing.wait_hs_inactive_before_tx:
            return True
        if not self.use_uartd_inject:
            if self.handshake.is_active():
                if strict_slv_idle or p0_close:
                    return False
                waited_ms = (now_ns - queued_perf_ns) / 1_000_000.0
                if waited_ms >= self.timing.hs_inactive_wait_ms:
                    if emit_timeout:
                        self.event_log.emit(
                            "inject_hs_timeout",
                            msg=timeout_msg,
                            tid=timeout_tid,
                            waited_ms=round(waited_ms, 1),
                        )
                else:
                    return False
        if p0_close:
            return True
        if self._last_hs_inactive_perf_ns is not None:
            since_inactive_us = (now_ns - self._last_hs_inactive_perf_ns) / 1000.0
            if since_inactive_us < self.timing.post_hs_inactive_us:
                return False
        else:
            since_queue_us = (now_ns - queued_perf_ns) / 1000.0
            if since_queue_us < self.timing.post_hs_inactive_us:
                return False
        return True

    def _dispatch_status_synth_frame(
        self,
        frame: bytes,
        synth_key: tuple[RoundKey, int],
        *,
        built: bool,
        build_source: str | None = None,
    ) -> None:
        rk, counter = synth_key
        game_id = rk[1]
        self._status_0d_synth_sent.add(synth_key)
        self._status_0d_python_fallback_rks.discard(rk)
        self._status_0d_python_fallback_ready_ns.pop(rk, None)
        while rk in self._synth_retry_rks:
            self._synth_retry_rks.remove(rk)
        self._note_global_close_counter(counter)
        self._status_0d_high_counter[game_id] = max(
            self._status_0d_high_counter.get(game_id, 0),
            counter,
        )
        close_cache_key = (game_id, counter)
        self._status_0d_cache.pop(close_cache_key, None)
        meta = parse_packet_meta(frame)
        now = time.perf_counter_ns()
        pending_skipped = len(self._pending)
        pending = PendingInject(
            frame=frame,
            queued_perf_ns=now,
            msg=meta.msg_type_hex,
            tid=meta.tid_hex,
            source="status_0d_synth",
            marshal_crc32=frame_marshal_fields(frame)["marshal_crc32"],
        )
        # Never nest send_frame under an active eth inject lock â€” queue P0 instead.
        if self._tx_lock.locked():
            self._enqueue_pending(pending, front=True)
            self.event_log.emit(
                "status_0d_synth_queued",
                game=game_id,
                tid=rk[0],
                counter=counter,
                len=len(frame),
                queue_depth=len(self._pending),
                deferred_hs=False,
                deferred_tx_lock=True,
                built=built,
                build_source=build_source,
            )
            return
        if self._inject_hs_ready(
            now,
            queued_perf_ns=now,
            emit_timeout=True,
            timeout_msg=meta.msg_type_hex,
            timeout_tid=meta.tid_hex,
            p0_close=True,
        ):
            ok = self.send_frame(frame, source="status_0d_synth")
            if ok is False:
                self._enqueue_pending(pending, front=True)
                self.event_log.emit(
                    "status_0d_synth_queued",
                    game=game_id,
                    tid=rk[0],
                    counter=counter,
                    len=len(frame),
                    queue_depth=len(self._pending),
                    deferred_hs=True,
                    built=built,
                    build_source=build_source,
                )
                return
            if ok is None:
                # Keep x1_pending so tick can retry build/send.
                self._status_0d_synth_sent.discard(synth_key)
                if self._uart_port_down():
                    self._enqueue_pending(pending, front=True)
                    self.event_log.emit(
                        "inject_requeued_uart_lost",
                        msg=meta.msg_type_hex,
                        tid=rk[0],
                        source="status_0d_synth",
                        queue_depth=len(self._pending),
                    )
                    return
                self.event_log.emit(
                    "error",
                    where="status_0d_synth",
                    message="inject_failed",
                    msg=meta.msg_type_hex,
                )
                return
            self._status_0d_x1_pending.discard(rk)
            self.event_log.emit(
                "status_0d_synth_immediate",
                game=game_id,
                tid=rk[0],
                counter=counter,
                len=len(frame),
                pending_skipped=pending_skipped,
                built=built,
                build_source=build_source,
            )
            self._trunk_round_close(rk[0], rk[1])
            self._mrt_risk_note_closing_0d(rk)
            self.event_log.emit(
                "status_0d_synth_done",
                msg=meta.msg_type_hex,
                len=len(frame),
                tid=meta.tid_hex,
                queue_wait_us=0.0,
                immediate=True,
                pending_skipped=pending_skipped,
                built=built,
                build_source=build_source,
            )
            return
        self._enqueue_pending(pending, front=True)
        self.event_log.emit(
            "status_0d_synth_queued",
            game=game_id,
            tid=rk[0],
            counter=counter,
            len=len(frame),
            queue_depth=len(self._pending),
            deferred_hs=True,
            built=built,
            build_source=build_source,
        )

    def queue_eth_frame(self, frame: bytes) -> None:
        if channel_uses_raw_hs_window(self.channel):
            self.queue_eth_raw(frame)
            return
        ok, reason = eth_forward_ok(frame)
        meta = parse_packet_meta(frame)
        bank_fields = packet_bank_event_fields(meta)
        if bank_fields.get("bank_ok") is False:
            self.event_log.emit(
                "eth_rx_skip",
                reason="bank_mismatch",
                msg=meta.msg_type_hex,
                len=len(frame),
                tid=meta.tid_hex,
                **bank_fields,
            )
            return
        # EG 3.0.2.10: eth_rx of winner x1 only stamps eth_seen / last_frame.
        # Multiport progressive flood waits for Master copper seal (close_ok).
        self._maybe_release_bank_jpot_fanout_on_x1(
            meta, source="eth_rx_x1", frame=frame
        )
        # Lab seat SM: arm x1 as soon as Master x9 is seen on ETH (do not wait for
        # UART inject â€” mid-chain synth hops may never complete local copper echo).
        self._lab_gameplay_note_eth_outcome(frame, meta)
        if not ok:
            self.event_log.emit(
                "eth_rx_skip",
                reason=reason,
                msg=meta.msg_type_hex,
                len=len(frame),
                tid=meta.tid_hex,
                **packet_bank_event_fields(meta),
            )
            return
        if self.suppress_uart_echo and self.uart_origin.is_uart_origin_echo(frame):
            self.event_log.emit(
                "eth_rx_skip",
                reason="uart_origin_echo",
                msg=meta.msg_type_hex,
                len=len(frame),
                tid=meta.tid_hex,
                **packet_bank_event_fields(meta),
            )
            return
        if meta.msg_type == SCR_MSG and self.scr_master_native_only:
            self.event_log.emit(
                "eth_rx_skip",
                reason="scr_master_native_only",
                msg=meta.msg_type_hex,
                len=len(frame),
                tid=meta.tid_hex,
                **packet_bank_event_fields(meta),
            )
            if self.scr_diag:
                self.event_log.emit(
                    "scr_diag",
                    origin="eth_relay_skipped",
                    **pog422_scr_diag_fields(frame, meta),
                )
            return
        now_scr = time.perf_counter_ns()
        if meta.msg_type == SCR_REPLY:
            self._scr_note_reply_seen(meta, now_ns=now_scr)
        coalesce = self._scr_eth_inject_coalesce_reason(meta, now_ns=now_scr)
        if coalesce is not None:
            self.event_log.emit(
                "eth_rx_skip",
                reason=coalesce,
                msg=meta.msg_type_hex,
                len=len(frame),
                tid=meta.tid_hex,
            )
            return
        if self.scr_diag and meta.msg_type in (SCR_MSG, SCR_REPLY):
            self.event_log.emit(
                "scr_diag",
                origin="eth_relay",
                **pog422_scr_diag_fields(frame, meta),
            )
        # --- ETHâ†’UART inject (always, except blocked Master 0x0D below) ---
        # Every ETH frame injects to the local T340+ POG422. Do not skip inject for
        # synth state parsing â€” uses UART + local-tid eth inject-store (see send_frame).
        if meta.msg_type == 0x0A and len(frame) >= 11:
            self._status_0d_bank_tid_tail = frame[9:11]
        self._try_progressive_csv_update(frame, source="eth")
        if self.status_0d_block_all and meta.msg_type == 0x0D:
            game_id = meta.game_id_hex
            # EG 3.0.1.42: while bank progressive is open/awarded, allow native
            # Master 0D onto POG422 (JP14 blocked 0D while synth-only).
            if self._bank_jpot_close_armed() or self._bank_jpot_award_x9_ok:
                self.event_log.emit(
                    "status_0d_passthrough",
                    game=game_id,
                    counter=meta.session_counter,
                    len=len(frame),
                    reason="bank_jpot_allow_master_0d",
                )
            else:
                passthrough = (
                    self.status_0d_first_passthrough
                    and self.status_0d_synth
                    and game_id
                    and not self._game_has_status_template(game_id)
                )
                if passthrough:
                    close_key = status_close_dedupe_key(meta)
                    if close_key is not None:
                        pas_game, pas_counter = close_key
                        for rk in list(self._status_0d_x1_pending):
                            if rk[1] == pas_game:
                                self._status_0d_synth_sent.add((rk, pas_counter))
                                self._status_0d_x1_pending.discard(rk)
                        if self._local_slave_tid is not None:
                            self._status_0d_synth_sent.add(
                                ((self._local_slave_tid, pas_game), pas_counter)
                            )
                    if self.status_0d_synth:
                        self._cache_status_0d(frame, meta)
                    self.event_log.emit(
                        "status_0d_passthrough",
                        game=game_id,
                        counter=meta.session_counter,
                        len=len(frame),
                    )
                else:
                    self.event_log.emit(
                        "eth_rx_skip",
                        reason="status_0d_block_all",
                        msg=meta.msg_type_hex,
                        len=len(frame),
                        game=meta.game_id_hex,
                        counter=meta.session_counter,
                    )
                    if self.status_0d_synth:
                        self._cache_status_0d(frame, meta)
                    return
        if self.status_0d_first_only:
            dedupe_key = status_close_dedupe_key(meta)
            if dedupe_key is not None:
                now_ns = time.perf_counter_ns()
                self._prune_status_0d_dedupe(now_ns)
                if dedupe_key in self._status_0d_seen:
                    self.event_log.emit(
                        "eth_rx_skip",
                        reason="status_0d_dedupe",
                        msg=meta.msg_type_hex,
                        len=len(frame),
                        game=dedupe_key[0],
                        counter=dedupe_key[1],
                    )
                    return
                self._status_0d_seen[dedupe_key] = now_ns
        if self._x9_outcome_eth_inject_duplicate(meta, frame):
            self.event_log.emit(
                "eth_rx_skip",
                reason="x9_eth_inject_dedupe",
                msg=meta.msg_type_hex,
                len=len(frame),
                tid=meta.tid_hex,
            )
            return
        # EG 3.0.2.7: EDGE spoke — peer game traffic is not local glass/Master path.
        if self._should_skip_spoke_peer_eth_uart_inject(meta, frame):
            self.event_log.emit(
                "eth_rx_skip",
                reason="spoke_peer_no_uart_inject",
                msg=meta.msg_type_hex,
                len=len(frame),
                tid=meta.tid_hex,
            )
            return
        # Hub Phase A: eth_rx x0 is the real start of STOM at the Master seat.
        try:
            self._note_hand_phase_eth_x0(meta)
        except Exception:
            pass
        # EG 3.0.1.38: progressive award is winner-seat only for eth→UART.
        # Peers use bank_wlan / OHD — do not inject Jackpot grant onto idle glass.
        # EG 3.0.1.42: arm/reopen **before** empty-close inject skip so a soft-close
        # earlier in the sequence cannot block progressive note on T340+.
        if is_bank_progressive_jackpot_frame(frame):
            if not self._meta_is_local_terminal(meta):
                self.event_log.emit(
                    "eth_rx_skip",
                    reason="bank_jpot_peer_no_uart_inject",
                    msg=meta.msg_type_hex,
                    len=len(frame),
                    tid=meta.tid_hex,
                )
                # Still stamp recent for peer storm/dedupe bookkeeping.
                if meta.tid_hex:
                    self._recent_x9_inject_ns[
                        (meta.tid_hex.upper(), meta.play_sequence)
                    ] = time.perf_counter_ns()
                return
            self._arm_bank_jpot_x1_priority(frame, source="eth_rx")
            tid_u = (meta.tid_hex or "").strip().upper()
            if (
                tid_u
                and self._tid_empty_close_blocks_award(tid_u)
                and tid_u not in self._bank_jpot_award_x9_ok
            ):
                self.event_log.emit(
                    "eth_rx_skip",
                    reason="bank_jpot_empty_close_no_award",
                    msg=meta.msg_type_hex,
                    len=len(frame),
                    tid=meta.tid_hex,
                )
                return
        self._blacklist_eth_inject_tid(meta.tid_hex)
        if is_gameplay_request(meta.msg_type):
            self._trunk_round_note_request(meta)
        # Master progressive jackpot on eth: arm was already taken above for the
        # winner seat. Peer progressive never reaches here (peer_no_uart_inject).
        # Non-eth progressive copper grant still arms via uart_to_eth paths.
        # Stamp eth x9 for bank-jpot peer/post-clear dedupe (local winner path also
        # stamps in _remember after inject-store).
        if is_bank_progressive_jackpot_frame(frame) and meta.tid_hex:
            tid_u = meta.tid_hex.upper()
            if not (
                self._meta_is_local_terminal(meta)
                and tid_u in self._bank_jpot_await_x1
            ):
                self._recent_x9_inject_ns[(tid_u, meta.play_sequence)] = (
                    time.perf_counter_ns()
                )
        # Stamp post-arm eth x1 queue time for seal gate (EG 3.0.1.38).
        if is_gameplay_ack(meta.msg_type) and meta.tid_hex:
            tid_u = meta.tid_hex.strip().upper()
            now_q = time.perf_counter_ns()
            self._bank_jpot_x1_eth_queued_ns[tid_u] = now_q
            self._note_tid_x1_empty_close(meta)
        if self._should_defer_transaction_inject(meta, frame):
            now = time.perf_counter_ns()
            pending = PendingInject(
                frame=frame,
                queued_perf_ns=now,
                msg=meta.msg_type_hex,
                tid=meta.tid_hex,
                strict_slv_idle=is_gameplay_response(meta.msg_type),
                marshal_crc32=frame_marshal_fields(frame)["marshal_crc32"],
            )
            self._stash_transaction_deferred(pending, meta)
            return
        now = time.perf_counter_ns()
        marshal = frame_marshal_fields(frame)
        bank_jpot_x1 = self._meta_is_bank_jpot_x1_priority(meta)
        bank_jpot_issue = bool(
            is_bank_progressive_jackpot_frame(frame)
            and self._meta_is_local_terminal(meta)
            and (meta.tid_hex or "").strip().upper() in self._bank_jpot_await_x1
        )
        pending = PendingInject(
            frame=frame,
            queued_perf_ns=now,
            msg=meta.msg_type_hex,
            tid=meta.tid_hex,
            strict_slv_idle=is_gameplay_response(meta.msg_type),
            marshal_crc32=marshal["marshal_crc32"],
        )
        if bank_jpot_x1 or bank_jpot_issue or is_stom_comm_priority(meta.msg_type):
            self._enqueue_pending(pending, front=True)
            self._reorder_pending_by_priority()
        else:
            self._enqueue_pending(pending)
        self.event_log.emit(
            "eth_rx_queued",
            msg=meta.msg_type_hex,
            len=len(frame),
            tid=meta.tid_hex,
            queue_depth=len(self._pending),
            slv_hs_idle_at_queue=not slv_in_active_cached(self.handshake),
            stom_comm_priority=is_stom_comm_priority(meta.msg_type),
            bank_jpot_x1_priority=bank_jpot_x1 or bank_jpot_issue,
            inject_priority=self._inject_priority_of(pending, now_ns=now),
            **marshal,
        )

    def queue_eth_raw(self, raw: bytes) -> None:
        """OIS: queue opaque HS window for inject when bus idle."""
        ok, reason = ois_raw_forward_ok(raw)
        if not ok:
            self.event_log.emit(
                "eth_rx_skip",
                reason=reason,
                len=len(raw),
                channel=self.channel.key,
            )
            return
        if self.suppress_uart_echo and self.uart_origin.is_uart_origin_echo(raw):
            self.event_log.emit(
                "eth_rx_skip",
                reason="uart_origin_echo",
                len=len(raw),
                first_byte=f"{raw[0]:02X}",
                channel=self.channel.key,
            )
            return
        now = time.perf_counter_ns()
        marshal = frame_marshal_fields(raw)
        self._enqueue_pending(
            PendingInject(
                frame=raw,
                queued_perf_ns=now,
                msg=f"{raw[0]:02X}",
                tid=None,
                strict_slv_idle=False,
                marshal_crc32=marshal["marshal_crc32"],
            ))
        self.event_log.emit(
            "eth_rx_queued",
            len=len(raw),
            first_byte=f"{raw[0]:02X}",
            channel=self.channel.key,
            queue_depth=len(self._pending),
            slv_hs_idle_at_queue=not slv_in_active_cached(self.handshake),
            **marshal,
        )

    def _emit_inject_gpio1_trace(
        self,
        *,
        source: str,
        msg: str,
        tx_us: float,
        slv_in_before: bool,
        slv_in_after_mark: bool | None,
        slv_in_after_body: bool | None,
        slv_in_after_drain: bool | None,
        slv_in_after_off: bool,
    ) -> None:
        self.event_log.emit(
            "inject_gpio1_trace",
            gpio="GPIOB1",
            signal="SLV-HS",
            source=source,
            msg=msg,
            tx_us=round(tx_us, 1),
            slv_in_before=slv_in_before,
            slv_in_after_mark=slv_in_after_mark,
            slv_in_after_body=slv_in_after_body,
            slv_in_after_drain=slv_in_after_drain,
            slv_in_after_off=slv_in_after_off,
        )

    def _machine_post_x9_grace_active(self, now_ns: int | None = None) -> bool:
        gate = self._grace_gate_machine
        if gate is None:
            return False
        return post_x9_grace_active(gate.state, now_ns=now_ns)

    def _uart_port_down(self) -> bool:
        """True while uartd has lost the XRUSB fd (USB re-enumerate / reopen)."""
        uartd = self.uartd
        if uartd is None:
            return False
        attached = getattr(uartd, "uart_attached", True)
        return not bool(attached)

    def _process_pending_injects(self) -> None:
        if not self._pending:
            # Still lift any progressive ACK stuck in transaction defer.
            self._lift_bank_jpot_x1_from_transaction_deferred()
            if not self._pending:
                return

        self._promote_status_0d_synth_pending()
        self._lift_bank_jpot_x1_from_transaction_deferred()
        self._reorder_pending_by_priority()

        if self._uart_port_down():
            head = self._pending[0]
            self._emit_inject_deferred_rate_limited(
                reason="uart_port_down",
                now_ns=time.perf_counter_ns(),
                msg=head.msg,
                tid=head.tid,
                channel=self.channel.key,
                queue_wait_us=round(
                    (time.perf_counter_ns() - head.queued_perf_ns) / 1000.0, 1
                ),
                pending_inject_count=len(self._pending),
            )
            return

        while self._pending:
            head = self._pending[0]
            if head.source == "status_0d_synth":
                break
            # While local close pending, stash non-P0 heads so synth 0D stays next.
            # Hub: do not black-hole other spokes' gameplay into transaction_deferred.
            # Bank progressive x1 is P1 — never stash.
            if self._local_close_pending() and not self._pending_is_p0_close_priority(
                head
            ):
                meta = parse_packet_meta(head.frame)
                if is_stom_comm_priority(meta.msg_type):
                    break
                if self._hub_allows_peer_gameplay_during_local_close(meta):
                    break
                self._stash_transaction_deferred(self._pop_pending_left(), meta)
                continue
            if head.source == "transaction_deferred":
                break
            meta = parse_packet_meta(head.frame)
            if not self._should_defer_transaction_inject(meta, head.frame):
                break
            self._stash_transaction_deferred(self._pop_pending_left(), meta)

        if not self._pending:
            return

        head = self._pending[0]
        now = time.perf_counter_ns()
        head_meta = parse_packet_meta(head.frame)
        bank_jpot_x1 = self._meta_is_bank_jpot_x1_priority(head_meta)
        # Progressive issue/retries on close path: never hold behind await_x1 peel /
        # post-x9 grace (Master must keep retrying out).
        bank_jpot_issue = bool(
            self._bank_jpot_close_armed()
            and is_bank_progressive_jackpot_frame(head.frame)
        )
        # Synth 0D never yields to STOM. T340+ CGS (0x02/0x09) must inject during
        # local close (copper would). Bank progressive x1 still bypasses grace.
        stom_comm = (
            is_stom_comm_priority(head_meta.msg_type)
            or bank_jpot_x1
            or bank_jpot_issue
        ) and head.source != "status_0d_synth"
        hub_peer_game = self._hub_allows_peer_gameplay_during_local_close(head_meta)

        if (
            self._local_close_pending()
            and not self._pending_is_p0_close_priority(head)
            and not hub_peer_game
            and not is_stom_comm_priority(head_meta.msg_type)
        ):
            await_rks = [
                {"tid": rk[0], "game": rk[1]} for rk in self._mrt_risk_await_0d
            ]
            self._emit_inject_deferred_rate_limited(
                reason="await_local_0d",
                now_ns=now,
                msg=head.msg,
                tid=head.tid,
                channel=self.channel.key,
                queue_wait_us=round((now - head.queued_perf_ns) / 1000.0, 1),
                await_rounds=await_rks,
            )
            return

        if (
            not stom_comm
            and head.source != "status_0d_synth"
            and channel_uses_raw_hs_window(self.channel)
            and self._machine_post_x9_grace_active(now_ns=now)
        ):
            self._emit_inject_deferred_rate_limited(
                reason="post_x9_grace_ois",
                now_ns=now,
                msg=head.msg,
                tid=head.tid,
                channel=self.channel.key,
                queue_wait_us=round((now - head.queued_perf_ns) / 1000.0, 1),
            )
            return

        if (
            not stom_comm
            and not hub_peer_game
            and not channel_uses_raw_hs_window(self.channel)
            and post_x9_local_grace_active(self.state, now_ns=now)
            and head.source == "eth_to_uart"
            and not self._pending_inject_is_local_terminal(head)
        ):
            self._emit_inject_deferred_rate_limited(
                reason="post_x9_grace_machine_relay",
                now_ns=now,
                msg=head.msg,
                tid=head.tid,
                channel=self.channel.key,
                queue_wait_us=round((now - head.queued_perf_ns) / 1000.0, 1),
            )
            return

        # Brief bus hold after local x9 so T340+ ACK can land (not forever —
        # forever-hold locked eth→UART and spam-logged in 2.2.2.17 soak).
        # Hub: other spokes' gameplay skips this hold (same-TID still waits).
        if (
            not stom_comm
            and not hub_peer_game
            and not channel_uses_raw_hs_window(self.channel)
            and self._local_x1_inject_hold_active(now)
            and head.source == "eth_to_uart"
            and not self._pending_inject_is_local_terminal(head)
        ):
            await_rks = [
                {"tid": rk[0], "game": rk[1]} for rk in self._mrt_risk_await_x1
            ]
            hold_left_ms = None
            until = self._local_x1_inject_hold_until_ns
            if until is not None:
                hold_left_ms = round(max(0, until - now) / 1_000_000.0, 1)
            self._emit_inject_deferred_rate_limited(
                reason="await_local_x1",
                now_ns=now,
                msg=head.msg,
                tid=head.tid,
                channel=self.channel.key,
                queue_wait_us=round((now - head.queued_perf_ns) / 1000.0, 1),
                await_rounds=await_rks,
                hold_left_ms=hold_left_ms,
                hold_ms=MRT_LOCAL_X1_INJECT_HOLD_MS,
            )
            return

        # EG 3.0.1.17: same-seq local x9 retry while await_x1 — do not barge peel.
        if (
            not stom_comm
            and not hub_peer_game
            and head.source == "eth_to_uart"
            and self._should_defer_same_seq_x9_await_x1_peel(head_meta)
        ):
            rk = self._round_key(head_meta)
            if rk is not None:
                self._mrt_risk_x9_retry_seen.add(rk)
            self._emit_inject_deferred_rate_limited(
                reason="await_x1_peel",
                now_ns=now,
                msg=head.msg,
                tid=head.tid,
                channel=self.channel.key,
                queue_wait_us=round((now - head.queued_perf_ns) / 1000.0, 1),
                play_sequence=head_meta.play_sequence,
                await_rounds=(
                    [{"tid": rk[0], "game": rk[1]}] if rk is not None else None
                ),
            )
            return

        # EG 3.0.1.2: after inject timeout+abort, do not immediately re-fire eth→UART.
        # EG 3.0.1.27: bank progressive close path (x1 + Master grant retries) skips cool.
        cool_until = self._inject_abort_cooldown_until_ns
        if (
            cool_until is not None
            and now < cool_until
            and head.source == "eth_to_uart"
            and not bank_jpot_x1
            and not bank_jpot_issue
            and not (head.source == "status_0d_synth")
        ):
            self._emit_inject_deferred_rate_limited(
                reason="inject_abort_cooldown",
                now_ns=now,
                msg=head.msg,
                tid=head.tid,
                channel=self.channel.key,
                queue_wait_us=round((now - head.queued_perf_ns) / 1000.0, 1),
                hold_left_ms=round((cool_until - now) / 1_000_000.0, 1),
            )
            return
        if cool_until is not None and now >= cool_until:
            self._inject_abort_cooldown_until_ns = None

        if not self._inject_hs_ready(
            now,
            queued_perf_ns=head.queued_perf_ns,
            strict_slv_idle=head.strict_slv_idle,
            emit_timeout=True,
            timeout_msg=head.msg,
            timeout_tid=head.tid,
            p0_close=(
                head.source == "status_0d_synth" or bank_jpot_x1
            ),
        ):
            return

        if (
            not self.use_uartd_inject
            and head.strict_slv_idle
            and slv_in_active(self.handshake)
        ):
            return

        pending = self._pop_pending_left()
        queue_wait_us = (time.perf_counter_ns() - pending.queued_perf_ns) / 1000.0
        pull = frame_marshal_fields(pending.frame)
        self.event_log.emit(
            "uart_marshal_pull",
            source=pending.source,
            msg=pending.msg,
            tid=pending.tid,
            queue_crc32=pending.marshal_crc32 or None,
            marshal_match=(
                not pending.marshal_crc32
                or pending.marshal_crc32 == pull["marshal_crc32"]
            ),
            queue_wait_us=round(queue_wait_us, 1),
            **pull,
        )
        ok = self.send_frame(pending.frame, source=pending.source)
        if ok is False:
            self._enqueue_pending(pending, front=True)
            defer_reason = (
                "uart_port_down" if self._uart_port_down() else "hs_busy"
            )
            self.event_log.emit(
                "inject_deferred",
                reason=defer_reason,
                msg=pending.msg,
                tid=pending.tid,
                queue_wait_us=round(queue_wait_us, 1),
            )
            if defer_reason == "uart_port_down":
                self.event_log.emit(
                    "inject_requeued_uart_lost",
                    msg=pending.msg,
                    tid=pending.tid,
                    source=pending.source,
                    queue_depth=len(self._pending),
                )
            return
        if ok is None:
            # Unsuccessful transfer on dead UART — keep in queue (not terminal).
            if self._uart_port_down():
                self._enqueue_pending(pending, front=True)
                self.event_log.emit(
                    "inject_requeued_uart_lost",
                    msg=pending.msg,
                    tid=pending.tid,
                    source=pending.source,
                    queue_depth=len(self._pending),
                )
                return
            self.event_log.emit(
                "error",
                where=pending.source,
                message="inject_failed",
                msg=pending.msg,
            )
            return
        done_ev = (
            "status_0d_synth_done"
            if pending.source == "status_0d_synth"
            else "eth_to_uart_done"
        )
        self.event_log.emit(
            done_ev,
            msg=pending.msg,
            len=len(pending.frame),
            tid=pending.tid,
            queue_wait_us=round(queue_wait_us, 1),
        )
        done_meta = parse_packet_meta(pending.frame)
        self._scr_note_inject_done(done_meta)
        tid_done = (done_meta.tid_hex or pending.tid or "").strip().upper()
        jpot_x1_track = (
            is_gameplay_ack(done_meta.msg_type)
            and tid_done
            and (
                self._meta_is_bank_jpot_x1_priority(done_meta)
                or pending.source == "bank_jpot_x1_reinject"
            )
        )
        if jpot_x1_track:
            # EG 3.0.1.35: copper inject echo matches seals progressive close.
            tid_u = tid_done
            echo_len = int(self._bank_jpot_x1_last_echo_len.get(tid_u, -1))
            post_de = int(self._bank_jpot_x1_last_post_de_len.get(tid_u, -1))
            echo_match = self._bank_jpot_x1_last_echo_match.get(tid_u)
            self.event_log.emit(
                "bank_jpot_x1_inject_done",
                tid=tid_u,
                source=pending.source,
                echo_len=echo_len,
                post_de_rx_len=post_de,
                echo_match=echo_match,
                reinjects=int(self._bank_jpot_x1_reinject_count.get(tid_u, 0)),
                frame_len=len(pending.frame),
            )
            if tid_u in self._bank_jpot_await_x1:
                if echo_len == 0 and post_de <= 0:
                    self._queue_bank_jpot_x1_reinject(tid_u, trigger="empty_inject_echo")
                elif echo_match in ("exact", "prefix", "embedded") or echo_len > 0:
                    self._bank_jpot_x1_close_ok(
                        tid_u,
                        evidence="inject_echo_ok",
                        msg=done_meta.msg_type_hex,
                    )
        # Progressive award inject (local) → ownership for seal gate.
        if (
            pending.source in ("eth_to_uart", "edge_fallback_eth_to_uart")
            and is_bank_progressive_jackpot_frame(pending.frame)
            and self._meta_is_local_terminal(done_meta)
        ):
            self._note_bank_jpot_award_x9(
                pending.frame, done_meta, path="inject"
            )
            self._note_tid_result_x9(done_meta, pending.frame)
        elif is_gameplay_response(done_meta.msg_type) or is_skill_jpot_grant(
            done_meta.msg_type
        ):
            self._note_tid_result_x9(done_meta, pending.frame)
        if pending.msg == "0D" and pending.source in ("eth_to_uart", "status_0d_synth"):
            close_meta = parse_packet_meta(pending.frame)
            tid = close_meta.tid_hex or self._local_slave_tid
            self._trunk_round_close(tid, close_meta.game_id_hex)
            self._tid_tx_note_0d(close_meta if close_meta.tid_hex else None)
            if not close_meta.tid_hex and tid:
                self._tid_tx_set_phase(tid, "idle", reason="0d_close")
            if pending.source == "status_0d_synth":
                self._mrt_risk_note_closing_0d(self._round_key(close_meta))
            elif tid and close_meta.game_id_hex:
                self._mrt_risk_note_closing_0d((tid, close_meta.game_id_hex))

    def send_frame(self, frame: bytes, *, source: str) -> bool | None:
        """Inject one POG422 frame. False = deferred (SLV-HS busy); None = hard error."""
        self.event_log.set_defer_debug_rotate(True)
        try:
            return self._send_frame_locked(frame, source=source)
        finally:
            self.event_log.set_defer_debug_rotate(False)

    def _send_frame_locked(self, frame: bytes, *, source: str) -> bool | None:
        """Inject one POG422 frame. False = deferred (SLV-HS busy); None = hard error."""
        synth_defer: list[RoundKey] = []
        skill_synth_defer: list[RoundKey] = []
        peer_after_inject = b""
        early_win_after_lock: tuple[bytes, Any] | None = None
        ois = channel_uses_raw_hs_window(self.channel)
        if ois:
            ok, reason = ois_raw_forward_ok(frame)
            if not ok:
                self.event_log.emit(
                    "error",
                    where="send_raw",
                    reason=reason,
                    len=len(frame),
                    source=source,
                )
                return None
        meta = parse_packet_meta(frame) if not ois else None
        msg_label = f"{frame[0]:02X}" if ois and frame else (meta.msg_type_hex if meta else "??")
        if meta is not None:
            self._lab_remember_touch6_wager(frame, meta)
            self._lab_remember_superpick_wager(frame, meta)
        if (
            self.scr_diag
            and meta is not None
            and meta.msg_type == SCR_MSG
            and source == "eth_to_uart"
        ):
            self.event_log.emit(
                "scr_diag",
                origin="eth_inject",
                **pog422_scr_diag_fields(frame, meta),
            )
        air_us = uart_air_time_us(len(frame))
        inject_path = (
            "hs_idle"
            if source in ("eth_to_uart", "status_0d_synth", "bank_jpot_x1_reinject")
            else "capture"
        )
        pre_tx_us = self._inject_pre_tx_us()
        post_hold_us = self._inject_post_hold_us(air_us)
        inject_engine = self._inject_engine_name()
        # Locals filled under lock; peer/synth/close run AFTER release so 0D is
        # not stalled behind inject epilogue I/O (.40 / pos2 08:04 MRT).
        # Disk logging (write_inject_echo flush) is deferred until AFTER synth /
        # pending injects — luxury vs close path (pos4 20:55 EDT MRT ~1.2s).
        t0 = 0
        t1 = 0
        drain_wait_us: float | None = None
        inject_total_us: float | None = None
        gpio1_edges = 0
        gpio1_first_edge_us = 0
        gpio1_last_edge_us = 0
        inject_echo = b""
        inject_post_de = b""
        inject_forensics = None
        inject_copper = None
        early_x1_eth_done = False  # EG 3.0.1.20 mid-inject x1 eth forward
        inject_attempt = 1
        slv_in_before = False
        # None = DE-high sample unavailable (see POG422_SLV_IN_* tri-state).
        slv_in_after_on: bool | None = None
        slv_in_after_body: bool | None = None
        slv_in_after_drain: bool | None = None
        slv_in_after_off = False
        tx_us = 0.0
        inject_total_us: float | None = None
        inject_wait_s: float | None = None
        idle_wait_ms: float | None = None
        arm_skip: str | None = None
        peer_msgs: list[str] = []
        peer = b""
        deferred_inject_echo: list[dict[str, Any]] = []

        def _flush_deferred_inject_echo() -> None:
            """Disk luxury — never raise into the inject/close path."""
            while deferred_inject_echo:
                row = deferred_inject_echo.pop(0)
                try:
                    self.event_log.write_inject_echo(
                        row["sent"],
                        row["echo"],
                        source=row["source"],
                        engine=row["engine"],
                        msg=row["msg"],
                        tid=row["tid"],
                        forensics=row["forensics"],
                        copper=row["copper"],
                        post_de_rx=row["post_de_rx"],
                        attempt=row["attempt"],
                    )
                except Exception as exc:
                    try:
                        self.event_log.emit(
                            "error",
                            where="deferred_inject_echo",
                            detail=str(exc)[:200],
                        )
                    except Exception:
                        pass

        with self._tx_lock:
            t0 = time.perf_counter_ns()
            slv_in_before = slv_in_active_cached(self.handshake)
            on_fields = {
                "source": source,
                "inject_path": inject_path,
                "inject_engine": inject_engine,
                "msg": msg_label,
                "len": len(frame),
                "air_us": round(air_us, 1),
                "signal": "SLV-HS",
                "manual_gpio7_slv": True,
                "slv_in_before": slv_in_before,
            }
            self.event_log.emit("slv_out_on", **on_fields)
            self.event_log.emit("gpio7_tx_on", **on_fields)

            # EG 4.1.0.11: every eth/synth inject must echo-compare vs sent.
            # Good match → Success; empty/garbled → assume T340+ collision →
            # retransmit immediately (one retry). Local x9 was the first path.
            local_x9_empty_echo_retry = (
                not ois
                and source == "eth_to_uart"
                and meta is not None
                and is_gameplay_response(meta.msg_type)
                and self.status_0d_synth
                and self._meta_is_local_terminal(meta)
            )
            inject_echo_require = (
                not ois
                and source
                in (
                    "eth_to_uart",
                    "edge_fallback_eth_to_uart",
                    "status_0d_synth",
                    "bank_jpot_x1_reinject",
                    "empty_harvest_x9_reinject",
                )
            )
            if self.use_uartd_inject and self.uartd is not None:
                if self.timing.flush_rx_before_tx:
                    self.event_log.emit("uart_rx_flush", source=source)
                idle_wait_ms = float(getattr(self.timing, "hs_inactive_wait_ms", 80) or 80)
                # Synth 0D: fail-fast on HS busy (requeue) — never park in idle wait.
                synth_close = source == "status_0d_synth"
                if synth_close:
                    idle_wait_ms = 0.0
                early_bus_ok = (
                    not ois
                    and not synth_close
                    and source == "eth_to_uart"
                    and meta is not None
                    and is_gameplay_response(meta.msg_type)
                    and self.status_0d_synth
                    and self._meta_is_local_terminal(meta)
                )
                if early_bus_ok:
                    self._store_local_x9_for_synth(frame, meta)

                def _on_bus_rx(echo: bytes, post_de: bytes) -> None:
                    if not early_bus_ok or meta is None:
                        return
                    self._harvest_x1_from_inject_bus(
                        frame,
                        meta,
                        echo=echo,
                        post_de=post_de,
                        early=True,
                    )

                def _on_x1_ack_frame(ack: bytes) -> None:
                    """EG 3.0.1.20+: forward local x1 to Master before C synth 0D.

                    EG 3.0.1.21: reject short/unparseable frames — do NOT latch
                    early_x1_eth_done (that blocked the real 12 B 0x81 eth_tx).
                    """
                    nonlocal early_x1_eth_done
                    if (
                        not early_bus_ok
                        or not ack
                        or self.forward_uart_origin_window is None
                        or early_x1_eth_done
                    ):
                        return
                    frames = self._local_x1_ack_frames_for_eth(ack)
                    if not frames:
                        self.event_log.emit(
                            "inject_x1_ack_early_eth_reject",
                            source=source,
                            sent_msg=msg_label,
                            sent_tid=(meta.tid_hex if meta is not None else None),
                            peer_len=len(ack),
                            reason="not_wire_len_local_ack",
                        )
                        return
                    fwd = b"".join(frames)
                    t_fwd0 = time.perf_counter_ns()
                    self.forward_uart_origin_window(
                        bytearray(fwd),
                        uart_first_byte=fwd[0],
                    )
                    t_fwd1 = time.perf_counter_ns()
                    early_x1_eth_done = True
                    ack_msgs = [f"{pkt[1]:02X}" for pkt in frames]
                    self.event_log.emit(
                        "inject_x1_ack_early_eth",
                        source=source,
                        sent_msg=msg_label,
                        sent_tid=(meta.tid_hex if meta is not None else None),
                        peer_len=len(fwd),
                        peer_msgs=",".join(ack_msgs) if ack_msgs else None,
                        fwd_us=round((t_fwd1 - t_fwd0) / 1000.0, 1),
                    )
                    emit_peer_gameplay_eth_fwd(
                        self,
                        fwd,
                        source=source,
                        sent_msg=msg_label,
                        sent_tid=(meta.tid_hex if meta is not None else None),
                    )

                max_attempts = 2 if inject_echo_require else 1
                # EG 2.2.2.69: gameplay / synth 0D fail-fast (~0.55s) — never pin
                # _tx_lock for the legacy 5s IPC hang (pos5 18:33).
                use_gameplay_wait = synth_close or (
                    source == "eth_to_uart"
                    and meta is not None
                    and is_gameplay_response(meta.msg_type)
                )
                inject_wait_s = (
                    INJECT_WAIT_S_GAMEPLAY if use_gameplay_wait else INJECT_WAIT_S_DEFAULT
                )
                # EG 3.0.1.1: arm C-side hard-RT auto-close for local eth x9.
                # EG 3.0.1.19: Master same-seq x9 barges after synth 0D must not
                # re-arm (pos5 SuperBall: false auto_close_miss harvest=0).
                arm_auto_close = bool(early_bus_ok and self._local_slave_tid)
                if (
                    arm_auto_close
                    and meta is not None
                    and self._mrt_risk_seq_already_closed(meta)
                ):
                    self.event_log.emit(
                        "status_0d_auto_close_skip",
                        reason="seq_already_closed",
                        tid=meta.tid_hex,
                        game=meta.game_id_hex,
                        msg=meta.msg_type_hex,
                        play_sequence=meta.play_sequence,
                    )
                    arm_auto_close = False
                if arm_auto_close and meta is not None:
                    _rk_ac = self._round_key(meta)
                    if self._lab_withhold_close_for_round(
                        _rk_ac,
                        where="auto_close_arm",
                        msg=meta.msg_type_hex,
                        play_sequence=meta.play_sequence,
                    ):
                        arm_auto_close = False
                auto_tid_tail = self._status_0d_bank_tid_tail or bytes((0xA4, 0x5B))
                auto_local_tid = (
                    bytes.fromhex(self._local_slave_tid)
                    if arm_auto_close and self._local_slave_tid
                    else None
                )
                if arm_auto_close and meta is not None:
                    self._push_ac_arm(bytes(frame), meta)
                inject_echo_final_ok = True
                inject_echo_final_how = "none"
                inject_echo = b""
                inject_post_de = b""
                for inject_attempt in range(1, max_attempts + 1):
                    try:
                        res = self.uartd.inject(
                            frame,
                            pre_tx_us=pre_tx_us,
                            post_hold_us=post_hold_us,
                            flush_rx_before_tx=self.timing.flush_rx_before_tx,
                            require_idle=True,
                            idle_wait_ms=idle_wait_ms,
                            on_bus_rx=_on_bus_rx if early_bus_ok else None,
                            on_x1_ack_frame=(
                                _on_x1_ack_frame if arm_auto_close else None
                            ),
                            wait_s=inject_wait_s,
                            auto_close=arm_auto_close,
                            auto_close_tid_tail=auto_tid_tail if arm_auto_close else None,
                            auto_close_local_tid=auto_local_tid,
                        )
                    except TimeoutError as exc:
                        aborted_de = False
                        hs_after = False
                        if self.uartd is not None:
                            try:
                                aborted_de = bool(self.uartd.abort_inject_de())
                            except Exception:
                                aborted_de = False
                            try:
                                # Longer drain so late DONE clears inject_busy before barge.
                                self.uartd.drain_orphan_inject_ipc(wait_s=0.30)
                            except Exception:
                                pass
                            try:
                                hs_after = bool(self.uartd.get_slv_in())
                            except Exception:
                                hs_after = bool(slv_in_active_cached(self.handshake))
                        self.event_log.emit(
                            "error",
                            where="uartd_inject_timeout",
                            msg=msg_label,
                            len=len(frame),
                            source=source,
                            attempt=inject_attempt,
                            wait_s=inject_wait_s,
                            idle_wait_ms=idle_wait_ms,
                            aborted_de=int(aborted_de),
                            hs_after=int(hs_after),
                            detail=str(exc),
                        )
                        # Port gone mid-inject: keep frame queued (not terminal fail).
                        if self._uart_port_down() and inject_attempt == 1:
                            _flush_deferred_inject_echo()
                            return False
                        # EG 3.0.0.9: after DE abort, requeue gameplay eth→UART
                        # (was None → hard fail while orphan INJECT held HS).
                        # EG 3.0.1.2: cooldown so we do not stack another 79 while HS up.
                        if inject_attempt == 1:
                            _flush_deferred_inject_echo()
                            if use_gameplay_wait:
                                cool_ms = 150 if (hs_after or not aborted_de) else 80
                                until = time.perf_counter_ns() + int(cool_ms * 1_000_000)
                                prev = self._inject_abort_cooldown_until_ns
                                if prev is None or until > prev:
                                    self._inject_abort_cooldown_until_ns = until
                                self.event_log.emit(
                                    "inject_abort_cooldown",
                                    msg=msg_label,
                                    cool_ms=cool_ms,
                                    aborted_de=int(aborted_de),
                                    hs_after=int(hs_after),
                                )
                                return False
                            return None
                        break
                    if self.uart_capture is not None:
                        self.uart_capture.drain()
                        self._sync_hs_inactive_from_capture()
                    if res.err == INJECT_ERR_HS_BUSY:
                        if inject_attempt == 1:
                            _flush_deferred_inject_echo()
                            return False
                        # Keep prior (empty) attempt forensics; do not arm below.
                        break
                    if int(res.err) in UART_PORT_DEAD_ERRNOS:
                        uartd = self.uartd
                        if uartd is not None and hasattr(uartd, "_uart_attached"):
                            uartd._uart_attached = False  # noqa: SLF001
                        self.event_log.emit(
                            "error",
                            where="uartd_inject",
                            errno=int(res.err),
                            msg=msg_label,
                            len=len(frame),
                            source=source,
                            attempt=inject_attempt,
                            uart_port_dead=True,
                        )
                        if inject_attempt == 1:
                            _flush_deferred_inject_echo()
                            return False
                        break
                    if res.err:
                        err_fields = {
                            "where": "uartd_inject",
                            "errno": int(res.err),
                            "msg": msg_label,
                            "len": len(frame),
                            "source": source,
                            "attempt": inject_attempt,
                            "tx_ns": int(res.tx_ns),
                            "total_ns": int(res.total_ns),
                            "air_us": int(res.air_us),
                            "wait_s": inject_wait_s,
                            "idle_wait_ms": idle_wait_ms,
                        }
                        if res.forensics is not None:
                            err_fields.update(
                                {
                                    "usb_actual": int(res.forensics.usb_actual),
                                    "tx_fifo_before": int(res.forensics.tx_fifo_before),
                                    "tx_fifo_after_bulk": int(
                                        res.forensics.tx_fifo_after_bulk
                                    ),
                                    "driver_errno": int(res.forensics.driver_errno),
                                    "de_release_reason": (
                                        res.forensics.de_release_reason_name
                                    ),
                                    "drain_wait_us": round(
                                        int(res.forensics.drain_wait_ns or 0) / 1000.0, 1
                                    ),
                                }
                            )
                        self.event_log.emit("error", **err_fields)
                        if inject_attempt == 1:
                            _flush_deferred_inject_echo()
                            return None
                        break
                    slv_in_after_on = res.slv_in_after_on
                    slv_in_after_body = res.slv_in_after_body
                    tx_us = res.tx_ns / 1000.0
                    slv_in_after_drain = res.slv_in_after_drain
                    slv_in_after_off = bool(res.slv_in_after_off)
                    gpio1_edges = int(res.gpio1_edges)
                    gpio1_first_edge_us = int(res.gpio1_first_edge_us)
                    gpio1_last_edge_us = int(res.gpio1_last_edge_us)
                    inject_total_us = res.total_ns / 1000.0
                    inject_echo = res.echo_bytes or b""
                    inject_forensics = res.forensics
                    inject_copper = res.copper
                    inject_post_de = res.post_de_rx_bytes or b""
                    # EG 3.0.1.57/58: always log stage split + uartd/Python wall.
                    de_hold = 0
                    if res.forensics is not None:
                        de_hold = int(getattr(res.forensics, "de_hold_us", 0) or 0)
                    idle_elapsed = int(getattr(res, "idle_wait_elapsed_us", 0) or 0)
                    pre_bulk = int(getattr(res, "pre_bulk_us", 0) or 0)
                    bulk_wall = int(getattr(res, "bulk_wall_us", 0) or 0)
                    total_u = int(res.total_ns // 1000)
                    rpc_wait = int(getattr(res, "inject_rpc_wait_us", 0) or 0)
                    job_to_done = int(getattr(res, "uartd_job_to_done_us", 0) or 0)
                    stage_fields = {
                        "source": source,
                        "msg": msg_label,
                        "len": len(frame),
                        "attempt": inject_attempt,
                        "require_idle": int(getattr(res, "require_idle", 0) or 0),
                        "idle_wait_budget_ms": int(
                            getattr(res, "idle_wait_budget_ms", 0) or 0
                        ),
                        "idle_saw_busy": int(getattr(res, "idle_saw_busy", 0) or 0),
                        "idle_wait_elapsed_us": idle_elapsed,
                        "pre_bulk_us": pre_bulk,
                        "bulk_wall_us": bulk_wall,
                        "de_hold_us": de_hold,
                        "drain_wait_us": round(
                            int(
                                getattr(res.forensics, "drain_wait_ns", 0)
                                if res.forensics is not None
                                else 0
                            )
                            / 1000.0,
                            1,
                        ),
                        "tx_us": round(res.tx_ns / 1000.0, 1),
                        "inject_total_us": round(res.total_ns / 1000.0, 1),
                        "air_us": int(res.air_us),
                        "slv_in_before": bool(res.slv_in_before),
                        # Wall pad before DE (expect ≈ idle wait when bus busy).
                        "pre_de_pad_us": max(0, total_u - de_hold),
                        "pre_bulk_minus_idle_us": max(0, pre_bulk - idle_elapsed),
                        # EG 3.0.1.58 — isolate residual ~90 ms outside inject_burst.
                        "uartd_pre_burst_us": int(
                            getattr(res, "uartd_pre_burst_us", 0) or 0
                        ),
                        "uartd_burst_wall_us": int(
                            getattr(res, "uartd_burst_wall_us", 0) or 0
                        ),
                        "uartd_post_burst_us": int(
                            getattr(res, "uartd_post_burst_us", 0) or 0
                        ),
                        "uartd_job_to_done_us": job_to_done,
                        "inject_rpc_wait_us": rpc_wait,
                        "inject_rpc_scoop_us": int(
                            getattr(res, "inject_rpc_scoop_us", 0) or 0
                        ),
                        "inject_capture_ipc_n": int(
                            getattr(res, "inject_capture_ipc_n", 0) or 0
                        ),
                        # Python waited past C job→DONE (IPC scheduling / socket).
                        "rpc_minus_uartd_us": max(0, rpc_wait - job_to_done),
                        # uartd after inject_burst before DONE (peel / post-DE / harvest).
                        "uartd_outside_burst_us": max(
                            0,
                            job_to_done
                            - int(getattr(res, "uartd_burst_wall_us", 0) or 0),
                        ),
                        # EG 3.0.2.4 — x1 peel event marks (µs from peel t0).
                        "peel_first_byte_us": int(
                            getattr(res, "peel_first_byte_us", 0) or 0
                        ),
                        "peel_hit_us": int(getattr(res, "peel_hit_us", 0) or 0),
                        "peel_src": int(getattr(res, "peel_src", 0) or 0),
                        "peel_x1_msg": int(getattr(res, "peel_x1_msg", 0) or 0),
                    }
                    if meta is not None:
                        stage_fields["tid"] = meta.tid_hex
                        stage_fields["game"] = meta.game_id_hex
                        stage_fields["play_sequence"] = meta.play_sequence
                    self.event_log.emit("inject_stage_diag", **stage_fields)
                    # EG 3.0.2.6: hub eth→UART x0 sw band for Master phase math.
                    if (
                        source in ("eth_to_uart", "edge_fallback_eth_to_uart")
                        and msg_label in ("40", "30")
                    ):
                        self._hand_phase_hub40_sw = {
                            "tid": (meta.tid_hex if meta is not None else None),
                            "play_sequence": (
                                meta.play_sequence if meta is not None else None
                            ),
                            "inject_rpc_wait_us": rpc_wait,
                            "pre_bulk_us": pre_bulk,
                            "uartd_job_to_done_us": job_to_done,
                            "air_us": int(res.air_us),
                            "de_hold_us": de_hold,
                        }
                        self.event_log.emit(
                            "master_phase_diag",
                            segment="hub_40_sw",
                            **{
                                k: v
                                for k, v in self._hand_phase_hub40_sw.items()
                                if v is not None
                            },
                        )
                    x1_ack = getattr(res, "x1_ack_frame", b"") or b""
                    if x1_ack:
                        if not inject_post_de:
                            inject_post_de = x1_ack
                        elif x1_ack not in inject_post_de:
                            inject_post_de = x1_ack + inject_post_de
                    if res.forensics is not None:
                        drain_wait_us = res.forensics.drain_wait_ns / 1000.0
                    t1 = time.perf_counter_ns()
                    # EG 3.0.1.1 / 3.0.2.2 / 4.0.0.63: C auto-close may complete
                    # after inject() returns (success 0D, or pending_expire miss).
                    # Sync empty peel → auto_close is None → keep await_x1; do
                    # not miss/reinject/hand-open until late AUTO_CLOSE_DONE.
                    if (
                        getattr(res, "auto_close", None) is not None
                        and res.auto_close is not None
                        and meta is not None
                        and arm_auto_close
                    ):
                        # Consumed from FIFO head — same arm as this inject.
                        self._pop_ac_arm()
                        self._apply_uartd_auto_close_result(
                            frame,
                            meta,
                            res.auto_close,
                            inject_echo=inject_echo,
                            inject_post_de=inject_post_de,
                            via="inject_result",
                        )
                    elif arm_auto_close and meta is not None:
                        # Mid-state: listen-until-x1 pending (not a miss).
                        rk_pend = self._round_key(meta)
                        if (
                            rk_pend is not None
                            and rk_pend in self._mrt_risk_await_x1
                        ):
                            self._mrt_risk_await_x1[rk_pend] = (
                                time.perf_counter_ns()
                            )
                        self.event_log.emit(
                            "status_0d_auto_close_pending",
                            tid=meta.tid_hex,
                            game=meta.game_id_hex,
                            msg=meta.msg_type_hex,
                            play_sequence=meta.play_sequence,
                            scoop_us=int(
                                getattr(res, "inject_rpc_scoop_us", 0) or 0
                            ),
                            peel_src=int(
                                getattr(res, "peel_src", 0) or 0
                            ),
                        )
                        self._arm_post_x9_orphan_rx(
                            meta,
                            peel_src=int(getattr(res, "peel_src", 0) or 0),
                            post_de_len=len(inject_post_de or b""),
                            reason="auto_close_pending",
                        )
                    # Queue only — never flush inject_echo.jsonl under _tx_lock.
                    if source in ("eth_to_uart", "status_0d_synth"):
                        deferred_inject_echo.append(
                            {
                                "sent": frame,
                                "echo": inject_echo,
                                "source": source,
                                "engine": inject_engine,
                                "msg": msg_label,
                                "tid": (
                                    meta.tid_hex if meta is not None else None
                                ),
                                "forensics": inject_forensics,
                                "copper": inject_copper,
                                "post_de_rx": inject_post_de,
                                "attempt": inject_attempt,
                            }
                        )
                    bus_rx_empty = not inject_echo and not inject_post_de
                    auto_closed_ok = (
                        getattr(res, "auto_close", None) is not None
                        and res.auto_close is not None
                        and res.auto_close.ok
                    )
                    # EG 3.0.2.2: pending AC after DONE is not a closed failure —
                    # do not empty-echo reinject while 0D may still be on the wire.
                    ac_pending = bool(
                        arm_auto_close
                        and getattr(res, "auto_close", None) is None
                    )
                    echo_ok, echo_how = inject_echo_bytes_match(frame, inject_echo)
                    if not echo_ok:
                        post_ok, post_how = inject_echo_bytes_match(
                            frame, inject_post_de
                        )
                        if post_ok:
                            echo_ok = True
                            echo_how = post_how
                    # EG 4.1.0.11: require sent-vs-echo match (not merely nonempty RX).
                    if (
                        inject_echo_require
                        and not echo_ok
                        and inject_attempt < max_attempts
                        and not auto_closed_ok
                        and not ac_pending
                    ):
                        self.event_log.emit(
                            "inject_echo_miss_reinject",
                            tid=(meta.tid_hex if meta is not None else None),
                            game=(meta.game_id_hex if meta is not None else None),
                            msg=msg_label,
                            play_sequence=(
                                meta.play_sequence if meta is not None else None
                            ),
                            attempt=inject_attempt,
                            next_attempt=inject_attempt + 1,
                            echo_len=len(inject_echo or b""),
                            post_de_rx_len=len(inject_post_de or b""),
                            echo_match=echo_how,
                            source=source,
                            local_x9=int(bool(local_x9_empty_echo_retry)),
                        )
                        if local_x9_empty_echo_retry:
                            self.event_log.emit(
                                "mrt_x9_empty_echo_reinject",
                                tid=meta.tid_hex,
                                game=meta.game_id_hex,
                                msg=meta.msg_type_hex,
                                play_sequence=meta.play_sequence,
                                attempt=inject_attempt,
                                next_attempt=inject_attempt + 1,
                            )
                        continue
                    # Stash final echo verdict for Success gate after the loop.
                    inject_echo_final_ok = (
                        (not inject_echo_require)
                        or echo_ok
                        or auto_closed_ok
                        or ac_pending
                    )
                    inject_echo_final_how = echo_how
                    break
            else:
                raise RuntimeError(
                    "EG 2.2: Python MARK/SPACE inject is unsupported â€” "
                    "use POG422_UARTD=1 POG422_UARTD_INJECT=1 (INJECT_TX)"
                )

            if not inject_echo_final_ok:
                self.event_log.emit(
                    "inject_echo_fail",
                    tid=(meta.tid_hex if meta is not None else None),
                    game=(meta.game_id_hex if meta is not None else None),
                    msg=msg_label,
                    play_sequence=(
                        meta.play_sequence if meta is not None else None
                    ),
                    echo_len=len(inject_echo or b""),
                    post_de_rx_len=len(inject_post_de or b""),
                    echo_match=inject_echo_final_how,
                    source=source,
                    attempts=max_attempts,
                )
                _flush_deferred_inject_echo()
                return None

            # Only a definite low is a misalign. None means the DE-high sample was
            # not taken (GPIO1 event cache off) — scoring that as low produced a
            # false alarm on every inject in 3.0.1.15.
            if slv_in_after_on is False:
                self.event_log.emit(
                    "slv_hs_misalign",
                    where="out_on",
                    slv_out=True,
                    slv_in=False,
                    source=source,
                    msg=msg_label,
                    len=len(frame),
                )
            if slv_in_after_drain is False:
                self.event_log.emit(
                    "slv_hs_misalign",
                    where="after_drain",
                    slv_out=True,
                    slv_in=False,
                    source=source,
                    msg=msg_label,
                    len=len(frame),
                )

            self._emit_inject_gpio1_trace(
                source=source,
                msg=msg_label,
                tx_us=tx_us,
                slv_in_before=slv_in_before,
                slv_in_after_mark=slv_in_after_on,
                slv_in_after_body=slv_in_after_body,
                slv_in_after_drain=slv_in_after_drain,
                slv_in_after_off=slv_in_after_off,
            )

            inject_ioctl_us = (t1 - t0) / 1000.0
            off_fields = {
                "source": source,
                "inject_path": inject_path,
                "inject_engine": inject_engine,
                "msg": msg_label,
                "len": len(frame),
                "inject_ioctl_us": round(inject_ioctl_us, 1),
                "dt_us": round(inject_ioctl_us, 1),
                "tx_us": round(tx_us, 1),
                "air_us": round(air_us, 1),
                "post_hold_us": round(post_hold_us, 1),
                "pre_tx_us": round(pre_tx_us, 1),
                "de_deadline_us": round(
                    pre_tx_us + air_us + post_hold_us, 1
                ),
                "signal": "SLV-HS",
                "manual_gpio7_slv": True,
                "slv_in_after_out_off": slv_in_after_off,
            }
            if drain_wait_us is not None:
                off_fields["drain_wait_us"] = round(drain_wait_us, 1)
            if inject_total_us is not None:
                off_fields["inject_total_us"] = round(inject_total_us, 1)
            # Stage fields when last inject left them on res (uartd path).
            if inject_forensics is not None:
                off_fields["de_hold_us"] = int(
                    getattr(inject_forensics, "de_hold_us", 0) or 0
                )
            if not self.use_uartd_inject:
                post_sleep = max(
                    air_us / 1_000_000.0,
                    self.timing.post_tx_us / 1_000_000.0,
                )
                off_fields["post_us"] = round(post_sleep * 1000.0, 1)
            self.event_log.emit("slv_out_off", **off_fields)
            self.event_log.emit("gpio7_tx_off", **off_fields)
            self._last_hs_inactive_perf_ns = t1
            result: bool | None = True

        # --- lock released: peer parse / x1 mark / synth must not wait on inject I/O ---
        if source in ("eth_to_uart", "status_0d_synth", "bank_jpot_x1_reinject"):
            self.event_log.note_eth_inject(
                start_perf_ns=t0,
                end_perf_ns=t1,
                msg=msg_label,
                tid=(meta.tid_hex if meta is not None else None),
                source=source,
            )
            peer = peer_uart_bytes_after_inject(frame, inject_echo, inject_post_de)
            if peer:
                if not ois:
                    normalized = normalize_window(peer)
                    if normalized:
                        packets = split_packets(normalized)
                        if packets:
                            for pkt in packets:
                                peer_msgs.append(parse_packet_meta(pkt).msg_type_hex)
                            peer_after_inject = normalized
                        else:
                            # Incomplete framing (e.g. lone SOH 0x05 of pending x1) â€”
                            # hold for grace/HS glue; do not window_skip/split_empty.
                            if peer[0] == SOH:
                                self.state.post_x9_grace_soh_hold.clear()
                                self.state.post_x9_grace_soh_hold.extend(peer)
                                self.event_log.emit(
                                    "post_de_soh_hold",
                                    source=source,
                                    count=len(peer),
                                    first_byte=f"{peer[0]:02X}",
                                )
                            peer_after_inject = b""
                    else:
                        if peer and peer[0] == SOH:
                            self.state.post_x9_grace_soh_hold.clear()
                            self.state.post_x9_grace_soh_hold.extend(peer)
                            self.event_log.emit(
                                "post_de_soh_hold",
                                source=source,
                                count=len(peer),
                                first_byte=f"{peer[0]:02X}",
                            )
                            peer_after_inject = b""
                        else:
                            peer_after_inject = peer
                else:
                    peer_msgs.append(f"{peer[0]:02X}" if peer else "??")
                    peer_after_inject = peer
                if peer_after_inject:
                    self.event_log.emit(
                        "inject_peer_uart_eth",
                        source=source,
                        sent_msg=msg_label,
                        sent_tid=(meta.tid_hex if meta is not None else None),
                        peer_len=len(peer_after_inject),
                        peer_msgs=",".join(peer_msgs) if peer_msgs else None,
                    )
            peer_has_local_x1 = False
            peer_x1_msg: str | None = None
            if (
                peer_after_inject
                and self._meta_is_local_terminal(meta)
                and meta is not None
                and not ois
            ):
                # Prefer TID-checked packets over opcode-only peer_msgs (Class D).
                for pkt in split_packets(peer_after_inject):
                    pm = parse_packet_meta(pkt)
                    if is_gameplay_ack(pm.msg_type) and self._meta_is_local_terminal(pm):
                        peer_has_local_x1 = True
                        peer_x1_msg = pm.msg_type_hex
                        break
            elif (
                peer_msgs
                and self._meta_is_local_terminal(meta)
                and meta is not None
            ):
                for pm in peer_msgs:
                    try:
                        pm_int = int(pm, 16)
                    except (ValueError, TypeError):
                        continue
                    if is_gameplay_ack(pm_int):
                        peer_has_local_x1 = True
                        peer_x1_msg = pm
                        break
            if (
                not ois
                and source == "eth_to_uart"
                and meta is not None
                and is_gameplay_response(meta.msg_type)
            ):
                bus_rx_empty = not inject_echo and not inject_post_de
                if bus_rx_empty and self._meta_is_local_terminal(meta):
                    rk = self._round_key(meta)
                    if rk is not None:
                        self.event_log.emit(
                            "mrt_risk_arm_skip",
                            tid=rk[0],
                            game=rk[1],
                            msg=meta.msg_type_hex,
                            reason="empty_echo",
                            play_sequence=meta.play_sequence,
                            attempt=inject_attempt,
                        )
                    arm_skip = "empty_echo"
                elif peer_has_local_x1 and self._meta_is_local_terminal(meta):
                    arm_post_x9_inject_grace(
                        self.event_log,
                        self.state,
                        slv_in_after_off=slv_in_after_off,
                        local_origin_x9=True,
                    )
                    rk = self._round_key(meta)
                    already_early = rk is not None and rk in self._status_0d_x1_pending
                    if rk is not None and not already_early:
                        self.event_log.emit(
                            "mrt_peer_x1_in_echo",
                            tid=rk[0],
                            game=rk[1],
                            msg=meta.msg_type_hex,
                            x1_msg=peer_x1_msg,
                            peer_msgs=",".join(peer_msgs),
                            echo_len=len(inject_echo),
                            play_sequence=meta.play_sequence,
                        )
                        self._mrt_risk_arm_after_local_x9(meta)
                        self._mark_local_x1_pending(
                            rk,
                            msg=peer_x1_msg or meta.msg_type_hex,
                            trigger="peer_x1_in_echo",
                            harvest_path="post_de_peel",
                            peer_msgs=",".join(peer_msgs),
                            echo_len=len(inject_echo),
                            play_sequence=meta.play_sequence,
                            synth_defer=synth_defer,
                        )
                    elif rk is not None and already_early:
                        # Early bus harvest already noted x1 → await_0d. Do not
                        # re-arm (that emits false mrt_risk_arm_skip await_0d_active).
                        self._schedule_synth_attempt(rk, synth_defer=synth_defer)
                    arm_skip = "peer_x1_in_echo"
                else:
                    arm_post_x9_inject_grace(
                        self.event_log,
                        self.state,
                        slv_in_after_off=slv_in_after_off,
                        local_origin_x9=self._meta_is_local_terminal(meta),
                    )
                    arm_skip = self._mrt_risk_arm_after_local_x9(meta)
                self._mrt_risk_emit_x9_inject_diag(
                    meta,
                    frame=frame,
                    echo=inject_echo,
                    post_de=inject_post_de,
                    peer=peer,
                    peer_msgs=peer_msgs,
                    arm_skip=arm_skip,
                    attempt=inject_attempt,
                )
                if inject_total_us is not None and inject_total_us >= INJECT_SLOW_LOG_US:
                    slow_fields = {
                        "tid": meta.tid_hex,
                        "game": meta.game_id_hex,
                        "msg": meta.msg_type_hex,
                        "play_sequence": meta.play_sequence,
                        "inject_total_us": round(inject_total_us, 1),
                        "tx_us": round(tx_us, 1),
                        "attempt": inject_attempt,
                        "arm_skip": arm_skip,
                        "wait_s": inject_wait_s,
                        "idle_wait_ms": idle_wait_ms,
                    }
                    if inject_forensics is not None:
                        slow_fields.update(
                            {
                                "drain_wait_us": round(
                                    int(inject_forensics.drain_wait_ns or 0) / 1000.0, 1
                                ),
                                "de_release_reason": (
                                    inject_forensics.de_release_reason_name
                                ),
                                "tx_fifo_after_bulk": int(
                                    inject_forensics.tx_fifo_after_bulk
                                ),
                                "de_hold_us": int(
                                    getattr(inject_forensics, "de_hold_us", 0) or 0
                                ),
                            }
                        )
                    self.event_log.emit("mrt_x9_inject_slow", **slow_fields)
        if source == "eth_to_uart" and not ois and meta is not None:
            if is_gameplay_response(meta.msg_type):
                self._trunk_round_mark_x9(meta.tid_hex)
                # Lab synth seat: forge x1 ack (full SM) â€” queued off the inject lock.
                synth = getattr(self, "_lab_gameplay_synth", None)
                if (
                    synth is not None
                    and self._meta_is_local_terminal(meta)
                    and callable(getattr(synth, "on_master_outcome", None))
                ):
                    try:
                        synth.on_master_outcome(frame, meta)
                    except Exception as exc:
                        self.event_log.emit(
                            "lab_gameplay_synth_skip",
                            reason="outcome_hook_failed",
                            tid=meta.tid_hex,
                            detail=str(exc),
                        )
            elif is_gameplay_ack(meta.msg_type):
                self._trunk_round_mark_x1(meta.tid_hex)
            self._remember_gameplay_round_eth_inject(
                frame, meta, synth_defer=synth_defer, skill_synth_defer=skill_synth_defer
            )
            # Early-win / fireworks AFTER lock â€” never stall 41â†’0D on IPC disk write.
            early_win_after_lock = (frame, meta)

        # EG 2.2.2.30 â€” EARLY ETH FORWARD: send peer x1 ACK to Master BEFORE hooks.
        # Hooks trigger 0D synth inject (~55 ms blocking); Master retries x9 at ~125 ms.
        # Forwarding first puts the x1 on the wire ~60 ms sooner.
        # EG 3.0.1.20: skip if mid-inject X1_ACK_FRAME already eth-forwarded.
        peer_forwarded_early = False
        if (
            peer_after_inject
            and self.forward_uart_origin_window is not None
            and not early_x1_eth_done
        ):
            t_fwd0 = time.perf_counter_ns()
            self.forward_uart_origin_window(
                bytearray(peer_after_inject),
                uart_first_byte=peer_after_inject[0],
            )
            t_fwd1 = time.perf_counter_ns()
            peer_forwarded_early = True
            self.event_log.emit(
                "inject_peer_early_eth",
                source=source,
                sent_msg=msg_label,
                sent_tid=(meta.tid_hex if meta is not None else None),
                peer_len=len(peer_after_inject),
                peer_msgs=",".join(peer_msgs) if peer_msgs else None,
                fwd_us=round((t_fwd1 - t_fwd0) / 1000.0, 1),
                echo_to_fwd_us=round((t_fwd1 - t1) / 1000.0, 1),
            )
            emit_peer_gameplay_eth_fwd(
                self,
                peer_after_inject,
                source=source,
                sent_msg=msg_label,
                sent_tid=(meta.tid_hex if meta is not None else None),
            )
        elif early_x1_eth_done and peer_after_inject:
            peer_forwarded_early = True
        # Then run hooks (packet_complete â†’ 0D synth) without duplicate eth forward.
        if peer_after_inject:
            now_ns = time.perf_counter_ns()
            if peer_forwarded_early:
                self.event_log.finalize_handshake_window(
                    bytearray(peer_after_inject),
                    window_start_perf_ns=now_ns,
                    window_start_mono_ns=now_ns,
                    first_byte_perf_ns=now_ns,
                    last_byte_perf_ns=now_ns,
                    uart_first_byte=peer_after_inject[0],
                    hs_inactive_perf_ns=now_ns,
                )
            else:
                self.event_log.finalize_handshake_window_for_channel(
                    self.channel,
                    bytearray(peer_after_inject),
                    window_start_perf_ns=now_ns,
                    window_start_mono_ns=now_ns,
                    first_byte_perf_ns=now_ns,
                    last_byte_perf_ns=now_ns,
                    uart_first_byte=peer_after_inject[0],
                    hs_inactive_perf_ns=now_ns,
                )
        for rk in synth_defer:
            self._try_synth_pending_for_round(rk)
        for rk in skill_synth_defer:
            self._try_synth_skill_grant_close_for_round(rk)
        self._drain_deferred_python_close_fallback()
        self._drain_synth_retries()
        # Early bus harvest may have appendleft'd 0D while this inject held the lock.
        self._process_pending_injects()
        if early_win_after_lock is not None:
            ew_frame, _ew_meta = early_win_after_lock
            self._early_win_inspect(ew_frame, source="eth_to_uart")
        # Luxury logging after close path (packets.raw / inject_echo.jsonl flush).
        if source in ("eth_to_uart", "status_0d_synth", "bank_jpot_x1_reinject"):
            if ois:
                self.event_log.write_raw_window_line(frame)
            elif meta is not None:
                self.event_log.write_packet_line(meta)
        if meta is not None and result is True and source in (
            "eth_to_uart",
            "bank_jpot_x1_reinject",
            "edge_fallback_eth_to_uart",
        ):
            self._note_bank_jpot_x1_inject_forensics(
                tid_hex=meta.tid_hex,
                msg_type=meta.msg_type,
                frame=frame,
                inject_echo=inject_echo,
                inject_post_de=inject_post_de,
            )
        _flush_deferred_inject_echo()
        return result


def uart_to_eth_thread(
    sock,
    eth_header: bytes,
    bridge: UartBridge,
    *,
    stats_interval_s: float,
    port: str,
    active_low: bool,
    stop: threading.Event,
) -> None:
    last_stats_at = time.time()
    while not stop.is_set():
        try:
            bridge.capture_tick()

            if stats_interval_s > 0 and time.time() - last_stats_at >= stats_interval_s:
                bridge.event_log.write_stats(
                    {
                        "mode": "bridge",
                        "direction": "uart_to_eth",
                        "serial_port": port,
                        "handshake_active_low": active_low,
                        "bytes_collected": bridge.state.bytes_collected,
                        "bytes_discarded_inactive": bridge.state.bytes_discarded_inactive,
                        "pre_tx_us": bridge.timing.pre_tx_us,
                        "post_tx_us": bridge.timing.post_tx_us,
                        "post_air_margin_us": bridge.timing.post_air_margin_us,
                        "wait_hs_inactive_before_tx": bridge.timing.wait_hs_inactive_before_tx,
                        "post_hs_inactive_us": bridge.timing.post_hs_inactive_us,
                        "pending_inject_count": len(bridge._pending),
                    }
                )
                # Same cadence as listen_loop: rotate debug capture when HS is idle.
                if not bridge.handshake.is_active():
                    bridge.event_log.try_rotate_debug_logs()
                    bridge.event_log.idle_flush()
                last_stats_at = time.time()
        except Exception as exc:
            msg = str(exc)
            # EG 3.0.1.3: IPC desync / disconnect → respawn uartd, do not sit dead.
            uartd = bridge.uartd
            if uartd is not None and (
                isinstance(exc, UartdIpcDesyncError)
                or "uartd not connected" in msg
                or "bad ipc magic" in msg
                or "uartd closed socket" in msg
            ):
                if uartd.ensure_alive(reason=msg[:80]):
                    bridge.event_log.emit(
                        "uartd_auto_reinit",
                        detail=getattr(uartd, "_last_ensure_detail", ""),
                        where="uart_to_eth",
                        prior=msg[:120],
                    )
                    continue
            bridge.event_log.emit("error", where="uart_to_eth", message=msg)
        time.sleep(bridge.timing.loop_sleep_us / 1_000_000.0)


def eth_to_uart_thread_legacy(
    sock,
    eth_header: bytes,
    bridge: UartBridge,
    stop: threading.Event,
) -> None:
    while not stop.is_set():
        ready, _, _ = select.select([sock], [], [], 0.005)
        if not ready:
            continue
        try:
            packet = sock.recv(4096)
        except Exception as exc:
            bridge.event_log.emit("error", where="eth_recv", message=str(exc))
            continue
        try:
            if len(packet) <= 14:
                continue
            payload = packet[14:]
            frame = _eth_payload_from_l2(bridge, payload)
            if frame is None:
                continue
            _emit_eth_rx_event(bridge, frame, eth_port=None)
            bridge.queue_eth_frame(frame)
        except Exception as exc:
            bridge.event_log.emit("error", where="eth_to_uart", message=str(exc))
            continue


def eth_to_uart_thread_directional(
    router: DirectionalEthRouter,
    bridge: UartBridge,
    stop: threading.Event,
    *,
    eth_relay_only: bool = False,
) -> None:
    while not stop.is_set():
        # Refresh each cycle so hub enroll can hot-add eg-sNN without restart.
        socks = router.sockets()
        if not socks:
            stop.wait(0.05)
            continue
        try:
            ready, _, _ = select.select(socks, [], [], 0.005)
        except (ValueError, OSError):
            # Closed fd race with hub add_port/reopen_port — retry next cycle.
            continue
        if not ready:
            continue
        for sock in ready:
            try:
                packet, pkttype, ifindex, skip_reason = recv_eth_l2_frame(sock)
            except Exception as exc:
                bridge.event_log.emit("error", where="eth_recv", message=str(exc))
                continue
            try:
                eth_port = router.resolve_ingress_port(sock, ifindex)
                if skip_reason:
                    bridge.event_log.emit(
                        "eth_rx_skip",
                        reason=skip_reason,
                        pkttype=pkttype,
                        eth_port=eth_port,
                    )
                    continue
                if not packet or len(packet) <= 14:
                    continue
                if router.is_local_loopback(packet):
                    bridge.event_log.emit(
                        "eth_rx_skip",
                        reason="local_eth_tx",
                        pkttype=pkttype,
                        eth_port=eth_port,
                    )
                    continue
                payload = packet[14:]
                try:
                    from pog_edge_pipe_probe import handle_pipe_eth_rx, is_pipe_wire

                    is_pipe, pipe_payload = is_pipe_wire(payload, dual=False)
                    if is_pipe:
                        handle_pipe_eth_rx(
                            payload=pipe_payload,
                            eth_port=eth_port,
                            router=router,
                            emit=bridge.event_log.emit,
                            dual=False,
                        )
                        continue
                except Exception as exc:
                    bridge.event_log.emit(
                        "error", where="pipe_probe_rx", message=str(exc)
                    )
                frame = _eth_payload_from_l2(bridge, payload)
                if frame is None:
                    continue
                if eth_port is not None and not channel_uses_raw_hs_window(bridge.channel):
                    meta_rx = parse_packet_meta(frame)
                    router.note_tid_port(meta_rx.tid_hex, eth_port)
                if eth_port is not None:
                    skip_fanout, fanout_skip_reason, tid_hex = (
                        _eth_skip_peer_fanout_for_ack(bridge, frame)
                    )
                    if skip_fanout:
                        bridge.event_log.emit(
                            "eth_fanout_skip",
                            reason=fanout_skip_reason,
                            ingress_port=eth_port,
                            tid=tid_hex,
                            msg=parse_packet_meta(frame).msg_type_hex,
                        )
                    else:
                        only_ports = _eth_only_ports_for_frame(router, frame, bridge)
                        sends, fanout_stats = router.send_eth_to_peers(
                            frame,
                            except_port=eth_port,
                            live_ports_only=router.eth_relay_live_ports_only,
                            only_ports=only_ports,
                        )
                        if tid_hex is None and not channel_uses_raw_hs_window(
                            bridge.channel
                        ):
                            tid_hex = parse_packet_meta(frame).tid_hex
                        _emit_eth_fanout(
                            bridge,
                            stats=fanout_stats,
                            ingress_port=eth_port,
                            tid=tid_hex,
                        )
                        for peer_port, dt_us in sends:
                            _emit_eth_relay_event(
                                bridge,
                                frame,
                                ingress_port=eth_port,
                                egress_port=peer_port,
                                dt_us=dt_us,
                            )
                _emit_eth_rx_event(bridge, frame, eth_port=eth_port)
                if not eth_relay_only:
                    bridge.queue_eth_frame(frame)
            except Exception as exc:
                # Never kill the ethâ†’UART hop (pos4 .43: TimeoutError in slv_in_active).
                bridge.event_log.emit("error", where="eth_to_uart", message=str(exc))
                continue


def _eth_skip_peer_fanout_for_ack(
    bridge: UartBridge,
    frame: bytes,
) -> tuple[bool, str | None, str | None]:
    """EG 4.1.0.11: never eth-fan Slave gameplay ACK to peers.

    Hub consumes spoke ``81`` via UART inject toward Master. Reflecting the ACK
    back (or sideways) wastes eth and arms spoke eth→UART reinject loops.
    Returns ``(skip, reason, tid_hex)``.
    """
    if channel_uses_raw_hs_window(bridge.channel):
        return False, None, None
    meta = parse_packet_meta(frame)
    if is_gameplay_ack(meta.msg_type):
        return True, "gameplay_ack_no_reflect", meta.tid_hex
    return False, None, meta.tid_hex


def _wager_eth_tx_recently_injected(
    bridge: UartBridge,
    meta,
) -> bool:
    if not is_gameplay_request(meta.msg_type) or not meta.tid_hex:
        return False
    key = (meta.tid_hex.upper(), meta.play_sequence)
    last_ns = bridge._recent_x9_inject_ns.get(key)
    if last_ns is None:
        return False
    age_ms = (time.perf_counter_ns() - last_ns) / 1_000_000.0
    return age_ms < X9_INJECT_WAGER_ETH_TX_DEDUPE_MS


def should_forward_uart_packet(
    bridge: UartBridge,
    pkt: bytes,
    *,
    uart_first_byte: int | None,
    packets_in_window: int = 1,
) -> tuple[bool, str | None]:
    ok, reason = eth_forward_ok(pkt)
    if not ok:
        return False, reason
    meta = parse_packet_meta(pkt)
    if uart_echo_false_glued_wager(
        meta.msg_type,
        packets_in_window=packets_in_window,
        uart_first_byte=uart_first_byte,
    ):
        return False, "uart_x9_echo_glued_wager"
    if (
        is_gameplay_request(meta.msg_type)
        and uart_window_lead_is_response_echo(uart_first_byte)
    ):
        return False, "uart_x9_echo_wager"
    if (
        meta.msg_type == 0x0D
        and uart_window_lead_is_response_echo(uart_first_byte)
    ):
        return False, "uart_x9_echo_status"
    if _wager_eth_tx_recently_injected(bridge, meta):
        return False, "x9_inject_dedupe"
    scr_skip = bridge._scr_uart_eth_coalesce_reason(
        meta, now_ns=time.perf_counter_ns()
    )
    if scr_skip is not None:
        return False, scr_skip
    return True, None


def note_uart_origin_after_eth_tx(bridge: UartBridge, meta) -> None:
    """Learn local TID or detect native Master status close from UARTâ†’ETH origin."""
    if meta.msg_type == 0x0D:
        bridge._note_native_uart_status_close()
        bridge._trunk_round_close_on_native_status(meta)
        return
    bridge._scr_note_uart_eth_tx(meta)
    bridge._note_local_slave_tid_from_uart_origin(meta.tid_hex, meta.msg_type)


def resolve_lab_gameplay_config(args) -> Any:
    """Merge env + CLI for lab synthetic T340+ seat."""
    cfg = lab_gameplay_config_from_env(fixture_pos=getattr(args, "fixture_pos", None))
    return lab_gameplay_merge_cli(
        cfg,
        enabled=True if getattr(args, "lab_gameplay_synth", False) else None,
        tid=getattr(args, "lab_gameplay_tid", None),
        games=getattr(args, "lab_gameplay_games", None),
        session_s=getattr(args, "lab_gameplay_session_s", None),
        period_s=getattr(args, "lab_gameplay_period_s", None),
        jitter_s=getattr(args, "lab_gameplay_jitter_s", None),
        phase_s=getattr(args, "lab_gameplay_phase_s", None),
        shuffle=True if getattr(args, "lab_gameplay_shuffle", False) else None,
        allow_prod=True
        if getattr(args, "lab_gameplay_synth_allow_prod", False)
        else None,
    )


def start_lab_gameplay_synth_for_bridge(
    args,
    *,
    bridge: UartBridge,
    stop: threading.Event,
    forward_uart_origin,
) -> Any:
    """Arm background synth thread if enabled. *forward_uart_origin(pkt)* sends UARTâ†’ETH."""
    if getattr(args, "channel", "machine") == "ois":
        return None
    cfg = resolve_lab_gameplay_config(args)
    if not cfg.enabled and not getattr(args, "lab_gameplay_synth", False):
        # Env may enable without CLI flag (shell exports LAB_GAMEPLAY_SYNTH=1).
        pass
    return try_start_lab_gameplay_synth(
        cfg,
        bridge=bridge,
        forward=forward_uart_origin,
        emit=bridge.event_log.emit,
        stop=stop,
        prod_mode=bool(getattr(args, "prod", False)),
    )


def uart_gameplay_eth_tx_extra(bridge: UartBridge, meta) -> dict[str, Any]:
    """Dev-light crumb fields for UARTâ†’ETH gameplay; tags same-seq Master outcome retry."""
    extra: dict[str, Any] = {}
    if meta.play_sequence is not None:
        extra["play_sequence"] = meta.play_sequence
    if meta.game_id_hex:
        extra["game"] = meta.game_id_hex
    if not is_gameplay_response(meta.msg_type) or meta.play_sequence is None:
        return extra
    key = (meta.tid_hex, int(meta.play_sequence), int(meta.msg_type))
    now = time.perf_counter_ns()
    prev = bridge._uart_x9_eth_tx_ns.get(key)
    if prev is not None:
        gap_ms = (now - prev) / 1_000_000.0
        if gap_ms < 2500.0:
            extra["outcome_retry"] = True
            extra["retry_gap_ms"] = round(gap_ms, 1)
    bridge._uart_x9_eth_tx_ns[key] = now
    if len(bridge._uart_x9_eth_tx_ns) > 256:
        # Drop oldest half by timestamp (bound soak memory).
        ordered = sorted(bridge._uart_x9_eth_tx_ns.items(), key=lambda kv: kv[1])
        for old_key, _ in ordered[: len(ordered) // 2]:
            bridge._uart_x9_eth_tx_ns.pop(old_key, None)
    return extra


def emit_peer_gameplay_eth_fwd(
    bridge: UartBridge,
    peer: bytes,
    *,
    source: str,
    sent_msg: str | None,
    sent_tid: str | None,
) -> None:
    """Log each gameplay response/ACK in early-forwarded inject peer (Master 59 / slave 51)."""
    if not peer or peer[0] != SOH:
        return
    normalized = normalize_window(peer)
    if not normalized:
        return
    for pkt in split_packets(normalized):
        pm = parse_packet_meta(pkt)
        if not (
            is_gameplay_response(pm.msg_type)
            or is_gameplay_ack(pm.msg_type)
            or pm.msg_type == 0x0D
        ):
            continue
        fields: dict[str, Any] = {
            "via": "inject_peer_early",
            "source": source,
            "msg": pm.msg_type_hex,
            "tid": pm.tid_hex,
            "len": len(pkt),
            "sent_msg": sent_msg,
            "sent_tid": sent_tid,
        }
        fields.update(packet_bank_event_fields(pm))
        if pm.play_sequence is not None:
            fields["play_sequence"] = pm.play_sequence
        if pm.game_id_hex:
            fields["game"] = pm.game_id_hex
        if is_gameplay_response(pm.msg_type) and pm.play_sequence is not None:
            key = (pm.tid_hex, int(pm.play_sequence), int(pm.msg_type))
            now = time.perf_counter_ns()
            prev = bridge._uart_x9_eth_tx_ns.get(key)
            if prev is not None:
                gap_ms = (now - prev) / 1_000_000.0
                if gap_ms < 2500.0:
                    fields["outcome_retry"] = True
                    fields["retry_gap_ms"] = round(gap_ms, 1)
            bridge._uart_x9_eth_tx_ns[key] = now
        bridge.event_log.emit("peer_gameplay_eth_fwd", **fields)


def on_uart_packet_to_eth_legacy(
    sock, eth_header: bytes, bridge: UartBridge, pkt: bytes, *, dual: bool = False
) -> None:
    pkt = bridge.transform_uart_egress_frame(pkt)
    if not pkt:
        bridge.event_log.emit("eth_tx_skip", reason="lab_uart_egress_suppressed")
        return
    ok, reason = eth_forward_ok(pkt)
    meta = parse_packet_meta(pkt)
    if not ok:
        bridge.event_log.emit(
            "eth_tx_skip",
            reason=reason,
            msg=meta.msg_type_hex,
            len=len(pkt),
            tid=meta.tid_hex,
        )
        return
    t0 = time.perf_counter_ns()
    wire = fixture_eth_tag_payload(bridge.channel, pkt, dual=dual)
    try:
        sock.send(eth_header + wire)
    except Exception as exc:
        bridge.event_log.emit("error", where="uart_to_eth", message=str(exc))
        return
    t1 = time.perf_counter_ns()
    bridge.uart_origin.note_uart_to_eth(pkt)
    note_uart_origin_after_eth_tx(bridge, meta)
    gx = uart_gameplay_eth_tx_extra(bridge, meta)
    bridge.event_log.emit(
        "eth_tx",
        msg=meta.msg_type_hex,
        len=len(pkt),
        tid=meta.tid_hex,
        dt_us=round((t1 - t0) / 1000.0, 1),
        **gx,
    )


def _eth_only_ports_for_frame(
    router: DirectionalEthRouter,
    frame: bytes,
    bridge: UartBridge | None = None,
) -> list[str] | None:
    """EG 2.2.2.71 / 3.0.1.31 / 4.1.0.1: TID→gretap unicast when mapped; flood only bank-wide.

    Wire TID is always in the packet. ``unknown`` means **hub TID→gretap map**
    missing (not missing TID bytes).

    - **Hub:** unmapped cash/skill/request → nowhere (`[]`) — never multi-spoke flood.
    - **Spoke:** unmapped → all local eth ports (`None`) — Master path is the uplink
      (typically one gretap). TID map is hub-side learning; spokes must not black-hole x0.
    - Bank progressive-win x9: **winner unicast until Master has x1**, then multiport
      flood (EG 3.0.1.31). Progressive **0A** still floods.
    - **SCR ``0x03``/``0x0B``:** TID unicast when mapped (EG 4.1.0.1). Not a broadcast —
      hub must not send Master enroll replies to every spoke. Unmapped hub → ``[]``;
      unmapped spoke → uplink flood.
    - **STOM / T340+ CGS** (`0x01`/`0x02`/`0x04`/`0x09`/`0x0E`): unmapped floods
      (`None`) — never hub `[]`. Config must reach Master and come back.
    """
    if not EDGE_TID_UNICAST:
        return None
    meta = parse_packet_meta(frame)
    if is_progressive_broadcast(meta.msg_type):
        return None
    # Bank progressive: winner-only until Master has x1 on copper (then multiport
    # flood). Eth_rx of spoke x1 alone does not release fanout (EG 3.0.2.10).
    if is_bank_progressive_jackpot_frame(frame):
        if bridge is not None:
            return bridge._bank_jpot_eth_ports_for_issue(router, frame)
        return None
    tid = (meta.tid_hex or "").upper()
    if not tid or tid == BANK_PROGRESSIVE_BROADCAST_TID:
        return None
    port = router.port_for_tid(tid)
    if port is None:
        topo = (os.environ.get("EDGE_TOPOLOGY") or "").strip().lower()
        if not topo:
            role = (
                os.environ.get("EDGE_ROLE")
                or os.environ.get("POG_EDGE_ROLE")
                or ""
            ).strip().lower()
            if role in ("spoke", "pos4", "peer-b", "b", "4"):
                topo = "spoke"
            elif role in ("hub", "office", "peer-a", "a", "1"):
                topo = "hub"
        return eth_only_ports_unmapped(meta.msg_type, topo=topo)
    return [port]


def _emit_eth_fanout(
    bridge: UartBridge,
    *,
    stats: dict,
    ingress_port: str | None,
    tid: str | None,
) -> None:
    if int(stats.get("port_count") or 0) < ETH_FANOUT_LOG_MIN_PORTS:
        return
    bridge.event_log.emit(
        "eth_fanout",
        port_count=stats.get("port_count"),
        wall_us=round(float(stats.get("wall_us") or 0.0), 1),
        mode=stats.get("mode"),
        ingress_port=ingress_port,
        tid=tid,
    )


def on_uart_packet_to_eth_directional(
    router: DirectionalEthRouter,
    bridge: UartBridge,
    pkt: bytes,
    *,
    dual: bool = False,
) -> None:
    pkt = bridge.transform_uart_egress_frame(pkt)
    if not pkt:
        bridge.event_log.emit("eth_tx_skip", reason="lab_uart_egress_suppressed")
        return
    ok, reason = eth_forward_ok(pkt)
    meta = parse_packet_meta(pkt)
    bank_fields = packet_bank_event_fields(meta)
    if bank_fields.get("bank_ok") is False:
        bridge.event_log.emit(
            "eth_tx_skip",
            reason="bank_mismatch",
            msg=meta.msg_type_hex,
            len=len(pkt),
            tid=meta.tid_hex,
            **bank_fields,
        )
        return
    if not ok:
        bridge.event_log.emit(
            "eth_tx_skip",
            reason=reason,
            msg=meta.msg_type_hex,
            len=len(pkt),
            tid=meta.tid_hex,
            **bank_fields,
        )
        return
    try:
        wire = fixture_eth_tag_payload(bridge.channel, pkt, dual=dual)
        only = _eth_only_ports_for_frame(router, pkt, bridge)
        if only == []:
            bridge.event_log.emit(
                "eth_tx_skip",
                reason="tid_port_unknown",
                msg=meta.msg_type_hex,
                tid=meta.tid_hex,
                len=len(pkt),
                **bank_fields,
            )
        sends = router.send_uart_to_eth(wire, only_ports=only)
        fanout_stats = {
            "port_count": len(sends),
            "wall_us": sum(dt for _, dt in sends),
            "mode": "unicast" if only is not None and len(only) <= 1 else "flood",
        }
        _emit_eth_fanout(
            bridge,
            stats=fanout_stats,
            ingress_port="uart",
            tid=meta.tid_hex,
        )
        # EG 2.2.2.71: hub also publishes meters/jackpots on clear WLAN (non-VPN).
        maybe_publish_bank_wlan(bridge, pkt, meta)
        # Master issued bank progressive on copper — reverse-path x1 is head-of-queue.
        if is_bank_progressive_jackpot_frame(pkt):
            bridge._arm_bank_jpot_x1_priority(pkt, source="uart_to_eth")
    except Exception as exc:
        bridge.event_log.emit("error", where="uart_to_eth", message=str(exc))
        return
    bridge.uart_origin.note_uart_to_eth(pkt)
    note_uart_origin_after_eth_tx(bridge, meta)
    gx = uart_gameplay_eth_tx_extra(bridge, meta)
    for eth_port, dt_us in sends:
        bridge.event_log.emit(
            "eth_tx",
            msg=meta.msg_type_hex,
            len=len(pkt),
            tid=meta.tid_hex,
            eth_port=eth_port,
            dt_us=round(dt_us, 1),
            **bank_fields,
            **gx,
        )


def forward_uart_raw_window_to_eth_legacy(
    sock,
    eth_header: bytes,
    bridge: UartBridge,
    raw: bytes,
    *,
    uart_first_byte: int | None = None,
    dual: bool = False,
) -> None:
    ok, reason = ois_raw_forward_ok(raw)
    if not ok:
        bridge.event_log.emit(
            "eth_tx_skip",
            reason=reason,
            len=len(raw),
            channel=bridge.channel.key,
            uart_first_byte=(
                f"{uart_first_byte:02X}" if uart_first_byte is not None else None
            ),
        )
        return
    t0 = time.perf_counter_ns()
    wire = fixture_eth_tag_payload(bridge.channel, raw, dual=dual)
    try:
        sock.send(eth_header + wire)
    except Exception as exc:
        bridge.event_log.emit("error", where="uart_to_eth", message=str(exc))
        return
    t1 = time.perf_counter_ns()
    bridge.uart_origin.note_uart_to_eth(raw)
    bridge.event_log.emit(
        "eth_tx",
        len=len(raw),
        first_byte=f"{raw[0]:02X}",
        channel=bridge.channel.key,
        dt_us=round((t1 - t0) / 1000.0, 1),
    )


def forward_uart_raw_window_to_eth_directional(
    router: DirectionalEthRouter,
    bridge: UartBridge,
    raw: bytes,
    *,
    uart_first_byte: int | None = None,
    dual: bool = False,
) -> None:
    ok, reason = ois_raw_forward_ok(raw)
    if not ok:
        bridge.event_log.emit(
            "eth_tx_skip",
            reason=reason,
            len=len(raw),
            channel=bridge.channel.key,
            uart_first_byte=(
                f"{uart_first_byte:02X}" if uart_first_byte is not None else None
            ),
        )
        return
    # Field target: OIS off VPN — flood HMAC-wrapped window on clear WiFi.
    try:
        from ois_wifi_plane import flood_ois_udp, ois_wifi_plane_enabled

        if ois_wifi_plane_enabled() and bridge.channel.key == "ois":
            bank = int(os.environ.get("EDGE_BANK") or os.environ.get("FIELD_BANK") or "0")
            flood_ois_udp(raw, bank=bank)
            bridge.uart_origin.note_uart_to_eth(raw)
            bridge.event_log.emit(
                "ois_wifi_tx",
                len=len(raw),
                first_byte=f"{raw[0]:02X}",
                channel="ois",
                bank=bank,
            )
            return
    except Exception as exc:
        bridge.event_log.emit("error", where="ois_wifi_tx", message=str(exc))
        # Fall through to eth/gretap transitional path.
    try:
        wire = fixture_eth_tag_payload(bridge.channel, raw, dual=dual)
        sends = router.send_uart_to_eth(wire)
    except Exception as exc:
        bridge.event_log.emit("error", where="uart_to_eth", message=str(exc))
        return
    bridge.uart_origin.note_uart_to_eth(raw)
    for eth_port, dt_us in sends:
        bridge.event_log.emit(
            "eth_tx",
            len=len(raw),
            first_byte=f"{raw[0]:02X}",
            channel=bridge.channel.key,
            eth_port=eth_port,
            dt_us=round(dt_us, 1),
        )


def _forward_machine_window_packets_legacy(
    sock,
    eth_header: bytes,
    bridge: UartBridge,
    buffer: bytearray,
    *,
    uart_first_byte: int | None,
    dual: bool = False,
) -> None:
    normalized = normalize_window(bytes(buffer))
    if not normalized:
        return
    packets = split_packets(normalized)
    packet_count = len(packets)
    for pkt in packets:
        ok, reason = should_forward_uart_packet(
            bridge,
            pkt,
            uart_first_byte=uart_first_byte,
            packets_in_window=packet_count,
        )
        meta = parse_packet_meta(pkt)
        if not ok:
            bridge.event_log.emit(
                "eth_tx_skip",
                reason=reason,
                msg=meta.msg_type_hex,
                len=len(pkt),
                tid=meta.tid_hex,
                uart_first_byte=(
                    f"{uart_first_byte:02X}" if uart_first_byte is not None else None
                ),
            )
            continue
        on_uart_packet_to_eth_legacy(sock, eth_header, bridge, pkt, dual=dual)


def _forward_machine_window_packets_directional(
    router: DirectionalEthRouter,
    bridge: UartBridge,
    buffer: bytearray,
    *,
    uart_first_byte: int | None,
    dual: bool = False,
) -> None:
    normalized = normalize_window(bytes(buffer))
    if not normalized:
        return
    packets = split_packets(normalized)
    packet_count = len(packets)
    for pkt in packets:
        ok, reason = should_forward_uart_packet(
            bridge,
            pkt,
            uart_first_byte=uart_first_byte,
            packets_in_window=packet_count,
        )
        meta = parse_packet_meta(pkt)
        if not ok:
            bridge.event_log.emit(
                "eth_tx_skip",
                reason=reason,
                msg=meta.msg_type_hex,
                len=len(pkt),
                tid=meta.tid_hex,
                uart_first_byte=(
                    f"{uart_first_byte:02X}" if uart_first_byte is not None else None
                ),
            )
            continue
        on_uart_packet_to_eth_directional(router, bridge, pkt, dual=dual)


def patch_event_log_for_eth_forward_legacy(
    event_log: EventLog,
    sock,
    eth_header: bytes,
    bridges: UartBridge | dict[str, UartBridge],
    *,
    dual: bool = False,
) -> None:
    """On HS inactive: machine â†’ parsed packets; OIS â†’ opaque raw window."""
    bridge_map = (
        bridges
        if isinstance(bridges, dict)
        else {bridges.channel.key: bridges}
    )
    original = event_log.finalize_handshake_window_for_channel

    def wrapped(channel, buffer, **kwargs):
        uart_first_byte = kwargs.get("uart_first_byte")
        bridge = bridge_map[channel.key]
        original(channel, buffer, **kwargs)
        if channel_uses_raw_hs_window(channel):
            raw = bytes(buffer)
            if raw:
                forward_uart_raw_window_to_eth_legacy(
                    sock,
                    eth_header,
                    bridge,
                    raw,
                    uart_first_byte=uart_first_byte,
                    dual=dual,
                )
        else:
            _forward_machine_window_packets_legacy(
                sock,
                eth_header,
                bridge,
                buffer,
                uart_first_byte=uart_first_byte,
                dual=dual,
            )

    def forward_origin(bridge: UartBridge, buffer: bytearray, *, uart_first_byte: int | None):
        if channel_uses_raw_hs_window(bridge.channel):
            raw = bytes(buffer)
            if raw:
                forward_uart_raw_window_to_eth_legacy(
                    sock,
                    eth_header,
                    bridge,
                    raw,
                    uart_first_byte=uart_first_byte,
                    dual=dual,
                )
        else:
            _forward_machine_window_packets_legacy(
                sock,
                eth_header,
                bridge,
                buffer,
                uart_first_byte=uart_first_byte,
                dual=dual,
            )

    for b in bridge_map.values():
        b.forward_uart_origin_window = (
            lambda buffer, uart_first_byte=None, _b=b: forward_origin(
                _b, buffer, uart_first_byte=uart_first_byte
            )
        )

    event_log.finalize_handshake_window_for_channel = wrapped  # type: ignore[method-assign]


def patch_event_log_for_eth_forward_directional(
    event_log: EventLog,
    router: DirectionalEthRouter,
    bridges: UartBridge | dict[str, UartBridge],
    *,
    dual: bool = False,
) -> None:
    bridge_map = (
        bridges
        if isinstance(bridges, dict)
        else {bridges.channel.key: bridges}
    )
    original = event_log.finalize_handshake_window_for_channel

    def wrapped(channel, buffer, **kwargs):
        uart_first_byte = kwargs.get("uart_first_byte")
        bridge = bridge_map[channel.key]
        original(channel, buffer, **kwargs)
        if channel_uses_raw_hs_window(channel):
            raw = bytes(buffer)
            if raw:
                forward_uart_raw_window_to_eth_directional(
                    router,
                    bridge,
                    raw,
                    uart_first_byte=uart_first_byte,
                    dual=dual,
                )
        else:
            _forward_machine_window_packets_directional(
                router,
                bridge,
                buffer,
                uart_first_byte=uart_first_byte,
                dual=dual,
            )

    def forward_origin(bridge: UartBridge, buffer: bytearray, *, uart_first_byte: int | None):
        if channel_uses_raw_hs_window(bridge.channel):
            raw = bytes(buffer)
            if raw:
                forward_uart_raw_window_to_eth_directional(
                    router,
                    bridge,
                    raw,
                    uart_first_byte=uart_first_byte,
                    dual=dual,
                )
        else:
            _forward_machine_window_packets_directional(
                router,
                bridge,
                buffer,
                uart_first_byte=uart_first_byte,
                dual=dual,
            )

    for b in bridge_map.values():
        b.forward_uart_origin_window = (
            lambda buffer, uart_first_byte=None, _b=b: forward_origin(
                _b, buffer, uart_first_byte=uart_first_byte
            )
        )

    event_log.finalize_handshake_window_for_channel = wrapped  # type: ignore[method-assign]


def _eth_payload_from_l2(bridge: UartBridge, payload: bytes) -> bytes | None:
    if channel_uses_raw_hs_window(bridge.channel):
        ok, reason = ois_raw_forward_ok(payload)
        if not ok:
            bridge.event_log.emit(
                "eth_rx_skip",
                reason=reason,
                len=len(payload),
                channel=bridge.channel.key,
            )
            return None
        return payload
    frame = trim_to_last_eop(payload)
    if not frame:
        bridge.event_log.emit(
            "eth_rx_skip",
            reason="no_eop",
            len=len(payload),
            channel=bridge.channel.key,
        )
        return None
    return frame


def _emit_eth_rx_event(bridge: UartBridge, frame: bytes, *, eth_port: str | None) -> None:
    if channel_uses_raw_hs_window(bridge.channel):
        fields: dict = {
            "len": len(frame),
            "first_byte": f"{frame[0]:02X}",
            "channel": bridge.channel.key,
        }
    else:
        meta = parse_packet_meta(frame)
        fields = {
            "msg": meta.msg_type_hex,
            "len": len(frame),
            "tid": meta.tid_hex,
            "channel": bridge.channel.key,
        }
        fields.update(packet_bank_event_fields(meta))
    if eth_port is not None:
        fields["eth_port"] = eth_port
    bridge.event_log.emit("eth_rx", **fields)
    if not channel_uses_raw_hs_window(bridge.channel):
        try:
            bridge._mrt_hub_note_spoke_eth_x1(meta, eth_port=eth_port)
        except Exception:
            pass


def _emit_eth_relay_event(
    bridge: UartBridge,
    frame: bytes,
    *,
    ingress_port: str,
    egress_port: str,
    dt_us: float,
) -> None:
    if channel_uses_raw_hs_window(bridge.channel):
        bridge.event_log.emit(
            "eth_relay",
            ingress_port=ingress_port,
            egress_port=egress_port,
            len=len(frame),
            first_byte=f"{frame[0]:02X}",
            channel=bridge.channel.key,
            dt_us=round(dt_us, 1),
        )
        return
    meta = parse_packet_meta(frame)
    bridge.event_log.emit(
        "eth_relay",
        ingress_port=ingress_port,
        egress_port=egress_port,
        msg=meta.msg_type_hex,
        len=len(frame),
        tid=meta.tid_hex,
        channel=bridge.channel.key,
        dt_us=round(dt_us, 1),
        **packet_bank_event_fields(meta),
    )


def copy_bridge_args(args) -> types.SimpleNamespace:
    return types.SimpleNamespace(**vars(args))


def make_uart_bridge(
    channel,
    args,
    *,
    timing: BridgeTiming,
    event_log: EventLog,
    ser: serial.Serial | None,
    handshake,
    use_uartd: bool,
    use_uartd_inject: bool,
    uartd_client,
    inject_port: str | None,
    active_low: bool,
) -> UartBridge:
    cfg = copy_bridge_args(args)
    if channel.key == "ois":
        apply_ois_bridge_defaults(channel, cfg)
    progressive_csv = None if channel.key == "ois" else getattr(cfg, "progressive_csv", None)
    return UartBridge(
        ser,
        channel,
        handshake,
        event_log,
        timing,
        use_uartd=use_uartd,
        use_uartd_inject=use_uartd_inject,
        uartd_client=uartd_client,
        inject_port=inject_port,
        inject_active_low=active_low,
        suppress_uart_echo=cfg.suppress_uart_echo,
        uart_origin_hold_ms=cfg.uart_origin_hold_ms,
        status_0d_first_only=cfg.status_0d_first_only,
        status_0d_block_all=cfg.status_0d_block_all,
        status_0d_synth=cfg.status_0d_synth,
        status_0d_first_passthrough=cfg.status_0d_first_passthrough,
        status_0d_synth_from_x9=cfg.status_0d_synth_from_x9,
        status_0d_synth_loose_counter=cfg.status_0d_synth_loose_counter,
        status_0d_dedupe_ms=cfg.status_0d_dedupe_ms,
        lab_keno_jpot_synth=cfg.lab_keno_jpot_synth,
        lab_keno_jpot_game=cfg.lab_keno_jpot_game,
        lab_keno_jpot_amount=cfg.lab_keno_jpot_amount,
        lab_keno_jpot_tid=cfg.lab_keno_jpot_tid,
        lab_poker_royal_synth=cfg.lab_poker_royal_synth,
        lab_poker_game=cfg.lab_poker_game,
        lab_poker_jpot_amount=cfg.lab_poker_jpot_amount,
        lab_poker_tid=cfg.lab_poker_tid,
        lab_spinjack_flushjack_synth=cfg.lab_spinjack_flushjack_synth,
        lab_spinjack_game=cfg.lab_spinjack_game,
        lab_spinjack_tid=cfg.lab_spinjack_tid,
        lab_touch6_jpot_synth=cfg.lab_touch6_jpot_synth,
        lab_touch6_jpot_game=cfg.lab_touch6_jpot_game,
        lab_touch6_jpot_amount=cfg.lab_touch6_jpot_amount,
        lab_touch6_jpot_tid=cfg.lab_touch6_jpot_tid,
        lab_superpick_jpot_synth=cfg.lab_superpick_jpot_synth,
        lab_superpick_jpot_game=cfg.lab_superpick_jpot_game,
        lab_superpick_jpot_amount=cfg.lab_superpick_jpot_amount,
        lab_superpick_jpot_tid=cfg.lab_superpick_jpot_tid,
        lab_bingo_jpot_synth=cfg.lab_bingo_jpot_synth,
        lab_bingo_jpot_game=cfg.lab_bingo_jpot_game,
        lab_bingo_jpot_amount=cfg.lab_bingo_jpot_amount,
        lab_bingo_jpot_tid=cfg.lab_bingo_jpot_tid,
        lab_triple7s_jpot_synth=cfg.lab_triple7s_jpot_synth,
        lab_triple7s_jpot_game=cfg.lab_triple7s_jpot_game,
        lab_triple7s_jpot_tid=cfg.lab_triple7s_jpot_tid,
        lab_withhold_status_0d=bool(getattr(cfg, "lab_withhold_status_0d", False)),
        lab_withhold_status_0d_count=int(
            getattr(cfg, "lab_withhold_status_0d_count", 1) or 1
        ),
        progressive_csv=progressive_csv,
        early_win_phase1=getattr(cfg, "early_win_phase1_trap", None),
        early_win_slots=getattr(cfg, "early_win_slots_trap", None),
        early_win_keno=getattr(cfg, "early_win_keno_trap", None),
        scr_diag=cfg.scr_diag,
        scr_master_native_only=cfg.scr_master_native_only,
    )


def dual_uart_to_eth_thread(
    bridges: dict[str, UartBridge],
    *,
    timing: BridgeTiming,
    stop: threading.Event,
    stats_interval_s: float,
) -> None:
    last_stats_at = time.time()
    machine = bridges.get("machine")
    while not stop.is_set():
        for bridge in bridges.values():
            try:
                bridge.capture_tick()
            except Exception as exc:
                msg = str(exc)
                uartd = bridge.uartd
                if uartd is not None and (
                    isinstance(exc, UartdIpcDesyncError)
                    or "uartd not connected" in msg
                    or "bad ipc magic" in msg
                    or "uartd closed socket" in msg
                ):
                    if uartd.ensure_alive(reason=msg[:80]):
                        bridge.event_log.emit(
                            "uartd_auto_reinit",
                            detail=getattr(uartd, "_last_ensure_detail", ""),
                            where="uart_to_eth_dual",
                            prior=msg[:120],
                        )
                        continue
                bridge.event_log.emit("error", where="uart_to_eth", message=msg)
        if (
            stats_interval_s > 0
            and machine is not None
            and time.time() - last_stats_at >= stats_interval_s
        ):
            machine.event_log.write_stats(
                {
                    "mode": "bridge",
                    "direction": "uart_to_eth",
                    "dual_channel": True,
                    "channels": sorted(bridges),
                    "bytes_collected": {
                        k: b.state.bytes_collected for k, b in bridges.items()
                    },
                    "pending_inject_count": sum(len(b._pending) for b in bridges.values()),
                }
            )
            # Rotate when all HS lines idle (shared EventLog / output_dir on dual).
            if not any(b.handshake.is_active() for b in bridges.values()):
                machine.event_log.try_rotate_debug_logs()
                machine.event_log.idle_flush()
            last_stats_at = time.time()
        time.sleep(timing.loop_sleep_us / 1_000_000.0)


def eth_to_uart_thread_directional_dual(
    router: DirectionalEthRouter,
    bridges: dict[str, UartBridge],
    stop: threading.Event,
    *,
    eth_relay_only: bool = False,
    dual: bool = True,
) -> None:
    log_bridge = bridges["machine"]
    while not stop.is_set():
        # Refresh each cycle so hub enroll can hot-add eg-sNN without restart.
        socks = router.sockets()
        if not socks:
            stop.wait(0.05)
            continue
        try:
            ready, _, _ = select.select(socks, [], [], 0.005)
        except (ValueError, OSError):
            # Closed fd race with hub add_port/reopen_port — retry next cycle.
            continue
        if not ready:
            continue
        for sock in ready:
            try:
                packet, pkttype, ifindex, skip_reason = recv_eth_l2_frame(sock)
            except Exception as exc:
                log_bridge.event_log.emit("error", where="eth_recv", message=str(exc))
                continue
            try:
                eth_port = router.resolve_ingress_port(sock, ifindex)
                if skip_reason:
                    log_bridge.event_log.emit(
                        "eth_rx_skip",
                        reason=skip_reason,
                        pkttype=pkttype,
                        eth_port=eth_port,
                    )
                    continue
                if not packet or len(packet) <= 14:
                    continue
                if router.is_local_loopback(packet):
                    log_bridge.event_log.emit(
                        "eth_rx_skip",
                        reason="local_eth_tx",
                        pkttype=pkttype,
                        eth_port=eth_port,
                    )
                    continue
                wire = packet[14:]
                ch_key, uart_payload, tag_reason = fixture_eth_untag_payload(wire, dual=dual)
                # EDGE pipe probe — bidirectional eth only; never inject to POG422.
                try:
                    from pog_edge_pipe_probe import handle_pipe_eth_rx, is_pipe_wire

                    is_pipe, pipe_payload = is_pipe_wire(wire, dual=dual)
                    if is_pipe or ch_key == "pipe":
                        handle_pipe_eth_rx(
                            payload=pipe_payload if is_pipe else uart_payload,
                            eth_port=eth_port,
                            router=router,
                            emit=log_bridge.event_log.emit,
                            dual=dual,
                        )
                        continue
                except Exception as exc:
                    log_bridge.event_log.emit(
                        "error", where="pipe_probe_rx", message=str(exc)
                    )
                if tag_reason:
                    log_bridge.event_log.emit(
                        "eth_rx_skip",
                        reason=tag_reason,
                        len=len(wire),
                        eth_port=eth_port,
                        dual_channel=True,
                    )
                    continue
                if ch_key is None or ch_key not in bridges:
                    log_bridge.event_log.emit(
                        "eth_rx_skip",
                        reason="dual_tag_channel",
                        len=len(wire),
                        eth_port=eth_port,
                    )
                    continue
                bridge = bridges[ch_key]
                frame = _eth_payload_from_l2(bridge, uart_payload)
                if frame is None:
                    continue
                if eth_port is not None and not channel_uses_raw_hs_window(bridge.channel):
                    meta_rx = parse_packet_meta(frame)
                    router.note_tid_port(meta_rx.tid_hex, eth_port)
                if eth_port is not None:
                    skip_fanout, fanout_skip_reason, tid_hex = (
                        _eth_skip_peer_fanout_for_ack(bridge, frame)
                    )
                    if skip_fanout:
                        bridge.event_log.emit(
                            "eth_fanout_skip",
                            reason=fanout_skip_reason,
                            ingress_port=eth_port,
                            tid=tid_hex,
                            msg=parse_packet_meta(frame).msg_type_hex,
                        )
                    else:
                        relay_wire = fixture_eth_tag_payload(
                            bridge.channel, frame, dual=dual
                        )
                        only_ports = _eth_only_ports_for_frame(router, frame, bridge)
                        sends, fanout_stats = router.send_eth_to_peers(
                            relay_wire,
                            except_port=eth_port,
                            live_ports_only=router.eth_relay_live_ports_only,
                            only_ports=only_ports,
                        )
                        if tid_hex is None and not channel_uses_raw_hs_window(
                            bridge.channel
                        ):
                            tid_hex = parse_packet_meta(frame).tid_hex
                        _emit_eth_fanout(
                            bridge,
                            stats=fanout_stats,
                            ingress_port=eth_port,
                            tid=tid_hex,
                        )
                        for peer_port, dt_us in sends:
                            _emit_eth_relay_event(
                                bridge,
                                frame,
                                ingress_port=eth_port,
                                egress_port=peer_port,
                                dt_us=dt_us,
                            )
                _emit_eth_rx_event(bridge, frame, eth_port=eth_port)
                if not eth_relay_only:
                    bridge.queue_eth_frame(frame)
            except Exception as exc:
                # Never kill the ethâ†’UART hop (pos4 .43: TimeoutError in slv_in_active).
                log_bridge.event_log.emit("error", where="eth_to_uart", message=str(exc))
                continue


def apply_ois_bridge_defaults(channel, args) -> None:
    """OIS bridge: raw HS forward only â€” no gameplay synth or packet parse."""
    if channel.key != "ois":
        return
    args.status_0d_first_only = False
    args.status_0d_block_all = False
    args.status_0d_synth = False
    args.status_0d_first_passthrough = False
    args.status_0d_synth_from_x9 = False
    args.status_0d_synth_loose_counter = False
    args.lab_keno_jpot_synth = False
    args.lab_poker_royal_synth = False
    args.lab_spinjack_flushjack_synth = False
    args.lab_touch6_jpot_synth = False
    args.lab_superpick_jpot_synth = False
    args.lab_bingo_jpot_synth = False
    args.lab_triple7s_jpot_synth = False
    args.lab_withhold_status_0d = False


def bridge_session_meta(channel, args, *, bridge: UartBridge, timing: BridgeTiming) -> dict:
    from pog422_gpio_hs import hs_in_bcm, resolve_hs_path

    hs_path = resolve_hs_path()
    gpio_name = channel.gpio_name
    if hs_path == "pi":
        try:
            gpio_name = f"BCM{hs_in_bcm(channel.key)}"
        except ValueError:
            gpio_name = channel.gpio_name
    meta = {
        "channel": channel.key,
        "gpio": gpio_name,
        "hs_path": hs_path,
        "hs_de_bcm": 10 if channel.key == "ois" else 11 if hs_path == "pi" else None,
        "hs_in_bcm": hs_in_bcm(channel.key) if hs_path == "pi" else None,
        "bridge_forward": "ois_raw_hs_window" if channel.key == "ois" else "machine_parsed",
        "suppress_uart_echo": args.suppress_uart_echo,
        "uart_origin_hold_ms": args.uart_origin_hold_ms,
        "pre_tx_us": timing.pre_tx_us,
        "post_tx_us": timing.post_tx_us,
        "post_air_margin_us": timing.post_air_margin_us,
        "wait_hs_inactive_before_tx": timing.wait_hs_inactive_before_tx,
        "post_hs_inactive_us": timing.post_hs_inactive_us,
        "uartd": bridge.use_uartd,
        "uartd_capture": bridge.use_uartd,
        "uartd_inject": bridge.use_uartd_inject,
        "inject_engine": bridge._inject_engine_name(),
        "local_bank": resolve_local_bank_number() or None,
    }
    if channel.key == "ois":
        return meta
    try:
        ident = latch_cabinet_identity()
        if ident is not None:
            meta.update(identity_as_session_fields(ident))
    except Exception:
        pass
    meta.update(
        {
            "status_0d_first_only": args.status_0d_first_only,
            "status_0d_block_all": args.status_0d_block_all,
            "status_0d_synth": args.status_0d_synth,
            "status_0d_first_passthrough": args.status_0d_first_passthrough,
            "status_0d_synth_from_x9": args.status_0d_synth_from_x9,
            "status_0d_synth_loose_counter": args.status_0d_synth_loose_counter,
            "status_0d_dedupe_ms": args.status_0d_dedupe_ms,
            "lab_keno_jpot_synth": args.lab_keno_jpot_synth,
            "lab_keno_jpot_game": args.lab_keno_jpot_game,
            "lab_keno_jpot_amount": args.lab_keno_jpot_amount,
            "lab_keno_jpot_tid": args.lab_keno_jpot_tid,
            "lab_poker_royal_synth": args.lab_poker_royal_synth,
            "lab_poker_game": args.lab_poker_game,
            "lab_poker_jpot_amount": args.lab_poker_jpot_amount,
            "lab_poker_tid": args.lab_poker_tid,
            "lab_spinjack_flushjack_synth": args.lab_spinjack_flushjack_synth,
            "lab_spinjack_game": args.lab_spinjack_game,
            "lab_spinjack_tid": args.lab_spinjack_tid,
            "lab_touch6_jpot_synth": args.lab_touch6_jpot_synth,
            "lab_touch6_jpot_game": args.lab_touch6_jpot_game,
            "lab_touch6_jpot_amount": args.lab_touch6_jpot_amount,
            "lab_touch6_jpot_tid": args.lab_touch6_jpot_tid,
            "lab_superpick_jpot_synth": args.lab_superpick_jpot_synth,
            "lab_superpick_jpot_game": args.lab_superpick_jpot_game,
            "lab_superpick_jpot_amount": args.lab_superpick_jpot_amount,
            "lab_superpick_jpot_tid": args.lab_superpick_jpot_tid,
            "lab_bingo_jpot_synth": args.lab_bingo_jpot_synth,
            "lab_bingo_jpot_game": args.lab_bingo_jpot_game,
            "lab_bingo_jpot_amount": args.lab_bingo_jpot_amount,
            "lab_bingo_jpot_tid": args.lab_bingo_jpot_tid,
            "lab_triple7s_jpot_synth": args.lab_triple7s_jpot_synth,
            "lab_triple7s_jpot_game": args.lab_triple7s_jpot_game,
            "lab_triple7s_jpot_tid": args.lab_triple7s_jpot_tid,
            "lab_withhold_status_0d": bool(
                getattr(args, "lab_withhold_status_0d", False)
            ),
            "lab_withhold_status_0d_count": int(
                getattr(args, "lab_withhold_status_0d_count", 1) or 1
            ),
            "early_win_phase1": args.early_win_phase1_enabled,
            "early_win_phase1_dry_run": args.early_win_phase1_dry_run,
            "scr_diag": args.scr_diag,
            "scr_master_native_only": args.scr_master_native_only,
            "hs_witness": not getattr(args, "no_hs_witness", False),
        }
    )
    if bridge.use_uartd:
        meta["uartd_socket"] = default_socket_path(args.fixture_pos)
    # Loaded driver + live uartd/inject â€” Dropbox-visible; do not SSH to ask.
    probe = probe_hat_session(
        "",
        uartd=bridge.uartd if bridge.use_uartd else None,
        use_uartd=bridge.use_uartd,
        use_uartd_inject=bridge.use_uartd_inject,
    )
    meta.update(probe)
    vmin = probe.get("eg_component_min")
    vmax = probe.get("eg_component_max")
    if not probe.get("component_versions_ok"):
        print(
            "WARN: component version out of range â€” "
            f"need [{vmin}, {vmax}] "
            f"driver={probe.get('driver_version')!r} "
            f"uartd={probe.get('uartd_version')!r} "
            f"inject={probe.get('inject_version')!r} "
            f"ok={probe.get('component_versions_ok')}",
            flush=True,
        )
    else:
        print(
            f"Versions: driver={probe.get('driver_version')} "
            f"uartd={probe.get('uartd_version')} "
            f"inject={probe.get('inject_version')} "
            f"OK in [{vmin}, {vmax}]",
            flush=True,
        )
    return meta


def dual_bridge_session_meta(
    args,
    *,
    bridges: dict[str, UartBridge],
    timing: BridgeTiming,
) -> dict:
    machine = bridges["machine"]
    ois = bridges["ois"]
    meta = bridge_session_meta(machine.channel, args, bridge=machine, timing=timing)
    meta["channel"] = "both"
    meta["bridge_forward"] = "dual_machine_plus_ois"
    meta["eth_channel_tag"] = True
    meta["ports"] = {
        "machine": resolve_serial_port(machine.channel, None),
        "ois": resolve_serial_port(ois.channel, None),
    }
    meta["ois"] = bridge_session_meta(ois.channel, args, bridge=ois, timing=timing)
    return meta


def run_bridge_directional_dual(
    args,
    *,
    bridges: dict[str, UartBridge],
    event_log: EventLog,
    timing: BridgeTiming,
    output_dir,
    port_names: list[str],
) -> None:
    router = DirectionalEthRouter.open(
        port_names,
        uart_live_ports_only=not args.uart_to_all_eth_ports,
        eth_relay_live_ports_only=not args.eth_relay_all_ports,
        uart_egress_port=args.uart_eth_egress,
    )
    dual = True
    for _b in bridges.values():
        _b.bind_eth_router(router, dual=True)
    if "ois" in bridges and "machine" in bridges:
        bridges["ois"]._grace_gate_machine = bridges["machine"]
    patch_event_log_for_eth_forward_directional(event_log, router, bridges, dual=dual)
    ports = dual_bridge_session_meta(args, bridges=bridges, timing=timing)["ports"]
    uart_routing = (
        f"egress_{args.uart_eth_egress}"
        if args.uart_eth_egress
        else ("all_eth_ports" if args.uart_to_all_eth_ports else "live_ports_only")
    )
    eth_relay_routing = (
        "all_eth_ports" if args.eth_relay_all_ports else "live_ports_only"
    )
    ingress_routing = (
        "peer_ports_only" if args.eth_relay_only else "peer_ports_and_uart"
    )
    print(
        f"Fixture dual bridge pos={args.fixture_pos} ports={ports} eth={router.port_names()} out={output_dir}",
        flush=True,
    )
    print(
        "Dual: machine parsed + status-0D synth; OIS opaque raw HS â€” ETH tag byte M/O",
        flush=True,
    )
    print(
        "UART: dual uartd (POG422_UARTD=1 INJECT=1) â€” machine + ois sockets",
        flush=True,
    )
    event_log.emit(
        "session_start",
        mode="bridge",
        parser_build=(
            FIXTURE_PARSER_BUILD_PROD if args.prod else FIXTURE_PARSER_BUILD
        ),
        eth_ports=router.port_names(),
        directional_routing=True,
        daisy_chain_router=True,
        dual_channel=True,
        eth_routing_uart_to=uart_routing,
        eth_routing_relay_to=eth_relay_routing,
        eth_routing_ingress_to=ingress_routing,
        eth_relay_only=args.eth_relay_only,
        log_eth_inject_to_packets_raw=True,
        **dual_bridge_session_meta(args, bridges=bridges, timing=timing),
    )
    stop = threading.Event()
    t_eth = threading.Thread(
        target=eth_to_uart_thread_directional_dual,
        args=(router, bridges, stop),
        kwargs={"eth_relay_only": args.eth_relay_only, "dual": dual},
        daemon=True,
    )
    t_eth.start()
    machine = bridges.get("machine")
    ois = bridges.get("ois")
    if machine is not None:
        start_bank_wlan_for_bridge(machine, stop)
        start_enrollment_hub_for_bridge(machine, stop, router=router)
        try:
            start_spoke_enroll_for_bridge(machine, stop)
        except Exception as exc:
            machine.event_log.emit("spoke_enroll_start_error", message=str(exc))

        def _fwd_dual(pkt: bytes) -> None:
            on_uart_packet_to_eth_directional(router, machine, pkt, dual=True)

        start_lab_gameplay_synth_for_bridge(
            args, bridge=machine, stop=stop, forward_uart_origin=_fwd_dual
        )
    if ois is not None:
        try:
            from ois_wifi_plane import start_ois_wifi_for_bridge

            if start_ois_wifi_for_bridge(ois, stop):
                event_log.emit("ois_wifi_plane", enabled=True, port=41235)
        except Exception as exc:
            event_log.emit("error", where="ois_wifi_plane_start", message=str(exc))
    try:
        from pog_edge_pipe_probe import start_pipe_probe_for_bridge

        start_pipe_probe_for_bridge(
            router, event_log.emit, stop, dual=True
        )
    except Exception as exc:
        event_log.emit("error", where="pipe_probe_start", message=str(exc))
    try:
        dual_uart_to_eth_thread(
            bridges,
            timing=timing,
            stop=stop,
            stats_interval_s=args.stats_interval,
        )
    finally:
        stop.set()
        stop_bank_wlan()
        t_eth.join(timeout=2.0)
        router.close()


def run_bridge_legacy(
    args,
    *,
    bridge: UartBridge,
    event_log: EventLog,
    port: str,
    active_low: bool,
    timing: BridgeTiming,
    output_dir,
) -> None:
    sock = create_eth_socket(args.interface)
    eth_header = build_eth_header(args.interface)
    patch_event_log_for_eth_forward_legacy(event_log, sock, eth_header, bridge)
    print(
        f"Fixture bridge pos={args.fixture_pos} port={port} if={args.interface} (legacy) out={output_dir}",
        flush=True,
    )
    if bridge.use_uartd:
        inj = bridge._inject_engine_name()
        print(
            f"UART: pog422_uartd {POG422_UARTD_VERSION} socket={default_socket_path(args.fixture_pos)} "
            f"capture=1 inject={inj}",
            flush=True,
        )
    event_log.emit(
        "session_start",
        mode="bridge",
        parser_build=(
            FIXTURE_PARSER_BUILD_PROD if args.prod else FIXTURE_PARSER_BUILD
        ),
        port=port,
        interface=args.interface,
        directional_routing=False,
        log_eth_inject_to_packets_raw=True,
        **bridge_session_meta(bridge.channel, args, bridge=bridge, timing=timing),
    )
    stop = threading.Event()
    t_eth = threading.Thread(
        target=eth_to_uart_thread_legacy,
        args=(sock, eth_header, bridge, stop),
        daemon=True,
    )
    t_eth.start()

    def _fwd_legacy(pkt: bytes) -> None:
        on_uart_packet_to_eth_legacy(sock, eth_header, bridge, pkt, dual=False)

    start_lab_gameplay_synth_for_bridge(
        args, bridge=bridge, stop=stop, forward_uart_origin=_fwd_legacy
    )
    try:
        uart_to_eth_thread(
            sock,
            eth_header,
            bridge,
            stats_interval_s=args.stats_interval,
            port=port,
            active_low=active_low,
            stop=stop,
        )
    finally:
        stop.set()
        t_eth.join(timeout=2.0)


def run_bridge_directional(
    args,
    *,
    bridge: UartBridge,
    event_log: EventLog,
    port: str,
    active_low: bool,
    timing: BridgeTiming,
    output_dir,
    port_names: list[str],
) -> None:
    router = DirectionalEthRouter.open(
        port_names,
        uart_live_ports_only=not args.uart_to_all_eth_ports,
        eth_relay_live_ports_only=not args.eth_relay_all_ports,
        uart_egress_port=args.uart_eth_egress,
    )
    bridge.bind_eth_router(router, dual=False)
    patch_event_log_for_eth_forward_directional(event_log, router, bridge)
    uart_routing = (
        f"egress_{args.uart_eth_egress}"
        if args.uart_eth_egress
        else ("all_eth_ports" if args.uart_to_all_eth_ports else "live_ports_only")
    )
    eth_relay_routing = (
        "all_eth_ports" if args.eth_relay_all_ports else "live_ports_only"
    )
    ingress_routing = (
        "peer_ports_only" if args.eth_relay_only else "peer_ports_and_uart"
    )
    print(
        f"Fixture bridge pos={args.fixture_pos} port={port} eth={router.port_names()} out={output_dir}",
        flush=True,
    )
    if bridge.use_uartd:
        inj = bridge._inject_engine_name()
        print(
            f"UART: pog422_uartd {POG422_UARTD_VERSION} socket={default_socket_path(args.fixture_pos)} "
            f"capture=1 inject={inj}",
            flush=True,
        )
    else:
        print("UART: FATAL path â€” pyserial unsupported on EG 2.1", flush=True)
    event_log.emit(
        "session_start",
        mode="bridge",
        parser_build=(
            FIXTURE_PARSER_BUILD_PROD if args.prod else FIXTURE_PARSER_BUILD
        ),
        port=port,
        eth_ports=router.port_names(),
        directional_routing=True,
        daisy_chain_router=True,
        eth_routing_uart_to=uart_routing,
        eth_routing_relay_to=eth_relay_routing,
        eth_routing_ingress_to=ingress_routing,
        eth_relay_only=args.eth_relay_only,
        log_eth_inject_to_packets_raw=True,
        **bridge_session_meta(bridge.channel, args, bridge=bridge, timing=timing),
    )
    stop = threading.Event()
    t_eth = threading.Thread(
        target=eth_to_uart_thread_directional,
        args=(router, bridge, stop),
        kwargs={"eth_relay_only": args.eth_relay_only},
        daemon=True,
    )
    t_eth.start()
    start_bank_wlan_for_bridge(bridge, stop)
    start_enrollment_hub_for_bridge(bridge, stop, router=router)
    try:
        start_spoke_enroll_for_bridge(bridge, stop)
    except Exception as exc:
        # Enroll is optional at runtime — do not tear down the UART bridge.
        bridge.event_log.emit("spoke_enroll_start_error", message=str(exc))
    try:
        from pog_edge_pipe_probe import start_pipe_probe_for_bridge

        start_pipe_probe_for_bridge(
            router, bridge.event_log.emit, stop, dual=False
        )
    except Exception as exc:
        bridge.event_log.emit("error", where="pipe_probe_start", message=str(exc))

    def _fwd_dir(pkt: bytes) -> None:
        on_uart_packet_to_eth_directional(router, bridge, pkt, dual=False)

    start_lab_gameplay_synth_for_bridge(
        args, bridge=bridge, stop=stop, forward_uart_origin=_fwd_dir
    )
    try:
        uart_to_eth_thread(
            None,
            b"",
            bridge,
            stats_interval_s=args.stats_interval,
            port=port,
            active_low=active_low,
            stop=stop,
        )
    finally:
        stop.set()
        stop_bank_wlan()
        t_eth.join(timeout=2.0)
        router.close()


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="POG422 fixture UART/Ethernet bridge.")
    add_common_cli(parser)
    parser.add_argument(
        "--eth-ports",
        default="eth0,eth1",
        help="Comma-separated ports for directional routing (default eth0,eth1)",
    )
    parser.add_argument(
        "--interface",
        default=None,
        help="Legacy: single interface (e.g. br0) â€” overrides --eth-ports",
    )
    parser.add_argument(
        "--pre-tx-us",
        type=float,
        default=500.0,
        help="Delay after GPIO7 TX enable before first byte (default 500 Âµs)",
    )
    parser.add_argument(
        "--post-tx-us",
        type=float,
        default=0.0,
        help="Added to --post-air-margin-us as DE wire margin (default 0 Âµs)",
    )
    parser.add_argument(
        "--post-air-margin-us",
        type=float,
        default=400.0,
        help="DE air deadline margin after theoretical UART air (default 400 Âµs)",
    )
    parser.add_argument(
        "--flush-rx-before-tx",
        action="store_true",
        help="Reset UART RX before ETHâ†’UART transmit (legacy; usually leave off)",
    )
    parser.add_argument(
        "--no-wait-hs-inactive",
        action="store_true",
        help="Inject ETHâ†’UART immediately (legacy; causes glued windows on pos 4)",
    )
    parser.add_argument(
        "--post-hs-inactive-us",
        type=float,
        default=150.0,
        help="Wait after SLV-IN release before injecting (default 150 Âµs)",
    )
    parser.add_argument(
        "--hs-inactive-wait-ms",
        type=float,
        default=80.0,
        help="Max wait for handshake inactive before forced inject (default 80 ms)",
    )
    parser.add_argument(
        "--loop-sleep-us",
        type=float,
        default=100.0,
        help="Main capture loop sleep (default 100 Âµs)",
    )
    parser.add_argument(
        "--uart-origin-hold-ms",
        type=float,
        default=2000.0,
        help="Suppress ETHâ†’UART only for local UARTâ†’ETH reflection (default 2000 ms)",
    )
    parser.add_argument(
        "--eth-relay-only",
        action="store_true",
        help="ETH ingress: relay only â€” skip all UART inject (no local game on this Pi)",
    )
    parser.add_argument(
        "--uart-eth-egress",
        default=None,
        metavar="PORT",
        help="UART origin: single egress port (optional lab override); default fans out to all live ports",
    )
    parser.add_argument(
        "--uart-to-all-eth-ports",
        action="store_true",
        help="UART origin: fan out to every ETH port (legacy); default is carrier-up ports only",
    )
    parser.add_argument(
        "--eth-relay-all-ports",
        action="store_true",
        help="ETH relay: send to every peer port regardless of carrier (default skips carrier-down ports)",
    )
    parser.add_argument(
        "--suppress-uart-echo",
        action="store_true",
        help="Skip ETHâ†’UART when frame matches recent local UARTâ†’ETH (experimental)",
    )
    parser.add_argument(
        "--status-0d-first-only",
        action="store_true",
        help="ETHâ†’UART inject: only first 0x0D status per game+counter (drops copies 2â€“3)",
    )
    parser.add_argument(
        "--status-0d-block-all",
        action="store_true",
        help="ETHâ†’UART inject: block ALL 0x0D status close frames (diagnostic; expect timeouts)",
    )
    parser.add_argument(
        "--status-0d-synth",
        action="store_true",
        help="Block Master 0x0D on ETHâ†’UART; cache and inject synth close after x1 ACK (solo or glued x9+x1)",
    )
    parser.add_argument(
        "--status-0d-first-passthrough",
        action="store_true",
        help="Per game: inject the first Master 0x0D naturally (seed template); block subsequent copies",
    )
    parser.add_argument(
        "--status-0d-synth-from-x9",
        action="store_true",
        help="Build 0x0D at x1 from wager+x9 (counter=x9 byte 17); default ON with --status-0d-synth",
    )
    parser.add_argument(
        "--no-status-0d-synth-from-x9",
        action="store_true",
        help="Disable x9 byte-17 synth at x1 (expect Master Reply Timeouts with block-all)",
    )
    parser.add_argument(
        "--status-0d-synth-loose-counter",
        action="store_true",
        help="Experimental with --status-0d-synth-from-x9: use counter=0 in minimal synth",
    )
    parser.add_argument(
        "--status-0d-dedupe-ms",
        type=float,
        default=5000.0,
        help="TTL for status 0x0D dedupe keys in ms (default 5000)",
    )
    parser.add_argument(
        "--lab-keno-jpot-synth",
        action="store_true",
        help="LAB ONLY (pos3 Master): replace Keno 0x49 on POG422 UART capture before ETH fan-out",
    )
    parser.add_argument(
        "--lab-keno-jpot-game",
        default=TOUCHEASY_KENO_GAME_ID,
        help=f"Bus game id for lab Keno synth (default {TOUCHEASY_KENO_GAME_ID} TouchEasy Keno)",
    )
    parser.add_argument(
        "--lab-keno-jpot-amount",
        type=lambda s: int(s, 0),
        default=None,
        help="Optional progwinval for lab Keno synth in cents (BE32 on wire; default: live meter)",
    )
    parser.add_argument(
        "--lab-keno-jpot-tid",
        default=None,
        help="Optional slave TID filter (e.g. A45B) â€” only synth 0x49 addressed to this terminal",
    )
    parser.add_argument(
        "--lab-poker-royal-synth",
        action="store_true",
        help="LAB ONLY (pos3 Master): replace Draw Poker 0x59/0x5A on POG422 UART before ETH fan-out",
    )
    parser.add_argument(
        "--lab-poker-game",
        default=DRAW_POKER_GAME_ID,
        help=f"Bus game id for lab poker synth (default {DRAW_POKER_GAME_ID} Jacks or Better)",
    )
    parser.add_argument(
        "--lab-poker-jpot-amount",
        type=lambda s: int(s, 0),
        default=None,
        help="Optional progwinval for lab poker 0x5A synth in cents (BE32 on wire; default: live meter)",
    )
    parser.add_argument(
        "--lab-poker-tid",
        default=None,
        help="Optional slave TID filter â€” only synth poker frames addressed to this terminal",
    )
    parser.add_argument(
        "--lab-spinjack-flushjack-synth",
        action="store_true",
        help="LAB ONLY (Master): replace SpinJack 0x79 player cards with suited A+10 before ETH fan-out",
    )
    parser.add_argument(
        "--lab-spinjack-game",
        default=SPINJACK_GAME_ID,
        help=f"Bus game id for lab SpinJack synth (default {SPINJACK_GAME_ID})",
    )
    parser.add_argument(
        "--lab-spinjack-tid",
        default=None,
        help="Optional slave TID filter â€” only synth SpinJack frames addressed to this terminal",
    )
    parser.add_argument(
        "--lab-touch6-jpot-synth",
        action="store_true",
        help="LAB ONLY (Master): replace Touch 6 0x29 on POG422 UART before ETH fan-out",
    )
    parser.add_argument(
        "--lab-touch6-jpot-game",
        default=TOUCH6_GAME_ID,
        help=f"Bus game id for lab Touch 6 synth (default {TOUCH6_GAME_ID})",
    )
    parser.add_argument(
        "--lab-touch6-jpot-amount",
        type=lambda s: int(s, 0),
        default=None,
        help="Optional progwinval for lab Touch 6 synth in cents (BE32 on wire; default: live meter)",
    )
    parser.add_argument(
        "--lab-touch6-jpot-tid",
        default=None,
        help="Optional slave TID filter â€” only synth Touch 6 frames addressed to this terminal",
    )
    parser.add_argument(
        "--lab-superpick-jpot-synth",
        action="store_true",
        help="LAB ONLY (Master): replace SuperPick 0x69 on POG422 UART before ETH fan-out",
    )
    parser.add_argument(
        "--lab-superpick-jpot-game",
        default=SUPERPICK_GAME_ID,
        help=f"Bus game id for lab SuperPick synth (default {SUPERPICK_GAME_ID})",
    )
    parser.add_argument(
        "--lab-superpick-jpot-amount",
        type=lambda s: int(s, 0),
        default=None,
        help="Optional progwinval for lab SuperPick synth in cents (BE32 on wire; default: live meter)",
    )
    parser.add_argument(
        "--lab-superpick-jpot-tid",
        default=None,
        help="Optional slave TID filter â€” only synth SuperPick frames addressed to this terminal",
    )
    parser.add_argument(
        "--lab-bingo-jpot-synth",
        action="store_true",
        help="LAB ONLY (Master): replace Bingo 0x39 on POG422 UART before ETH fan-out",
    )
    parser.add_argument(
        "--lab-bingo-jpot-game",
        default=BINGO_GAME_ID,
        help=f"Bus game id for lab Bingo synth (default {BINGO_GAME_ID} SuperGold Bingo)",
    )
    parser.add_argument(
        "--lab-bingo-jpot-amount",
        type=lambda s: int(s, 0),
        default=None,
        help="Optional progwinval for lab Bingo synth in cents (BE32 on wire; default: LP $7488.50)",
    )
    parser.add_argument(
        "--lab-bingo-jpot-tid",
        default=None,
        help="Optional slave TID filter â€” only synth Bingo frames addressed to this terminal",
    )
    parser.add_argument(
        "--lab-triple7s-jpot-synth",
        action="store_true",
        help="LAB ONLY (Master): replace Triple 7's 0x89 with 3Ã— TRIPLE7 on payline 1",
    )
    parser.add_argument(
        "--lab-triple7s-jpot-game",
        default=TRIPLE7S_GAME_ID,
        help=f"Bus game id for lab Triple 7's synth (default {TRIPLE7S_GAME_ID})",
    )
    parser.add_argument(
        "--lab-triple7s-jpot-tid",
        default=None,
        help="Optional slave TID filter â€” only synth Triple 7's frames for this terminal",
    )
    parser.add_argument(
        "--lab-withhold-status-0d",
        action="store_true",
        help="LAB ONLY: withhold local synth/auto-close 0x0D for N rounds (intentional Slave MRT)",
    )
    parser.add_argument(
        "--lab-withhold-status-0d-count",
        type=int,
        default=1,
        help="Rounds to withhold local 0x0D when --lab-withhold-status-0d (default 1)",
    )
    parser.add_argument(
        "--lab-gameplay-synth",
        action="store_true",
        help="LAB ONLY: forge rotating multi-game slave wagers as UARTâ†’ETH (bus-load soak)",
    )
    parser.add_argument(
        "--lab-gameplay-tid",
        default=None,
        help="Required with gameplay synth: unique lab TID (E000â€“E0FF; pilot E001)",
    )
    parser.add_argument(
        "--lab-gameplay-games",
        default=None,
        help="Comma game keys (default touch6,superpick,bingo,poker)",
    )
    parser.add_argument(
        "--lab-gameplay-session-s",
        type=float,
        default=None,
        help="Seconds per game session before rotating (default 300)",
    )
    parser.add_argument(
        "--lab-gameplay-period-s",
        type=float,
        default=None,
        help="Nominal seconds between wager rounds (default 3)",
    )
    parser.add_argument(
        "--lab-gameplay-jitter-s",
        type=float,
        default=None,
        help="Â± jitter on period (default 0.5)",
    )
    parser.add_argument(
        "--lab-gameplay-phase-s",
        type=float,
        default=None,
        help="Startup delay / hop stagger seconds (default: hash of FIXTURE_POS)",
    )
    parser.add_argument(
        "--lab-gameplay-shuffle",
        action="store_true",
        help="Shuffle game catalog each full cycle (default round-robin)",
    )
    parser.add_argument(
        "--lab-gameplay-synth-allow-prod",
        action="store_true",
        help="Dangerous: allow gameplay synth with --prod (default refuse)",
    )
    parser.add_argument(
        "--no-progressive-csv",
        action="store_true",
        help="Disable Master 0x0A â†’ progressives.csv writer (DyzineFrame/OHD format)",
    )
    parser.add_argument(
        "--early-win-phase1",
        action="store_true",
        help="Enable Phase 1 progressive trap â†’ DyzineFrame /flag/fireworks (bank-wide)",
    )
    parser.add_argument(
        "--early-win-phase1-dry-run",
        action="store_true",
        help="Log early_win_phase1_* events only; do not HTTP-trigger DyzineFrame",
    )
    parser.add_argument(
        "--early-win-dedupe-ms",
        type=float,
        default=None,
        help="Dedupe window ms for Phase 1 jpot trap (default 5000 or FIXTURE_EARLY_WIN_DEDUPE_MS)",
    )
    parser.add_argument(
        "--early-win-max-loops",
        type=int,
        default=None,
        help="Cap fireworks loop count (default 6 or FIXTURE_EARLY_WIN_MAX_LOOPS)",
    )
    parser.add_argument(
        "--early-win-phase2-slots",
        action="store_true",
        help="Enable Phase 2 slot trap: 8-liner (970C) paytable replay -> local fireworks",
    )
    parser.add_argument(
        "--early-win-phase2-keno",
        action="store_true",
        help="Enable Phase 2 keno trap: TouchEasy/SuperBall paytable replay -> local fireworks",
    )
    parser.add_argument(
        "--fireworks-win-threshold-usd",
        type=float,
        default=None,
        help="FireworksWinThreshold in USD for Phase 2 slot/keno wins "
        "(default 50.00 or FIXTURE_FIREWORKS_WIN_THRESHOLD_USD); dollar-based, not tied to tier",
    )
    parser.add_argument(
        "--scr-diag",
        action="store_true",
        help="Log scr_diag/scr_round events for native UART 0x03/0x0B (Master SCR bench)",
    )
    parser.add_argument(
        "--scr-master-native-only",
        action="store_true",
        help="Block ETHâ†’UART inject of relayed 0x03 (Master native SCR capture only)",
    )
    parser.add_argument(
        "--progressives-csv",
        default=None,
        help="progressives.csv output path (default: $ETHERGOLD_PROGRESSIVES_CSV or $ETHERGOLD_ROOT/progressives.csv)",
    )
    uartd = parser.add_mutually_exclusive_group()
    uartd.add_argument(
        "--uartd",
        action="store_true",
        help="Use pog422_uartd for UART capture + inject (POG422_UARTD=1)",
    )
    uartd.add_argument(
        "--no-uartd",
        action="store_true",
        help="Unsupported on EG 2.1 (POG422_UARTD=0) â€” hard-fail at startup",
    )
    uartd_inject = parser.add_mutually_exclusive_group()
    uartd_inject.add_argument(
        "--uartd-inject",
        action="store_true",
        help="ETHâ†’UART inject via uartd IPC (POG422_UARTD_INJECT=1, EG 2.1 default)",
    )
    uartd_inject.add_argument(
        "--no-uartd-inject",
        action="store_true",
        help="Unsupported on EG 2.1 â€” Python MARK/SPACE inject removed",
    )
    for action in parser._actions:
        if action.dest == "channel":
            action.choices = sorted(list(BUS_CHANNELS) + ["both"])
            break
    return parser


def resolve_uartd(args) -> bool:
    return resolve_uartd_enabled(args, default=True)


def resolve_uartd_inject(args) -> bool:
    return resolve_uartd_inject_enabled(args, default=True)


def main() -> int:
    from pathlib import Path

    args = build_parser().parse_args()
    args.fixture_pos = resolve_fixture_pos(args.fixture_pos)
    dual = args.channel == "both"
    from pog422_gpio_hs import park_hs_out_idle_low, resolve_hs_path

    hsl = None
    try:
        import hat_status_led as _hsl

        hsl = _hsl
        hsl.phase(5)
    except Exception:
        hsl = None

    # Always park BCM10/11 output-low. When hs_path=pi, uartd inject drives
    # them for DE (EXTERNAL_DE); UART path still uses XR GPIO7/GPIO1.
    park_hs_out_idle_low()
    hs_path = resolve_hs_path()
    if hs_path == "pi":
        print(
            "POG422_HS_PATH=pi — uartd inject uses BCM11/BCM10 DE + BCM9/BCM8 IN "
            "(machine/OIS); keep J3/J4 on PI",
            flush=True,
        )

    if dual:
        print(
            "Dual bridge: machine gameplay synth + OIS raw HS forward â€” ETH tags M/O",
            flush=True,
        )
    elif args.channel == "ois":
        print(
            "OIS bridge: opaque raw HS window forward â€” no gameplay parse/synth",
            flush=True,
        )
    elif args.status_0d_synth and not args.status_0d_block_all:
        print(
            "status-0d-synth requires block-all â€” enabling --status-0d-block-all",
            flush=True,
        )
        args.status_0d_block_all = True

    if args.channel != "ois":
        if args.no_status_0d_synth_from_x9:
            args.status_0d_synth_from_x9 = False
        elif args.status_0d_synth:
            args.status_0d_synth_from_x9 = True
        if args.status_0d_first_passthrough and not args.status_0d_synth_from_x9:
            print(
                "warning: first-passthrough without round synth â€” round 1 only seeds template",
                flush=True,
            )
        if args.lab_keno_jpot_synth:
            print(
                "WARNING: LAB Keno jackpot synth ENABLED on UARTâ†’ETH (pos3 Master) â€” "
                "corrupts progressive accounting; NEVER use in production",
                flush=True,
            )
            if args.prod:
                print("FATAL: --lab-keno-jpot-synth cannot be combined with --prod", flush=True)
                return 2
        if args.lab_poker_royal_synth:
            print(
                "WARNING: LAB Draw Poker royal flush synth ENABLED on UARTâ†’ETH (Master) â€” "
                "corrupts progressive accounting; NEVER use in production",
                flush=True,
            )
            if args.prod:
                print("FATAL: --lab-poker-royal-synth cannot be combined with --prod", flush=True)
                return 2
        if args.lab_spinjack_flushjack_synth:
            print(
                "WARNING: LAB SpinJack Flushjack synth ENABLED on UARTâ†’ETH (Master) â€” "
                "corrupts game accounting; NEVER use in production",
                flush=True,
            )
            if args.prod:
                print(
                    "FATAL: --lab-spinjack-flushjack-synth cannot be combined with --prod",
                    flush=True,
                )
                return 2
        if args.lab_touch6_jpot_synth:
            print(
                "WARNING: LAB Touch 6 progressive synth ENABLED on UARTâ†’ETH (Master) â€” "
                "corrupts progressive accounting; NEVER use in production",
                flush=True,
            )
            if args.prod:
                print("FATAL: --lab-touch6-jpot-synth cannot be combined with --prod", flush=True)
                return 2
        if args.lab_superpick_jpot_synth:
            print(
                "WARNING: LAB SuperPick progressive synth ENABLED on UARTâ†’ETH (Master) â€” "
                "corrupts progressive accounting; NEVER use in production",
                flush=True,
            )
            if args.prod:
                print("FATAL: --lab-superpick-jpot-synth cannot be combined with --prod", flush=True)
                return 2
        if args.lab_bingo_jpot_synth:
            print(
                "WARNING: LAB Bingo coverall synth ENABLED on UARTâ†’ETH (Master) â€” "
                "corrupts progressive accounting; NEVER use in production",
                flush=True,
            )
            if args.prod:
                print("FATAL: --lab-bingo-jpot-synth cannot be combined with --prod", flush=True)
                return 2
        if args.lab_triple7s_jpot_synth:
            print(
                "WARNING: LAB Triple 7's jackpot grid synth ENABLED on UARTâ†’ETH (Master) â€” "
                "corrupts game accounting; NEVER use in production",
                flush=True,
            )
            if args.prod:
                print("FATAL: --lab-triple7s-jpot-synth cannot be combined with --prod", flush=True)
                return 2
        if args.lab_withhold_status_0d:
            print(
                "WARNING: LAB withhold local status 0x0D ENABLED — "
                f"count={args.lab_withhold_status_0d_count} intentional Slave MRT; "
                "NEVER use in production",
                flush=True,
            )
            if args.prod:
                print(
                    "FATAL: --lab-withhold-status-0d cannot be combined with --prod",
                    flush=True,
                )
                return 2
            if int(args.lab_withhold_status_0d_count or 0) < 1:
                print(
                    "FATAL: --lab-withhold-status-0d-count must be >= 1",
                    flush=True,
                )
                return 2
        # Early validate gameplay synth (also started from run_bridge_*); fail fast.
        try:
            resolve_lab_gameplay_config(args)
            from lab_gameplay_synth import validate_config as _vgp

            _gerr = _vgp(resolve_lab_gameplay_config(args), prod_mode=bool(args.prod))
            if _gerr:
                print(f"FATAL: {_gerr}", flush=True)
                return 2
        except ValueError as exc:
            print(f"FATAL: lab gameplay synth: {exc}", flush=True)
            return 2

    output_dir = resolve_output_dir(args.output_dir)
    logs_dir = Path(args.logs_dir)
    active_low = not args.active_high

    if not args.append_capture:
        cleared = clear_capture_files(output_dir, logs_dir=logs_dir)
        if cleared:
            print(f"Cleared capture: {', '.join(cleared)}", flush=True)

    identity = FixtureIdentity(
        fixture_pos=args.fixture_pos,
        pi_label=args.pi_label or f"pos{args.fixture_pos}",
        role="bridge",
    )
    timing = BridgeTiming(
        pre_tx_us=0.0,  # EG 2.2.1.5: forced; CLI --pre-tx-us ignored
        post_tx_us=float(args.post_tx_us),
        post_air_margin_us=float(args.post_air_margin_us),
        flush_rx_before_tx=args.flush_rx_before_tx,
        loop_sleep_us=args.loop_sleep_us,
        wait_hs_inactive_before_tx=not args.no_wait_hs_inactive,
        post_hs_inactive_us=args.post_hs_inactive_us,
        hs_inactive_wait_ms=args.hs_inactive_wait_ms,
    )

    event_log = EventLog(
        output_dir,
        identity,
        echo=args.echo_events,
        prod_mode=args.prod,
        dev_light=resolve_dev_light(
            cli_flag=bool(getattr(args, "dev_light", False)),
            prod_mode=bool(args.prod),
        ),
        hs_witness=not args.no_hs_witness,
    )
    args.progressive_csv = None
    if not args.no_progressive_csv and args.channel != "ois":
        args.progressive_csv = ProgressiveCsvWriter(
            args.progressives_csv,
            event_log=event_log,
        )
        print(f"Progressive CSV: {args.progressive_csv.csv_path}", flush=True)
        try:
            print(f"Machine status JSON: {resolve_machine_status_path()}", flush=True)
        except Exception:
            pass
    phase1_enabled = args.early_win_phase1 or os.environ.get(
        "FIXTURE_EARLY_WIN_PHASE1", "0"
    ) in ("1", "true", "yes")
    args.early_win_phase1_enabled = phase1_enabled
    args.early_win_phase1_trap = None
    if phase1_enabled and args.channel != "ois":
        dedupe_ms = args.early_win_dedupe_ms
        if dedupe_ms is None:
            dedupe_ms = float(os.environ.get("FIXTURE_EARLY_WIN_DEDUPE_MS", "5000"))
        max_loops = args.early_win_max_loops
        if max_loops is None:
            max_loops = int(os.environ.get("FIXTURE_EARLY_WIN_MAX_LOOPS", "6"))
        dry_run = args.early_win_phase1_dry_run or os.environ.get(
            "FIXTURE_EARLY_WIN_DRY_RUN", "0"
        ) in ("1", "true", "yes")
        flag_base = os.environ.get("DYZINEFRAME_FLAG_BASE") or None
        args.early_win_phase1_trap = Phase1ProgressiveTrap(
            dedupe_ms=dedupe_ms,
            max_loops=max_loops,
            flag_base=flag_base,
            dry_run=dry_run,
            on_event=event_log.emit,
        )
        print(
            f"Early win Phase 1: enabled dry_run={dry_run} dedupe_ms={dedupe_ms} "
            f"max_loops={max_loops}",
            flush=True,
        )
    slots_enabled = args.early_win_phase2_slots or os.environ.get(
        "FIXTURE_EARLY_WIN_PHASE2_SLOTS", "0"
    ) in ("1", "true", "yes")
    args.early_win_phase2_slots_enabled = slots_enabled
    args.early_win_slots_trap = None
    if slots_enabled and args.channel != "ois":
        if args.fireworks_win_threshold_usd is not None:
            threshold_cents = max(0, round(args.fireworks_win_threshold_usd * 100.0))
        else:
            threshold_cents = resolve_slot_threshold_cents()
        credit_cents = resolve_slot_credit_cents()
        s_dedupe_ms = args.early_win_dedupe_ms
        if s_dedupe_ms is None:
            s_dedupe_ms = float(os.environ.get("FIXTURE_EARLY_WIN_DEDUPE_MS", "5000"))
        s_dry_run = args.early_win_phase1_dry_run or os.environ.get(
            "FIXTURE_EARLY_WIN_DRY_RUN", "0"
        ) in ("1", "true", "yes")
        args.early_win_slots_trap = Phase2SlotTrap(
            threshold_cents=threshold_cents,
            credit_cents=credit_cents,
            dedupe_ms=s_dedupe_ms,
            dry_run=s_dry_run,
            on_event=event_log.emit,
        )
        print(
            f"Early win Phase 2 slots: enabled dry_run={s_dry_run} "
            f"threshold_usd={threshold_cents / 100.0:.2f} credit_cents={credit_cents}",
            flush=True,
        )
    keno_enabled = args.early_win_phase2_keno or os.environ.get(
        "FIXTURE_EARLY_WIN_PHASE2_KENO", "0"
    ) in ("1", "true", "yes")
    args.early_win_phase2_keno_enabled = keno_enabled
    args.early_win_keno_trap = None
    if keno_enabled and args.channel != "ois":
        if args.fireworks_win_threshold_usd is not None:
            k_threshold_cents = max(0, round(args.fireworks_win_threshold_usd * 100.0))
        else:
            k_threshold_cents = resolve_keno_threshold_cents()
        k_credit_cents = resolve_keno_credit_cents()
        k_dedupe_ms = args.early_win_dedupe_ms
        if k_dedupe_ms is None:
            k_dedupe_ms = float(os.environ.get("FIXTURE_EARLY_WIN_DEDUPE_MS", "5000"))
        k_dry_run = args.early_win_phase1_dry_run or os.environ.get(
            "FIXTURE_EARLY_WIN_DRY_RUN", "0"
        ) in ("1", "true", "yes")
        args.early_win_keno_trap = Phase2KenoTrap(
            threshold_cents=k_threshold_cents,
            credit_cents=k_credit_cents,
            dedupe_ms=k_dedupe_ms,
            dry_run=k_dry_run,
            on_event=event_log.emit,
        )
        print(
            f"Early win Phase 2 keno: enabled dry_run={k_dry_run} "
            f"threshold_usd={k_threshold_cents / 100.0:.2f} credit_cents={k_credit_cents}",
            flush=True,
        )
    use_uartd = resolve_uartd(args)
    use_uartd_inject = resolve_uartd_inject(args) if use_uartd else False
    if use_uartd and not use_uartd_inject:
        print(
            "FATAL: EG 2.2 requires POG422_UARTD_INJECT=1 with uartd "
            "(Python MARK/SPACE inject removed)",
            flush=True,
        )
        return 2
    if use_uartd_inject and not use_uartd:
        print("FATAL: --uartd-inject requires --uartd", flush=True)
        return 2
    if not use_uartd:
        print(
            "FATAL: EG 2.2 requires POG422_UARTD=1 (C owns UART). "
            "Python-only path is unsupported on wide/preciseflags drivers.",
            flush=True,
        )
        return 2
    if dual and args.interface:
        print(
            "FATAL: CHANNEL=both requires directional eth0/eth1 (not --interface br0)",
            flush=True,
        )
        return 2

    serials: list[serial.Serial] = []
    uartd_client: UartDaemonClient | None = None
    uartd_clients: list[Any] = []

    try:
        if dual:
            if UartDaemonClient is None or DaemonHandshake is None:
                print("FATAL: --uartd but pog422_uart_client.py is missing", flush=True)
                return 2
            bridges: dict[str, UartBridge] = {}
            for ch in resolve_bridge_channels("both"):
                sock_path = default_socket_path(args.fixture_pos, ch.key)
                client = UartDaemonClient(sock_path)
                client.configure_auto_restart(
                    restart_config_from_env(
                        sock_path=sock_path,
                        uart_port=resolve_serial_port(ch, None),
                        channel=ch.key,
                    )
                )
                if hsl is not None:
                    hsl.phase(7)
                _cerr: Exception | None = None
                for _try in range(1, 4):
                    try:
                        client.connect()
                        _cerr = None
                        break
                    except Exception as exc:
                        _cerr = exc
                        print(
                            f"WARN: uartd connect ({ch.key}) try {_try}/3: {exc}",
                            flush=True,
                        )
                        time.sleep(1.5 * _try)
                if _cerr is not None:
                    print(f"FATAL: uartd connect ({ch.key}): {_cerr}", flush=True)
                    return 2
                uartd_clients.append(client)
                bridges[ch.key] = make_uart_bridge(
                    ch,
                    args,
                    timing=timing,
                    event_log=event_log,
                    ser=None,
                    handshake=DaemonHandshake(client, ch),
                    use_uartd=True,
                    use_uartd_inject=True,
                    uartd_client=client,
                    inject_port=None,
                    active_low=active_low,
                )
            port_names = resolve_eth_port_names(
                eth_ports=args.eth_ports,
                legacy_interface=None,
            )
            if hsl is not None:
                hsl.healthy()
            run_bridge_directional_dual(
                args,
                bridges=bridges,
                event_log=event_log,
                timing=timing,
                output_dir=output_dir,
                port_names=port_names,
            )
        else:
            channel = resolve_channel(args.channel)
            apply_ois_bridge_defaults(channel, args)
            port = resolve_serial_port(channel, args.port)
            ser: serial.Serial | None = None
            if use_uartd:
                if UartDaemonClient is None or DaemonHandshake is None:
                    print("FATAL: --uartd but pog422_uart_client.py is missing", flush=True)
                    return 2
                if hsl is not None:
                    hsl.phase(6)
                    hsl.phase(7)
                sock_path = default_socket_path(args.fixture_pos)
                uartd_client = UartDaemonClient(sock_path)
                uartd_client.configure_auto_restart(
                    restart_config_from_env(sock_path=sock_path, uart_port=port)
                )
                # Phase 7 hardening: retry connect before bail.
                _conn_err: Exception | None = None
                for _try in range(1, 4):
                    try:
                        uartd_client.connect()
                        _conn_err = None
                        break
                    except Exception as exc:  # ConnectionError and IPC variants
                        _conn_err = exc
                        print(
                            f"WARN: uartd connect failed try {_try}/3: {exc}",
                            flush=True,
                        )
                        time.sleep(1.5 * _try)
                if _conn_err is not None:
                    print(f"FATAL: uartd connect failed: {_conn_err}", flush=True)
                    return 2
                handshake = DaemonHandshake(uartd_client, channel)
            else:
                ser, handshake = open_serial(port, channel, active_low=active_low)
                serials.append(ser)
            if hsl is not None:
                # Phase 8 soft: CSV path issues stay WARN; enter healthy unless soft fails marked.
                try:
                    from pog422_core import resolve_progressives_csv_path

                    csv_path = resolve_progressives_csv_path()
                    parent = Path(csv_path).parent
                    if not parent.is_dir() or not os.access(parent, os.W_OK):
                        hsl.soft_fail(8)
                except Exception:
                    pass
                hsl.healthy()
            bridge = UartBridge(
                ser,
                channel,
                handshake,
                event_log,
                timing,
                use_uartd=use_uartd,
                use_uartd_inject=use_uartd_inject,
                uartd_client=uartd_client,
                inject_port=port if use_uartd and not use_uartd_inject else None,
                inject_active_low=active_low,
                suppress_uart_echo=args.suppress_uart_echo,
                uart_origin_hold_ms=args.uart_origin_hold_ms,
                status_0d_first_only=args.status_0d_first_only,
                status_0d_block_all=args.status_0d_block_all,
                status_0d_synth=args.status_0d_synth,
                status_0d_first_passthrough=args.status_0d_first_passthrough,
                status_0d_synth_from_x9=args.status_0d_synth_from_x9,
                status_0d_synth_loose_counter=args.status_0d_synth_loose_counter,
                status_0d_dedupe_ms=args.status_0d_dedupe_ms,
                lab_keno_jpot_synth=args.lab_keno_jpot_synth,
                lab_keno_jpot_game=args.lab_keno_jpot_game,
                lab_keno_jpot_amount=args.lab_keno_jpot_amount,
                lab_keno_jpot_tid=args.lab_keno_jpot_tid,
                lab_poker_royal_synth=args.lab_poker_royal_synth,
                lab_poker_game=args.lab_poker_game,
                lab_poker_jpot_amount=args.lab_poker_jpot_amount,
                lab_poker_tid=args.lab_poker_tid,
                lab_spinjack_flushjack_synth=args.lab_spinjack_flushjack_synth,
                lab_spinjack_game=args.lab_spinjack_game,
                lab_spinjack_tid=args.lab_spinjack_tid,
                lab_touch6_jpot_synth=args.lab_touch6_jpot_synth,
                lab_touch6_jpot_game=args.lab_touch6_jpot_game,
                lab_touch6_jpot_amount=args.lab_touch6_jpot_amount,
                lab_touch6_jpot_tid=args.lab_touch6_jpot_tid,
                lab_superpick_jpot_synth=args.lab_superpick_jpot_synth,
                lab_superpick_jpot_game=args.lab_superpick_jpot_game,
                lab_superpick_jpot_amount=args.lab_superpick_jpot_amount,
                lab_superpick_jpot_tid=args.lab_superpick_jpot_tid,
                lab_bingo_jpot_synth=args.lab_bingo_jpot_synth,
                lab_bingo_jpot_game=args.lab_bingo_jpot_game,
                lab_bingo_jpot_amount=args.lab_bingo_jpot_amount,
                lab_bingo_jpot_tid=args.lab_bingo_jpot_tid,
                lab_triple7s_jpot_synth=args.lab_triple7s_jpot_synth,
                lab_triple7s_jpot_game=args.lab_triple7s_jpot_game,
                lab_triple7s_jpot_tid=args.lab_triple7s_jpot_tid,
                lab_withhold_status_0d=bool(args.lab_withhold_status_0d),
                lab_withhold_status_0d_count=int(
                    args.lab_withhold_status_0d_count or 1
                ),
                progressive_csv=args.progressive_csv,
                early_win_phase1=args.early_win_phase1_trap,
                early_win_slots=args.early_win_slots_trap,
                early_win_keno=args.early_win_keno_trap,
                scr_diag=args.scr_diag,
                scr_master_native_only=args.scr_master_native_only,
            )
            legacy_if = args.interface
            if legacy_if:
                run_bridge_legacy(
                    args,
                    bridge=bridge,
                    event_log=event_log,
                    port=port,
                    active_low=active_low,
                    timing=timing,
                    output_dir=output_dir,
                )
            else:
                port_names = resolve_eth_port_names(
                    eth_ports=args.eth_ports,
                    legacy_interface=None,
                )
                run_bridge_directional(
                    args,
                    bridge=bridge,
                    event_log=event_log,
                    port=port,
                    active_low=active_low,
                    timing=timing,
                    output_dir=output_dir,
                    port_names=port_names,
                )
    except KeyboardInterrupt:
        print("Bridge stopped.", flush=True)
    finally:
        if hsl is not None:
            try:
                hsl.shutdown()
            except Exception:
                pass
        if uartd_client is not None:
            try:
                uartd_client.close()
            except Exception:
                pass
        for client in uartd_clients:
            try:
                client.close()
            except Exception:
                pass
        for ser in serials:
            try:
                ser.close()
            except Exception:
                pass
        event_log.write_stats({"mode": "bridge", "stopped": True, "dual_channel": dual})
        event_log.close()
    return 0


if __name__ == "__main__":
    sys.exit(main())
