Skip to content
Snippets Groups Projects
Commit 10da10ce authored by Reinier van der Walle's avatar Reinier van der Walle
Browse files

initial commit of cocotb tb_rdma_packetiser

parent f8cb830b
No related branches found
No related tags found
1 merge request!382Resolve HPR-150
Pipeline #72935 passed
from typing import Iterable, Union, Optional
import cocotb
from cocotb.utils import hexdump
from cocotb.triggers import RisingEdge, ReadOnly
from cocotb.binary import BinaryValue
from cocotb.result import TestError
from cocotb_bus.drivers import ValidatedBusDriver
from cocotb_bus.monitors import BusMonitor
class SosiDriver(ValidatedBusDriver):
_optional_signals = ['valid', 'sop', 'eop', 'sync', 'bsn', 'data', 're', 'im', 'empty', 'channel', 'err']
_signals = []
_default_config = {
"dataBitsPerSymbol" : 8,
"firstSymbolInHighOrderBits" : True,
"maxChannel" : 1,
"readyLatency" : 1
}
def __init__(self, dut, name, clk, data_w = None):
ValidatedBusDriver.__init__(self, dut, name, clk, bus_separator='.')
self.config = self._default_config.copy()
self.use_empty = True
self.config["useEmpty"] = self.use_empty
if data_w == None:
self.data_w = len(self.bus.data)
else:
self.data_w = data_w
async def _wait_ready(self):
"""Wait for a ready cycle on the bus before continuing.
Can no longer drive values this cycle...
FIXME assumes readyLatency of 0
"""
await ReadOnly()
while False: #not self.bus.ready.value:
await RisingEdge(self.clock)
await ReadOnly()
async def _send_string(self, string: bytes, sync: bool = True, channel: Optional[int] = None) -> None:
"""Args:
string: A string of bytes to send over the bus.
channel: Channel to send the data on.
"""
# Avoid spurious object creation by recycling
clkedge = RisingEdge(self.clock)
firstword = True
# FIXME: buses that aren't an integer numbers of bytes
bus_width = int(self.data_w / 8)
word = BinaryValue(n_bits=len(self.bus.data),
bigEndian=self.config["firstSymbolInHighOrderBits"])
single = BinaryValue(n_bits=1, bigEndian=False)
if self.use_empty:
empty = BinaryValue(n_bits=len(self.bus.empty), bigEndian=False)
# Drive some defaults since we don't know what state we're in
if self.use_empty:
self.bus.empty.value = 0
self.bus.sop.value = 0
self.bus.eop.value = 0
self.bus.valid.value = 0
if hasattr(self.bus, 'err'):
self.bus.err.value = 0
if hasattr(self.bus, 'channel'):
self.bus.channel.value = 0
elif channel is not None:
raise TestError("%s does not have a channel signal" % self.name)
while string:
if not firstword or (firstword and sync):
await clkedge
# Insert a gap where valid is low
if not self.on:
self.bus.valid.value = 0
for _ in range(self.off):
await clkedge
# Grab the next set of on/off values
self._next_valids()
# Consume a valid cycle
if self.on is not True and self.on:
self.on -= 1
self.bus.valid.value = 1
if hasattr(self.bus, 'channel'):
if channel is None:
self.bus.channel.value = 0
elif channel > self.config['maxChannel'] or channel < 0:
raise TestError("%s: Channel value %d is outside range 0-%d" %
(self.name, channel, self.config['maxChannel']))
else:
self.bus.channel.value = channel
if firstword:
self.bus.sop.value = 1
firstword = False
else:
self.bus.sop.value = 0
nbytes = min(len(string), bus_width)
data = string[:nbytes]
# set unused bits of dp_sosi.data field to 0
data = bytes(word.n_bits // 8 - bus_width) + data
word.buff = data
if len(string) <= bus_width:
self.bus.eop.value = 1
if self.use_empty:
self.bus.empty.value = bus_width - len(string)
string = b""
else:
string = string[bus_width:]
self.bus.data.value = word
# If this is a bus with a ready signal, wait for this word to
# be acknowledged
if hasattr(self.bus, "ready"):
await self._wait_ready()
await clkedge
self.bus.valid.value = 0
self.bus.eop.value = 0
word.binstr = "x" * self.data_w
single.binstr = "x"
self.bus.data.value = word
self.bus.sop.value = single
self.bus.eop.value = single
if self.use_empty:
empty.binstr = "x" * len(self.bus.empty)
self.bus.empty.value = empty
if hasattr(self.bus, 'channel'):
channel_value = BinaryValue(n_bits=len(self.bus.channel), bigEndian=False,
value="x" * len(self.bus.channel))
self.bus.channel.value = channel_value
async def _send_iterable(self, pkt: Iterable, sync: bool = True) -> None:
"""Args:
pkt: Will yield objects with attributes matching the
signal names for each individual bus cycle.
"""
clkedge = RisingEdge(self.clock)
firstword = True
for word in pkt:
if not firstword or (firstword and sync):
await clkedge
firstword = False
# Insert a gap where valid is low
if not self.on:
self.bus.valid.value = 0
for _ in range(self.off):
await clkedge
# Grab the next set of on/off values
self._next_valids()
# Consume a valid cycle
if self.on is not True and self.on:
self.on -= 1
if not hasattr(word, "valid"):
self.bus.valid.value = 1
else:
self.bus.value = word
# Wait for valid words to be acknowledged
if not hasattr(word, "valid") or word.valid:
if hasattr(self.bus, "ready"):
await self._wait_ready()
await clkedge
self.bus.valid.value = 0
async def _driver_send(self, pkt: Union[bytes, Iterable], sync: bool = True, channel: Optional[int] = None):
"""Send a packet over the bus.
Args:
pkt: Packet to drive onto the bus.
channel: Channel attributed to the packet.
If ``pkt`` is a string, we simply send it word by word
If ``pkt`` is an iterable, it's assumed to yield objects with
attributes matching the signal names.
"""
# Avoid spurious object creation by recycling
if isinstance(pkt, bytes):
self.log.debug("Sending packet of length %d bytes", len(pkt))
self.log.debug(hexdump(pkt))
await self._send_string(pkt, sync=sync, channel=channel)
self.log.debug("Successfully sent packet of length %d bytes", len(pkt))
elif isinstance(pkt, str):
raise TypeError("pkt must be a bytestring, not a unicode string")
else:
if channel is not None:
self.log.warning("%s is ignoring channel=%d because pkt is an iterable", self.name, channel)
await self._send_iterable(pkt, sync=sync)
class SisoDriver(ValidatedBusDriver):
_optional_signals = ['ready', 'xon']
_signals = []
def __init__(self, dut, name, clk):
ValidatedBusDriver.__init__(self, dut, name, clk, bus_separator='.')
class SosiMonitor(BusMonitor):
_optional_signals = ['valid', 'sop', 'eop', 'sync', 'bsn', 'data', 're', 'im', 'empty', 'channel', 'err']
_signals = []
_default_config = {
"dataBitsPerSymbol" : 8,
"firstSymbolInHighOrderBits" : True,
"maxChannel" : 1,
"readyLatency" : 1,
"invalidTimeout" : 0,
}
def __init__(self, dut, name, clk, data_w=None, reset=None, reset_n=None, report_channel=False):
BusMonitor.__init__(self, dut, name, clk, reset=reset, reset_n=reset_n, bus_separator='.')
self.config = self._default_config.copy()
self.use_empty = True
self.config["useEmpty"] = self.use_empty
self.report_channel = report_channel
self.data_w = data_w
async def _monitor_recv(self):
"""Watch the pins and reconstruct transactions."""
# Avoid spurious object creation by recycling
clkedge = RisingEdge(self.clock)
pkt = b""
in_pkt = False
invalid_cyclecount = 0
channel = None
def valid():
if hasattr(self.bus, 'ready'):
return self.bus.valid.value and self.bus.ready.value
return self.bus.valid.value
while True:
await clkedge
if self.in_reset:
continue
if valid():
invalid_cyclecount = 0
if self.bus.sop.value:
if pkt:
raise Exception("Duplicate start-of-packet received on %s" %
str(self.bus.sop))
pkt = b""
in_pkt = True
if not in_pkt:
raise Exception("Data transfer outside of "
"packet")
# Handle empty and X's in empty / data
vec = BinaryValue()
if not self.bus.eop.value:
value = self.bus.data.value.get_binstr()[-1*self.data_w:]
vec.assign(value)
else:
value = self.bus.data.value.get_binstr()[-1*self.data_w:]
if self.config["useEmpty"] and self.bus.empty.value.integer:
empty = self.bus.empty.value.integer * self.config["dataBitsPerSymbol"]
if self.config["firstSymbolInHighOrderBits"]:
value = value[:-empty]
else:
value = value[empty:]
vec.assign(value)
if not vec.is_resolvable:
raise Exception("After empty masking value is still bad? "
"Had empty {:d}, got value {:s}".format(empty,
self.bus.data.value.get_binstr()))
vec.big_endian = self.config['firstSymbolInHighOrderBits']
pkt += vec.buff
if hasattr(self.bus, 'channel'):
if channel is None:
channel = self.bus.channel.value.integer
if channel > self.config["maxChannel"]:
raise Exception("Channel value (%d) is greater than maxChannel (%d)" %
(channel, self.config["maxChannel"]))
elif self.bus.channel.value.integer != channel:
raise Exception("Channel value changed during packet")
if self.bus.eop.value:
self.log.info("Received a packet of %d bytes", len(pkt))
self.log.debug(hexdump(pkt))
self.channel = channel
if self.report_channel:
self._recv({"data": pkt, "channel": channel})
else:
self._recv(pkt)
pkt = b""
in_pkt = False
channel = None
else:
if in_pkt:
invalid_cyclecount += 1
if self.config["invalidTimeout"]:
if invalid_cyclecount >= self.config["invalidTimeout"]:
raise Exception(
"In-Packet Timeout. Didn't receive any valid data for %d cycles!" %
invalid_cyclecount)
class SisoMonitor(BusMonitor):
_optional_signals = ['ready', 'xon']
_signals = []
def __init__(self, dut, name, clk, reset=None, reset_n=None):
BusMonitor.__init__(self, dut, name, clk, reset=reset, reset_n=reset_n, bus_separator='.')
self.clk = clk
async def _monitor_recv(self):
"""Watch the pins and reconstruct transactions."""
# Avoid spurious object creation by recycling
clkedge = RisingEdge(self.clock)
# NB could await on valid here more efficiently?
while True:
await clkedge
self._recv({"ready": self.bus.ready.value, "xon": self.bus.xon.value})
class DpStream:
# at the time, cocotb_bus does not officially support VHDL record type bus.
# It does work in the following way:
# 1. Only use _optional_signals, no _signals.
# 2. Use bus_separator='.'
def __init__(self, dut, sosi_name, siso_name, clk, rst, data_w = None):
self.sosi_drv = SosiDriver(dut, sosi_name, clk, data_w)
self.siso_drv = SisoDriver(dut, siso_name, clk)
self.sosi_mon = SosiMonitor(dut, sosi_name, clk, data_w, reset=rst)
self.siso_mon = SisoMonitor(dut, siso_name, clk, reset=rst)
cocotb.start_soon(self.siso_mon._monitor_recv())
from typing import Iterable, Union, Optional
import cocotb.binary
from cocotb.log import SimLog
from cocotb.utils import hexdump
from cocotb.triggers import RisingEdge, ReadOnly
from cocotb.binary import BinaryValue
from cocotb.result import TestError
from cocotb_bus.drivers import BusDriver
from cocotb.decorators import coroutine
class CopiDriver(BusDriver):
_signals = []
_optional_signals = ["rd", "wr", "wrdata", "address"]
def __init__(self, entity, name, clock, **kwargs):
BusDriver.__init__(self, entity, name, clock, **kwargs)
self._can_read = False
self._can_write = False
# Drive some sensible defaults (setimmediatevalue to avoid x asserts)
if hasattr(self.bus, "rd"):
self.bus.rd.setimmediatevalue(0)
self._can_read = True
if hasattr(self.bus, "wr"):
self.bus.wr.setimmediatevalue(0)
v = self.bus.wrdata.value
v.binstr = "x" * len(self.bus.wrdata)
self.bus.wrdata.value = v
self._can_write = True
if hasattr(self.bus, "address"):
v = self.bus.address.value
v.binstr = "x" * len(self.bus.address)
self.bus.address.setimmediatevalue(v)
def read(self, address):
pass
def write(self, address, value):
pass
class CipoDriver(BusDriver):
_signals = []
_optional_signals = ["rddata", "waitrequest","rdval"]
def __init__(self, entity, name, clock, **kwargs):
BusDriver.__init__(self, entity, name, clock, **kwargs)
self._can_read = True
self._can_write = False
# Drive some sensible defaults (setimmediatevalue to avoid x asserts)
if hasattr(self.bus, "rdval"):
self.bus.rdval.setimmediatevalue(0)
if hasattr(self.bus, "rddata"):
v = self.bus.rddata.value
v.binstr = "x" * len(self.bus.rddata)
self.bus.rddata.value = v
if hasattr(self.bus, "waitrequest"):
self.bus.waitrequest.setimmediatevalue(0)
def read(self, address):
pass
def write(self, address, value):
pass
class MMController():
"""Memory Mapped Interface (MM) Controller."""
def __init__(self, entity, copi_name, cipo_name, clock):
self._log = SimLog("cocotb.%s.%s" % (entity._name, copi_name))
self._log.debug("Memory Mapped Controller created")
self.copi = CopiDriver(entity, copi_name, clock, bus_separator='.')
self.cipo = CipoDriver(entity, cipo_name, clock, bus_separator='.')
self.clock = clock
def __len__(self):
return 2**len(self.copi.bus.address)
@coroutine
async def read(self, address: int, sync: bool = True) -> BinaryValue:
"""Issue a request to the bus and block until this comes back.
Simulation time still progresses
but syntactically it blocks.
Args:
address: The address to read from.
sync: Wait for rising edge on clock initially.
Defaults to True.
Returns:
The read data value.
Raises:
:any:`TestError`: If controller is write-only.
"""
if not self.copi._can_read:
self._log.error("Cannot read - have no read signal")
raise TestError("Attempt to read on a write-only MM Controller")
await self.copi._acquire_lock()
# Apply values for next clock edge
if sync:
await RisingEdge(self.clock)
self.copi.bus.address.value = address
self.copi.bus.rd.value = 1
# Wait for waitrequest to be low
if hasattr(self.cipo.bus, "waitrequest"):
await self.cipo._wait_for_nsignal(self.cipo.bus.waitrequest)
await RisingEdge(self.clock)
# Deassert read
self.copi.bus.rd.value = 0
#v = self.copi.bus.address.value
#v.binstr = "x" * len(self.copi.bus.address)
#self.copi.bus.address.value = v
if hasattr(self.cipo.bus, "rdval"):
while True:
await ReadOnly()
if int(self.cipo.bus.rdval):
break
await RisingEdge(self.clock)
else:
# Assume readLatency = 1 if no readdatavalid
# FIXME need to configure this,
# should take a dictionary of Avalon properties.
await ReadOnly()
# Get the data
data = self.cipo.bus.rddata.value
self.copi._release_lock()
return data
@coroutine
async def write(self, address: int, value: int) -> None:
"""Issue a write to the given address with the specified
value.
Args:
address: The address to write to.
value: The data value to write.
Raises:
:any:`TestError`: If controller is read-only.
"""
if not self.copi._can_write:
self._log.error("Cannot write - have no write signal")
raise TestError("Attempt to write on a read-only MM Controller")
await self.copi._acquire_lock()
# Apply values to bus
await RisingEdge(self.clock)
self.copi.bus.address.value = address
self.copi.bus.wrdata.value = value
self.copi.bus.wr.value = 1
# Wait for waitrequest to be low
if hasattr(self.cipo.bus, "waitrequest"):
await self.cipo._wait_for_nsignal(self.cipo.bus.waitrequest)
# Deassert write
await RisingEdge(self.clock)
self.copi.bus.wr.value = 0
#v = self.copi.bus.address.value
#v.binstr = "x" * len(self.copi.bus.address)
#self.copi.bus.address.value = v
#v = self.copi.bus.wrdata.value
#v.binstr = "x" * len(self.copi.bus.wrdata)
#self.copi.bus.wrdata.value = v
self.copi._release_lock()
import cocotb
from cocotb.utils import hexdump
from cocotb.triggers import FallingEdge, Timer, ReadOnly
from cocotb.clock import Clock
from cocotb.binary import BinaryValue
from dp_bus import DpStream
from mm_bus import MMController
async def perform_rst(rst, clk, cycles):
rst.value = 1
await Timer(cycles * clk.period)
rst.value = 0
async def read_mm_dict(mm: MMController, read_dict: dict) -> dict:
# remove all write-only entries
d = {k: v for k, v in read_dict.items() if 'r' in v['access'].lower()}
return_dict = {}
for k, v in d.items():
os = v['offset']
rddata = [(await mm.read(addr)).binstr for addr in range(os, os + v['size'])]
# concat to 32 bits
rddata = [x[-32:] for x in rddata]
# reverse data order to match the write order
rddata = [x for x in rddata[::-1]]
rddata = BinaryValue(''.join(rddata))
return_dict[k] = rddata
return return_dict
async def write_mm_dict(mm: MMController, write_dict: dict) -> None:
# remove all read-only entries
d = {k: v for k, v in write_dict.items() if 'w' in v['access'].lower()}
for k, v in d.items():
# get data to write from dictionary
os = v['offset']
data_w = 32
n_bits = v['size'] * data_w
val = BinaryValue(v['value'], n_bits, False).binstr
wrdata = [BinaryValue(val[i:i+data_w]).integer for i in range(0, n_bits, data_w)]
# reverse data order to match the write order
wrdata = [x for x in wrdata[::-1]]
# write data
for i in range(v['size']):
await mm.write(v['offset'] + i, wrdata[i])
async def send_multi_dp_packet(dp_stream: DpStream, data, n):
for i in range(n):
await cocotb.start_soon(dp_stream.sosi_drv._driver_send(data))
@cocotb.test()
async def tb_rdma_packetiser(dut):
"""Try accessing the design. run with < run -a >"""
n_bytes = dut.c_nof_byte.value
c_data_w = dut.c_data_w.value
n_words = 120 # = 7680 bytes
c_block_len = n_words * n_bytes
c_nof_packets_in_msg = 5
c_dma_len = c_block_len * c_nof_packets_in_msg
n_hdr_regs = 46
hdr_dict = {
"eth_dst_mac": {"access": "RW", "size": 2, "offset": 44, "value": 0xCAFEBABE1996},
"eth_src_mac": {"access": "RW", "size": 2, "offset": 42, "value": 0x1DECAFC0FFEE},
"eth_type": {"access": "RO", "size": 1, "offset": 41, "value": 0x0800},
"ip_version": {"access": "RO", "size": 1, "offset": 40, "value": 4},
"ip_header_length": {"access": "RO", "size": 1, "offset": 39, "value": 5},
"ip_services": {"access": "RO", "size": 1, "offset": 38, "value": 0},
"ip_total_length": {"access": "RO", "size": 1, "offset": 37, "value": -1},
"ip_identification": {"access": "RO", "size": 1, "offset": 36, "value": 0},
"ip_flags": {"access": "RO", "size": 1, "offset": 35, "value": 2},
"ip_fragment_offset": {"access": "RO", "size": 1, "offset": 34, "value": 0},
"ip_time_to_live": {"access": "RO", "size": 1, "offset": 33, "value": 127},
"ip_protocol": {"access": "RO", "size": 1, "offset": 32, "value": 17},
"ip_header_checksum": {"access": "RO", "size": 1, "offset": 31, "value": -1},
"ip_src_addr": {"access": "RW", "size": 1, "offset": 30, "value": 0xFACE0FF},
"ip_dst_addr": {"access": "RW", "size": 1, "offset": 29, "value": 0x7DECADE},
"udp_src_port": {"access": "RW", "size": 1, "offset": 28, "value": 1234},
"udp_dst_port": {"access": "RW", "size": 1, "offset": 27, "value": 4321},
"udp_total_length": {"access": "RO", "size": 1, "offset": 26, "value": -1},
"udp_checksum": {"access": "RO", "size": 1, "offset": 25, "value": 0},
"bth_opcode": {"access": "RO", "size": 1, "offset": 24, "value": -1},
"bth_se": {"access": "RW", "size": 1, "offset": 23, "value": 0},
"bth_m": {"access": "RW", "size": 1, "offset": 22, "value": 0},
"bth_pad": {"access": "RW", "size": 1, "offset": 21, "value": 0},
"bth_tver": {"access": "RW", "size": 1, "offset": 20, "value": 0},
"bth_partition_key": {"access": "RW", "size": 1, "offset": 19, "value": 65535},
"bth_fres": {"access": "RW", "size": 1, "offset": 18, "value": 0},
"bth_bres": {"access": "RW", "size": 1, "offset": 17, "value": 0},
"bth_reserved_a": {"access": "RO", "size": 1, "offset": 16, "value": 0},
"bth_dest_qp": {"access": "RW", "size": 1, "offset": 15, "value": 0},
"bth_ack_req": {"access": "RW", "size": 1, "offset": 14, "value": 0},
"bth_reserved_b": {"access": "RO", "size": 1, "offset": 13, "value": 0},
"bth_psn": {"access": "RO", "size": 1, "offset": 12, "value": -1},
"reth_virtual_address": {"access": "RO", "size": 2, "offset": 10, "value": -1},
"reth_r_key": {"access": "RW", "size": 1, "offset": 9, "value": 0},
"reth_dma_length": {"access": "RW", "size": 1, "offset": 8, "value": c_dma_len},
"immediate_data": {"access": "RW", "size": 1, "offset": 7, "value": 0xABADCAFE},
"config_reserved": {"access": "RO", "size": 1, "offset": 6, "value": 0},
"config_use_immediate": {"access": "RW", "size": 1, "offset": 5, "value": 1},
"config_use_msg_cnt_as_immediate": {"access": "RW", "size": 1, "offset": 4, "value": 0},
"config_nof_packets_in_msg": {"access": "RW", "size": 1, "offset": 3, "value": c_nof_packets_in_msg},
"config_nof_msg": {"access": "RW", "size": 1, "offset": 2, "value": 3},
"config_start_address": {"access": "RW", "size": 2, "offset": 0, "value": 1000000},
}
# simple counter value per byte
snk_in_data = b''.join([(i % 2**8).to_bytes(1, 'little') for i in range(n_words * n_bytes)])
# Create clocks
dpClock = Clock(dut.dp_clk, 5, units="ns")
mmClock = Clock(dut.mm_clk, 1, units="ns")
# DP streams
in_stream = DpStream(dut, 'snk_in', 'snk_out', dut.dp_clk, dut.dp_rst, c_data_w)
out_stream = DpStream(dut, 'src_out', 'src_in', dut.dp_clk, dut.dp_rst, c_data_w)
# MM busses
reg_hdr_dat = MMController(dut, 'reg_hdr_dat_copi', 'reg_hdr_dat_cipo', dut.mm_clk)
cocotb.start_soon(dpClock.start()) # run the dp clock "in the background"
cocotb.start_soon(mmClock.start()) # run the mm clock "in the background"
cocotb.start_soon(perform_rst(dut.dp_rst, dpClock, 7))
cocotb.start_soon(perform_rst(dut.mm_rst, mmClock, 7))
await Timer(dpClock.period * 10) # wait a bit for resets to occur
await FallingEdge(dut.dp_clk) # wait for falling edge/"negedge"
await write_mm_dict(reg_hdr_dat, hdr_dict)
#print(snk_in_data)
await Timer(dpClock.period * 10) # wait a bit for resets to occur
await FallingEdge(dut.dp_clk) # wait for falling edge/"negedge"
mm_rd = await read_mm_dict(reg_hdr_dat, hdr_dict)
for k, v in mm_rd.items():
print(f'{k} = {hex(v.integer)} in hex, {v.integer} in dec')
await FallingEdge(dut.dp_clk) # wait for falling edge/"negedge"
cocotb.start_soon(send_multi_dp_packet(in_stream, snk_in_data, 10))
#cocotb.start_soon(in_stream.sosi_drv._driver_send(snk_in_data))
# wait for packet to arrive on src_out
for i in range(10):
data = await out_stream.sosi_mon.wait_for_recv()
dut._log.info("src_out data = \n%s", hexdump(data))
...@@ -16,6 +16,8 @@ test_bench_files = ...@@ -16,6 +16,8 @@ test_bench_files =
regression_test_vhdl = regression_test_vhdl =
tb/vhdl/tb_tb_rdma_packetiser_assemble_header.vhd tb/vhdl/tb_tb_rdma_packetiser_assemble_header.vhd
cocotb_files =
cocotb/tb_rdma_packetiser.py # DUT is whatever comes after tb_ (so work.rdma_packetiser)
[modelsim_project_file] [modelsim_project_file]
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment