feat(spi): Increase Spi2TLUL bulk transfer size to 16-bit

This change increases the maximum bulk transfer size of the Spi2TLUL module from 256 bytes to 65536 bytes by widening the length registers from 8 to 16 bits.

The key changes include:
- Updated the Spi2TLUL Chisel module to use 16-bit length registers for both bulk and regular TileLink transfers.
- Modified the SPI command state machine to handle two-byte length transactions.
- Re-implemented the internal data buffers using SyncReadMem to support larger, configurable depths, and increased the buffer size to 4KB.
- Introduced pipelining for SRAM reads to improve performance with larger data transfers.
- Updated the Python SPI master utility and cocotb tests to support 16-bit register accesses and larger data transfers.
- Added new cocotb tests to verify large (up to 4KB) packed writes and pipelined reads.

Change-Id: I4420f969584976c4fb16b25513c1791e54aced4c
diff --git a/hdl/chisel/src/bus/Spi2TLUL.scala b/hdl/chisel/src/bus/Spi2TLUL.scala
index 8cb6027..bdba746 100644
--- a/hdl/chisel/src/bus/Spi2TLUL.scala
+++ b/hdl/chisel/src/bus/Spi2TLUL.scala
@@ -32,6 +32,10 @@
         val tl = new OpenTitanTileLink.Host2Device(new TLULParameters(p))
     })
 
+    val kBufferDepth = 256
+    val kBufferWidth = p.lsuDataBits
+    require(kBufferDepth * kBufferWidth / 8 <= 65536, "Total buffer size cannot exceed 65536 bytes")
+
 
     // Synchronize the main asynchronous reset to the SPI clock domain.
     val spi_domain_reset = withClock(io.spi.clk) {
@@ -69,14 +73,14 @@
     val byte_received = bit_count_reg === 7.U
 
     val spi_bulk_read_len_reg = withClockAndReset(io.spi.clk, spi_domain_reset.asAsyncReset) {
-        RegInit(0.U(8.W))
+        RegInit(0.U(16.W))
     }
     val spi_bulk_read_sent_count_reg = withClockAndReset(io.spi.clk, spi_domain_reset.asAsyncReset) {
-        RegInit(0.U(8.W))
+        RegInit(0.U(16.W))
     }
 
     object SpiCmdState extends ChiselEnum {
-        val sIdle, sGotBulkReadAddr, sSendData, sGotOtherCmd = Value
+        val sIdle, sGotBulkReadAddr, sGotBulkReadLenL, sWaitBulkReadLenH, sSendData, sGotOtherCmd = Value
     }
     val spi_cmd_state = withClock(io.spi.clk) { RegInit(SpiCmdState.sIdle) }
 
@@ -88,21 +92,29 @@
 
     val is_write_cmd = completed_byte(7)
     val cmd_addr = completed_byte(6,0)
-    val is_truly_a_bulk_read_cmd = (spi_cmd_state === SpiCmdState.sIdle) && is_first_byte && is_write_cmd && (cmd_addr === SpiRegAddress.BULK_READ_PORT.asUInt)
+    val is_bulk_read_cmd_l = is_first_byte && is_write_cmd && (cmd_addr === SpiRegAddress.BULK_READ_PORT_L.asUInt)
+    val is_bulk_read_cmd_h = !is_first_byte && is_write_cmd && (cmd_addr === SpiRegAddress.BULK_READ_PORT_H.asUInt)
 
     val next_spi_cmd_state = MuxCase(spi_cmd_state, Seq(
-      (spi_cmd_state === SpiCmdState.sIdle && is_truly_a_bulk_read_cmd) -> SpiCmdState.sGotBulkReadAddr,
-      (spi_cmd_state === SpiCmdState.sIdle && !is_truly_a_bulk_read_cmd && is_first_byte && is_write_cmd) -> SpiCmdState.sGotOtherCmd,
-      (spi_cmd_state === SpiCmdState.sGotBulkReadAddr) -> SpiCmdState.sSendData,
+      (spi_cmd_state === SpiCmdState.sIdle && is_bulk_read_cmd_l) -> SpiCmdState.sGotBulkReadAddr,
+      (spi_cmd_state === SpiCmdState.sIdle && !is_bulk_read_cmd_l && is_first_byte && is_write_cmd) -> SpiCmdState.sGotOtherCmd,
+      (spi_cmd_state === SpiCmdState.sGotBulkReadAddr) -> SpiCmdState.sGotBulkReadLenL,
+      (spi_cmd_state === SpiCmdState.sGotBulkReadLenL && is_bulk_read_cmd_h) -> SpiCmdState.sWaitBulkReadLenH,
+      (spi_cmd_state === SpiCmdState.sWaitBulkReadLenH) -> SpiCmdState.sSendData,
       (spi_cmd_state === SpiCmdState.sSendData && spi_bulk_read_sent_count_reg === spi_bulk_read_len_reg) -> SpiCmdState.sIdle,
       (spi_cmd_state === SpiCmdState.sGotOtherCmd) -> SpiCmdState.sIdle,
     ))
     spi_cmd_state := Mux(byte_received, next_spi_cmd_state, spi_cmd_state)
 
     val is_bulk_read_start = byte_received && (spi_cmd_state === SpiCmdState.sGotBulkReadAddr)
-    val block_cmd_enqueue = (spi_cmd_state === SpiCmdState.sGotBulkReadAddr) || (is_truly_a_bulk_read_cmd && byte_received)
+    val is_bulk_read_len_h_byte = byte_received && (spi_cmd_state === SpiCmdState.sWaitBulkReadLenH)
+    val block_cmd_enqueue = (is_bulk_read_cmd_l && byte_received) ||
+                            (spi_cmd_state === SpiCmdState.sGotBulkReadAddr) ||
+                            (spi_cmd_state === SpiCmdState.sGotBulkReadLenL) ||
+                            (spi_cmd_state === SpiCmdState.sWaitBulkReadLenH) ||
+                            (spi_cmd_state === SpiCmdState.sSendData)
 
-    val is_bulk_status_read = is_first_byte && !completed_byte(7) && (completed_byte(6,0) === SpiRegAddress.BULK_READ_STATUS_REG.asUInt) && (spi_cmd_state =/= SpiCmdState.sGotOtherCmd)
+    val is_bulk_status_read = is_first_byte && !completed_byte(7) && (cmd_addr === SpiRegAddress.BULK_READ_STATUS_REG_L.asUInt || cmd_addr === SpiRegAddress.BULK_READ_STATUS_REG_H.asUInt) && (spi_cmd_state =/= SpiCmdState.sGotOtherCmd)
 
     spi2tlul_q.io.enq.valid := byte_received && !is_bulk_status_read && !block_cmd_enqueue
     spi2tlul_q.io.enq.bits  := completed_byte
@@ -119,37 +131,42 @@
         val TL_ADDR_REG_1 = 0x01.U
         val TL_ADDR_REG_2 = 0x02.U
         val TL_ADDR_REG_3 = 0x03.U
-        val TL_LEN_REG    = 0x04.U
-        val TL_CMD_REG    = 0x05.U
-        val TL_STATUS_REG = 0x06.U
-        val DATA_BUF_PORT = 0x07.U
-        val TL_WRITE_STATUS_REG = 0x08.U
-        val BULK_WRITE_PORT = 0x09.U
-        val BULK_READ_PORT = 0x0A.U
-        val BULK_READ_STATUS_REG = 0x0B.U
+        val TL_LEN_REG_L  = 0x04.U
+        val TL_LEN_REG_H  = 0x05.U
+        val TL_CMD_REG    = 0x06.U
+        val TL_STATUS_REG = 0x07.U
+        val DATA_BUF_PORT = 0x08.U
+        val TL_WRITE_STATUS_REG = 0x09.U
+        val BULK_WRITE_PORT_L = 0x0A.U
+        val BULK_WRITE_PORT_H = 0x0B.U
+        val BULK_READ_PORT_L = 0x0C.U
+        val BULK_READ_PORT_H = 0x0D.U
+        val BULK_READ_STATUS_REG_L = 0x0E.U
+        val BULK_READ_STATUS_REG_H = 0x0F.U
     }
 
     // Physical registers backing the map
     val tl_addr_reg = RegInit(VecInit(Seq.fill(4)(0.U(8.W))))
-    val tl_len_reg = RegInit(0.U(8.W))
-    val bulk_len_reg = RegInit(0.U(8.W))
-    val bulk_count_reg = RegInit(0.U(8.W))
+    val tl_len_reg = RegInit(0.U(16.W))
+    val bulk_len_reg = RegInit(0.U(16.W))
+    val bulk_count_reg = RegInit(0.U(16.W))
     // Command and Status registers are handled by the TL FSM, not stored directly here.
-    val write_data_buffer = RegInit(VecInit(Seq.fill(16)(0.U(128.W))))
-    val read_data_buffer = withClockAndReset(io.spi.clk, spi_domain_reset.asAsyncReset) {
-        RegInit(VecInit(Seq.fill(16)(0.U(128.W))))
+    // val write_data_buffer = SyncReadMem(kBufferDepth, UInt(kBufferWidth.W))
+    val write_data_buffer = SRAM(kBufferDepth, UInt(kBufferWidth.W), /*readPorts=*/3, /*writePorts=*/1, /*readWritePorts=*/0)
+    val read_data_buffer = withClock(io.spi.clk) {
+        SRAM(kBufferDepth, UInt(kBufferWidth.W), /*readPorts=*/1, /*writePorts=*/1, /*readWritePorts=*/0)
     }
     val bulk_read_write_ptr = withClockAndReset(io.spi.clk, spi_domain_reset.asAsyncReset) {
-        RegInit(0.U(4.W))
+        RegInit(0.U(log2Ceil(kBufferDepth).W))
     }
     val spi_bulk_read_ptr = withClockAndReset(io.spi.clk, spi_domain_reset.asAsyncReset) {
-        RegInit(0.U(8.W)) // Byte pointer into the data buffer
+        RegInit(0.U(log2Ceil(kBufferDepth * kBufferWidth / 8).W)) // Byte pointer into the data buffer
     }
-    val bulk_write_ptr = RegInit(0.U(8.W)) // Byte pointer for writes
+    val bulk_write_ptr = RegInit(0.U(log2Ceil(kBufferDepth * kBufferWidth / 8).W)) // Byte pointer for writes
 
-    val bytes_written = Cat(bulk_read_write_ptr, 0.U(4.W))
+    val bytes_written = bulk_read_write_ptr << log2Ceil(kBufferWidth / 8)
     val bytes_read = spi_bulk_read_ptr
-    val bulk_read_bytes_available = Wire(UInt(9.W))
+    val bulk_read_bytes_available = Wire(UInt(17.W))
     bulk_read_bytes_available := bytes_written - bytes_read
 
     val addr_reg = RegInit(0.U(7.W))
@@ -162,8 +179,8 @@
 
     // Internal registers for the TL transaction
     val tl_addr_fsm_reg = RegInit(0.U(32.W))
-    val tl_len_fsm_reg = RegInit(0.U(8.W))
-    val tl_beat_count_reg = RegInit(0.U(8.W))
+    val tl_len_fsm_reg = RegInit(0.U(16.W))
+    val tl_beat_count_reg = RegInit(0.U(16.W))
 
     // === TileLink Write FSM ===
     object TlWriteState extends ChiselEnum {
@@ -173,15 +190,16 @@
 
     // Internal registers for the TL write transaction
     val tl_write_addr_fsm_reg = RegInit(0.U(32.W))
-    val tl_write_len_fsm_reg = RegInit(0.U(8.W))
-    val tl_write_beat_count_reg = RegInit(0.U(8.W))
+    val tl_write_len_fsm_reg = RegInit(0.U(16.W))
+    val tl_write_beat_count_reg = RegInit(0.U(16.W))
+    val sram_addr_reg = RegInit(0.U(log2Ceil(kBufferDepth).W))
 
     // Wire to detect a write to the command register
     val do_write = spi_state_reg === SpiState.sWAIT_WRITE_DATA && spi2tlul_q.io.deq.fire
     val tl_cmd_reg_write = do_write && (addr_reg === SpiRegAddress.TL_CMD_REG.asUInt)
     val tl_cmd_reg_data  = spi2tlul_q.io.deq.bits
 
-    val tl_to_spi_bulk_q = Module(new AsyncQueue(UInt(128.W), AsyncQueueParams(depth = 2, safe = false)))
+    val tl_to_spi_bulk_q = Module(new AsyncQueue(UInt(kBufferWidth.W), AsyncQueueParams(depth = 2, safe = false)))
     tl_to_spi_bulk_q.io.enq_clock := clock
     tl_to_spi_bulk_q.io.enq_reset := reset.asBool
     tl_to_spi_bulk_q.io.deq_clock := io.spi.clk
@@ -222,8 +240,8 @@
       (spi_state_reg === SpiState.sIDLE && spi2tlul_q.io.deq.fire) ->
         Mux(is_write, SpiState.sWAIT_WRITE_DATA, SpiState.sSEND_READ_DATA),
       (spi_state_reg === SpiState.sWAIT_WRITE_DATA && spi2tlul_q.io.deq.fire) ->
-        Mux(addr_reg === SpiRegAddress.BULK_WRITE_PORT.asUInt, SpiState.sBULK_WRITE_DATA,
-        Mux(addr_reg === SpiRegAddress.BULK_READ_PORT.asUInt, SpiState.sBULK_READ_DATA,
+        Mux(addr_reg === SpiRegAddress.BULK_WRITE_PORT_H.asUInt, SpiState.sBULK_WRITE_DATA,
+        Mux(addr_reg === SpiRegAddress.BULK_READ_PORT_H.asUInt, SpiState.sBULK_READ_DATA,
             SpiState.sIDLE)),
       (spi_state_reg === SpiState.sSEND_READ_DATA && tlul2spi_q.io.enq.fire) ->
         SpiState.sIDLE,
@@ -247,31 +265,51 @@
       tl_addr_reg(i) := Mux(writing_addr_reg && (addr_reg === (SpiRegAddress.TL_ADDR_REG_0 + i.U)), data, tl_addr_reg(i))
     }
 
-    val writing_len_reg = do_write && addr_reg === SpiRegAddress.TL_LEN_REG.asUInt
-    tl_len_reg := Mux(writing_len_reg, data, tl_len_reg)
+    val writing_len_reg_l = do_write && addr_reg === SpiRegAddress.TL_LEN_REG_L.asUInt
+    val writing_len_reg_h = do_write && addr_reg === SpiRegAddress.TL_LEN_REG_H.asUInt
+    tl_len_reg := Cat(
+        Mux(writing_len_reg_h, data, tl_len_reg(15, 8)),
+        Mux(writing_len_reg_l, data, tl_len_reg(7, 0))
+    )
 
-    val writing_bulk_write_port = do_write && addr_reg === SpiRegAddress.BULK_WRITE_PORT.asUInt
-    val writing_bulk_read_port = do_write && addr_reg === SpiRegAddress.BULK_READ_PORT.asUInt
-    bulk_len_reg := Mux(writing_bulk_write_port || writing_bulk_read_port, data, bulk_len_reg)
+    val writing_bulk_write_port_l = do_write && addr_reg === SpiRegAddress.BULK_WRITE_PORT_L.asUInt
+    val writing_bulk_write_port_h = do_write && addr_reg === SpiRegAddress.BULK_WRITE_PORT_H.asUInt
+    val writing_bulk_read_port_l = do_write && addr_reg === SpiRegAddress.BULK_READ_PORT_L.asUInt
+    val writing_bulk_read_port_h = do_write && addr_reg === SpiRegAddress.BULK_READ_PORT_H.asUInt
+    val writing_bulk_len_l = writing_bulk_write_port_l || writing_bulk_read_port_l
+    val writing_bulk_len_h = writing_bulk_write_port_h || writing_bulk_read_port_h
+    bulk_len_reg := Cat(
+        Mux(writing_bulk_len_h, data, bulk_len_reg(15, 8)),
+        Mux(writing_bulk_len_l, data, bulk_len_reg(7, 0))
+    )
 
-    val write_word_index = bulk_write_ptr(7,4)
-    val write_byte_index = bulk_write_ptr(3,0)
+    val byte_index_bits = log2Ceil(kBufferWidth / 8)
+    val write_word_index = bulk_write_ptr >> byte_index_bits
+    val write_byte_index = bulk_write_ptr(byte_index_bits - 1, 0)
+
+    // Pipelined read for single-byte access
+    val single_read_addr_reg = RegNext(spi_bulk_read_ptr >> byte_index_bits, 0.U)
+    write_data_buffer.readPorts(0).enable := true.B
+    write_data_buffer.readPorts(0).address := single_read_addr_reg
+    val selected_word_reg = RegNext(write_data_buffer.readPorts(0).data, 0.U)
+
     val write_shift = write_byte_index << 3
     val write_mask = ~(0xFF.U << write_shift)
-    val write_old_word = write_data_buffer(write_word_index)
+    write_data_buffer.readPorts(1).enable := true.B
+    write_data_buffer.readPorts(1).address := write_word_index
+    val write_old_word = write_data_buffer.readPorts(1).data // Still combinational for write
     val write_new_word = (write_old_word & write_mask) | (data << write_shift)
     val write_cmd_fire = tl_cmd_reg_write && tl_cmd_reg_data === 2.U
     val writing_data_buf_single = do_write && addr_reg === SpiRegAddress.DATA_BUF_PORT.asUInt
     val writing_bulk_data = spi_state_reg === SpiState.sBULK_WRITE_DATA && spi2tlul_q.io.deq.fire
     val writing_data_buf = writing_data_buf_single || writing_bulk_data
-    val start_bulk_write = do_write && addr_reg === SpiRegAddress.BULK_WRITE_PORT.asUInt
+    val start_bulk_write = do_write && addr_reg === SpiRegAddress.BULK_WRITE_PORT_H.asUInt
     bulk_write_ptr := Mux(write_cmd_fire || start_bulk_write, 0.U,
                       Mux(writing_data_buf, bulk_write_ptr + 1.U, bulk_write_ptr))
 
     // sSEND_READ_DATA
-    val word_index = spi_bulk_read_ptr(7,4)
-    val byte_index = spi_bulk_read_ptr(3,0)
-    val selected_word = write_data_buffer(word_index)
+    val selected_word = selected_word_reg
+    val byte_index = RegNext(spi_bulk_read_ptr(byte_index_bits - 1, 0), 0.U)
 
     val status_map = Seq(
         TlReadState.sIdle.asUInt -> 0x00.U,
@@ -294,18 +332,18 @@
         SpiRegAddress.TL_ADDR_REG_1.asUInt -> tl_addr_reg(1),
         SpiRegAddress.TL_ADDR_REG_2.asUInt -> tl_addr_reg(2),
         SpiRegAddress.TL_ADDR_REG_3.asUInt -> tl_addr_reg(3),
-        SpiRegAddress.TL_LEN_REG.asUInt    -> tl_len_reg,
+        SpiRegAddress.TL_LEN_REG_L.asUInt  -> tl_len_reg(7, 0),
+        SpiRegAddress.TL_LEN_REG_H.asUInt  -> tl_len_reg(15, 8),
         SpiRegAddress.TL_STATUS_REG.asUInt -> MuxLookup(tl_read_state_reg.asUInt, 0.U)(status_map),
         SpiRegAddress.TL_WRITE_STATUS_REG.asUInt ->
             MuxLookup(tl_write_state_reg.asUInt, 0.U)(write_status_map),
         SpiRegAddress.DATA_BUF_PORT.asUInt -> (selected_word.asUInt >> (byte_index << 3.U))(7,0),
-        SpiRegAddress.BULK_READ_PORT.asUInt -> (selected_word.asUInt >> (byte_index << 3.U))(7,0),
     )
     tlul2spi_q.io.enq.bits := MuxLookup(addr_reg, 0.U(8.W))(read_map)
 
     val read_cmd_fire = tl_cmd_reg_write && tl_cmd_reg_data === 1.U
     val reading_bulk_data = spi_state_reg === SpiState.sBULK_READ_DATA && tlul2spi_q.io.enq.fire
-    val start_bulk_read = do_write && addr_reg === SpiRegAddress.BULK_READ_PORT.asUInt
+    val start_bulk_read = do_write && addr_reg === SpiRegAddress.BULK_READ_PORT_H.asUInt
 
     bulk_count_reg := Mux(start_bulk_write || start_bulk_read, 0.U,
                       Mux(writing_bulk_data || reading_bulk_data, bulk_count_reg + 1.U, bulk_count_reg))
@@ -314,7 +352,9 @@
         // Combinational signal for decrementing byte counter
         val reading_bulk_data_byte = spi_cmd_state === SpiCmdState.sSendData && byte_received
 
-        read_data_buffer(bulk_read_write_ptr) := Mux(tl_to_spi_bulk_q.io.deq.fire, tl_to_spi_bulk_q.io.deq.bits, read_data_buffer(bulk_read_write_ptr))
+        read_data_buffer.writePorts(0).enable := tl_to_spi_bulk_q.io.deq.fire
+        read_data_buffer.writePorts(0).address := bulk_read_write_ptr
+        read_data_buffer.writePorts(0).data := tl_to_spi_bulk_q.io.deq.bits
         bulk_read_write_ptr := Mux(tl_to_spi_bulk_q.io.deq.fire, bulk_read_write_ptr + 1.U, bulk_read_write_ptr)
 
         spi_bulk_read_ptr := Mux(reading_bulk_data_byte, spi_bulk_read_ptr + 1.U, spi_bulk_read_ptr)
@@ -323,22 +363,31 @@
         spi_bulk_read_sent_count_reg := Mux(reset_sent_count, 0.U,
                                           Mux(reading_bulk_data_byte, spi_bulk_read_sent_count_reg + 1.U, spi_bulk_read_sent_count_reg))
 
-        spi_bulk_read_len_reg := Mux(is_bulk_read_start, completed_byte, spi_bulk_read_len_reg)
+        spi_bulk_read_len_reg := Cat(
+            Mux(is_bulk_read_len_h_byte, completed_byte, spi_bulk_read_len_reg(15, 8)),
+            Mux(is_bulk_read_start,      completed_byte, spi_bulk_read_len_reg(7, 0))
+        )
 
         mosi_data_reg := Cat(mosi_data_reg(6,0), io.spi.mosi)
         bit_count_reg := bit_count_reg + 1.U
 
-        val read_word_index = spi_bulk_read_ptr(7,4)
-        val read_byte_index = spi_bulk_read_ptr(3,0)
-        val selected_read_word = read_data_buffer(read_word_index)
-        val selected_read_byte = (selected_read_word >> (read_byte_index << 3.U))(7,0)
+        val read_word_index = spi_bulk_read_ptr >> byte_index_bits
+        val read_byte_index = spi_bulk_read_ptr(byte_index_bits - 1, 0)
+        val read_byte_index_reg = RegNext(read_byte_index, 0.U)
+        read_data_buffer.readPorts(0).enable := true.B
+        read_data_buffer.readPorts(0).address := read_word_index
+        val selected_read_word = read_data_buffer.readPorts(0).data
+        val selected_read_byte = (selected_read_word >> (read_byte_index_reg << 3.U))(7,0)
 
         // --- MISO Path Refactor with Forwarding ---
 
         // 1. Define the single source of new data and its validity
+        val selected_status_byte = Mux(cmd_addr === SpiRegAddress.BULK_READ_STATUS_REG_L.asUInt,
+                                       bulk_read_bytes_available(7, 0),
+                                       bulk_read_bytes_available(15, 8))
         val miso_data_source_bits = MuxCase(0.U, Seq(
             (is_bulk_read_start || reading_bulk_data_byte) -> selected_read_byte,
-            is_bulk_status_read     -> bulk_read_bytes_available,
+            is_bulk_status_read     -> selected_status_byte,
             tlul2spi_q.io.deq.fire  -> tlul2spi_q.io.deq.bits
         ))
         val miso_data_source_valid = is_bulk_read_start || reading_bulk_data_byte || is_bulk_status_read || tlul2spi_q.io.deq.fire
@@ -393,16 +442,18 @@
 
     a_bits.opcode   := Mux(write_fsm_active, TLULOpcodesA.PutFullData.asUInt, TLULOpcodesA.Get.asUInt)
     a_bits.address  := Mux(write_fsm_active,
-                           tl_write_addr_fsm_reg + (tl_write_beat_count_reg << log2Ceil(tlul_p.w)),
+                           tl_write_addr_fsm_reg + (sram_addr_reg << log2Ceil(tlul_p.w)),
                            tl_addr_fsm_reg + (tl_beat_count_reg << log2Ceil(tlul_p.w)))
-    a_bits.data     := Mux(write_fsm_active, write_data_buffer(tl_write_beat_count_reg(3,0)), 0.U)
+    write_data_buffer.readPorts(2).enable := true.B
+    write_data_buffer.readPorts(2).address := sram_addr_reg
+    val tl_write_data = write_data_buffer.readPorts(2).data
+    a_bits.data     := Mux(write_fsm_active, RegNext(tl_write_data, 0.U), 0.U)
 
     tl_a_q.io.enq.bits := a_bits
 
-    for (i <- 0 until write_data_buffer.length) {
-        val write_to_buffer = i.U === write_word_index && writing_data_buf
-        write_data_buffer(i) := Mux(write_to_buffer, write_new_word, write_data_buffer(i))
-    }
+    write_data_buffer.writePorts(0).enable := writing_data_buf
+    write_data_buffer.writePorts(0).address := write_word_index
+    write_data_buffer.writePorts(0).data := write_new_word
 
     val clear_command = tl_cmd_reg_write && tl_cmd_reg_data === 0.U
 
@@ -451,8 +502,13 @@
                                        !tl_d_q.io.deq.bits.error,
                                        tl_write_beat_count_reg + 1.U,
                                        tl_write_beat_count_reg)
+    val is_last_beat_ack = (tl_write_state_reg === TlWriteState.sWaitBeatAck) && tl_d_q.io.deq.fire && (tl_write_beat_count_reg === tl_write_len_fsm_reg)
     tl_write_beat_count_reg := Mux(write_cmd_fire, 0.U, tl_write_beat_count_next)
 
+    val sram_addr_inc = tl_a_q.io.enq.fire && write_fsm_active
+    val sram_addr_next = Mux(sram_addr_inc, sram_addr_reg + 1.U, sram_addr_reg)
+    sram_addr_reg := Mux(is_last_beat_ack, 0.U, sram_addr_next)
+
     tl_write_addr_fsm_reg := Mux(write_cmd_fire, tl_addr_reg.asUInt, tl_write_addr_fsm_reg)
     tl_write_len_fsm_reg := Mux(write_cmd_fire, tl_len_reg, tl_write_len_fsm_reg)
 }
diff --git a/kelvin_test_utils/spi_constants.py b/kelvin_test_utils/spi_constants.py
index 197e63f..adf4cc4 100644
--- a/kelvin_test_utils/spi_constants.py
+++ b/kelvin_test_utils/spi_constants.py
@@ -19,14 +19,18 @@
     TL_ADDR_REG_1 = 0x01
     TL_ADDR_REG_2 = 0x02
     TL_ADDR_REG_3 = 0x03
-    TL_LEN_REG    = 0x04
-    TL_CMD_REG    = 0x05
-    TL_STATUS_REG = 0x06
-    DATA_BUF_PORT = 0x07
-    TL_WRITE_STATUS_REG = 0x08
-    BULK_WRITE_PORT = 0x09
-    BULK_READ_PORT = 0x0A
-    BULK_READ_STATUS_REG = 0x0B
+    TL_LEN_REG_L  = 0x04
+    TL_LEN_REG_H  = 0x05
+    TL_CMD_REG    = 0x06
+    TL_STATUS_REG = 0x07
+    DATA_BUF_PORT = 0x08
+    TL_WRITE_STATUS_REG = 0x09
+    BULK_WRITE_PORT_L = 0x0A
+    BULK_WRITE_PORT_H = 0x0B
+    BULK_READ_PORT_L = 0x0C
+    BULK_READ_PORT_H = 0x0D
+    BULK_READ_STATUS_REG_L = 0x0E
+    BULK_READ_STATUS_REG_H = 0x0F
 
 class SpiCommand(IntEnum):
     CMD_NULL = 0x00
diff --git a/kelvin_test_utils/spi_master.py b/kelvin_test_utils/spi_master.py
index 060fba5..9eb22fd 100644
--- a/kelvin_test_utils/spi_master.py
+++ b/kelvin_test_utils/spi_master.py
@@ -83,6 +83,13 @@
         if wait_cycles > 0:
             await ClockCycles(self.main_clk, wait_cycles)
 
+    async def write_reg_16b(self, base_addr, data, wait_cycles=10):
+        """Writes a 16-bit value to a register pair via SPI."""
+        await self.write_reg(base_addr, data & 0xFF, wait_cycles=0)
+        await self.write_reg(base_addr + 1, (data >> 8) & 0xFF, wait_cycles=0)
+        if wait_cycles > 0:
+            await ClockCycles(self.main_clk, wait_cycles)
+
     async def read_reg(self, reg_addr):
         """Reads a byte from a register via SPI."""
         read_cmd = reg_addr # MSB is 0 for read
@@ -106,6 +113,12 @@
         await ClockCycles(self.main_clk, 1)
         return read_data
 
+    async def read_spi_domain_reg_16b(self, base_addr):
+        """Reads a 16-bit value from a register pair in the SPI clock domain."""
+        val_l = await self.read_spi_domain_reg(base_addr)
+        val_h = await self.read_spi_domain_reg(base_addr + 1)
+        return (val_h << 8) | val_l
+
     async def poll_reg_for_value(self, reg_addr, expected_value, max_polls=20):
         """Polls a register until it reads an expected value."""
         read_cmd = reg_addr # MSB is 0 for read
@@ -149,8 +162,11 @@
         await self._clock_byte((target_addr >> 24) & 0xFF)
 
         # Write beats
-        await self._clock_byte(CMD_WRITE | SpiRegAddress.TL_LEN_REG)
-        await self._clock_byte(len(data) - 1)
+        num_beats = len(data)
+        await self._clock_byte(CMD_WRITE | SpiRegAddress.TL_LEN_REG_L)
+        await self._clock_byte((num_beats - 1) & 0xFF)
+        await self._clock_byte(CMD_WRITE | SpiRegAddress.TL_LEN_REG_H)
+        await self._clock_byte(((num_beats - 1) >> 8) & 0xFF)
 
         # Write data using bulk transfer
         all_data_bytes = []
@@ -159,9 +175,11 @@
                 all_data_bytes.append((beat >> (i * 8)) & 0xFF)
 
         # Command for bulk write
-        await self._clock_byte(CMD_WRITE | SpiRegAddress.BULK_WRITE_PORT)
-        # Length
-        await self._clock_byte(len(all_data_bytes) - 1)
+        num_bytes = len(all_data_bytes)
+        await self._clock_byte(CMD_WRITE | SpiRegAddress.BULK_WRITE_PORT_L)
+        await self._clock_byte((num_bytes - 1) & 0xFF)
+        await self._clock_byte(CMD_WRITE | SpiRegAddress.BULK_WRITE_PORT_H)
+        await self._clock_byte(((num_bytes - 1) >> 8) & 0xFF)
         # Data stream
         for byte in all_data_bytes:
             await self._clock_byte(byte)
@@ -180,12 +198,12 @@
 
         await self.start_clock()
 
-        # Command byte for bulk write
-        await self._clock_byte(CMD_WRITE | SpiRegAddress.BULK_WRITE_PORT)
-
-        # Length byte
+        # Command and Length for bulk write (L, H)
         num_bytes = len(data)
-        await self._clock_byte(num_bytes - 1)
+        await self._clock_byte(CMD_WRITE | SpiRegAddress.BULK_WRITE_PORT_L)
+        await self._clock_byte((num_bytes - 1) & 0xFF)
+        await self._clock_byte(CMD_WRITE | SpiRegAddress.BULK_WRITE_PORT_H)
+        await self._clock_byte(((num_bytes - 1) >> 8) & 0xFF)
 
         # Data stream
         for byte in data:
@@ -202,21 +220,18 @@
 
         await self.start_clock()
 
-        # Command byte to initiate a bulk read (this is a WRITE command)
-        await self._clock_byte(CMD_WRITE | SpiRegAddress.BULK_READ_PORT)
+        # Command and Length to initiate a bulk read (L, H)
+        await self._clock_byte(CMD_WRITE | SpiRegAddress.BULK_READ_PORT_L)
+        await self._clock_byte((num_bytes - 1) & 0xFF)
+        await self._clock_byte(CMD_WRITE | SpiRegAddress.BULK_READ_PORT_H)
+        await self._clock_byte(((num_bytes - 1) >> 8) & 0xFF)
 
-        # Length byte
-        await self._clock_byte(num_bytes - 1)
+        # The MISO pipeline has latency. The first dummy transfer flushes a junk byte.
+        await self._clock_byte(0x00)
 
-        # The MISO pipeline is two bytes deep. We need to send two dummy transfers
-        # to discard the junk bytes from the command/length phases before the
-        # first valid data byte is received.
-        await self._clock_byte(0x00) # Flush junk from command phase
-
-        # Read data stream
+        # The subsequent transfers clock in the actual data.
         received_bytes = []
         for _ in range(num_bytes):
-            # The data is clocked out on MISO during this dummy byte transfer
             byte_in = await self._clock_byte(0x00)
             received_bytes.append(byte_in)
 
diff --git a/tests/cocotb/tlul/BUILD b/tests/cocotb/tlul/BUILD
index c8f4683..2044827 100644
--- a/tests/cocotb/tlul/BUILD
+++ b/tests/cocotb/tlul/BUILD
@@ -358,6 +358,9 @@
     "test_tlul_bulk_write",
     "test_tlul_bulk_read",
     "test_large_tlul_transfer",
+    "test_large_packed_write_transaction",
+    "test_large_pipelined_read",
+    "test_large_write_then_pipelined_read",
 ]
 # END_TESTCASES_FOR_spi2tlul_cocotb
 
@@ -374,6 +377,7 @@
         "deps": [
             "//kelvin_test_utils:TileLinkULInterface",
             "//kelvin_test_utils:spi_master",
+            "//kelvin_test_utils:spi_constants",
         ],
     },
     verilator_model = "//hdl/chisel/src/bus:spi2tlul_128_model",
diff --git a/tests/cocotb/tlul/test_spi_to_tlul.py b/tests/cocotb/tlul/test_spi_to_tlul.py
index 419d68e..976b663 100644
--- a/tests/cocotb/tlul/test_spi_to_tlul.py
+++ b/tests/cocotb/tlul/test_spi_to_tlul.py
@@ -20,6 +20,7 @@
 from cocotb.triggers import RisingEdge, ClockCycles, FallingEdge
 from kelvin_test_utils.TileLinkULInterface import TileLinkULInterface
 from kelvin_test_utils.spi_master import SPIMaster
+from kelvin_test_utils.spi_constants import SpiRegAddress, SpiCommand, TlStatus
 
 async def setup_dut(dut, spi_master):
     # Main clock started by the test
@@ -50,10 +51,10 @@
 
     # Write Transaction
     write_data = random.randint(0, 255)
-    await spi_master.write_reg(0x04, write_data)
+    await spi_master.write_reg(SpiRegAddress.TL_LEN_REG_L, write_data)
 
     # Read Transaction
-    read_data = await spi_master.read_reg(0x04)
+    read_data = await spi_master.read_reg(SpiRegAddress.TL_LEN_REG_L)
     assert read_data == write_data, f"Read data 0x{read_data:x} does not match written data 0x{write_data:x}"
 
     await ClockCycles(dut.clock, 20)
@@ -105,20 +106,20 @@
         # Write address (32 bits) byte by byte
         for j in range(4):
             addr_byte = (target_addr >> (j * 8)) & 0xFF
-            await spi_master.write_reg(0x00 + j, addr_byte)
+            await spi_master.write_reg(SpiRegAddress.TL_ADDR_REG_0 + j, addr_byte)
 
         # Write length (0 means 1 beat)
-        await spi_master.write_reg(0x04, 0x00)
+        await spi_master.write_reg_16b(SpiRegAddress.TL_LEN_REG_L, 0)
 
         # 2. Issue the read command
-        await spi_master.write_reg(0x05, 0x01, wait_cycles=0)
+        await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_READ_START, wait_cycles=0)
 
         # --- Verification ---
         # 1. Poll the status register until the transaction is done
-        assert await spi_master.poll_reg_for_value(0x06, 0x02), "Timed out waiting for status to be Done"
+        assert await spi_master.poll_reg_for_value(SpiRegAddress.TL_STATUS_REG, TlStatus.DONE), "Timed out waiting for status to be Done"
 
         # 2. Check that the correct number of bytes are available
-        bytes_available = await spi_master.read_spi_domain_reg(0x0B)
+        bytes_available = await spi_master.read_spi_domain_reg_16b(SpiRegAddress.BULK_READ_STATUS_REG_L)
         assert bytes_available == 16
 
         # 3. Read the data from the buffer port using the new bulk read
@@ -132,7 +133,7 @@
         assert read_data == expected_data
 
         # 4. Clear the status to return FSM to Idle
-        await spi_master.write_reg(0x05, 0x00)
+        await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_NULL)
 
     await responder_task
 
@@ -184,24 +185,21 @@
     # Write address (32 bits) byte by byte
     for j in range(4):
         addr_byte = (target_addr >> (j * 8)) & 0xFF
-        await spi_master.write_reg(0x00 + j, addr_byte)
+        await spi_master.write_reg(SpiRegAddress.TL_ADDR_REG_0 + j, addr_byte)
 
     # Write length (N-1 for N beats)
-    await spi_master.write_reg(0x04, num_beats - 1)
+    await spi_master.write_reg_16b(SpiRegAddress.TL_LEN_REG_L, num_beats - 1)
 
     # 2. Issue the read command
-    await spi_master.write_reg(0x05, 0x01, wait_cycles=0)
-
-    # Add a delay to allow the status to propagate across the CDC
-    await ClockCycles(dut.clock, 20)
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_READ_START, wait_cycles=0)
 
     # --- Verification ---
     # 1. Poll the status register until the transaction is done
-    assert await spi_master.poll_reg_for_value(0x06, 0x02), "Timed out waiting for status to be Done"
+    assert await spi_master.poll_reg_for_value(SpiRegAddress.TL_STATUS_REG, TlStatus.DONE), "Timed out waiting for status to be Done"
 
     # 2. Check that the correct number of bytes are available
     bytes_to_read = num_beats * 16
-    bytes_available = await spi_master.read_spi_domain_reg(0x0B)
+    bytes_available = await spi_master.read_spi_domain_reg_16b(SpiRegAddress.BULK_READ_STATUS_REG_L)
     assert bytes_available == bytes_to_read
 
     # 3. Read the data from the buffer port using the new bulk read
@@ -219,7 +217,7 @@
     assert read_data == expected_data
 
     # 4. Clear the status to return FSM to Idle
-    await spi_master.write_reg(0x05, 0x00)
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_NULL)
 
     await responder_task
 
@@ -281,20 +279,20 @@
         # Write address (32 bits) byte by byte
         for j in range(4):
             addr_byte = (target_addr >> (j * 8)) & 0xFF
-            await spi_master.write_reg(0x00 + j, addr_byte)
+            await spi_master.write_reg(SpiRegAddress.TL_ADDR_REG_0 + j, addr_byte)
 
         # Write length (0 means 1 beat)
-        await spi_master.write_reg(0x04, 0x00)
+        await spi_master.write_reg_16b(SpiRegAddress.TL_LEN_REG_L, 0)
 
         # 3. Issue the write command
-        await spi_master.write_reg(0x05, 0x02, wait_cycles=20) # Start write command
+        await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_WRITE_START, wait_cycles=20) # Start write command
 
         # --- Verification ---
         # 1. Poll the status register until the transaction is done
-        assert await spi_master.poll_reg_for_value(0x08, 0x02), "Timed out waiting for write status to be Done"
+        assert await spi_master.poll_reg_for_value(SpiRegAddress.TL_WRITE_STATUS_REG, TlStatus.DONE), "Timed out waiting for write status to be Done"
 
         # 4. Clear the status to return FSM to Idle
-        await spi_master.write_reg(0x05, 0x00)
+        await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_NULL)
 
     # Wait for the responder to finish handling all requests
     await responder_task
@@ -361,17 +359,17 @@
     # Write address (32 bits) byte by byte
     for j in range(4):
         addr_byte = (target_addr >> (j * 8)) & 0xFF
-        await spi_master.write_reg(0x00 + j, addr_byte)
+        await spi_master.write_reg(SpiRegAddress.TL_ADDR_REG_0 + j, addr_byte)
 
     # Write length (N-1 for N beats)
-    await spi_master.write_reg(0x04, num_beats - 1)
+    await spi_master.write_reg_16b(SpiRegAddress.TL_LEN_REG_L, num_beats - 1)
 
     # 3. Issue the write command
-    await spi_master.write_reg(0x05, 0x02, wait_cycles=20) # Start write command
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_WRITE_START, wait_cycles=20) # Start write command
 
     # --- Verification ---
     # 1. Poll the status register until the transaction is done
-    assert await spi_master.poll_reg_for_value(0x08, 0x02), "Timed out waiting for write status to be Done"
+    assert await spi_master.poll_reg_for_value(SpiRegAddress.TL_WRITE_STATUS_REG, TlStatus.DONE), "Timed out waiting for write status to be Done"
 
     # 2. Wait for the responder to finish
     await responder_task
@@ -381,7 +379,7 @@
     assert received_data_list == expected_data_list, f"Received data {received_data_list} does not match expected data {expected_data_list}"
 
     # 4. Clear the status to return FSM to Idle
-    await spi_master.write_reg(0x05, 0x00)
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_NULL)
 
 @cocotb.test()
 async def test_packed_write_transaction(dut):
@@ -425,8 +423,8 @@
         data=data,
     )
 
-    assert await spi_master.poll_reg_for_value(0x08, 0x02), "Timed out waiting for write status to be Done"
-    await spi_master.write_reg(0x05, 0x00)
+    assert await spi_master.poll_reg_for_value(SpiRegAddress.TL_WRITE_STATUS_REG, TlStatus.DONE), "Timed out waiting for write status to be Done"
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_NULL)
     await responder_task
 
 
@@ -485,16 +483,16 @@
     target_addr = 0x40003000
     for j in range(4):
         addr_byte = (target_addr >> (j * 8)) & 0xFF
-        await spi_master.write_reg(0x00 + j, addr_byte)
+        await spi_master.write_reg(SpiRegAddress.TL_ADDR_REG_0 + j, addr_byte)
 
-    await spi_master.write_reg(0x04, num_beats - 1)
+    await spi_master.write_reg_16b(SpiRegAddress.TL_LEN_REG_L, num_beats - 1)
 
     # 3. Issue the write command
-    await spi_master.write_reg(0x05, 0x02, wait_cycles=20)
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_WRITE_START, wait_cycles=20)
 
     # --- Verification ---
     # 1. Poll the status register until the transaction is done
-    assert await spi_master.poll_reg_for_value(0x08, 0x02), "Timed out waiting for write status to be Done"
+    assert await spi_master.poll_reg_for_value(SpiRegAddress.TL_WRITE_STATUS_REG, TlStatus.DONE), "Timed out waiting for write status to be Done"
 
     # 2. Wait for the responder to finish
     await responder_task
@@ -504,7 +502,7 @@
     assert received_data_list == expected_data_list
 
     # 4. Clear the status to return FSM to Idle
-    await spi_master.write_reg(0x05, 0x00)
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_NULL)
 
 
 @cocotb.test()
@@ -554,18 +552,18 @@
     target_addr = 0x40001000
     for j in range(4):
         addr_byte = (target_addr >> (j * 8)) & 0xFF
-        await spi_master.write_reg(0x00 + j, addr_byte)
+        await spi_master.write_reg(SpiRegAddress.TL_ADDR_REG_0 + j, addr_byte)
 
-    await spi_master.write_reg(0x04, num_beats - 1)
+    await spi_master.write_reg_16b(SpiRegAddress.TL_LEN_REG_L, num_beats - 1)
 
     # 2. Issue the read command
-    await spi_master.write_reg(0x05, 0x01, wait_cycles=0)
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_READ_START, wait_cycles=0)
 
     # 3. Poll the status register until the transaction is done
-    assert await spi_master.poll_reg_for_value(0x06, 0x02), "Timed out waiting for status to be Done"
+    assert await spi_master.poll_reg_for_value(SpiRegAddress.TL_STATUS_REG, TlStatus.DONE), "Timed out waiting for status to be Done"
 
     # 3a. Read the bulk read status register
-    bytes_available = await spi_master.read_spi_domain_reg(0x0B)
+    bytes_available = await spi_master.read_spi_domain_reg_16b(SpiRegAddress.BULK_READ_STATUS_REG_L)
     dut._log.info(f"BULK_READ_STATUS_REG = {bytes_available}")
 
     # 4. Initiate the bulk read from the data buffer
@@ -585,7 +583,7 @@
     assert read_data_list == expected_data_list, f"{[hex(x) for x in read_data_list]} =/= {[hex(x) for x in expected_data_list]}"
 
     # 3. Clear the status to return FSM to Idle
-    await spi_master.write_reg(0x05, 0x00)
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_NULL)
 
     await responder_task
 
@@ -686,13 +684,13 @@
 
             for j in range(4):
                 addr_byte = (current_addr >> (j * 8)) & 0xFF
-                await spi_master.write_reg(0x00 + j, addr_byte)
+                await spi_master.write_reg(SpiRegAddress.TL_ADDR_REG_0 + j, addr_byte)
 
-            await spi_master.write_reg(0x04, num_beats - 1)
-            await spi_master.write_reg(0x05, 0x02, wait_cycles=20)
+            await spi_master.write_reg_16b(SpiRegAddress.TL_LEN_REG_L, num_beats - 1)
+            await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_WRITE_START, wait_cycles=20)
 
-            assert await spi_master.poll_reg_for_value(0x08, 0x02), f"Timed out waiting for write status at addr {current_addr:x}"
-            await spi_master.write_reg(0x05, 0x00)
+            assert await spi_master.poll_reg_for_value(SpiRegAddress.TL_WRITE_STATUS_REG, TlStatus.DONE), f"Timed out waiting for write status at addr {current_addr:x}"
+            await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_NULL)
         dut._log.info("Write phase complete.")
 
         # --- Main Test Logic: Read Phase ---
@@ -704,20 +702,20 @@
 
             for j in range(4):
                 addr_byte = (current_addr >> (j * 8)) & 0xFF
-                await spi_master.write_reg(0x00 + j, addr_byte)
+                await spi_master.write_reg(SpiRegAddress.TL_ADDR_REG_0 + j, addr_byte)
 
-            await spi_master.write_reg(0x04, num_beats - 1)
-            await spi_master.write_reg(0x05, 0x01, wait_cycles=0)
+            await spi_master.write_reg_16b(SpiRegAddress.TL_LEN_REG_L, num_beats - 1)
+            await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_READ_START, wait_cycles=0)
 
-            assert await spi_master.poll_reg_for_value(0x06, 0x02), f"Timed out waiting for read status at addr {current_addr:x}"
+            assert await spi_master.poll_reg_for_value(SpiRegAddress.TL_STATUS_REG, TlStatus.DONE), f"Timed out waiting for read status at addr {current_addr:x}"
 
-            bytes_available = await spi_master.read_spi_domain_reg(0x0B)
+            bytes_available = await spi_master.read_spi_domain_reg_16b(SpiRegAddress.BULK_READ_STATUS_REG_L)
             assert bytes_available == read_chunk_size
 
             read_data_bytes = await spi_master.bulk_read(read_chunk_size)
             read_back_data.extend(read_data_bytes)
 
-            await spi_master.write_reg(0x05, 0x00)
+            await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_NULL)
         dut._log.info("Read phase complete.")
 
         # --- Verification ---
@@ -728,3 +726,277 @@
         await responder_task
         dut._log.info(f"--- {total_size // 1024}KB Transfer Test Passed ---")
 
+
+@cocotb.test(timeout_time=300, timeout_unit="sec")
+async def test_large_packed_write_transaction(dut):
+    """Tests a single large (4KB) packed write transaction."""
+    clock = Clock(dut.clock, 10)
+    cocotb.start_soon(clock.start())
+
+    spi_master = SPIMaster(
+        clk=dut.io_spi_clk,
+        csb=dut.io_spi_csb,
+        mosi=dut.io_spi_mosi,
+        miso=dut.io_spi_miso,
+        main_clk=dut.clock,
+        log=dut._log
+    )
+    await setup_dut(dut, spi_master)
+    tl_device = TileLinkULInterface(dut, device_if_name="io_tl", width=128)
+    await tl_device.init()
+
+    num_beats = 256  # 256 beats * 16 bytes/beat = 4096 bytes
+    dut._log.info(f"Generating {num_beats * 16 // 1024}KB of random data...")
+    golden_data = [random.randint(0, (1 << 128) - 1) for _ in range(num_beats)]
+    dut._log.info("Data generation complete.")
+
+    async def device_responder():
+        dut._log.info(f"Device responder waiting for {num_beats} beats...")
+        for i in range(num_beats):
+            req = await tl_device.device_get_request()
+            assert int(req['opcode']) in [0, 1], f"Expected PutFullData or PutPartialData, got opcode {req['opcode']}"
+            assert req['data'] == golden_data[i], f"Data mismatch on beat {i}"
+
+            # Send an AccessAck after each beat
+            await tl_device.device_respond(
+                opcode=0,  # AccessAck
+                param=0,
+                size=req['size'],
+                source=req['source'],
+                error=0,
+                width=128
+            )
+        dut._log.info("Device responder received all beats successfully.")
+
+    responder_task = cocotb.start_soon(device_responder())
+
+    await spi_master.packed_write_transaction(
+        target_addr=0x40001000,
+        data=golden_data,
+    )
+
+    # Need large max_polls here for the large transfer size.
+    assert await spi_master.poll_reg_for_value(SpiRegAddress.TL_WRITE_STATUS_REG, TlStatus.DONE, max_polls=2000), "Timed out waiting for write status to be Done"
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_NULL)
+    await responder_task
+    dut._log.info("--- Large Packed Write Test Passed ---")
+
+
+@cocotb.test(timeout_time=400, timeout_unit="sec")
+async def test_large_pipelined_read(dut):
+    """Tests two back-to-back large (2KB) read transactions."""
+    clock = Clock(dut.clock, 10)
+    cocotb.start_soon(clock.start())
+
+    spi_master = SPIMaster(
+        clk=dut.io_spi_clk,
+        csb=dut.io_spi_csb,
+        mosi=dut.io_spi_mosi,
+        miso=dut.io_spi_miso,
+        main_clk=dut.clock,
+        log=dut._log
+    )
+    await setup_dut(dut, spi_master)
+    tl_device = TileLinkULInterface(dut, device_if_name="io_tl", width=128)
+    await tl_device.init()
+
+    total_size = 4096
+    read_chunk_size = 2048
+    bus_width_bytes = 128 // 8
+    num_beats_total = total_size // bus_width_bytes
+    num_beats_per_read = read_chunk_size // bus_width_bytes
+    base_addr = 0x20000000
+
+    dut._log.info(f"Generating {total_size // 1024}KB of random data...")
+    golden_data_words = [random.randint(0, (1 << 128) - 1) for _ in range(num_beats_total)]
+    golden_data_bytes = bytearray()
+    for word in golden_data_words:
+        golden_data_bytes.extend(word.to_bytes(16, 'little'))
+    dut._log.info("Data generation complete.")
+
+    # --- Device Responder Task ---
+    async def device_responder():
+        mem = {}  # Byte-addressable memory
+        # Pre-populate memory
+        for i, byte_val in enumerate(golden_data_bytes):
+            mem[base_addr + i] = byte_val
+
+        # --- Read Phase: Serve data from memory for two separate reads ---
+        dut._log.info(f"Device responder waiting for {num_beats_total} read beats...")
+        for i in range(num_beats_total):
+            req = await tl_device.device_get_request()
+            assert int(req['opcode']) == 4
+            addr = int(req['address'])
+            response_data = 0
+            for byte_idx in range(bus_width_bytes):
+                byte_val = mem.get(addr + byte_idx, 0)
+                response_data |= (byte_val << (byte_idx * 8))
+            await tl_device.device_respond(
+                opcode=1, param=0, size=req['size'], source=req['source'], data=response_data, error=0, width=128
+            )
+        dut._log.info("Device responder read phase complete.")
+
+    responder_task = cocotb.start_soon(device_responder())
+
+    # --- Main Test Logic ---
+    # Read Phase: Read the 4KB back in two 2KB chunks
+    read_data_bytes = bytearray()
+
+    # First 2KB read
+    dut._log.info("Starting first 2KB read from device...")
+    await spi_master.write_reg_16b(SpiRegAddress.TL_LEN_REG_L, num_beats_per_read - 1)
+    for j in range(4):
+        addr_byte = (base_addr >> (j * 8)) & 0xFF
+        await spi_master.write_reg(SpiRegAddress.TL_ADDR_REG_0 + j, addr_byte)
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_READ_START, wait_cycles=0)
+    assert await spi_master.poll_reg_for_value(SpiRegAddress.TL_STATUS_REG, TlStatus.DONE, max_polls=2000), "Timed out waiting for first read status to be Done"
+    bytes_available = await spi_master.read_spi_domain_reg_16b(SpiRegAddress.BULK_READ_STATUS_REG_L)
+    assert bytes_available == read_chunk_size, f"Expected {read_chunk_size} bytes available, but got {bytes_available}"
+    read_data_bytes.extend(await spi_master.bulk_read(read_chunk_size))
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_NULL)
+    dut._log.info("First 2KB read complete.")
+
+    # Second 2KB read
+    dut._log.info("Starting second 2KB read from device...")
+    second_chunk_addr = base_addr + read_chunk_size
+    await spi_master.write_reg_16b(SpiRegAddress.TL_LEN_REG_L, num_beats_per_read - 1)
+    for j in range(4):
+        addr_byte = (second_chunk_addr >> (j * 8)) & 0xFF
+        await spi_master.write_reg(SpiRegAddress.TL_ADDR_REG_0 + j, addr_byte)
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_READ_START, wait_cycles=0)
+    assert await spi_master.poll_reg_for_value(SpiRegAddress.TL_STATUS_REG, TlStatus.DONE, max_polls=2000), "Timed out waiting for second read status to be Done"
+    bytes_available = await spi_master.read_spi_domain_reg_16b(SpiRegAddress.BULK_READ_STATUS_REG_L)
+    assert bytes_available == read_chunk_size, f"Expected {read_chunk_size} bytes available, but got {bytes_available}"
+    read_data_bytes.extend(await spi_master.bulk_read(read_chunk_size))
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_NULL)
+    dut._log.info("Second 2KB read complete.")
+
+    # --- Verification ---
+    dut._log.info("Verifying data...")
+    assert read_data_bytes == golden_data_bytes, "Read-back data does not match golden data"
+    dut._log.info("Data verification successful!")
+
+    await responder_task
+    dut._log.info("--- Large Pipelined Read Test Passed ---")
+
+
+@cocotb.test(timeout_time=500, timeout_unit="sec")
+async def test_large_write_then_pipelined_read(dut):
+    """Tests a large write (4KB) followed by two pipelined reads (2KB each)."""
+    clock = Clock(dut.clock, 10)
+    cocotb.start_soon(clock.start())
+
+    spi_master = SPIMaster(
+        clk=dut.io_spi_clk,
+        csb=dut.io_spi_csb,
+        mosi=dut.io_spi_mosi,
+        miso=dut.io_spi_miso,
+        main_clk=dut.clock,
+        log=dut._log
+    )
+    await setup_dut(dut, spi_master)
+    tl_device = TileLinkULInterface(dut, device_if_name="io_tl", width=128)
+    await tl_device.init()
+
+    total_size = 4096
+    read_chunk_size = 2048
+    bus_width_bytes = 128 // 8
+    num_beats_total = total_size // bus_width_bytes
+    num_beats_per_read = read_chunk_size // bus_width_bytes
+    base_addr = 0x20000000
+
+    dut._log.info(f"Generating {total_size // 1024}KB of random data...")
+    golden_data = [random.randint(0, (1 << 128) - 1) for _ in range(num_beats_total)]
+    dut._log.info("Data generation complete.")
+
+    # --- Device Responder Task ---
+    async def device_responder():
+        mem = {}  # Byte-addressable memory
+
+        # --- Write Phase: Populate memory ---
+        dut._log.info(f"Device responder waiting for {num_beats_total} write beats...")
+        for i in range(num_beats_total):
+            req = await tl_device.device_get_request()
+            assert int(req['opcode']) in [0, 1]
+            addr = int(req['address'])
+            data = int(req['data'])
+            for byte_idx in range(bus_width_bytes):
+                byte_val = (data >> (byte_idx * 8)) & 0xFF
+                mem[addr + byte_idx] = byte_val
+            await tl_device.device_respond(
+                opcode=0, param=0, size=req['size'], source=req['source'], error=0, width=128
+            )
+        dut._log.info("Device responder write phase complete.")
+
+        # --- Read Phase: Serve data from memory for two separate reads ---
+        dut._log.info(f"Device responder waiting for {num_beats_total} read beats...")
+        for i in range(num_beats_total):
+            req = await tl_device.device_get_request()
+            assert int(req['opcode']) == 4
+            addr = int(req['address'])
+            response_data = 0
+            for byte_idx in range(bus_width_bytes):
+                byte_val = mem.get(addr + byte_idx, 0)
+                response_data |= (byte_val << (byte_idx * 8))
+            await tl_device.device_respond(
+                opcode=1, param=0, size=req['size'], source=req['source'], data=response_data, error=0, width=128
+            )
+        dut._log.info("Device responder read phase complete.")
+
+    responder_task = cocotb.start_soon(device_responder())
+
+    # --- Main Test Logic ---
+    # 1. Write Phase: Write the 4KB of data to the device's memory
+    dut._log.info("Starting 4KB write to device...")
+    await spi_master.packed_write_transaction(
+        target_addr=base_addr,
+        data=golden_data,
+    )
+    assert await spi_master.poll_reg_for_value(SpiRegAddress.TL_WRITE_STATUS_REG, TlStatus.DONE, max_polls=2000), "Timed out waiting for write status to be Done"
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_NULL)
+    dut._log.info("Write to device complete.")
+
+    # 2. Read Phase: Read the 4KB back in two 2KB chunks
+    read_data_bytes = bytearray()
+
+    # First 2KB read
+    dut._log.info("Starting first 2KB read from device...")
+    await spi_master.write_reg_16b(SpiRegAddress.TL_LEN_REG_L, num_beats_per_read - 1)
+    for j in range(4):
+        addr_byte = (base_addr >> (j * 8)) & 0xFF
+        await spi_master.write_reg(SpiRegAddress.TL_ADDR_REG_0 + j, addr_byte)
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_READ_START, wait_cycles=0)
+    assert await spi_master.poll_reg_for_value(SpiRegAddress.TL_STATUS_REG, TlStatus.DONE, max_polls=2000), "Timed out waiting for first read status to be Done"
+    bytes_available = await spi_master.read_spi_domain_reg_16b(SpiRegAddress.BULK_READ_STATUS_REG_L)
+    assert bytes_available == read_chunk_size, f"Expected {read_chunk_size} bytes available, but got {bytes_available}"
+    read_data_bytes.extend(await spi_master.bulk_read(read_chunk_size))
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_NULL)
+    dut._log.info("First 2KB read complete.")
+
+    # Second 2KB read
+    dut._log.info("Starting second 2KB read from device...")
+    second_chunk_addr = base_addr + read_chunk_size
+    await spi_master.write_reg_16b(SpiRegAddress.TL_LEN_REG_L, num_beats_per_read - 1)
+    for j in range(4):
+        addr_byte = (second_chunk_addr >> (j * 8)) & 0xFF
+        await spi_master.write_reg(SpiRegAddress.TL_ADDR_REG_0 + j, addr_byte)
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_READ_START, wait_cycles=0)
+    assert await spi_master.poll_reg_for_value(SpiRegAddress.TL_STATUS_REG, TlStatus.DONE, max_polls=2000), "Timed out waiting for second read status to be Done"
+    bytes_available = await spi_master.read_spi_domain_reg_16b(SpiRegAddress.BULK_READ_STATUS_REG_L)
+    assert bytes_available == read_chunk_size, f"Expected {read_chunk_size} bytes available, but got {bytes_available}"
+    read_data_bytes.extend(await spi_master.bulk_read(read_chunk_size))
+    await spi_master.write_reg(SpiRegAddress.TL_CMD_REG, SpiCommand.CMD_NULL)
+    dut._log.info("Second 2KB read complete.")
+
+    # --- Verification ---
+    dut._log.info("Verifying data...")
+    golden_data_bytes = bytearray()
+    for word in golden_data:
+        golden_data_bytes.extend(word.to_bytes(16, 'little'))
+
+    assert read_data_bytes == golden_data_bytes, "Read-back data does not match golden data"
+    dut._log.info("Data verification successful!")
+
+    await responder_task
+    dut._log.info("--- Large Write/Pipelined Read Test Passed ---")
+