# -*- coding: utf-8 -*-
"""
test_MbMultiRead_simulation.py
FB_MbMultiRead v3（S7-1500 Modbus TCP 多从站 / 多任务 / 大数据量 / 读+写）
的 1:1 行为复刻与仿真验证

做法：把 SCL 里的状态机、8 个连接槽位、MB_CLIENT 的握手时序、三个定时器
      逐段照抄成 Python，外面接一个"从站模型"，然后跑场景看行为对不对。
      目的不是证明代码能编译（那是结构自检的事），而是证明**逻辑在时序上是对的**：
      分片会不会漏、切换从站会不会串数据、掉线会不会把整轮拖死、
      写方向会不会偷偷发出去、同一个从站又读又写会不会打架。

v3 相对 v2 仿真新增的东西：
  - Task 多了 bWrite / uTrig；Slave 多了 write()；MBClient 认 106 / 116 两个写模式
  - 参数体检里加了"方向↔功能码自洽"、"写不分片"、"uTrig 只能 0/1"
  - 写总闸 bWriteEnable 与 bWriteLocked
  - 逐任务反馈 abTaskOK / abTaskErr / awTaskStatus
  - 触发锁存 bTrigLatch（单周期脉冲只对应一轮）

v3.1 修复（2026-09-30）与本仿真的对应关系：
  ①段 6 缺"作业在跑"门控 —— 场景 15 用 bGate 做 v3.0/v3.1 对照；
  ②REQ 保持高电平 —— 场景 15 用 bReqPulse 做对照；
  ③轮次计时器漏算重连三拍 —— 场景 15 断言 tRoundTime 不再是 0；
  ④段 6 在 REQ 那一拍就结算（报 16#7005 而非真错误码、成功帧被吞）
    —— 场景 15 用 bBusyGate 做对照，场景 16 验证"从站恢复后第一帧不白跑"。
  三个开关默认都是 True，即"块当前的真实行为"。

v3.2 修复（2026-10-09）与本仿真的对应关系：
  ⑤段 6 门控写成复合条件，而 SCL **不短路求值** —— iSlot=0 的那几拍
    照样会去读 abBusy[0]，数组越界（读访问）。现场指纹：上电进 RUN 的
    头两拍各一条 FB2 区域长度错误，之后干干净净。
    场景 17 用 bNestedGate 做 A/B：False = 复合条件（v3.1，2 条越界），
    True = 嵌套 IF（v3.2，0 条）。

v3.3 新增（2026-10-10）：
  ⑥UDT_MbStation 加 uUnitId（设备 ID / MB_UNIT_ID）。之前块里没接这个
    引脚，报文里的设备 ID 恒为 MB_CLIENT 默认 16#FF(255)——现场表现：
    TCP 连得上（空闲 16#7004），一发作业就断，wStatus 在 16#80A1 /
    16#8200 之间循环。场景 18 验证 uUnitId 被原样带进每一帧请求。
    Sta 现在带 uUnitId 参数（默认 255，与 MB_CLIENT 默认一致）。

模型本身也在这轮补了两块（否则这个 bug 在仿真里永远抓不到）：
  - Bnd：带**运行时边界检查**的数组替身。S7-1500 的数组越界检查是运行时
    内建、不可关闭的，普通 list 模拟不出来。
  - 求值语义：SCL 的 AND / OR 会算完所有操作数，与 Python 相反。门控照样
    写"先算操作数、再算条件"，把两边的语义对齐。

用法：
  "C:/Users/HawkZhang/.workbuddy/binaries/python/versions/3.13.12/python.exe" test_MbMultiRead_simulation.py
"""

# ---------------------------------------------------------------- 时间与常量

SCAN_MS = 10          # 一个扫描周期 = 10ms
RESP_MS = 30          # 从站正常响应耗时 = 3 个扫描周期
BUF_SIZE = 1000       # aBuf 长度，与 FB 里的 ARRAY[0..999] 对应

WRITE_MODES = (106, 116)   # MB_MODE：106 = FC06 写单个，116 = FC16 写多个


# ---------------------------------------------------------------- 定时器

class TON:
    """复刻 SCL 的 TON_TIME：IN 为假时 ET 归零；IN 为真时 ET 累加，够 PT 就把 Q 置起"""

    def __init__(self, name=''):
        self.name = name
        self.et = 0
        self.q = False

    def __call__(self, IN, PT):
        if IN:
            self.et += SCAN_MS
            if self.et >= PT:
                self.et = PT
                self.q = True
            else:
                self.q = False
        else:
            self.et = 0
            self.q = False
        return self.q


# ---------------------------------------------------------------- 从站模型

class Slave:
    """一个 Modbus TCP 从站。

    mode = 'ok'     正常响应，读返回寄存器值、写落进寄存器
           'error'  明确报错（等价于 MB_CLIENT 收到 ERROR，STATUS=16#80C8）
           'hang'   收了包但不回话 —— MB_CLIENT 会一直 BUSY，永远不给 DONE/ERROR。
                    这正是"从站断电 / 网线拔了"的现场表现，用来验证兜底超时。
           'refuse' 远程主动拒绝/断开连接（16#80C5）—— IP 或端口不对、
                    对端没起 Modbus 服务、防火墙挡掉，现场都是这个表现。

    persist_conn_err：把"连接类错误（80Cx）会持续有效"建进模型。
           真实 MB_CLIENT 遇到 80Cx 时，连接对象会进入错误态，**此后每次调用都
           继续报 ERROR**，直到重连成功 —— 它不是单周期脉冲。
           这条是从现场数据反推的：FB 的 dwErrCount 在约 3 分钟里涨到 46524
           （≈ 每扫描周期一次），而全块只有"段 6 失败分支"会加它，
           只有"ERROR 持续有效"能解释这个速率。
           默认 False，只有场景 15 主动打开，保证既有 92 项结论一字不变。

    写方向很关键：真实的写如果"没发出去"是看不出来的，所以这里把每一次
    成功的写都记进 writes_log，供场景断言"到底发没发、发的是什么"。
    recover_after：'refuse' 模式下被请求多少次之后恢复正常，模拟现场把
           IP / 端口 / 防火墙修好。默认 None = 一直拒绝。
           这一项是场景 16 的关键：唯有"从站真的恢复了"，才暴露得出
           "结算太早会把刚发出去的那一帧掐死在半路"的后果。
    """

    def __init__(self, ip, mode='ok', base=1000, persist_conn_err=False,
                 recover_after=None):
        self.ip = ip
        self.mode = mode
        self.base = base
        self.persist_conn_err = persist_conn_err
        self.recover_after = recover_after
        self.attempts = 0
        self.regs = {a: (base + a) & 0xFFFF for a in range(0, 2000)}
        self.writes_log = []

    def _refuse(self):
        """'refuse' 模式：计一次尝试，扛过 recover_after 次就转成正常从站"""
        if self.mode != 'refuse':
            return False
        self.attempts += 1
        if self.recover_after is not None and self.attempts > self.recover_after:
            self.mode = 'ok'           # 现场修好了：从这一帧起正常响应
            return False
        return True

    def read(self, addr, count):
        """返回 (ok, data_or_status)；ok 为 None 表示"装死"（无回应）"""
        if self._refuse():
            return False, 0x80C5       # 远程主动拒绝/断开（TCP 层）
        if self.mode == 'error':
            return False, 0x80C8
        if self.mode == 'hang':
            return None, None
        if addr + count > 2000:
            return False, 0x8382       # 从站：非法数据地址
        return True, [self.regs[addr + i] for i in range(count)]

    def write(self, addr, data):
        """返回 (ok, None)；写成功没有数据要回，所以第二项恒为 None"""
        if self._refuse():
            return False, 0x80C5
        if self.mode == 'error':
            return False, 0x80C8
        if self.mode == 'hang':
            return None, None
        if addr + len(data) > 2000:
            return False, 0x8382
        for i, v in enumerate(data):
            self.regs[addr + i] = v & 0xFFFF
        self.writes_log.append((addr, list(data)))
        return True, None




class MBClient:
    """复刻 MB_CLIENT 的握手：REQ 上升沿起一个作业 -> BUSY -> DONE/ERROR（单周期脉冲）

    读 / 写走同一条路，区别只在作业完成时是 slave.read() 还是 slave.write()。
    写模式的数据来自 chunk（对应 SCL 里 MB_DATA_PTR 指向 aChunk）——
    注意方向是**外部 -> 从站**，写模式下 MB_DATA_PTR 是数据源不是接收区。
    """

    def __init__(self):
        self.req_prev = False
        self.pending = False
        self.busy = False
        self.done = False
        self.error = False
        self.status = 0x7000
        self.countdown = 0
        self.connected = False
        self.conn = None
        self.tx = None                  # 写作业的发送数据快照
        self.conn_error = False         # 连接层处于错误态（80Cx）：ERROR 会一直报
        self.conn_status = 0x7000

    def call(self, REQ, DISCONNECT, conn, mode, addr, length, slave, chunk=None):
        # 输出脉冲每周期先清（这就是 MB_CLIENT "只脉冲一个扫描周期"的特性）
        self.done = False
        self.error = False

        # 连接层已经处于错误态时，ERROR 不会因为"这一拍没作业"就消失。
        # 注意 DISCONNECT 也清不掉它：连接**从来没建立成功过**，也就无从拆除。
        if self.conn_error:
            self.error = True
            self.status = self.conn_status

        if DISCONNECT:
            self.connected = False
            self.pending = False
            self.busy = False
            self.status = 0x7000
            self.req_prev = REQ
            return None

        if slave is None:
            # 该槽位没配从站：REQ 恒假，不会真的建连接
            self.req_prev = REQ
            return None

        # REQ 上升沿启动新作业
        if REQ and not self.req_prev:
            self.pending = True
            self.busy = True
            self.status = 0x7001       # 7001 = 正在建立连接
            self.conn = (tuple(conn), mode, addr, length)
            # 写模式：请求发起时就把要发的数据快照下来。
            # 真实 MB_CLIENT 是在传输时才从 MB_DATA_PTR 取数，但本块串行轮询，
            # 同一时刻只有一个作业在跑，快照等价，而且能钉住"发出去的是什么"。
            self.tx = list(chunk[:length]) if (mode in WRITE_MODES and chunk) else None
            self.countdown = RESP_MS

        self.req_prev = REQ

        if not self.pending:
            return None

        self.countdown -= SCAN_MS
        if self.countdown > 0:
            self.status = 0x7005       # 7005 = 正在等待响应
            return None

        if mode in WRITE_MODES:
            ok, data = slave.write(addr, self.tx or [])
        else:
            ok, data = slave.read(addr, length)

        if ok is None:
            # 从站在"装死"：MB_CLIENT 会一直停在 BUSY，等它自己的响应超时。
            # 这里让它继续挂着，由 FB 的兜底超时来收场。
            self.status = 0x7005
            return None

        self.pending = False
        self.busy = False
        if ok:
            self.connected = True
            self.conn_error = False
            self.status = 0x7000
            self.done = True
            return data
        else:
            self.error = True
            self.status = data
            # 80Cx = TCP 层错误：连接对象进入错误态，之后会一直报，直到重连成功
            if 0x80C0 <= data <= 0x80CF and getattr(slave, 'persist_conn_err', False):
                self.conn_error = True
                self.conn_status = data
            return None


CONN_MS = 150      # TCP 建连耗时（三次握手 + MB_CLIENT 内部初始化）
DISC_MS = 120      # TCP 拆除耗时


class MBClientConn(MBClient):
    """带"连接层"的 MB_CLIENT —— 专供场景 19，既有场景一律用父类。

    为什么非得单独建一个类（2026-10-10 的教训）：
      父类模型里 DISCONNECT 是**瞬间**生效的（那一拍直接 connected=False、
      busy=False），TCP 建连更是根本没建模 —— REQ 一上来倒计时几十毫秒就
      出结果。于是"拆连接还没拆完就发下一帧"这种错，131 项断言一条也
      抓不出来，而现场就是这么坏的。

    本类补上：连接要花时间建立，也要花时间拆除；拆除/建立期间这条连接
    不能承载报文，此时来 REQ 上升沿，MB_CLIENT 回报
        16#80A1 = 端口/连接资源不可用（连接正在拆/正在建）
        16#8200 = 这一条连接还在处理另一个请求
    这正是现场 wStatus 在 80A1 与 8200 之间循环的原因。
    """

    def __init__(self):
        super().__init__()
        self.state = 'closed'      # closed / opening / open / closing
        self.timer = 0
        # v3.5：被卡死的连接 ID（连过一次、非正常掉线后就再也建不起来的号）。
        # 现场与官方社区的说法一致：换号立刻通，旧号继续 80A1。
        self.dead_ids = set()
        self.cur_key = None

    @staticmethod
    def key_of(conn):
        """把连接描述做成可哈希的键。

        sIP 在仿真里是 list，直接 tuple(conn) 会得到一个含 list 的元组，
        丢进 set 当场 TypeError —— 这里先把 IP 摊平成 tuple。
        键里带不带连接 ID，由 conn 的长度决定（v3.5 起 _c 里多了一位 ID）。
        """
        ip = conn[2]
        ip = tuple(ip) if isinstance(ip, (list, tuple)) else ip
        cid = conn[4] if len(conn) > 4 else 0
        return (conn[0], conn[1], ip, conn[3], cid)

    def inject_drop(self):
        """模拟一次非正常掉线：对端把 TCP 掐了，这个连接 ID 从此卡死。

        现场三幕的共同起点：上一帧还好好的，下一帧连接没了；之后同一个 ID
        再也建不起来，反过来换个号立刻通。这两件事一起建模，场景 20 才做得
        出 v3.4 死循环 / v3.5 自愈的对照。
        """
        if self.cur_key is not None:
            self.dead_ids.add(self.cur_key)
        self.state = 'closing'
        self.timer = DISC_MS
        self.pending = False
        self.busy = True
        self.status = 0x7006

    def call(self, REQ, DISCONNECT, conn, mode, addr, length, slave, chunk=None):
        self.done = False
        self.error = False

        # ---- 连接状态推进（每周期先走一步） ----
        if self.state == 'opening':
            self.timer -= SCAN_MS
            if self.timer <= 0:
                self.state = 'open'
                self.busy = self.pending
                self.status = 0x7004        # 7004 = 连接已建立、空闲监视
            else:
                self.busy = True
                self.status = 0x7001        # 7001 = 正在建立连接
        elif self.state == 'closing':
            self.timer -= SCAN_MS
            if self.timer <= 0:
                self.state = 'closed'
                self.pending = False
                self.busy = False
                self.conn_error = False
                self.status = 0x7000
            else:
                self.busy = True
                self.status = 0x7006        # 正在拆除

        if self.conn_error:
            self.error = True
            self.status = self.conn_status

        if DISCONNECT:
            if self.state == 'open':
                self.state = 'closing'
                self.timer = DISC_MS
                self.pending = False
                self.busy = True
            self.req_prev = REQ
            return None

        if slave is None:
            self.req_prev = REQ
            return None

        if REQ and not self.req_prev:
            if self.state == 'closed':
                # v3.5：这个 ID 已经卡死 —— 换个号才活得过来
                if self.dead_ids and self.key_of(conn) in self.dead_ids:
                    self.error = True
                    self.status = 0x80A1
                    self.busy = False
                    self.req_prev = REQ
                    return None
                # 连接还没建立：先建连，这一拍还没法发报文
                self.state = 'opening'
                self.cur_key = self.key_of(conn)
                self.timer = CONN_MS
                self.busy = True
                self.status = 0x7001
                self.conn = (tuple(conn), mode, addr, length)
                self.tx = list(chunk[:length]) if (mode in WRITE_MODES and chunk) else None
                self.pending = True
                self.countdown = RESP_MS
            elif self.state in ('opening', 'closing'):
                # ★ 连接还没建好 / 正在拆除 —— 现场那条 80A1 就是这么来的
                self.error = True
                self.status = 0x80A1
            elif self.state == 'open':
                if self.pending:
                    # ★ 这条连接上还有一个请求没处理完 —— 8200
                    self.error = True
                    self.status = 0x8200
                else:
                    self.pending = True
                    self.busy = True
                    self.status = 0x7005
                    self.conn = (tuple(conn), mode, addr, length)
                    self.tx = list(chunk[:length]) if (mode in WRITE_MODES and chunk) else None
                    self.countdown = RESP_MS

        self.req_prev = REQ

        if not self.pending or self.state != 'open':
            return None

        self.countdown -= SCAN_MS
        if self.countdown > 0:
            self.status = 0x7005
            return None

        if mode in WRITE_MODES:
            ok, data = slave.write(addr, self.tx or [])
        else:
            ok, data = slave.read(addr, length)

        if ok is None:
            self.status = 0x7005
            return None

        self.pending = False
        self.busy = False
        if ok:
            self.status = 0x7000
            self.done = True
            return data
        self.error = True
        self.status = data
        if 0x80C0 <= data <= 0x80CF and getattr(slave, 'persist_conn_err', False):
            self.conn_error = True
            self.conn_status = data
        return None
# ---------------------------------------------------------------- 被测 FB 的复刻

# ---------------------------------------------------- 运行时边界检查
FAULTS = []          # 每次越界访问记一条 (数组名, 下标)：
                     # 复刻诊断缓冲区里的"区域长度错误"
FAULTS_TOTAL = 0     # 同上，但**永不清零** —— 供最后做"全量"健康检查。
                     # FAULTS 会被"故意注入"的对照场景清掉重新计数，
                     # 只有它能把整个测试套件的越界总数一次算清。


class Bnd:
    """带边界检查的数组替身。

    为什么要有它（2026-10-09 的教训，值得写进肌肉记忆）：
      S7-1500 对数组访问做**运行时**边界检查，SCL **不短路求值**。
      老模型用普通 list —— 既不检查边界（Python 不检查），又按 Python 的
      短路语义求值（左值已定就不再算右值）。两处失真叠加，结果就是
      "iSlot=0 时去读 abBusy[0]" 这种在现场天天报的越界，仿真里一条都不报，
      115 项断言照样全绿。断言再全，也挡不住**模型失真**。
    """

    __slots__ = ('lo', 'hi', 'name', 'data', 'default')

    def __init__(self, lo, hi, name, data=None, default=False):
        self.lo, self.hi, self.name = lo, hi, name
        self.default = default
        self.data = data if data is not None else [default] * (hi + 1)

    @staticmethod
    def _hit(name, i):
        global FAULTS_TOTAL
        FAULTS.append((name, i))
        FAULTS_TOTAL += 1

    def __getitem__(self, i):
        if isinstance(i, slice):
            return self.data[i]
        if not (self.lo <= i <= self.hi):
            Bnd._hit(self.name, i)
            return self.default
        return self.data[i]

    def __setitem__(self, i, v):
        if isinstance(i, slice):
            self.data[i] = v
            return
        if not (self.lo <= i <= self.hi):
            Bnd._hit(self.name, i)
            return
        self.data[i] = v

    def __len__(self):
        return len(self.data)

    def __iter__(self):
        return iter(self.data)


class FbMbMultiRead:
    """FB_MbMultiRead v3 的 1:1 复刻，段号与 SCL 源码一一对应"""

    def __init__(self, uTaskCount=1, uMaxRegs=125,
                 tPollGap=200, tPollFail=3000, tTimeout=3000,
                 bAutoRecover=True, bSkipDead=True,
                 bWriteEnable=False, aBuf=None,
                 bGate=True, bReqPulse=True, bBusyGate=True,
                 bNestedGate=True, bDiscWait=True, bV35=True, mb_cls=MBClient):
        self.bEnable = True
        self.iInterfaceId = 64
        self.uTaskCount = uTaskCount
        self.uMaxRegs = uMaxRegs
        self.bWriteEnable = bWriteEnable
        self.bTrigWrite = False
        self.tPollGap = tPollGap
        self.tPollFail = tPollFail
        self.tTimeout = tTimeout
        self.bAutoRecover = bAutoRecover
        self.bSkipDead = bSkipDead
        self.bReset = False

        # VAR_IN_OUT
        self.aSta = Bnd(1, 8, 'aSta', [None] * 9)          # 下标 1..8
        self.aTask = Bnd(1, 32, 'aTask', [None] * 33)      # 下标 1..32
        # aBuf 也是 VAR_IN_OUT：允许外部传入一个数组，模拟"写任务的数据源在块外"。
        # Bnd 包着原来那个 list，所以外部引用照旧有效（改的是同一块数据）。
        self.aBuf = Bnd(0, BUF_SIZE - 1, 'aBuf',
                        aBuf if aBuf is not None else [0] * BUF_SIZE)

        # VAR_OUTPUT
        self.bRoundDone = False
        self.bBusy = False
        self.bError = False
        self.bOnline = False
        self.uCurTask = 0
        self.uCurSlot = 0
        self.uRoundOK = 0
        self.uRoundErr = 0
        self.uFailRun = 0
        self.dwOKCount = 0
        self.dwErrCount = 0
        self.wStatus = 0
        self.wStatusLive = 0x7000
        self.tRoundTime = 0
        self.iStepOut = 0
        self.bTimeout = False
        self.bParaFault = False
        self.bWriteLocked = False
        self.abTaskOK = Bnd(1, 32, 'abTaskOK', [False] * 33)
        self.abTaskErr = Bnd(1, 32, 'abTaskErr', [False] * 33)
        self.awTaskStatus = Bnd(1, 32, 'awTaskStatus', [0] * 33)
        self.iCurWrite = False
        self.dwWrOKCount = 0
        self.dwWrErrCount = 0
        self.byVer = 0x08               # v3.5：SCL 里 byVer 已升到 16#08

        # VAR
        self.astMB = Bnd(1, 8, 'astMB', [mb_cls() for _ in range(9)])
        self.abConnInit = Bnd(1, 8, 'abConnInit', [False] * 9)
        self.astConn = Bnd(1, 8, 'astConn', [None] * 9)
        self.auMode = Bnd(1, 8, 'auMode', [0] * 9)
        self.auAddr = Bnd(1, 8, 'auAddr', [0] * 9)
        self.auLen = Bnd(1, 8, 'auLen', [0] * 9)
        self.auUnit = Bnd(1, 8, 'auUnit', [0] * 9)   # v3.3：各槽位设备 ID
        self.abReq = Bnd(1, 8, 'abReq', [False] * 9)
        self.abDisc = Bnd(1, 8, 'abDisc', [False] * 9)
        self.abDone = Bnd(1, 8, 'abDone', [False] * 9)
        self.abBusy = Bnd(1, 8, 'abBusy', [False] * 9)
        self.abErr = Bnd(1, 8, 'abErr', [False] * 9)
        self.awSt = Bnd(1, 8, 'awSt', [0] * 9)
        self.aChunk = Bnd(0, 124, 'aChunk', [0] * 125)

        self.abTaskDone = Bnd(1, 32, 'abTaskDone', [False] * 33)
        self.abTaskBad = Bnd(1, 32, 'abTaskBad', [False] * 33)
        self.abSlotDead = Bnd(1, 8, 'abSlotDead', [False] * 9)

        self.iStep = 0
        self.iSlot = 0
        self.iTaskNo = 0
        self.iChunkOff = 0
        self.iReqLen = 0
        self.iWriteOff = 0
        self.bFirst = True
        self.bPrevEn = False
        self.bPrevTrig = False
        self.bTrigLatch = False

        # 2026-09-30 两个对照开关，用来跑"v3.0 复刻 vs v3.1"，证明缺陷真实存在、
        # 修复真的有效（这是评审既有通信块的固定套路：每条结论都要有对照实验）。
        #   bGate     True = 段 6 用 bJobRun 门控（v3.1）；False = 不门控（v3.0）
        #   bReqPulse True = REQ 单扫描脉冲（v3.1）；False = 保持到出结果（v3.0）
        #   bBusyGate True = 段 3 / 段 6 都等 BUSY 落下才结算（v3.1 第四次修复）；
        #             False = 只看 DONE/ERROR（修第四次之前：REQ 那一拍就结算）
        self.bGate = bGate
        self.bReqPulse = bReqPulse
        self.bBusyGate = bBusyGate
        #   bNestedGate True  = 段 6 门控用嵌套 IF（v3.2，正确写法）
        #               False = 复刻 v3.1 的复合条件（SCL 不短路 → iSlot=0 越界）
        self.bNestedGate = bNestedGate
        #   bDiscWait True  = 拆连接时按住 DISCONNECT 等 tDiscWait 走完，
        #                    再冷却 tReconn 才发下一帧（v3.4，正确写法）
        #              False = 复刻 v3.3 的"数两拍就走"（41→42→43，20ms）
        self.bDiscWait = bDiscWait
        self.tDiscWait = 300
        self.tReconn = 200
        self.bJobRun = False
        #   bV35      True  = v3.5：80A1/80A3 自动换号 + 838x 不拆线 + 首次失败取证
        #             False = 复刻 v3.4（换号 / 不拆线 / 取证三样都不做）
        self.bV35 = bV35
        # v3.4：连接 ID 起始号；v3.5：运行中的 ID 基号（0 = 上电后还没同步过）
        self.uConnIdBase = 1
        self.uConnIdRun = 0
        # v3.5 首次失败取证：现场要的从来不是"最后显示的是谁"（80A1 会刷屏），
        # 而是"最开始哪一帧先坏、坏在什么码上"。
        self.wStatusFirst = 0
        self.uFirstErrTask = 0

        self.tGapTmr = TON('gap')
        self.tToTmr = TON('timeout')
        self.tRoundTmr = TON('round')
        self.tDiscTmr = TON('disc')
        self.tReconTmr = TON('recon')

        # 仿真观测用
        self.slaves = {}
        self.request_log = []           # (task, slot, addr, len, mode, unit_id) 每次真实发出的请求

    # ---------------- 每扫描周期调用一次 ----------------
    def scan(self, slaves):
        self.slaves = slaves

        # ---- 段 1：版本与参数校验 ----
        if self.uConnIdRun == 0:
            self.uConnIdRun = self.uConnIdBase   # 上电首拍：运行 ID 对齐输入起点
        self.byVer = 0x08 if self.bV35 else 0x07   # 版本指纹（与 SCL 源码保持一致）
        self.bParaFault = False
        bHasWrite = False

        if self.uTaskCount > 32:
            iTaskCnt = 32
            self.bParaFault = True
        else:
            iTaskCnt = self.uTaskCount

        if self.uMaxRegs < 1 or self.uMaxRegs > 125:
            self.bParaFault = True
            iMaxReq = 125 if self.uMaxRegs > 125 else 1
        else:
            iMaxReq = self.uMaxRegs

        # 任务表体检，每周期都做，bParaFault 才能是稳态输出
        for i in range(1, 33):
            bThisBad = False
            bThisWr = False
            if i <= iTaskCnt and self.aTask[i].bEnable:
                t = self.aTask[i]

                # a. 从站号越界直接判死（数组越界会让 PLC 进 STOP）
                if t.uStaNo < 1 or t.uStaNo > 8:
                    bThisBad = True

                # b. 方向与功能码必须自洽（白名单，不是排除法）
                diFuncMax = 0
                if t.bWrite:
                    bThisWr = True
                    if t.uFunc == 6:
                        diFuncMax = 1      # FC06 只能写一个寄存器
                    elif t.uFunc == 16:
                        diFuncMax = 123    # FC16 最多 123，而且不分片
                    else:
                        bThisBad = True
                else:
                    if t.uFunc == 3 or t.uFunc == 4:
                        diFuncMax = 125
                    else:
                        bThisBad = True

                # c. 数量校验。写任务不分片：一次写不完就直接报错
                if t.uCount == 0:
                    bThisBad = True
                elif t.bWrite and t.uCount > diFuncMax:
                    bThisBad = True

                # d. 数据落点必须在 aBuf 范围内
                if (t.uBufOff + t.uCount) > BUF_SIZE:
                    bThisBad = True

                # e. 触发模式只认 0 和 1
                if t.uTrig > 1:
                    bThisBad = True

                if bThisWr and not bThisBad:
                    bHasWrite = True
            self.abTaskBad[i] = bThisBad
            if bThisBad:
                self.bParaFault = True

        # 表里有能用的写任务，但总闸没开 —— 这个状态必须让现场看得见
        self.bWriteLocked = bHasWrite and (not self.bWriteEnable)

        # ---- 段 2：复位 ----
        if self.bReset:
            self.dwOKCount = 0
            self.dwErrCount = 0
            self.dwWrOKCount = 0
            self.dwWrErrCount = 0
            self.uFailRun = 0
            self.bError = False
            self.bOnline = False
            self.wStatus = 0
            self.iStep = 0
            self.bJobRun = False
            self.bFirst = True
            self.bTrigLatch = False
            for i in range(1, 9):
                self.abReq[i] = False
                self.abDisc[i] = False
            for i in range(1, 33):
                self.abTaskOK[i] = False
                self.abTaskErr[i] = False
                self.awTaskStatus[i] = 0
            self.uConnIdRun = self.uConnIdBase   # v3.5：ID 拉回起点，取证一并清掉
            self.wStatusFirst = 0
            self.uFirstErrTask = 0

        # ---- 段 3：三个定时器 ----
        self.tToTmr(self.iStep == 3, self.tTimeout)
        tPT = self.tPollFail if self.uRoundErr > 0 else self.tPollGap
        self.tGapTmr(self.iStep == 5, tPT)
        self.tRoundTmr((1 <= self.iStep <= 4) or (41 <= self.iStep <= 44), 3600000)
        # v3.4：拆连接保持 / 重连冷却
        self.tDiscTmr(self.iStep == 42, self.tDiscWait)
        self.tReconTmr(self.iStep == 44, self.tReconn)

        # ---- 段 4：状态机 ----
        bEnRise = self.bEnable and not self.bPrevEn
        self.bPrevEn = self.bEnable
        self.bRoundDone = False
        bOvertime = False

        # 触发锁存：bTrigWrite 就算只给一个扫描周期的脉冲，也能等到下一个轮次初始化
        self.bTrigRise = self.bTrigWrite and not self.bPrevTrig
        self.bPrevTrig = self.bTrigWrite
        if self.bTrigRise:
            self.bTrigLatch = True
        if not self.bEnable:
            self.bTrigLatch = False    # 停用期间攒下的触发作废

        if (not self.bEnable) and (1 <= self.iStep <= 6):
            self.iStep = 0
            self.bJobRun = False

        iNext = 0

        if self.iStep == 0:
            # ---- 空闲 ----
            for i in range(1, 9):
                self.abReq[i] = False
            if self.bEnable and (self.bFirst or bEnRise):
                self.bFirst = False
                self.iStep = 6

        elif self.iStep == 1:
            # ---- 调度：挑下一条要做的任务（同槽位优先） ----
            if self.iSlot >= 1:
                for i in range(1, 33):
                    if i <= iTaskCnt and not self.abTaskDone[i]:
                        if (self.aTask[i].uStaNo == self.iSlot
                                and not (self.bSkipDead and self.abSlotDead[self.iSlot])):
                            iNext = i
                            break
            if iNext == 0:
                for i in range(1, 33):
                    if i <= iTaskCnt and not self.abTaskDone[i]:
                        iSlotTmp = self.aTask[i].uStaNo
                        if 1 <= iSlotTmp <= 8:
                            if not (self.bSkipDead and self.abSlotDead[iSlotTmp]):
                                iNext = i
                                break

            if iNext == 0:
                self.tRoundTime = self.tRoundTmr.et
                self.bRoundDone = True
                self.iStep = 5
            elif iNext != self.iTaskNo:
                self.iTaskNo = iNext
                self.iSlot = self.aTask[iNext].uStaNo
                self.iChunkOff = 0
                self.iStep = 2
            else:
                self.iStep = 2

        elif self.iStep == 2:
            # ---- 组装本帧请求 ----
            t = self.aTask[self.iTaskNo]
            if t.bWrite:
                # 写任务：整条一次发完，不分片
                self.iReqLen = t.uCount
            else:
                iChunkRemain = t.uCount - self.iChunkOff
                self.iReqLen = iMaxReq if iChunkRemain > iMaxReq else iChunkRemain

            slot = self.iSlot
            # v3.4 第七个修复：连接参数只在"确实变了"时才重写。
            # 原来每帧照抄一遍（含 LocalPort），MB_CLIENT 每次都以为参数被改
            # 而要重新建连。没有连接层时看不出差别，所以既有场景不受影响。
            # v3.5：连接 ID = 运行基号 + 槽位 - 1。换号后 ID 变了，
            # 下面的"参数变了才重写"就会把新连接参数递给 MB_CLIENT。
            if self.uConnIdRun == 0:
                self.uConnIdRun = self.uConnIdBase
            _cid = self.uConnIdRun + slot - 1
            _c = (self.iInterfaceId, slot,
                  self.aSta[slot].sIP, self.aSta[slot].uPort, _cid)
            if (not self.abConnInit[slot]) or (self.astConn[slot] != _c):
                self.astConn[slot] = _c
                self.abConnInit[slot] = True
            self.auMode[slot] = 100 + t.uFunc
            self.auAddr[slot] = t.uAddr + self.iChunkOff
            self.auLen[slot] = self.iReqLen
            self.auUnit[slot] = self.aSta[slot].uUnitId   # v3.3：设备 ID 一并带上
            self.iWriteOff = t.uBufOff + self.iChunkOff

            # 写任务：把外部缓冲里的内容搬进发送缓冲，赶在 REQ 拉起来之前搬完
            if t.bWrite:
                for i in range(0, 125):
                    if i < self.iReqLen and 0 <= (self.iWriteOff + i) <= 999:
                        self.aChunk[i] = self.aBuf[self.iWriteOff + i]
                    else:
                        self.aChunk[i] = 0    # 尾部清零，不让残留跟着发出去

            # REQ：v3.1 = 单扫描脉冲（下一拍就拉低）；v3.0 = 保持到出结果。
            # 手册原文："当前请求一完成，若以 REQ=1 执行，就可以处理新请求"
            # ⇒ 电平保持 = 完成即重发，连接类故障时会变成每扫描周期都报一次错。
            self.abReq[slot] = True
            self.bJobRun = True            # 门控：从这一刻起段 6 才允许处理结果
            self.iStep = 3

        elif self.iStep == 3:
            # ---- 等结果（读的是上一周期 MB_CLIENT 的输出） ----
            slot = self.iSlot
            if self.bReqPulse:
                self.abReq[slot] = False   # 脉冲到此结束：REQ 只高了一拍
            # v3.1 第四次修复：BUSY 还在高 = 我们发出去的那一发还在飞。
            # 这时候既不能判结果（ERROR 可能还挂着上一次故障），也不能拆连接
            # （拆了就把这一帧掐死在半路，从站恢复了也永远等不到）。
            # 兜底超时优先级最高：MB_CLIENT 自己都不回话时不能一直等。
            if self.tToTmr.q:
                self.abReq[slot] = False
                bOvertime = True
                self.iStep = 41 if self.bAutoRecover else 1
            elif (not self.abBusy[slot]) or not self.bBusyGate:
                if self.abErr[slot]:
                    self.abReq[slot] = False
                    st = self.awSt[slot]
                    if self.bV35 and 0x8380 <= st <= 0x838F:
                        # v3.5：从站回的是 Modbus 协议异常，TCP 连接好端端的，
                        # 拆它只会把一条健康的连接折进"ID 卡死"。
                        self.iStep = 1
                    else:
                        self.iStep = 41 if self.bAutoRecover else 1
                elif self.abDone[slot]:
                    self.abReq[slot] = False
                    self.iStep = 1

        elif self.iStep == 5:
            # ---- 一轮做完，等间隔 ----
            if self.tGapTmr.q:
                self.iStep = 6

        elif self.iStep == 6:
            # ---- 轮次初始化 ----
            self.uRoundOK = 0
            self.uRoundErr = 0
            self.iTaskNo = 0
            self.iChunkOff = 0
            for i in range(1, 9):
                self.abSlotDead[i] = False

            # 本轮触发条件：持续电平算数，脉冲靠锁存抓住。
            # 锁存无条件消费 —— 一次触发只对应一轮（若写成"只在 bTrigWrite 为假时
            # 才清"，单周期脉冲恰好落在这一拍会因电平与锁存同时成立而连做两轮）。
            bTrigNow = self.bTrigWrite or self.bTrigLatch
            self.bTrigLatch = False

            for i in range(1, 33):
                if (i > iTaskCnt or not self.aTask[i].bEnable
                        or self.abTaskBad[i]):
                    self.abTaskDone[i] = True
                elif self.aTask[i].uTrig == 1 and not bTrigNow:
                    # 触发条件没成立：这条本轮不参与
                    self.abTaskDone[i] = True
                elif self.aTask[i].bWrite and not self.bWriteEnable:
                    # 写任务遇到没打开的总闸：本轮一律跳过
                    self.abTaskDone[i] = True
                else:
                    self.abTaskDone[i] = False
            self.iStep = 1

        elif self.iStep == 41:
            self.abReq[self.iSlot] = False
            self.abDisc[self.iSlot] = True
            self.iStep = 42

        elif self.iStep == 42:
            # v3.4 第六个修复：按住 DISCONNECT 直到 tDiscWait 走完，
            # 让 TCP 有时间真正拆掉。bDiscWait=False 复刻 v3.3 的"数一拍就走"。
            self.abDisc[self.iSlot] = True
            if (not self.bDiscWait) or self.tDiscTmr.q:
                self.iStep = 43

        elif self.iStep == 43:
            self.abDisc[self.iSlot] = False
            self.iStep = 44 if self.bDiscWait else 1

        elif self.iStep == 44:
            # v3.4：重连冷却 —— DISCONNECT 释放后先空 tReconn 再发下一帧
            if self.tReconTmr.q:
                self.iStep = 1

        # ---- 段 5：调用 8 个槽位的 MB_CLIENT ----
        for slot in range(1, 9):
            slv = self.slaves.get(slot)
            if slv is None:
                self.astMB[slot].call(False, self.abDisc[slot], None, 0, 0, 0, None, None)
            else:
                was_req = self.astMB[slot].req_prev
                data = self.astMB[slot].call(
                    self.abReq[slot], self.abDisc[slot], self.astConn[slot],
                    self.auMode[slot], self.auAddr[slot], self.auLen[slot],
                    slv, self.aChunk)

                if self.abReq[slot] and not was_req:
                    self.request_log.append((self.iTaskNo, slot,
                                             self.auAddr[slot], self.auLen[slot],
                                             self.auMode[slot],
                                             self.auUnit[slot]))
                if data:
                    self.aChunk[0:len(data)] = data

            self.abDone[slot] = self.astMB[slot].done
            self.abBusy[slot] = self.astMB[slot].busy
            self.abErr[slot] = self.astMB[slot].error
            self.awSt[slot] = self.astMB[slot].status

        # ---- 段 6：结果处理（必须在调用之后同一周期做完） ----
        # v3.1 的关键修复：用 bJobRun 门控 —— 只有"本槽位确实有作业在跑"才处理结果。
        # 不门控（v3.0）时，状态机空闲（步 0/5/6）也会反复进这里，而连接类错误的
        # ERROR 持续有效，于是每个扫描周期都给 dwErrCount / uFailRun 加 1。
        # bGate=False 复刻旧行为，专供场景 15 做对照。
        #
        # 第四次修复再加一道 BUSY 门控：只有"发出去的那一发收尾了"才算数。
        # 光有 bJobRun 时，REQ 起来的同一拍 ERROR 还挂着上一次连接故障，
        # 于是当场结算，报出去的是 16#7005（正在发送）这种进程状态，
        # 真错误码 16#80C5 随后才到却没人接；这一发若侥幸成功，DONE 也被吞掉。
        # ★ v3.2 第五次修复：门控必须写成嵌套 IF。
        # SCL 不短路求值 —— 复合条件里 AND / OR 的每个操作数都会被执行，
        # 与书写顺序无关。所以 bNestedGate=False（复刻 v3.1）时，iSlot=0 的那几拍
        # 照样会去读 abBusy[0]，数组越界（abBusy 是 ARRAY[1..8]）。
        # 现场指纹：上电进 RUN 的头两拍各一条 FB2 区域长度错误，之后干干净净。
        # 这里故意把"算操作数"和"算条件"分成两步写，就是为了对齐 SCL 的语义，
        # 让这个 bug 在仿真里能复现 —— 场景 17 就是拿它做 A/B 的。
        gate_ok = bool(self.bJobRun or not self.bGate)     # 纯布尔，安全
        slot_ok = self.iSlot >= 1                          # 纯比较，安全
        if self.bNestedGate:
            # v3.2：外层成立才碰数组 —— 等价于 IF 的嵌套结构
            busy_ok = ((not self.abBusy[self.iSlot]) or bOvertime
                       or not self.bBusyGate) if slot_ok else False
        else:
            # v3.1 的复合条件：这一步**一定会**执行，slot_ok 救不了它
            busy_ok = (not self.abBusy[self.iSlot]) or bOvertime or not self.bBusyGate
        if gate_ok and slot_ok and busy_ok:
            slot = self.iSlot
            t = self.aTask[self.iTaskNo] if 1 <= self.iTaskNo <= 32 else None

            if self.abDone[slot]:
                # 本帧成功
                self.bError = False
                self.bOnline = True
                self.wStatus = 0
                self.uFailRun = 0
                self.dwOKCount += 1
                if self.uRoundOK < 65535:
                    self.uRoundOK += 1

                if 1 <= self.iTaskNo <= 32:
                    self.abTaskOK[self.iTaskNo] = True
                    self.abTaskErr[self.iTaskNo] = False
                    self.awTaskStatus[self.iTaskNo] = 0

                if t is not None and t.bWrite:
                    # 写任务：数据是往外发的，不需要回拷
                    self.dwWrOKCount += 1
                else:
                    # 读任务：把这一帧搬进数据缓冲（下界保护不能省）
                    for i in range(0, 125):
                        if i < self.iReqLen and 0 <= (self.iWriteOff + i) <= 999:
                            self.aBuf[self.iWriteOff + i] = self.aChunk[i]

                self.iChunkOff += self.iReqLen
                if t is not None and self.iChunkOff >= t.uCount:
                    self.abTaskDone[self.iTaskNo] = True
                    self.iChunkOff = 0

                self.bJobRun = False       # 这一发结算完，门控落下来

            elif self.abErr[slot] or bOvertime:
                # 本帧失败
                self.bError = True
                self.bOnline = False
                self.dwErrCount += 1
                if self.uRoundErr < 65535:
                    self.uRoundErr += 1
                if self.uFailRun < 65535:
                    self.uFailRun += 1

                if bOvertime:
                    self.bTimeout = True
                    self.wStatus = 0x80C8
                else:
                    self.bTimeout = False
                    self.wStatus = self.awSt[slot]

                # ---- v3.5：首次失败取证 + 连接 ID 自动换号 ----
                if self.bV35:
                    if self.uFirstErrTask == 0:
                        self.wStatusFirst = self.wStatus
                        self.uFirstErrTask = self.iTaskNo
                    # 注意用原始 awSt 而不是 wStatus：兜底超时时 wStatus 是 80C8
                    st = self.awSt[slot]
                    if st == 0x80A1 or st == 0x80A3:
                        self.uConnIdRun = (self.uConnIdRun + 1
                                           if self.uConnIdRun < 0x0FFF
                                           else self.uConnIdBase)

                # 逐任务反馈：写失败意味着从站那边没有变成你要的值
                if 1 <= self.iTaskNo <= 32:
                    self.abTaskOK[self.iTaskNo] = False
                    self.abTaskErr[self.iTaskNo] = True
                    self.awTaskStatus[self.iTaskNo] = self.wStatus
                if t is not None and t.bWrite:
                    self.dwWrErrCount += 1

                self.abSlotDead[slot] = True
                self.abTaskDone[self.iTaskNo] = True
                self.iChunkOff = 0

                self.bJobRun = False       # 失败也结算完，同样落门控

        # ---- 段 7：输出刷新 ----
        self.bBusy = 1 <= self.iStep <= 3
        self.iStepOut = self.iStep
        self.uCurTask = self.iTaskNo
        self.uCurSlot = self.iSlot
        if 1 <= self.iTaskNo <= 32:
            self.iCurWrite = self.aTask[self.iTaskNo].bWrite
        if self.iSlot >= 1:
            self.wStatusLive = self.awSt[self.iSlot]


# ---------------------------------------------------------------- 测试脚手架

class Task:
    def __init__(self, uStaNo, uFunc=3, uAddr=0, uCount=10, uBufOff=0,
                 bEnable=True, bWrite=False, uTrig=0):
        self.uStaNo = uStaNo
        self.bWrite = bWrite
        self.uFunc = uFunc
        self.uAddr = uAddr
        self.uCount = uCount
        self.uBufOff = uBufOff
        self.bEnable = bEnable
        self.uTrig = uTrig


class Sta:
    def __init__(self, ip, uPort=502, uUnitId=0xFF):
        self.sIP = ip
        self.uPort = uPort
        self.uUnitId = uUnitId      # 设备 ID（v3.3）。默认 255 = MB_CLIENT 默认值


PASS, FAIL = [], []


def check(name, cond, detail=''):
    if cond:
        PASS.append(name)
        print('  [PASS] %s' % name)
    else:
        FAIL.append(name)
        print('  [FAIL] %s   %s' % (name, detail))


def run(fb, slaves, scans, until=None, each=None):
    """跑 scans 个扫描周期；until 返回 True 就提前停；each(fb) 每拍回调一次"""
    n_fault = len(FAULTS)
    for _ in range(scans):
        fb.scan(slaves)
        if each:
            each(fb)
        if until and until(fb):
            break
    if len(FAULTS) > n_fault:
        print('   [越界] 本段新增 %d 条数组越界访问：%s'
              % (len(FAULTS) - n_fault, FAULTS[n_fault:n_fault + 4]))
    return False


def mk_slaves(*pairs):
    """pairs: (slot, Slave)"""
    return {slot: s for slot, s in pairs}


def wr_count(fb):
    """request_log 里写方向的帧数"""
    return sum(1 for r in fb.request_log if r[4] in WRITE_MODES)


# ================================================================ 场景 1
print('=' * 76)
print('场景 1  多从站轮询：3 个从站各读 20 个寄存器')
print('=' * 76)
fb = FbMbMultiRead(uTaskCount=3)
fb.aSta[1] = Sta([192, 168, 0, 51])
fb.aSta[2] = Sta([192, 168, 0, 52])
fb.aSta[3] = Sta([192, 168, 0, 53])
fb.aTask[1] = Task(uStaNo=1, uAddr=0, uCount=20, uBufOff=0)
fb.aTask[2] = Task(uStaNo=2, uAddr=100, uCount=20, uBufOff=20)
fb.aTask[3] = Task(uStaNo=3, uAddr=200, uCount=20, uBufOff=40)

slaves = mk_slaves((1, Slave([192, 168, 0, 51], base=1000)),
                   (2, Slave([192, 168, 0, 52], base=2000)),
                   (3, Slave([192, 168, 0, 53], base=3000)))
run(fb, slaves, 400, until=lambda f: f.uRoundOK + f.uRoundErr >= 3 and f.bRoundDone)

check('三个从站的数据都读回来了（aBuf[0..59] 有值）',
      all(fb.aBuf[i] != 0 for i in range(60)), 'aBuf[0:6]=%s' % fb.aBuf[0:6])
check('从站 1 的数据落在 aBuf[0]（base+0）', fb.aBuf[0] == 1000, 'got %s' % fb.aBuf[0])
check('从站 2 的数据落在 aBuf[20]（从站内偏移 100）', fb.aBuf[20] == 2100, 'got %s' % fb.aBuf[20])
check('从站 3 的数据落在 aBuf[40]（从站内偏移 200）', fb.aBuf[40] == 3200, 'got %s' % fb.aBuf[40])
check('数据没有互相串位（aBuf[19] 仍是从站1的最后一个）', fb.aBuf[19] == 1019, 'got %s' % fb.aBuf[19])
check('一轮完成脉冲出现过', fb.dwOKCount >= 3)
check('bParaFault 没有误报', fb.bParaFault is False)
check('bWriteLocked 没有误报（表里没有写任务）', fb.bWriteLocked is False)
print('  请求序列（任务, 槽位, 地址, 长度, 模式）：%s' % fb.request_log[:6])


# ================================================================ 场景 2
print()
print('=' * 76)
print('场景 2  同一个 IP 读 600 个寄存器（自动分片）')
print('=' * 76)
fb = FbMbMultiRead(uTaskCount=1, uMaxRegs=125)
fb.aSta[1] = Sta([192, 168, 0, 51])
fb.aTask[1] = Task(uStaNo=1, uAddr=0, uCount=600, uBufOff=0)
slaves = mk_slaves((1, Slave([192, 168, 0, 51], base=0)))

run(fb, slaves, 600, until=lambda f: f.bRoundDone)

lens = [r[3] for r in fb.request_log]
check('600 个寄存器被切成 5 帧', len(lens) == 5, '实际 %d 帧: %s' % (len(lens), lens))
check('分片长度是 125+125+125+125+100', lens == [125, 125, 125, 125, 100], 'got %s' % lens)
check('分片起始地址连续：0,125,250,375,500',
      [r[2] for r in fb.request_log] == [0, 125, 250, 375, 500],
      'got %s' % [r[2] for r in fb.request_log])
check('600 个寄存器全部落位且值正确',
      all(fb.aBuf[i] == i for i in range(600)),
      'aBuf[:3]=%s aBuf[598:600]=%s' % (fb.aBuf[:3], fb.aBuf[598:600]))
check('500..599 这一段没漏（分片边界）', fb.aBuf[500] == 500 and fb.aBuf[599] == 599)
check('aBuf[600] 之后保持为 0（没有越界多写）',
      all(fb.aBuf[i] == 0 for i in range(600, 700)))
check('一轮里 5 帧全部成功', fb.uRoundErr == 0 and fb.dwErrCount == 0)


# ================================================================ 场景 3
print()
print('=' * 76)
print('场景 3  一个从站掉线：它后面的任务被跳过，其他从站不受影响')
print('=' * 76)
fb = FbMbMultiRead(uTaskCount=4, tTimeout=300)
fb.aSta[1] = Sta([192, 168, 0, 51])
fb.aSta[2] = Sta([192, 168, 0, 52])
fb.aTask[1] = Task(uStaNo=1, uAddr=0, uCount=10, uBufOff=0)
fb.aTask[2] = Task(uStaNo=2, uAddr=0, uCount=10, uBufOff=10)     # 掉线从站的第一段
fb.aTask[3] = Task(uStaNo=2, uAddr=10, uCount=10, uBufOff=20)    # 掉线从站的第二段 -> 应被跳过
fb.aTask[4] = Task(uStaNo=2, uAddr=20, uCount=10, uBufOff=30)    # 掉线从站的第三段 -> 应被跳过

slaves = mk_slaves((1, Slave([192, 168, 0, 51], base=1000)),
                   (2, Slave([192, 168, 0, 52], mode='error')))
run(fb, slaves, 800, until=lambda f: f.bRoundDone and f.uRoundErr > 0)

req_slots = [r[1] for r in fb.request_log]
check('掉线从站的后续任务被跳过：对槽位 2 只发过 1 次请求',
      req_slots.count(2) == 1, '请求序列 %s' % fb.request_log)
check('健康从站照常读到数据', fb.aBuf[0] == 1000, 'got %s' % fb.aBuf[0])
check('本轮记到失败帧', fb.uRoundErr == 1, 'uRoundErr=%s' % fb.uRoundErr)
check('错误码被锁存（wStatus != 0）', fb.wStatus == 0x80C8, 'wStatus=16#%04X' % fb.wStatus)
check('bTimeout = FALSE（这是从站明确报的错，不是彻底没响应）', fb.bTimeout is False)
check('uFailRun 开始累计', fb.uFailRun >= 1)

# 对照：关掉 bSkipDead，应该对槽位 2 连发 3 次
fb2 = FbMbMultiRead(uTaskCount=4, tTimeout=300, bSkipDead=False)
for i in range(1, 3):
    fb2.aSta[i] = fb.aSta[i]
for i, t in enumerate(fb.aTask[1:5], start=1):
    fb2.aTask[i] = Task(t.uStaNo, t.uFunc, t.uAddr, t.uCount, t.uBufOff)
run(fb2, slaves, 800, until=lambda f: f.bRoundDone and f.uRoundErr > 0)
check('bSkipDead=FALSE 时同槽位的 3 条任务都会发（对照组）',
      [r[1] for r in fb2.request_log].count(2) == 3,
      '完整请求序列 %s ；uRoundErr=%s uRoundOK=%s'
      % (fb2.request_log, fb2.uRoundErr, fb2.uRoundOK))
check('对照组证明 bSkipDead 确实起作用了（跳过节省了 2 帧超时等待）',
      fb2.uRoundErr == 3, 'uRoundErr=%s（开启跳过时应为 1）' % fb2.uRoundErr)


# ================================================================ 场景 4
print()
print('=' * 76)
print('场景 4  从站彻底装死（连 TCP 层都不回）：兜底超时必须算失败')
print('=' * 76)
fb = FbMbMultiRead(uTaskCount=1, tTimeout=200)
fb.aSta[1] = Sta([192, 168, 0, 51])
fb.aTask[1] = Task(uStaNo=1, uAddr=0, uCount=100, uBufOff=0)
slaves = mk_slaves((1, Slave([192, 168, 0, 51], mode='hang')))

run(fb, slaves, 400, until=lambda f: f.dwErrCount > 0)
check('从站装死时照样计入失败（不会一片干净）', fb.dwErrCount > 0,
      'dwErrCount=%s' % fb.dwErrCount)
check('bTimeout = TRUE（识别成兜底超时）', fb.bTimeout is True)
check('wStatus 兜底填了 16#80C8', fb.wStatus == 0x80C8, 'wStatus=16#%04X' % fb.wStatus)
check('剩下 4 个分片不再傻等（只发过 1 次请求）', len(fb.request_log) == 1,
      '发了 %d 帧' % len(fb.request_log))


# ================================================================ 场景 5
print()
print('=' * 76)
print('场景 5  任务表乱序填：调度要自动按从站归拢，少切槽位')
print('=' * 76)
fb = FbMbMultiRead(uTaskCount=6)
for i in range(1, 4):
    fb.aSta[i] = Sta([192, 168, 0, 50 + i])
# 故意按 1,2,3,1,2,3 乱序填
order = [1, 2, 3, 1, 2, 3]
for idx, sta in enumerate(order, start=1):
    fb.aTask[idx] = Task(uStaNo=sta, uAddr=(idx - 1) * 10, uCount=10, uBufOff=(idx - 1) * 10)

slaves = mk_slaves((1, Slave([192, 168, 0, 51], base=1000)),
                   (2, Slave([192, 168, 0, 52], base=2000)),
                   (3, Slave([192, 168, 0, 53], base=3000)))
run(fb, slaves, 600, until=lambda f: f.bRoundDone and len(fb.request_log) >= 6)

slots = [r[1] for r in fb.request_log[:6]]
switches = sum(1 for a, b in zip(slots, slots[1:]) if a != b)
check('6 条乱序任务按从站归拢成 3 组', sorted(slots) == [1, 1, 2, 2, 3, 3],
      '槽位序列 %s' % slots)
check('槽位只切换了 2 次（3 组之间）', switches == 2, '切了 %d 次' % switches)

# 全量核对：每条任务每个字都必须落在它自己的 uBufOff 上，且值 = 该从站 base + 地址
expect_ok, bad_at = True, None
bases = {1: 1000, 2: 2000, 3: 3000}
for idx, sta in enumerate(order, start=1):
    addr, buf = (idx - 1) * 10, (idx - 1) * 10
    for k in range(10):
        if fb.aBuf[buf + k] != (bases[sta] + addr + k) & 0xFFFF:
            expect_ok, bad_at = False, (idx, buf + k, fb.aBuf[buf + k])
check('乱序填表后 60 个字全部按 uBufOff 正确落位', expect_ok, '首个错位 %s' % (bad_at,))


# ================================================================ 场景 6
print()
print('=' * 76)
print('场景 6  非法任务被过滤，并且点亮 bParaFault')
print('=' * 76)
fb = FbMbMultiRead(uTaskCount=6)
fb.aSta[1] = Sta([192, 168, 0, 51])
fb.aTask[1] = Task(uStaNo=1, uAddr=0, uCount=10, uBufOff=0)                    # 合法
fb.aTask[2] = Task(uStaNo=1, uAddr=0, uCount=0, uBufOff=10)                    # uCount=0
fb.aTask[3] = Task(uStaNo=9, uAddr=0, uCount=10, uBufOff=20)                   # uStaNo 越界
fb.aTask[4] = Task(uStaNo=1, uFunc=5, uAddr=0, uCount=10, uBufOff=30)          # uFunc 非法
fb.aTask[5] = Task(uStaNo=1, uAddr=0, uCount=200, uBufOff=950)                 # 950+200 > 1000
fb.aTask[6] = Task(uStaNo=1, uAddr=0, uCount=10, uBufOff=40, bEnable=False)    # 有意停用
slaves = mk_slaves((1, Slave([192, 168, 0, 51], base=1000)))

run(fb, slaves, 400, until=lambda f: f.bRoundDone)
check('4 条坏任务全部被跳过（只对合法任务发过请求）',
      len(fb.request_log) == 1, '发了 %d 帧' % len(fb.request_log))
check('bParaFault 被点亮（体检结果必须是稳态输出）', fb.bParaFault is True)
check('逐任务体检标志 abTaskBad 逐个标出坏行',
      [fb.abTaskBad[i] for i in range(1, 7)] == [False, True, True, True, True, False],
      'got %s' % [fb.abTaskBad[i] for i in range(1, 7)])
check('越界任务没把 aBuf 写坏（aBuf[999] 仍是 0）', fb.aBuf[999] == 0)
check('合法任务的数据正常', fb.aBuf[0] == 1000)

# 单独验证：只有"有意停用"时不该报警
fb2 = FbMbMultiRead(uTaskCount=1)
fb2.aSta[1] = Sta([192, 168, 0, 51])
fb2.aTask[1] = Task(uStaNo=1, uCount=10, uBufOff=0, bEnable=False)
run(fb2, slaves, 200, until=lambda f: f.bRoundDone)
check('仅 bEnable=FALSE 时不点亮 bParaFault（正常配置不报警）',
      fb2.bParaFault is False)


# ================================================================ 场景 7
print()
print('=' * 76)
print('场景 7  使能停启：停要停得干净，启要能立刻继续')
print('=' * 76)
fb = FbMbMultiRead(uTaskCount=1, tPollGap=100)
fb.aSta[1] = Sta([192, 168, 0, 51])
fb.aTask[1] = Task(uStaNo=1, uAddr=0, uCount=10, uBufOff=0)
slaves = mk_slaves((1, Slave([192, 168, 0, 51], base=1000)))

run(fb, slaves, 200, until=lambda f: f.dwOKCount >= 1)
fb.bEnable = False
fb.scan(slaves)
check('停止后状态机回到 0', fb.iStep == 0, 'iStep=%s' % fb.iStep)
check('停止后 REQ 全部拉低', all(not fb.abReq[i] for i in range(1, 9)))
check('停止后请求不再增加（连接保持但不再发帧）',
      (lambda n: (run(fb, slaves, 200), len(fb.request_log) == n)[1])(len(fb.request_log)))

fb.bEnable = True
run(fb, slaves, 200, until=lambda f: f.dwOKCount >= 2)
check('重新使能后立刻恢复轮询', fb.dwOKCount >= 2, 'dwOKCount=%s' % fb.dwOKCount)


# ================================================================ 场景 8
print()
print('=' * 76)
print('场景 8  连接保持：切回同一个从站时不重复建连（不用 DISCONNECT）')
print('=' * 76)
fb = FbMbMultiRead(uTaskCount=1, tPollGap=100)
fb.aSta[1] = Sta([192, 168, 0, 51])
fb.aTask[1] = Task(uStaNo=1, uAddr=0, uCount=10, uBufOff=0)
slaves = mk_slaves((1, Slave([192, 168, 0, 51], base=1000)))
run(fb, slaves, 300, until=lambda f: f.dwOKCount >= 3)
check('连续多轮都读成功（连接复用没断）', fb.dwOKCount >= 3, 'dwOKCount=%s' % fb.dwOKCount)
check('健康通信期间从不发 DISCONNECT',
      all(not fb.abDisc[i] for i in range(1, 9)))
check('槽位 1 的连接状态是"已建立"', fb.astMB[1].connected is True)


# ================================================================ 场景 9
print()
print('=' * 76)
print('场景 9  【v3】写数据流向：写任务把 aBuf 的内容发到从站，读任务只读不写')
print('=' * 76)
fb = FbMbMultiRead(uTaskCount=2, bWriteEnable=True, tPollGap=100)
fb.aSta[1] = Sta([192, 168, 0, 51])
fb.aTask[1] = Task(uStaNo=1, uFunc=3, uAddr=0, uCount=5, uBufOff=0)                    # 读 5 个
fb.aTask[2] = Task(uStaNo=1, bWrite=True, uFunc=16, uAddr=100, uCount=3, uBufOff=500)   # 写 3 个
fb.aBuf[500], fb.aBuf[501], fb.aBuf[502] = 111, 222, 333
slaves = mk_slaves((1, Slave([192, 168, 0, 51], base=1000)))

run(fb, slaves, 400, until=lambda f: f.dwWrOKCount >= 1 and f.bRoundDone)
s = slaves[1]
check('写帧用的是 FC16（模式 116）', any(r[4] == 116 for r in fb.request_log),
      '模式序列 %s' % [r[4] for r in fb.request_log])
check('aBuf[500..502] 的内容被发到从站 100 号起',
      s.regs[100] == 111 and s.regs[101] == 222 and s.regs[102] == 333,
      '从站 100..102 = %s' % [s.regs[100 + k] for k in range(3)])
check('从站把这次写记录下来了（地址 100 + 值）',
      s.writes_log and s.writes_log[0] == (100, [111, 222, 333]),
      'writes_log=%s' % s.writes_log)
check('读任务照常把从站数据读回 aBuf[0..4]',
      all(fb.aBuf[i] == 1000 + i for i in range(5)), 'aBuf[0:5]=%s' % fb.aBuf[0:5])
check('写任务不做回拷（aBuf[500] 仍是 111，没被读回内容覆盖）', fb.aBuf[500] == 111)
check('写成功单独计数', fb.dwWrOKCount >= 1, 'dwWrOKCount=%s' % fb.dwWrOKCount)
check('逐任务反馈 abTaskOK[2] 为真', fb.abTaskOK[2] is True)
check('uTrig=0 的写任务每轮都写（dwWrOKCount 继续增长）',
      (run(fb, slaves, 600, until=lambda f: f.dwWrOKCount >= 3), fb.dwWrOKCount >= 3)[1],
      'dwWrOKCount=%s' % fb.dwWrOKCount)
check('bWriteLocked 不亮（总闸已开）', fb.bWriteLocked is False)

# FC06 单寄存器写
fb6 = FbMbMultiRead(uTaskCount=1, bWriteEnable=True, tPollGap=50)
fb6.aSta[1] = Sta([192, 168, 0, 51])
fb6.aTask[1] = Task(uStaNo=1, bWrite=True, uFunc=6, uAddr=7, uCount=1, uBufOff=10)
fb6.aBuf[10] = 4242
sl6 = mk_slaves((1, Slave([192, 168, 0, 51], base=0)))
run(fb6, sl6, 200, until=lambda f: f.dwWrOKCount >= 1)
check('FC06 单寄存器写：模式 106，写到寄存器 7',
      any(r[4] == 106 for r in fb6.request_log) and sl6[1].regs[7] == 4242,
      '模式 %s，regs[7]=%s' % ([r[4] for r in fb6.request_log], sl6[1].regs[7]))


# ================================================================ 场景 10
print()
print('=' * 76)
print('场景 10  【v3】方向防呆：bWrite 与 uFunc 对不上、写超标、uTrig 非法，一律拦下')
print('=' * 76)


def one_task(task):
    """单任务跑一轮，返回 FB（用来单独看这一条会不会发出去）"""
    f = FbMbMultiRead(uTaskCount=1, bWriteEnable=True, tPollGap=50)
    f.aSta[1] = Sta([192, 168, 0, 51])
    f.aTask[1] = task
    f.aBuf[0], f.aBuf[1] = 11, 22
    run(f, mk_slaves((1, Slave([192, 168, 0, 51]))), 300, until=lambda x: x.bRoundDone)
    return f


bad_cases = [
    ('写功能码配 bWrite=FALSE（会被真的当写发出去，必须拦）',
     Task(uStaNo=1, bWrite=False, uFunc=16, uAddr=0, uCount=2, uBufOff=0)),
    ('读功能码配 bWrite=TRUE（方向写反）',
     Task(uStaNo=1, bWrite=True, uFunc=3, uAddr=0, uCount=2, uBufOff=0)),
    ('写任务 uCount=200 > 123（写不分片，只能报错）',
     Task(uStaNo=1, bWrite=True, uFunc=16, uAddr=0, uCount=200, uBufOff=0)),
    ('FC06 却要写 3 个（单个寄存器只能写 1 个）',
     Task(uStaNo=1, bWrite=True, uFunc=6, uAddr=0, uCount=3, uBufOff=0)),
    ('uTrig=2（触发模式只认 0/1）',
     Task(uStaNo=1, bWrite=True, uFunc=16, uAddr=0, uCount=2, uBufOff=0, uTrig=2)),
]
for name, task in bad_cases:
    f = one_task(task)
    check(name + ' -> 一帧都没发 + bParaFault 亮',
          len(f.request_log) == 0 and f.bParaFault is True,
          '发了 %d 帧，bParaFault=%s' % (len(f.request_log), f.bParaFault))

f_ok = one_task(Task(uStaNo=1, bWrite=True, uFunc=16, uAddr=0, uCount=2, uBufOff=0))
check('对照组：合法的写任务照常发出去，且不报警',
      len(f_ok.request_log) == 1 and f_ok.request_log[0][4] == 116
      and f_ok.bParaFault is False,
      '发了 %s，bParaFault=%s' % (f_ok.request_log, f_ok.bParaFault))


# ================================================================ 场景 11
print()
print('=' * 76)
print('场景 11  【v3】写总闸：默认关着时写任务一律不发，开闸后立刻生效')
print('=' * 76)
fb = FbMbMultiRead(uTaskCount=2, bWriteEnable=False, tPollGap=80)
fb.aSta[1] = Sta([192, 168, 0, 51])
fb.aTask[1] = Task(uStaNo=1, uFunc=3, uAddr=0, uCount=5, uBufOff=0)                    # 读
fb.aTask[2] = Task(uStaNo=1, bWrite=True, uFunc=16, uAddr=100, uCount=2, uBufOff=500)   # 写
fb.aBuf[500], fb.aBuf[501] = 7, 8
slaves = mk_slaves((1, Slave([192, 168, 0, 51], base=1000)))

run(fb, slaves, 400, until=lambda f: f.dwOKCount >= 2)
check('总闸关闭时写任务一次都没发', wr_count(fb) == 0, '发了 %d 次写' % wr_count(fb))
check('bWriteLocked 亮起（看得见的"配了写却没写"）', fb.bWriteLocked is True)
check('dwWrOKCount 保持 0', fb.dwWrOKCount == 0)
check('从站寄存器没被改动', slaves[1].regs[100] == 1100,
      'regs[100]=%s' % slaves[1].regs[100])
check('读任务仍然正常执行（总闸只拦写）', fb.aBuf[0] == 1000)
check('bParaFault 不因总闸关闭而报警（这是配置，不是错误）', fb.bParaFault is False)

fb.bWriteEnable = True
run(fb, slaves, 400, until=lambda f: f.dwWrOKCount >= 1)
check('开闸后写任务立刻生效', fb.dwWrOKCount >= 1 and slaves[1].regs[100] == 7,
      'dwWrOKCount=%s regs[100]=%s' % (fb.dwWrOKCount, slaves[1].regs[100]))
check('bWriteLocked 熄灭', fb.bWriteLocked is False)


# ================================================================ 场景 12
print()
print('=' * 76)
print('场景 12  【v3 重点】同一个 IP 同一个端口，又读又写 —— 共用一条 TCP 连接')
print('=' * 76)
fb = FbMbMultiRead(uTaskCount=3, bWriteEnable=True, tPollGap=100)
fb.aSta[1] = Sta([192, 168, 0, 51], uPort=502)
fb.aTask[1] = Task(uStaNo=1, uFunc=3, uAddr=0, uCount=20, uBufOff=0)                     # 读 20
fb.aTask[2] = Task(uStaNo=1, bWrite=True, uFunc=16, uAddr=100, uCount=2, uBufOff=500)    # 写 2
fb.aTask[3] = Task(uStaNo=1, uFunc=3, uAddr=200, uCount=10, uBufOff=20)                  # 读 10
fb.aBuf[500], fb.aBuf[501] = 1500, 1
slaves = mk_slaves((1, Slave([192, 168, 0, 51], base=1000)))

run(fb, slaves, 600, until=lambda f: f.bRoundDone and f.dwWrOKCount >= 1)

check('读和写都只用了槽位 1（同一条 TCP 连接，没有为写另开连接）',
      {r[1] for r in fb.request_log} == {1}, '用过的槽位 %s' % sorted({r[1] for r in fb.request_log}))
check('请求里同时出现读模式 103 和写模式 116',
      103 in {r[4] for r in fb.request_log} and 116 in {r[4] for r in fb.request_log},
      '模式集合 %s' % sorted({r[4] for r in fb.request_log}))
check('同槽位优先：一轮里的顺序就是 读 -> 写 -> 读（任务 1/2/3）',
      [r[0] for r in fb.request_log[:3]] == [1, 2, 3],
      '前三帧任务号 %s' % [r[0] for r in fb.request_log[:3]])
check('健康期间从不发 DISCONNECT（一条连接自始至终没被拆过）',
      all(not fb.abDisc[i] for i in range(1, 9)))
check('槽位 1 的连接保持着', fb.astMB[1].connected is True)
check('写的数据落到从站 100/101',
      slaves[1].regs[100] == 1500 and slaves[1].regs[101] == 1,
      'regs[100..101]=%s' % [slaves[1].regs[100], slaves[1].regs[101]])
check('两段读各自正确落位（aBuf[0..19] 与 aBuf[20..29]）',
      all(fb.aBuf[i] == 1000 + i for i in range(20))
      and all(fb.aBuf[20 + i] == 1200 + i for i in range(10)),
      'aBuf[0]=%s aBuf[20]=%s' % (fb.aBuf[0], fb.aBuf[20]))
check('写来源 aBuf[500..501] 原样保留（写任务不回拷）',
      fb.aBuf[500] == 1500 and fb.aBuf[501] == 1)
check('一轮 3 帧（读2+写1）全部成功', fb.uRoundOK == 3 and fb.uRoundErr == 0,
      'uRoundOK=%s uRoundErr=%s' % (fb.uRoundOK, fb.uRoundErr))
check('只有槽位 1 参与通信，2..8 一个请求都没有',
      all(fb.astMB[i].conn is None for i in range(2, 9)))


# ================================================================ 场景 13
print()
print('=' * 76)
print('场景 13  【v3】触发模式：uTrig=1 的写任务只在 bTrigWrite 有效的那一轮做')
print('=' * 76)
fb = FbMbMultiRead(uTaskCount=2, bWriteEnable=True, tPollGap=50)
fb.aSta[1] = Sta([192, 168, 0, 51])
fb.aTask[1] = Task(uStaNo=1, uFunc=3, uAddr=0, uCount=5, uBufOff=0)                              # 每轮都读
fb.aTask[2] = Task(uStaNo=1, bWrite=True, uFunc=16, uAddr=100, uCount=1, uBufOff=500, uTrig=1)  # 只按触发写
fb.aBuf[500] = 999
slaves = mk_slaves((1, Slave([192, 168, 0, 51], base=1000)))

run(fb, slaves, 500)
check('不给触发时一次都不写', wr_count(fb) == 0, '写了 %d 次' % wr_count(fb))
check('读任务（uTrig=0）照常每轮执行', fb.dwOKCount > 0, 'dwOKCount=%s' % fb.dwOKCount)
check('从站寄存器没被动过', slaves[1].regs[100] == 1100, 'regs[100]=%s' % slaves[1].regs[100])

# 一个扫描周期的脉冲
fb.bTrigWrite = True
fb.scan(slaves)
fb.bTrigWrite = False
run(fb, slaves, 400)
check('单周期脉冲触发恰好写 1 次（锁存抓住了脉冲）', wr_count(fb) == 1,
      '写了 %d 次' % wr_count(fb))
check('写的内容正确（aBuf[500] = 999 发到寄存器 100）', slaves[1].regs[100] == 999,
      'regs[100]=%s' % slaves[1].regs[100])

run(fb, slaves, 400)
check('脉冲消费掉之后不再重复写（一次触发只对应一轮）', wr_count(fb) == 1,
      '写了 %d 次' % wr_count(fb))

# 保持电平：持续电平算数
n_before = wr_count(fb)
fb.bTrigWrite = True
run(fb, slaves, 600)
fb.bTrigWrite = False
check('持续电平时每轮都写（持续电平算数）', wr_count(fb) - n_before >= 3,
      '保持电平期间写了 %d 次' % (wr_count(fb) - n_before))


# ================================================================ 场景 14
print()
print('=' * 76)
print('场景 14  【v3】写失败：单独计数 + 逐任务反馈，且不影响其他从站')
print('=' * 76)
fb = FbMbMultiRead(uTaskCount=2, bWriteEnable=True, tTimeout=300, tPollGap=100)
fb.aSta[1] = Sta([192, 168, 0, 51])
fb.aSta[2] = Sta([192, 168, 0, 52])
fb.aTask[1] = Task(uStaNo=1, uFunc=3, uAddr=0, uCount=5, uBufOff=0)                     # 健康读
fb.aTask[2] = Task(uStaNo=2, bWrite=True, uFunc=16, uAddr=100, uCount=2, uBufOff=500)   # 对报错从站写
fb.aBuf[500], fb.aBuf[501] = 1, 2
slaves = mk_slaves((1, Slave([192, 168, 0, 51], base=1000)),
                   (2, Slave([192, 168, 0, 52], mode='error')))

run(fb, slaves, 600, until=lambda f: f.dwWrErrCount >= 1)
check('写失败被单独计数', fb.dwWrErrCount >= 1, 'dwWrErrCount=%s' % fb.dwWrErrCount)
check('abTaskErr[2] 被点起（逐任务反馈）', fb.abTaskErr[2] is True)
check('awTaskStatus[2] 锁存了错误码', fb.awTaskStatus[2] != 0,
      'awTaskStatus[2]=16#%04X' % fb.awTaskStatus[2])
check('dwWrOKCount 仍为 0（没有把失败算成成功）', fb.dwWrOKCount == 0)
check('dwWrErrCount 只加写方向，没污染 dwErrCount 的语义（都加了，但各自独立）',
      fb.dwErrCount >= fb.dwWrErrCount)
check('健康从站的读不受影响', fb.aBuf[0] == 1000, 'aBuf[0]=%s' % fb.aBuf[0])
check('bError 亮起', fb.bError is True)
check('请求里确实没有出现"误判成读"的写', all(r[4] in (103, 104, 106, 116)
                                              for r in fb.request_log))


# ================================================================
print()
print('=' * 76)
print('场景 15  【v3.1 回归】从站拒绝连接（16#80C5）：错误计数不该被刷爆')
print('=' * 76)

# —— 前序健康检查：场景 1–14 全是正常工况，段 6 门控生效，一次越界都不该有 ——
# （放在这里是因为下面马上要跑"故意不门控"的 v3.0 对照，得先把干净的那部分清账）
check('场景 1–14 正常工况：全程没有一次数组越界访问（运行时边界检查健康度）',
      len(FAULTS) == 0, '累计 %d 条：%s' % (len(FAULTS), FAULTS[:4]))
print()


def refuse_case(bGate, bReqPulse, bBusyGate):
    """从站远程拒绝连接，跑 60 秒。

    返回 (fb, 作业数, REQ 高电平拍数, 从站实收请求数, 本段越界明细)。
    入口清一次 FAULTS —— 这样每个对照各自报账，也顺便看清 v3.0 复刻
    "不门控"到底越界成什么样。
    """
    del FAULTS[:]
    fb = FbMbMultiRead(uTaskCount=1, bGate=bGate, bReqPulse=bReqPulse,
                       bBusyGate=bBusyGate, aBuf=[0] * BUF_SIZE)
    fb.aSta[1] = Sta([192, 168, 0, 51])
    fb.aTask[1] = Task(uStaNo=1, uFunc=3, uAddr=0, uCount=10, uBufOff=0)
    sl = {1: Slave([192, 168, 0, 51], mode='refuse', persist_conn_err=True)}
    cnt = {'req_high': 0}

    def each(f):
        if f.abReq[1]:
            cnt['req_high'] += 1

    run(fb, sl, 6000, each=each)          # 6000 拍 x 10ms = 60 秒
    return fb, len(fb.request_log), cnt['req_high'], sl[1].attempts, list(FAULTS)


fb_old, jobs_old, rh_old, at_old, f_old = refuse_case(False, False, False)   # v3.0 复刻：三个修复全关
fb_mid, jobs_mid, rh_mid, at_mid, f_mid = refuse_case(True,  True,  False)   # 只修了前三次（还结算太早）
fb_new, jobs_new, rh_new, at_new, f_new = refuse_case(True,  True,  True)    # v3.1 四次全修

n_wr_old = sum(1 for x in f_old if x == ('abTaskDone', 0))
n_gate_old = sum(1 for x in f_old if x[0] in ('aTask', 'abTaskDone'))
print('  v3.0 复刻越界明细：共 %d 条，其中 abTaskDone[0] = %d 条' % (len(f_old), n_wr_old))
print('  v3.1 有门控越界明细：%d 条（%s）' % (len(f_new), f_new[:4]))

print('  v3.0 复刻   : dwErrCount=%-6d 作业数=%-4d REQ高电平=%-4d 拍 tRoundTime=%-6d wStatus=16#%04X 从站实收=%d'
      % (fb_old.dwErrCount, jobs_old, rh_old, fb_old.tRoundTime, fb_old.wStatus, at_old))
print('  只修前三次  : dwErrCount=%-6d 作业数=%-4d REQ高电平=%-4d 拍 tRoundTime=%-6d wStatus=16#%04X 从站实收=%d'
      % (fb_mid.dwErrCount, jobs_mid, rh_mid, fb_mid.tRoundTime, fb_mid.wStatus, at_mid))
print('  四次全修后  : dwErrCount=%-6d 作业数=%-4d REQ高电平=%-4d 拍 tRoundTime=%-6d wStatus=16#%04X 从站实收=%d'
      % (fb_new.dwErrCount, jobs_new, rh_new, fb_new.tRoundTime, fb_new.wStatus, at_new))
print()

check('两个版本都从来没有成功过（dwOKCount 都是 0）',
      fb_old.dwOKCount == 0 and fb_new.dwOKCount == 0)
check('v3.0 复刻确实被刷爆：错误计数 ≈ 每扫描周期 +1（> 5000 次）',
      fb_old.dwErrCount > 5000, 'dwErrCount=%d' % fb_old.dwErrCount)
check('v3.1 的错误计数回到"真实失败轮数"量级（< 100 次）',
      fb_new.dwErrCount < 100, 'dwErrCount=%d' % fb_new.dwErrCount)
check('v3.1 的错误计数与实际发出的作业数吻合（误差 <= 2）—— 统计终于可信了',
      abs(fb_new.dwErrCount - jobs_new) <= 2,
      'dwErr=%d jobs=%d' % (fb_new.dwErrCount, jobs_new))
check('v3.1 的错误计数比 v3.0 低两个数量级以上（对照证明修复有效）',
      fb_old.dwErrCount > fb_new.dwErrCount * 50,
      'old=%d new=%d' % (fb_old.dwErrCount, fb_new.dwErrCount))
check('v3.1：REQ 每个作业只高 1 个扫描周期',
      rh_new <= jobs_new + 2, 'REQ高电平=%d 拍，作业数=%d' % (rh_new, jobs_new))
check('v3.0 复刻：REQ 一直保持到出结果（手册明说这会"完成即重发"）',
      rh_old >= jobs_old * 3, 'REQ高电平=%d 拍，作业数=%d' % (rh_old, jobs_old))
check('四次全修后：wStatus 锁存的是真错误码 16#80C5',
      fb_new.wStatus == 0x80C5, 'wStatus=16#%04X' % fb_new.wStatus)
check('四次全修后：awTaskStatus 也是真错误码 16#80C5（逐任务反馈同样可信）',
      fb_new.awTaskStatus[1] == 0x80C5,
      'awTaskStatus[1]=16#%04X' % fb_new.awTaskStatus[1])
check('只修前三次：报出来的是 16#7005（正在发送）这种进程状态 —— 这就是第四次修复要解决的',
      fb_mid.wStatus == 0x7005, 'wStatus=16#%04X' % fb_mid.wStatus)
check('第四次修复只改"报什么码"，不改失败帧数（两者应一致）',
      abs(fb_mid.dwErrCount - fb_new.dwErrCount) <= 2,
      'mid=%d new=%d' % (fb_mid.dwErrCount, fb_new.dwErrCount))
check('两个版本的 bError 都亮起', fb_old.bError is True and fb_new.bError is True)
check('v3.1 的轮次计时器不再是 0（修好了漏算失败重连三拍）',
      fb_new.tRoundTime > fb_old.tRoundTime,
      'old=%dms new=%dms' % (fb_old.tRoundTime, fb_new.tRoundTime))
check('版本指纹升到 16#08（v3.5，与 SCL 源码一致）', fb_new.byVer == 0x08,
      'byVer=16#%02X' % fb_new.byVer)

# —— v3.2 顺带坐实的发现：bJobRun 门控是"承重墙" ——
# 给模型补上运行时边界检查后才看得见：v3.0 的"不门控"写法，段 6 会在
# iTaskNo = 0 的那几拍去读 aTask[0]、写 abTaskDone[#iTaskNo]，两个都越界。
# 这正是 9-30 那份诊断缓冲区里"写访问 16#02:2523"那一类事件的来路 ——
# 也就是说门控不只是"防刷计数"，它还挡着一条真实的越界写。
check('v3.0 无门控：60 秒里段 6 越界写 abTaskDone[0] 数千次（承重墙的反证）',
      n_wr_old > 5000, 'abTaskDone[0] 越界 %d 条' % n_wr_old)
check('v3.0 无门控：越界清一色是 aTask / abTaskDone（正是段 6 该碰的那两个数组）',
      n_gate_old == len(f_old), 'aTask/abTaskDone=%d 总=%d' % (n_gate_old, len(f_old)))
check('v3.1/v3.2 有门控：同一路况下段 6 一次都不越界（读、写两类都堵住了）',
      len(f_mid) == 0 and len(f_new) == 0,
      'mid=%d new=%d' % (len(f_mid), len(f_new)))


# ================================================================
print()
print('=' * 76)
print('场景 16  【第四次修复】从站拒绝几次后恢复：恢复后的第一帧不该被白跑')
print('=' * 76)


def recover_case(bBusyGate):
    """从站前 2 次请求拒绝连接，第 3 次起恢复正常。跑 40 秒。
    返回 (fb, 从站)；slave.attempts 就是从站真正收到过多少次请求。"""
    fb = FbMbMultiRead(uTaskCount=1, bGate=True, bReqPulse=True,
                       bBusyGate=bBusyGate, aBuf=[0] * BUF_SIZE)
    fb.aSta[1] = Sta([192, 168, 0, 51])
    fb.aTask[1] = Task(uStaNo=1, uFunc=3, uAddr=0, uCount=10, uBufOff=0)
    sl = {1: Slave([192, 168, 0, 51], mode='refuse', persist_conn_err=True,
                   recover_after=2)}
    run(fb, sl, 4000)          # 4000 拍 x 10ms = 40 秒
    return fb, sl[1]


fb_g, sl_g = recover_case(bBusyGate=False)     # 只修了前三次：结算太早
fb_r, sl_r = recover_case(bBusyGate=True)      # v3.1 四次全修

print('  只修前三次 : dwOKCount=%-4d dwErrCount=%-6d 从站实际收到请求 %d 次'
      % (fb_g.dwOKCount, fb_g.dwErrCount, sl_g.attempts))
print('  四次全修后 : dwOKCount=%-4d dwErrCount=%-6d 从站实际收到请求 %d 次'
      % (fb_r.dwOKCount, fb_r.dwErrCount, sl_r.attempts))
print()

check('只修前三次：从站只收到过第一次请求，之后每一发都在半路被掐死',
      sl_g.attempts <= 1, 'attempts=%d' % sl_g.attempts)
check('只修前三次：因此从站恢复了也白搭，一帧都没读回来',
      fb_g.dwOKCount == 0, 'dwOKCount=%d' % fb_g.dwOKCount)
check('四次全修后：请求真的发到从站（所以才有机会发现它已恢复）',
      sl_r.attempts >= 3, 'attempts=%d' % sl_r.attempts)
check('四次全修后：从站恢复后的第一帧就被收下，没有白跑',
      fb_r.dwOKCount >= 1, 'dwOKCount=%d' % fb_r.dwOKCount)
check('四次全修后：读回来的数据正确落进 aBuf',
      fb_r.aBuf[0] == 1000 and fb_r.aBuf[9] == 1009,
      'aBuf[0]=%s aBuf[9]=%s' % (fb_r.aBuf[0], fb_r.aBuf[9]))
check('四次全修后：恢复之后每一轮都读得回来（轮询彻底恢复正常）',
      fb_r.dwOKCount >= 50, 'dwOKCount=%d' % fb_r.dwOKCount)
check('四次全修后：累计失败次数就等于被拒绝的那几次，没有多余账',
      fb_r.dwErrCount <= 3, 'dwErrCount=%d' % fb_r.dwErrCount)
check('四次全修后：从站恢复即视为在线，bError 落下去',
      fb_r.bError is False, 'bError=%s' % fb_r.bError)
check('两个版本的失败统计都没被刷爆（前三次修复的效果保持住了）',
      fb_g.dwErrCount < 200 and fb_r.dwErrCount < 200,
      'gate_off=%d gate_on=%d' % (fb_g.dwErrCount, fb_r.dwErrCount))
check('场景 16 正常路径：一次数组越界都没有（门控生效，段 6 不碰越界下标）',
      len(FAULTS) == 0, '累计 %d 条：%s' % (len(FAULTS), FAULTS[:4]))


# ============================================================ 场景 17
print()
print('=' * 76)
print('场景 17  上电冷启动：段 6 门控会不会去碰数组（SCL 不短路求值）')
print('=' * 76)

def cold_start(bNestedGate, scans=10):
    """冷启动跑几拍。

    iSlot 要到"步 1 挑到任务"才变成 >=1，在这之前每一拍段 6 的门控都会
    被执行一次。bNestedGate=False 复刻 v3.1 的复合条件（SCL 语义：所有
    操作数都求值），True 是 v3.2 的嵌套 IF。
    """
    del FAULTS[:]
    fb = FbMbMultiRead(uTaskCount=1, bNestedGate=bNestedGate,
                       aBuf=[0] * BUF_SIZE)
    fb.aSta[1] = Sta([192, 168, 0, 51])
    fb.aTask[1] = Task(uStaNo=1, uFunc=3, uAddr=0, uCount=10, uBufOff=0)
    sl = mk_slaves((1, Slave([192, 168, 0, 51])))
    run(fb, sl, scans)
    return fb, list(FAULTS)


fb_u, faults_u = cold_start(bNestedGate=False)   # v3.1：复合条件
fb_n, faults_n = cold_start(bNestedGate=True)    # v3.2：嵌套 IF

print('  v3.1 复合条件：越界 %d 条 %s' % (len(faults_u), faults_u))
print('  v3.2 嵌套 IF ：越界 %d 条 %s' % (len(faults_n), faults_n))
print()

check('v3.1 复合条件在冷启动时越界读 abBusy[0]（就是现场那两条）',
      len(faults_u) == 2 and all(x == ('abBusy', 0) for x in faults_u),
      '实际 %d 条：%s' % (len(faults_u), faults_u))
check('越界条数与现场诊断缓冲区一致：上电头两拍各一条',
      len(faults_u) == 2, '实际 %d 条' % len(faults_u))
check('v3.2 嵌套 IF：同一场景 0 条越界',
      len(faults_n) == 0, '实际 %d 条：%s' % (len(faults_n), faults_n))
check('两种写法最终都挑到任务并跑起来了（修复没改行为）',
      fb_u.iSlot == 1 and fb_n.iSlot == 1,
      'v3.1 iSlot=%d / v3.2 iSlot=%d' % (fb_u.iSlot, fb_n.iSlot))
check('两种写法的成功帧数一致（只堵越界、不动逻辑）',
      fb_u.dwOKCount == fb_n.dwOKCount and fb_u.dwOKCount > 0,
      'v3.1=%d / v3.2=%d' % (fb_u.dwOKCount, fb_n.dwOKCount))
check('全量越界账目对得上：只有两处"故意注入"贡献越界，其余场景都是 0',
      FAULTS_TOTAL == len(f_old) + 2,
      '全量=%d，预期 %d（场景 15 v3.0 复刻 %d + 场景 17 v3.1 复合条件 2）'
      % (FAULTS_TOTAL, len(f_old) + 2, len(f_old)))


# ============================================================ 场景 18
print()
print('=' * 76)
print('场景 18  设备 ID（MB_UNIT_ID）透传：uUnitId 要原样进每一帧请求')
print('=' * 76)


def unit_id_case(uUnitId, scans=200):
    """配置一个从站 + 一条读任务，跑一段，返回 (fb, 每帧的设备 ID 列表)。"""
    del FAULTS[:]
    fb = FbMbMultiRead(uTaskCount=1, aBuf=[0] * BUF_SIZE)
    fb.aSta[1] = Sta([192, 168, 0, 51], uPort=502, uUnitId=uUnitId)
    fb.aTask[1] = Task(uStaNo=1, uFunc=3, uAddr=0, uCount=10, uBufOff=0)
    sl = mk_slaves((1, Slave([192, 168, 0, 51])))
    run(fb, sl, scans, until=lambda f: f.dwOKCount >= 3)
    return fb, [r[5] for r in fb.request_log]


fb_id1, ids1 = unit_id_case(1)
fb_idff, idsff = unit_id_case(0xFF)

print('  uUnitId=1   ：发出 %d 帧，设备 ID 序列 %s' % (len(ids1), sorted(set(ids1))))
print('  uUnitId=255 ：发出 %d 帧，设备 ID 序列 %s' % (len(idsff), sorted(set(idsff))))
print()

check('uUnitId=1：每一帧请求带的设备 ID 都是 1（不是默认 255）',
      len(ids1) >= 3 and set(ids1) == {1},
      '设备 ID 序列 %s' % sorted(set(ids1))[:6])
check('uUnitId=255（默认）：帧里带的就是 MB_CLIENT 的默认值 255',
      len(idsff) >= 3 and set(idsff) == {0xFF},
      '设备 ID 序列 %s' % sorted(set(idsff))[:6])
check('设备 ID 改配置立即生效（两次配置读数不同）',
      set(ids1) != set(idsff))
check('设备 ID 只是个透传字段：两种配置下读回的数据一模一样',
      fb_id1.aBuf[0] == fb_idff.aBuf[0] and fb_id1.aBuf[9] == fb_idff.aBuf[9]
      and fb_id1.dwOKCount >= 3 and fb_idff.dwOKCount >= 3,
      'ok1=%d ok255=%d' % (fb_id1.dwOKCount, fb_idff.dwOKCount))
check('uUnitId 不配也不炸：默认 255 照常通信（仿真从站不校验 ID）',
      fb_idff.dwErrCount == 0, 'dwErrCount=%d' % fb_idff.dwErrCount)


# ============================================================ 场景 19
print('场景 19  【v3.4】拆连接时序：等不够就会撞上 80A1 / 8200')
print('-' * 76)
print('  现场指纹：wStatusLive=16#7004（连接已建立），但 wStatus 在 16#80A1 与')
print('  16#8200 之间循环，dwOKCount 恒为 0。本场景用带连接层的 MB_CLIENT')
print('  模型做 A/B：v3.3 的"拆两拍就发下一帧" vs v3.4 的"等够再发"。')


def disc_case(bDiscWait, slave_mode='error', scans=600):
    """从站报错 -> 块失败后走 41 步拆连接重来。

    返回 (fb, 80A1/8200 出现次数, 成功帧数)。
    """
    fb = FbMbMultiRead(uTaskCount=3, bDiscWait=bDiscWait, bV35=False,
                       mb_cls=MBClientConn, aBuf=[0] * BUF_SIZE)
    fb.aSta[1] = Sta([192, 168, 0, 11], uUnitId=1)
    for k in range(1, 4):
        fb.aTask[k] = Task(uStaNo=1, uFunc=3, uAddr=(k - 1) * 10,
                           uCount=10, uBufOff=(k - 1) * 10)
    sl = {1: Slave([192, 168, 0, 11], mode=slave_mode)}
    fb.bSkipDead = False          # 失败后不跳过，让它在同一轮里连续重试
    seen = []

    def each(f):
        seen.append(f.awSt[1])

    run(fb, sl, scans, each=each)
    bad = sum(1 for s in seen if s in (0x80A1, 0x8200))
    return fb, bad, fb.dwOKCount


fb_d33, bad33, ok33 = disc_case(False)    # v3.3：41→42→43，只等两拍
fb_d34, bad34, ok34 = disc_case(True)     # v3.4：按住 DISCONNECT 等够再发
print('  v3.3 拆两拍就发下一帧：80A1/8200 命中 %d 次，成功帧 %d' % (bad33, ok33))
print('  v3.4 等够再发        ：80A1/8200 命中 %d 次，成功帧 %d' % (bad34, ok34))

check('v3.3 写法（拆连接只等两拍）真的会撞上 80A1 / 8200', bad33 > 0,
      '命中 %d 次' % bad33)
check('v3.4 写法（等够再发）一条 80A1 / 8200 都没有', bad34 == 0,
      '命中 %d 次' % bad34)
check('两种写法都还在轮询（失败计数都 > 0，不是停摆）',
      fb_d33.dwErrCount > 0 and fb_d34.dwErrCount > 0,
      'v33=%d v34=%d' % (fb_d33.dwErrCount, fb_d34.dwErrCount))

# 从站正常时，v3.4 的等待不该影响正常通信
_, _, ok_norm = disc_case(True, slave_mode='ok')
check('从站正常时 v3.4 照样读得回数据（等待没有拖死轮询）', ok_norm > 0,
      '成功帧 %d' % ok_norm)

# 连接参数不再每帧重写：稳定后 astConn 的内容应当保持不变
fb_ci = FbMbMultiRead(uTaskCount=1, mb_cls=MBClientConn, aBuf=[0] * BUF_SIZE)
fb_ci.aSta[1] = Sta([192, 168, 0, 11], uUnitId=1)
fb_ci.aTask[1] = Task(uStaNo=1, uFunc=3, uAddr=0, uCount=10, uBufOff=0)
run(fb_ci, {1: Slave([192, 168, 0, 11], mode='ok')}, 100)
check('连接参数填过一次就置起 abConnInit（不再每帧重写）',
      fb_ci.abConnInit[1] is True)


# ============================================================ 场景 20
print('场景 20  【v3.5】连接 ID 卡死：v3.4 死循环，v3.5 自动换号自愈')
print('-' * 76)
print('  现场第三幕：断电重启后 54 帧正常，掉线后同一个 ID 再也建不起来，')
print('  wStatus 一直在 16#80A1；换个 ID 立刻恢复。')
print()


def stuck_id_case(bV35, warm=400, after=1500):
    """先正常跑一阵 -> 注入一次非正常掉线（该 ID 卡死）-> 看能不能自己回来"""
    fb = FbMbMultiRead(uTaskCount=3, bV35=bV35, mb_cls=MBClientConn,
                       aBuf=[0] * BUF_SIZE)
    fb.aSta[1] = Sta([192, 168, 0, 11], uUnitId=1)
    for k in range(1, 4):
        fb.aTask[k] = Task(uStaNo=1, uFunc=3, uAddr=(k - 1) * 10,
                           uCount=10, uBufOff=(k - 1) * 10)
    sl = {1: Slave([192, 168, 0, 11], mode='ok')}
    fb.bSkipDead = False

    n_fault = len(FAULTS)
    run(fb, sl, warm)
    ok_before = fb.dwOKCount
    fb.astMB[1].inject_drop()          # 对端掐断 + 这个 ID 卡死
    run(fb, sl, after)
    return fb, ok_before, len(FAULTS) - n_fault


s20_old, s20_ok0, s20_f0 = stuck_id_case(False)
s20_new, s20_ok1, s20_f1 = stuck_id_case(True)
print('  v3.4：掉线前成功帧 %d -> 之后成功帧 %d，uFailRun=%d'
      % (s20_ok0, s20_old.dwOKCount, s20_old.uFailRun))
print('  v3.5：掉线前成功帧 %d -> 之后成功帧 %d，uFailRun=%d，ID 基号=%d'
      % (s20_ok1, s20_new.dwOKCount, s20_new.uFailRun, s20_new.uConnIdRun))

check('掉线前两者都通讯正常（注入前确有成功帧）',
      s20_ok0 > 0 and s20_ok1 > 0, 'v34=%d v35=%d' % (s20_ok0, s20_ok1))
check('v3.4 撞上 ID 卡死后再也回不来（成功帧不再增加）',
      s20_old.dwOKCount == s20_ok0,
      '之前 %d，之后 %d' % (s20_ok0, s20_old.dwOKCount))
check('v3.4 的错误循环一直挂着（uFailRun 持续累加）', s20_old.uFailRun > 5,
      'uFailRun=%d' % s20_old.uFailRun)
check('v3.5 自动换号后通讯自愈（成功帧继续增加）',
      s20_new.dwOKCount > s20_ok1,
      '之前 %d，之后 %d' % (s20_ok1, s20_new.dwOKCount))
check('v3.5 确实换了号（运行 ID 基号不再是起始值）',
      s20_new.uConnIdRun > s20_new.uConnIdBase,
      'uConnIdRun=%d' % s20_new.uConnIdRun)
check('v3.5 换号后错误循环被打断（uFailRun 归零）', s20_new.uFailRun == 0,
      'uFailRun=%d' % s20_new.uFailRun)
check('场景 20 全程没有数组越界', s20_f0 == 0 and s20_f1 == 0,
      'v34=%d v35=%d' % (s20_f0, s20_f1))


# ============================================================ 场景 21
print('场景 21  【v3.5】从站协议异常 838x：连接是好的，不该拆')
print('-' * 76)
print('  16#8382 = 从站回"非法数据地址"。这类错误里 TCP 连接完好无损，')
print('  逢错必拆等于把一条健康连接反复折腾，最后作死自己。')


def proto_err_case(bV35, scans=3000):
    fb = FbMbMultiRead(uTaskCount=2, bV35=bV35, mb_cls=MBClientConn,
                       aBuf=[0] * BUF_SIZE)
    fb.aSta[1] = Sta([192, 168, 0, 11], uUnitId=1)
    fb.aTask[1] = Task(uStaNo=1, uFunc=3, uAddr=0, uCount=10, uBufOff=0)
    # 地址越界 -> 从站回 16#8382（非法数据地址），连接本身没问题
    fb.aTask[2] = Task(uStaNo=1, uFunc=3, uAddr=1999, uCount=2, uBufOff=100)
    fb.bSkipDead = False
    sl = {1: Slave([192, 168, 0, 11], mode='ok')}
    cnt = [0]
    prev = [False]

    def each(f):
        d = bool(f.abDisc[1])
        if d and not prev[0]:
            cnt[0] += 1
        prev[0] = d

    n_fault = len(FAULTS)
    run(fb, sl, scans, each=each)
    return fb, cnt[0], len(FAULTS) - n_fault


s21_old, s21_d34, s21_f0 = proto_err_case(False)
s21_new, s21_d35, s21_f1 = proto_err_case(True)
print('  v3.4：拆连接 %d 次，成功帧 %d' % (s21_d34, s21_old.dwOKCount))
print('  v3.5：拆连接 %d 次，成功帧 %d' % (s21_d35, s21_new.dwOKCount))

check('v3.4 见错就拆：8382 也把连接拆了', s21_d34 > 0, '拆了 %d 次' % s21_d34)
check('v3.5 认得出协议异常：一次都没拆连接', s21_d35 == 0,
      '拆了 %d 次' % s21_d35)
check('8382 仍在逐任务反馈里留痕（错误没有被藏起来）',
      s21_new.abTaskErr[2] and s21_new.awTaskStatus[2] == 0x8382,
      'TaskErr=%s Status=16#%04X' % (s21_new.abTaskErr[2],
                                     s21_new.awTaskStatus[2]))
check('另一条正常任务没被这条坏任务拖死（照样读得回来）',
      s21_new.dwOKCount > 0 and s21_new.abTaskOK[1],
      '成功帧 %d' % s21_new.dwOKCount)
check('不拆连接后有效帧更多（少折腾 = 少 downtime）',
      s21_new.dwOKCount >= s21_old.dwOKCount,
      'v34=%d v35=%d' % (s21_old.dwOKCount, s21_new.dwOKCount))
check('场景 21 全程没有数组越界', s21_f0 == 0 and s21_f1 == 0,
      'v34=%d v35=%d' % (s21_f0, s21_f1))


# ============================================================ 场景 22
print('场景 22  【v3.5】首次失败取证：80A1 刷屏时不该丢掉真正的触发者')
print('-' * 76)
print('  现场最难查的就是这个：wStatus 到最后只剩 16#80A1（ID 不可用），')
print('  但 80A1 是**结果** —— 第一次失败的那个码才是**原因**。')


def forensic_case(bV35, warm=200, after=1500):
    """先让从站拒绝连接（80C5），再注入掉线制造 80A1 循环"""
    fb = FbMbMultiRead(uTaskCount=2, bV35=bV35, mb_cls=MBClientConn,
                       aBuf=[0] * BUF_SIZE)
    fb.aSta[1] = Sta([192, 168, 0, 11], uUnitId=1)
    fb.aTask[1] = Task(uStaNo=1, uFunc=3, uAddr=0, uCount=10, uBufOff=0)
    fb.aTask[2] = Task(uStaNo=1, uFunc=3, uAddr=10, uCount=10, uBufOff=10)
    fb.bSkipDead = False
    # 前 2 次请求拒连（80C5），之后恢复 —— 模拟对端那一下子没扛住
    sl = {1: Slave([192, 168, 0, 11], mode='refuse', recover_after=2)}
    seen = []

    def each(f):
        seen.append(f.awSt[1])

    run(fb, sl, warm, each=each)
    fb.astMB[1].inject_drop()          # 之后非正常掉线 -> 80A1 循环
    run(fb, sl, after, each=each)
    saw_a1 = any(s == 0x80A1 for s in seen)
    return fb, saw_a1


s22_new, s22_saw = forensic_case(True)
print('  v3.5：wStatusFirst=16#%04X  uFirstErrTask=%d  当前 wStatus=16#%04X'
      % (s22_new.wStatusFirst, s22_new.uFirstErrTask, s22_new.wStatus))

check('80A1 循环确实发生过（否则这个场景没意义）', s22_saw)
check('取证留住了真正的触发者 16#80C5，而不是刷屏的 80A1',
      s22_new.wStatusFirst == 0x80C5,
      'wStatusFirst=16#%04X' % s22_new.wStatusFirst)
check('同时记下了第一次失败发生在哪条任务', s22_new.uFirstErrTask in (1, 2),
      'uFirstErrTask=%d' % s22_new.uFirstErrTask)
check('后来再失败也不会篡改这条记录（只记第一次）',
      s22_new.uFirstErrTask != 0 and s22_new.wStatusFirst == 0x80C5)

# bReset 要能把取证记录一并清掉，方便现场重新取证
s22_new.bReset = True
s22_new.scan({1: Slave([192, 168, 0, 11], mode='refuse', recover_after=2)})
check('bReset 清掉取证记录（重新取证时可再用）',
      s22_new.uFirstErrTask == 0 and s22_new.wStatusFirst == 0,
      'uFirstErrTask=%d wStatusFirst=16#%04X'
      % (s22_new.uFirstErrTask, s22_new.wStatusFirst))
check('bReset 同时把运行 ID 基号拉回起点',
      s22_new.uConnIdRun == s22_new.uConnIdBase,
      'uConnIdRun=%d' % s22_new.uConnIdRun)


# ================================================================ 汇总
print()
print('=' * 76)
print('汇总：%d 项通过，%d 项失败' % (len(PASS), len(FAIL)))
if FAIL:
    print('失败项：')
    for f in FAIL:
        print('  - %s' % f)
else:
    print('全部通过：True')
print('=' * 76)
