Rogue and PyRogue Clients for SimLink

August 28, 2026 ยท View on GitHub

SimLink speaks the established Rogue TCP simulation protocols over loopback ZeroMQ sockets. Use Rogue's production client classes in applications; the tests/simlink/common pyzmq peer is a regression oracle, not a replacement for Rogue.

See getting started for environment creation and the HDL integration guide for wrapper direction and port allocation.

Client map

HDL wrapperProduction software endpointConstructor
RogueTcpMemoryWraprogue.interfaces.memory.TcpClientTcpClient(host, base_port, wait_ready)
RogueTcpStreamWraprogue.interfaces.stream.TcpClientTcpClient(host, base_port)
RogueSideBandWrappyrogue.interfaces.simulation.SideBandSimSideBandSim(host, base_port)

Pass the HDL base port, not base_port+1. Each client internally connects both members of the pair. Use 127.0.0.1; the simulator binds only to loopback.

Install and verify Rogue

The checked SURF contract uses the environment pinned in the repository-root conda-rogue.yml:

conda env create -f conda-rogue.yml
conda activate surf-rogue-test
python -c 'import rogue, pyrogue; print(rogue.Version.current())'

That file currently pins rogue=v6.15.0. A site's standard Rogue environment is also suitable when it exposes the same interfaces. Do not use the pyzmq-only SURF regression environment as evidence that Rogue itself is installed.

Memory client

Memory is the strongest production-client path: SURF has a checked real-Rogue GHDL contract for readiness, Write, Verify, Read, Post, and a subsequent Read.

import pyrogue as pr
import rogue.interfaces.memory


class Device(pr.Device):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.add(pr.RemoteVariable(
            name="Scratch",
            offset=0x0000,
            bitSize=32,
            bitOffset=0,
            mode="RW",
            verify=True,
        ))


class Root(pr.Root):
    def __init__(self):
        super().__init__(name="SimLinkRoot", timeout=2.0, pollEn=False)
        self.mem = rogue.interfaces.memory.TcpClient(
            "127.0.0.1", 9010, True)
        self.addInterface(self.mem)
        self.add(Device(name="Device", memBase=self.mem))


root = Root()
with root:
    if not root.mem.waitReady(2.0, 0.05):
        raise TimeoutError("SimLink Memory did not become ready")

    root.Device.Scratch.set(0x12345678, write=False)
    root.Device.writeAndVerifyBlocks(force=True)
    print(hex(root.Device.Scratch.get()))

The third constructor argument enables the production readiness behavior used by the checked contract. Calling waitReady(timeout, period) sends a special probe through the complete request/response socket path. SimLink answers it without creating an AXI-Lite cycle.

Enter the PyRogue Root context before transactions and leave it on every shutdown path. PyRogue then starts and stops the interfaces it owns. The full checked example is rogue_memory_client.py.

Stream client

rogue.interfaces.stream.TcpClient is both a Rogue stream slave and master:

local Rogue source -> TcpClient -> HDL mAxisMaster
HDL sAxisMaster -> TcpClient -> local Rogue sink

Connect it to the application's normal Rogue stream graph:

import threading

import rogue.interfaces.stream as ris


class Capture(ris.Slave):
    def __init__(self):
        super().__init__()
        self.frames = []
        self.received = threading.Event()

    def _acceptFrame(self, frame):
        self.frames.append(bytes(frame.getBa()))
        self.received.set()


source = ris.Master()
sink = Capture()
tcp = ris.TcpClient("127.0.0.1", 9000)

source >> tcp
tcp >> sink

try:
    payload = b"SimLink stream frame"
    frame = source._reqFrame(len(payload), True)
    frame.write(payload)
    frame.setFirstUser(0x00)
    frame.setLastUser(0x00)
    source._sendFrame(frame)

    # If the DUT is an echo path, wait for the returned frame.
    if not sink.received.wait(2.0):
        raise TimeoutError("No HDL-to-Rogue frame received")
    assert sink.frames[0] == payload
finally:
    tcp.close()

This example assumes the connected DUT returns the transmitted payload; SimLink does not echo frames itself. In a real application replace ris.Master and Capture with the protocol source, sink, file writer, SRP engine, or other Rogue modules used by the design.

The underscore-prefixed frame allocation/send methods are the established Python subclass interface used by Rogue stream modules. Allocate through the connected master, finish payload and metadata updates before _sendFrame(), and do not modify a frame after sending it.

With SSI_EN_G=true, setFirstUser(), setLastUser(), and setError() map to the SSI metadata transported by the wrapper. With SSI_EN_G=false, those SSI interpretations are disabled. The current SimLink wrapper selects multi-channel routes by the client's port pair, not by frame.setChannel(): create one TcpClient for each derived base port. HDL-to-Rogue frames currently report Rogue channel zero on each client.

Stream has no production equivalent of Memory waitReady(). Constructing the client issues asynchronous ZeroMQ connects but does not prove that the simulator has bound or that an application sink is ready. Coordinate startup with the simulator's listening diagnostic and an application-level handshake when the first frame cannot be retried. In the SimLink test harness this is handled by driving the first frame HDL-to-client: the model's outbound PUSH sets ZMQ_IMMEDIATE and retries on EAGAIN, so that first frame blocks until the client's PULL connects rather than being dropped, warming the pipe before the client sends.

The constructor and graph operations above are verified Rogue APIs. A checked real-Rogue Stream contract now exists (test_RogueStreamRogue.py), exchanging a frame in each direction against RogueTcpStreamWrap under GHDL with the real stream.TcpClient. Extending the required Rogue Regression Tests CI job to run it (and the SideBand contract) alongside the Memory contract is a tracked follow-up; until then it runs locally with SIMLINK_ROGUE_PYTHON set.

SideBand client

SideBandSim sends optional opcode and remote-data fields and invokes a callback for events/state received from HDL:

import threading

import pyrogue.interfaces.simulation as pis


received = []
event = threading.Event()


def on_sideband(op_code, remote_data):
    received.append((op_code, remote_data))
    event.set()


with pis.SideBandSim("127.0.0.1", 9032) as sideband:
    sideband.setRecvCb(on_sideband)

    sideband.send(opCode=0x12)
    sideband.send(remData=0x34)
    sideband.send(opCode=0x56, remData=0x78)

    if not event.wait(2.0):
        raise TimeoutError("No HDL-to-Rogue SideBand update received")

Each argument to send() is optional. An opcode becomes a one-clock pulse on rxOpCodeEn; remote data updates the retained rxRemData state. In the other direction, a one-clock HDL txOpCodeEn pulse produces a callback with an opcode, while changes to txRemData produce a callback with remote data. An unchanged field is passed to the callback as None.

The receive callback runs on the SideBand worker thread. Keep it short and thread-safe; signal another thread for substantial processing. Prefer the context manager so _stop() joins the receive worker and closes both sockets.

SideBand has no production readiness operation. Do not call send() until the simulation has released reset, printed its listening diagnostic, and had an appropriate connection settle or application handshake.

As with Stream, the class and methods above are verified Rogue APIs. A checked real-Rogue SideBand contract now exists (test_RogueSideBandRogue.py), exchanging opcode/remData in each direction against RogueSideBandWrap under GHDL with the real SideBandSim (note the None-for-unchanged-field callback convention above: the receive callback must guard int(...) on each field). Wiring it into the required Rogue Regression Tests CI job is the same tracked follow-up noted for Stream.

PGP2b convenience endpoint

Rogue provides pyrogue.interfaces.simulation.Pgp2bSim for the established PGP2b simulation layout:

import pyrogue.interfaces.simulation as pis

with pis.Pgp2bSim(vcCount=4, host="127.0.0.1", port=9000) as pgp:
    vc0 = pgp.vc[0]
    pgp.sb.setRecvCb(lambda op_code, rem_data: print(op_code, rem_data))
    pgp.sb.send(opCode=0x12)

It creates Stream clients at 9000, 9002, 9004, and 9006 and SideBand at 9008, matching RoguePgp2bSim. Pgp2bSim always uses base+8 for SideBand, so do not use a vcCount or extra allocation that overlaps that pair. PGP3, PGP4, HTSP, and custom layouts should create their Stream clients and SideBandSim explicitly using the offsets in the corresponding HDL wrapper.

Startup and shutdown

Recommended Memory order:

create Root/TcpClient -> start simulation -> release reset
 -> waitReady -> transact -> leave Root context -> stop simulation

Recommended Stream/SideBand order when the peer has no retry protocol:

start simulation -> release reset -> observe Listening on ports N & N+1
 -> create/connect client -> application settle/handshake -> send
 -> close TcpClient or SideBand context -> stop simulation

Clients may be constructed earlier, but the first application send must account for asynchronous connection establishment. SimLink's default 30-second transport timeout bounds an HDL-to-software send when no peer is draining; it is not a readiness delay that applications should routinely consume.

Socket directions for diagnosis

These are the sockets owned by the simulator. Rogue owns the complementary connect-side sockets:

LinkBase NN+1
StreamSimLink PULL; Rogue sends to HDLSimLink PUSH; Rogue receives from HDL
MemorySimLink PULL; Rogue sends requestsSimLink PUSH; Rogue receives completions
SideBandSimLink PUSH; Rogue receives HDL events/stateSimLink PULL; Rogue sends events/state

If one direction is idle, check the correct member of the pair, the wrapper direction table in the HDL guide, and whether the local Rogue graph has both its source and sink connected.