// retroDE_ps2 — gs_lpddr_axi_master (Ch318)
//
// HARDWARE-facing wrapper that takes the PSMCT16 tile-FLUSH pixel stream (GS clock)
// and writes it to real LPDDR over the qsys f2sdram AXI4 port (f2sdram clock). It
// does NOT modify the proven gs_lpddr_fb_writer (the Ch317 sim model) — it is a
// sibling hardware path with the same input stream.
//
// Pipeline (per the Ch318 directive):
//   GS clock  : PACKER — accumulate 16 PSMCT16 pixels of a tile-row into one 256-bit
//               (32-byte) beat {addr, data, strb}. A tile-row is exactly 16 px on a
//               32-byte-aligned line, so a beat completes naturally on its 16th px
//               (no dangling partial beat). On completion, push to the async FIFO.
//   async FIFO: gray-code CDC, carries {addr[31:0], data[255:0], strb[31:0]} (320b).
//   f2sdram   : AXI burst FSM — pop a beat and issue a single-beat INCR write
//               (AWSIZE=5 = 32 B, AWLEN=0, AWBURST=INCR, full per-byte WSTRB, never
//               crossing a 4 KiB boundary since each beat is one 32-byte line). AW
//               then W then B, all with backpressure (await ready/valid).
//
// Address: awaddr = FB_BASE + packet_addr (packet_addr is the FB-relative byte addr
// from raster_pixel_fb_addr_q). FB_BASE must point at a LINUX-SAFE reserved LPDDR
// region before any board run — the qsys aperture proves fabric CAN address SDRAM,
// not which physical range is safe to scribble on (Ch318 board gate).
//
// Counters (f2sdram domain, TB/status readable): beats, bursts, bresp_err, fifo
// overflow, done-ish (idle && fifo empty). enable=0 → fully inert.

module gs_lpddr_axi_master #(
    parameter int  FIFO_DEPTH = 16,
    // Ch353 — pixel width in BYTES: 2 = PSMCT16 (default, byte-identical to Ch318), 4 = PSMCT32.
    // A 32-byte (256-bit) beat holds 32/PIX_BYTES lanes (16 for PSMCT16, 8 for PSMCT32). The
    // &ns beat-complete test is unchanged (a full beat is always 32 strobe bits either way).
    parameter int  PIX_BYTES = 2,
    // Ch357 (Codex) — ELASTIC_BACKPRESSURE: when 1, insert a one-entry elastic (skid) stage between the packer and the
    // async FIFO and expose real producer backpressure via px_ready (= the stage can accept a beat this cycle). The packer
    // then advances ONLY on px_emit && px_ready, and the partial/EOF flushes retry until the stage accepts — so a beat is
    // NEVER dropped even if the FIFO momentarily fills, WITHOUT relying on the "packer never outruns the drain" invariant.
    // Default 0 = the legacy direct-write packer, byte-identical (px_ready tied 1). Only u_zc_emit|u_c sets this to 1.
    parameter bit  ELASTIC_BACKPRESSURE = 1'b0
) (
    // GS clock domain — flush pixel stream
    input  logic        gs_clk,
    input  logic        gs_rst_n,
    input  logic        enable,
    // ---- RUNTIME controls (driven by the HPS bridge register, axi_clk domain) ----
    // arm: HARD SAFETY GATE — no AXI write can issue unless high. Defaults LOW at the
    //   bridge register, so the booted core is inert until the HPS explicitly arms it.
    //   Synced into gs_clk for the packer; used directly in the axi_clk FSM.
    input  logic        arm,
    // canary: when high, write ONLY the offset-0 beat (the 32-byte top-of-frame line)
    //   and discard all others — a deterministic, blast-radius-limited first test.
    input  logic        canary,
    // fb_base: LPDDR byte base address for the framebuffer (e.g. 0x8000_0000). awaddr
    //   = fb_base + frame-relative offset. Runtime so a wrong base is re-targetable
    //   without a rebuild.
    input  logic [31:0] fb_base,
    // Ch352 CDC (Codex) — {arm,canary,fb_base} arrive RAW from the HPS bridge (CLOCK2_50), NOT axi_clk as the
    // legacy comment above implies. ctrl_commit is a TOGGLE the bridge flips on any control write; we sync it
    // into axi_clk and latch the controls on its edge, so the multi-bit fb_base crosses COHERENTLY (the CDC
    // lives here, at the receiving boundary, so no caller can supply raw controls into the AW path).
    input  logic        ctrl_commit,
    input  logic        px_emit,
    input  logic [31:0] px_addr,      // FB-relative byte address (raster_pixel_fb_addr_q)
    input  logic [31:0] px_pix32,     // pixel data; PSMCT16 callers drive {16'd0, pix16} (low PIX_BITS used)
    // Ch357 (Codex) — producer backpressure. ELASTIC_BACKPRESSURE=1: high when the packer can accept px_emit this cycle
    // (the elastic stage can take a beat); the producer must HOLD px_emit/px_addr/px_pix32 until px_ready. Tied 1 for the
    // legacy packer (no backpressure), so legacy producers that leave it unconnected are unaffected.
    output logic        px_ready,
    // Ch353 — end-of-scene partial-beat flush (pulse). Sparse triangle coverage may leave the final
    // beat incomplete; pulse `flush` after the last px_emit to push the dangling partial beat. The
    // PSMCT16 tile path always completes beats on tile-row boundaries, so it ties this to 0.
    input  logic        flush,

    // f2sdram (LPDDR AXI) clock domain
    input  logic        axi_clk,
    input  logic        axi_rst_n,
    // AXI4 write-address
    output logic [31:0] awaddr,
    output logic [7:0]  awlen,
    output logic [2:0]  awsize,
    output logic [1:0]  awburst,
    output logic [4:0]  awid,
    output logic        awvalid,
    input  logic        awready,
    // AXI4 write-data
    output logic [255:0] wdata,
    output logic [31:0]  wstrb,
    output logic         wlast,
    output logic         wvalid,
    input  logic         wready,
    // AXI4 write-response
    input  logic        bvalid,
    output logic        bready,
    input  logic [1:0]  bresp,

    // status / counters (axi domain)
    output logic [31:0] beats_written,
    output logic [31:0] bursts_issued,
    output logic [31:0] bresp_err_count,
    output logic [31:0] fifo_overflow_count,
    output logic        idle,
    // Ch353 (Codex) — ORDERED drain ack: high after the EOF marker pops (i.e. after the last data beat's BRESP).
    // Coherent in the axi_clk domain — the scanout gate uses THIS, not a GS-domain sample of fbw_idle.
    output logic        frame_drained
);
    localparam int PW = 321;   // {marker, addr[31:0], data[255:0], strb[31:0]}
    localparam int MARK = PW-1; // Ch353 (Codex) — ordered EOF marker bit. A flush pushes any partial beat then an
                                // EOF marker; the AXI FSM sets frame_drained when it POPS the marker — which, by the
                                // in-order FIFO + per-beat B handshake, happens only AFTER the last data beat's BRESP.
                                // This is a coherent same-(axi)-domain drain ack for the scanout gate (no GS-domain
                                // sampling of fbw_idle, no pointer-in-flight race).
    // Ch353 — pixel-width derived params. At PIX_BYTES=2 these reproduce the exact Ch318 PSMCT16 logic.
    localparam int PIX_BITS = PIX_BYTES * 8;          // 16 or 32
    localparam int LANE_LO  = (PIX_BYTES == 4) ? 2 : 1; // low addr bits inside one pixel ($clog2 PIX_BYTES)
    localparam logic [PIX_BYTES-1:0] STRB1 = '1;      // per-pixel byte-strobe mask (2'b11 or 4'b1111)
    localparam logic [255:0] PIX_MASK = {{(256-PIX_BITS){1'b0}}, {PIX_BITS{1'b1}}};

    // ============================ GS-clock PACKER ============================
    logic [31:0]  cur_addr;
    logic [255:0] cur_data;
    logic [31:0]  cur_strb;
    logic         has_data;
    logic         flush_pending;   // Ch353 — latched end-of-scene flush request (pushes partial then EOF marker)
    logic         fifo_wr;
    logic [PW-1:0] fifo_wdata;
    logic          fifo_wfull;

    // Ch352 — axi_clk control snapshot: sync the bridge commit toggle and latch {arm,canary,fb_base} on its
    // edge. Init to the bridge's SAFE defaults (arm=0, canary=1, fb_base=0x8000_0000) so the booted core is
    // inert until the HPS arms it, even before the first commit. All axi_clk uses + the gs_clk arm-sync read
    // these coherent latched copies instead of the raw bridge buses.
    logic [2:0]  commit_sync;
    logic        arm_axi, canary_axi;
    logic [31:0] fb_base_axi;
    always_ff @(posedge axi_clk or negedge axi_rst_n) begin
        if (!axi_rst_n) begin
            commit_sync <= 3'd0; arm_axi <= 1'b0; canary_axi <= 1'b1; fb_base_axi <= 32'h8000_0000;
        end else begin
            commit_sync <= {commit_sync[1:0], ctrl_commit};
            if (commit_sync[2] != commit_sync[1]) begin  // commit edge: bridge buses are stable, latch them
                arm_axi     <= arm;
                canary_axi  <= canary;
                fb_base_axi <= fb_base;
            end
        end
    end

    // arm crosses from axi_clk into gs_clk — 2-FF synchronizer (from the COHERENT latched arm).
    logic arm_s1, arm_gs;
    always_ff @(posedge gs_clk or negedge gs_rst_n) begin
        if (!gs_rst_n) begin arm_s1 <= 1'b0;    arm_gs <= 1'b0; end
        else          begin arm_s1 <= arm_axi;  arm_gs <= arm_s1; end
    end

    // Ch357 (Codex) — effective FIFO write bus (muxed by the generate below) + one-entry elastic stage (elastic mode only).
    logic          fifo_wr_ram;      // drive to u_fifo.wr  (already ANDed with !fifo_wfull in both modes)
    logic [PW-1:0] fifo_wdata_ram;   // drive to u_fifo.wdata
    logic          stage_valid;      // elastic: a beat waits in the skid stage
    logic [PW-1:0] stage_data;       // elastic: the staged beat
    wire           stage_ready = !stage_valid || !fifo_wfull;  // elastic: stage can accept a beat THIS cycle
    wire           stage_drain = stage_valid && !fifo_wfull;   // elastic: staged beat enters FIFO THIS cycle

    generate
    if (!ELASTIC_BACKPRESSURE) begin : g_legacy
        // ------- LEGACY packer (byte-identical to the pre-Ch357 direct-write path) -------
        assign px_ready       = 1'b1;                          // no backpressure exposed
        assign fifo_wr_ram    = fifo_wr && !fifo_wfull;
        assign fifo_wdata_ram = fifo_wdata;
    always_ff @(posedge gs_clk or negedge gs_rst_n) begin
        if (!gs_rst_n) begin
            cur_addr <= '0; cur_data <= '0; cur_strb <= '0; has_data <= 1'b0;
            fifo_wr  <= 1'b0; fifo_wdata <= '0; fifo_overflow_count <= '0; flush_pending <= 1'b0;
        end else begin
            fifo_wr <= 1'b0;
            if (flush) flush_pending <= 1'b1;   // Ch353 — latch the end-of-scene flush request
            if (enable && arm_gs && px_emit) begin   // gate: no accumulation until armed
                logic [31:0]  abeat;
                logic [4:0]   lane;        // 0..LANES-1 (which PIX_BYTES-wide lane in the 32-byte beat)
                logic [255:0] nd;
                logic [31:0]  ns;
                abeat = {px_addr[31:5], 5'd0};
                lane  = px_addr[4:0] >> LANE_LO;
                if (has_data && (abeat != cur_addr)) begin
                    // line changed before the previous beat filled — flush it (marker=0), restart
                    fifo_wdata <= {1'b0, cur_addr, cur_data, cur_strb};
                    fifo_wr    <= 1'b1;
                    cur_addr   <= abeat;
                    cur_data   <= (256'(px_pix32[PIX_BITS-1:0]) << ({27'd0, lane} * PIX_BITS));
                    cur_strb   <= (32'(STRB1) << ({27'd0, lane} * PIX_BYTES));
                    has_data   <= 1'b1;
                end else begin
                    nd = has_data ? cur_data : 256'd0;
                    ns = has_data ? cur_strb : 32'd0;
                    nd[ ({27'd0, lane} * PIX_BITS)  +: PIX_BITS ]  = px_pix32[PIX_BITS-1:0];
                    ns[ ({27'd0, lane} * PIX_BYTES) +: PIX_BYTES ] = STRB1;
                    if (&ns) begin
                        // beat complete (all lanes strobed) — flush (marker=0), beat consumed
                        fifo_wdata <= {1'b0, abeat, nd, ns};
                        fifo_wr    <= 1'b1;
                        has_data   <= 1'b0;
                    end else begin
                        cur_addr <= abeat;
                        cur_data <= nd;
                        cur_strb <= ns;
                        has_data <= 1'b1;
                    end
                end
            end else if (enable && arm_gs && flush_pending && has_data && !fifo_wr && !fifo_wfull) begin
                // Ch353 — end-of-scene partial-beat flush: push the dangling beat (marker=0) so no pixels are
                // stranded. Codex — gate on `!fifo_wr && !fifo_wfull`: a PRIOR registered push may still be entering
                // the FIFO this cycle (fifo_wfull is REGISTERED, lags a cycle), so wait until no push is in flight
                // AND not full, then the !wfull check is accurate. Covers a scene ending on a full beat, a partial
                // right after a line-change, and (since the marker also waits for fifo_wr to clear) partial->marker.
                // If gated, state is RETAINED and retried — the partial is never dropped.
                fifo_wdata <= {1'b0, cur_addr, cur_data, cur_strb};
                fifo_wr    <= 1'b1;
                has_data   <= 1'b0;
            end else if (enable && arm_gs && flush_pending && !has_data && !fifo_wr && !fifo_wfull) begin
                // Ch353 (Codex) — partial (if any) is pushed; now push the ordered EOF MARKER (payload irrelevant),
                // same `!fifo_wr && !fifo_wfull` gate. flush_pending retained until accepted. The AXI FSM sets
                // frame_drained when it pops this, i.e. after the last data beat's BRESP.
                fifo_wdata <= {1'b1, cur_addr, cur_data, cur_strb};
                fifo_wr    <= 1'b1;
                flush_pending <= 1'b0;
            end
            // overflow witness: a push attempt while the FIFO is full (must stay 0)
            if (fifo_wr && fifo_wfull)
                fifo_overflow_count <= fifo_overflow_count + 32'd1;
        end
    end
    end else begin : g_elastic
        // ------- ELASTIC packer (Codex): full producer backpressure via a one-entry skid stage -------
        // px_ready: the input stage can accept a pixel THIS cycle. The packer advances (accumulates, produces a
        // beat, retries a flush) ONLY when stage_ready, so every produced beat lands in the stage the SAME cycle
        // it is produced -> never dropped. The stage drains into the FIFO whenever !fifo_wfull; simultaneous
        // drain+refill keeps the stage full with the new beat (the drain writes the OLD stage_data to the FIFO
        // first, via fifo_wr_ram below).
        //
        // Ch358 (Codex) — REGISTERED INPUT STAGE: capture {derived beat address, lane, pixel} into in_* registers
        // BEFORE the packer. The high-address equality (beat-change) compare and the lane shifts then run off
        // LOCAL in_* registers instead of gating stage_data[255:0] straight from the producer's cross-module
        // px_addr register — the Ch358 fit's failing setup cone (col_out_addr -> stage_data, WNS -0.176). +1 cycle
        // pixel latency; ordering preserved: flush_pending is serviced only when in_valid is EMPTY, so the
        // partial/EOF marker can never overtake a captured pixel.
        logic                in_valid;
        logic [31:0]         in_beat;    // {px_addr[31:5], 5'd0} — derived beat address (low 5 bits constant 0)
        logic [4:0]          in_lane;    // px_addr[4:0] >> LANE_LO
        logic [PIX_BITS-1:0] in_pix;
        // Ch358 (Codex) — PRE-REGISTERED beat-change decision (the 26.1 fit's residual -0.012 family was
        // in_beat -> the compare -> stage_data[255:0]): in_same_q = (this pixel's beat == the PREVIOUSLY captured
        // pixel's beat, last_beat_q), registered at capture. Exact by invariant: the packer reads the compare ONLY
        // when has_data=1 at consumption, and has_data=1 implies the preceding captured pixel MERGED into cur_addr
        // (beat-complete and flush both clear has_data and never read it) -> cur_addr == last_beat_q at capture.
        // A pending flush cannot intervene while in_valid holds a pixel. No latency change.
        logic                in_same_q;
        logic [31:0]         last_beat_q;
        // Ch367 — map the lane-selected pixel into full beat masks in a distinct registered stage. The beat
        // accumulator below then sees only registered masks, not in_lane driving cur_data[255:0] directly.
        logic                map_valid, map_same_q;
        logic [31:0]         map_beat;
        logic [255:0]        map_data;
        logic [255:0]        map_mask;
        logic [31:0]         map_strb;
        wire map_ready = !map_valid || stage_ready;
        wire in_advance = in_valid && map_ready;      // input stage advances into the map stage
        assign px_ready       = !in_valid || map_ready;
        assign fifo_wr_ram    = stage_drain;          // drains the CURRENT stage_data (already implies !fifo_wfull)
        assign fifo_wdata_ram = stage_data;
        always_ff @(posedge gs_clk or negedge gs_rst_n) begin
            if (!gs_rst_n) begin
                cur_addr <= '0; cur_data <= '0; cur_strb <= '0; has_data <= 1'b0;
                stage_valid <= 1'b0; stage_data <= '0; fifo_overflow_count <= '0; flush_pending <= 1'b0;
                in_valid <= 1'b0; in_beat <= '0; in_lane <= '0; in_pix <= '0;
                in_same_q <= 1'b0; last_beat_q <= '0;
                map_valid <= 1'b0; map_same_q <= 1'b0; map_beat <= '0; map_data <= '0; map_mask <= '0; map_strb <= '0;
            end else begin
                logic stage_load;                      // a beat is being loaded into the stage this cycle
                stage_load = 1'b0;
                // (0) INPUT STAGE: consume first; a same-cycle capture below overrides (set wins) — classic
                // pipeline advance. Capture is gated on px_ready so an occupied, non-advancing stage is never
                // overwritten (the producer must HOLD px_emit/px_addr/px_pix32 until px_ready, as before).
                if (in_advance) in_valid <= 1'b0;
                if (enable && arm_gs && px_emit && px_ready) begin
                    in_valid    <= 1'b1;
                    in_beat     <= {px_addr[31:5], 5'd0};
                    in_lane     <= px_addr[4:0] >> LANE_LO;
                    in_pix      <= px_pix32[PIX_BITS-1:0];
                    in_same_q   <= ({px_addr[31:5], 5'd0} == last_beat_q);   // vs the PREVIOUS captured pixel's beat
                    last_beat_q <= {px_addr[31:5], 5'd0};
                end
                // (1) MAP: a drain and refill can coincide; the refill wins and preserves throughput.
                if (in_advance) begin
                    map_valid  <= 1'b1;
                    map_same_q <= in_same_q;
                    map_beat   <= in_beat;
                    map_data   <= (256'(in_pix) << ({27'd0, in_lane} * PIX_BITS));
                    map_mask   <= (PIX_MASK << ({27'd0, in_lane} * PIX_BITS));
                    map_strb   <= (32'(STRB1) << ({27'd0, in_lane} * PIX_BYTES));
                end else if (stage_ready && map_valid) begin
                    map_valid <= 1'b0;
                end
                // (2) DRAIN: the staged beat enters the FIFO if there is room (may be re-loaded below same cycle).
                if (stage_drain) stage_valid <= 1'b0;
                // (3) latch the flush request UNCONDITIONALLY so it is never lost while backpressured.
                if (flush) flush_pending <= 1'b1;
                // (4) PACKER: only when the stage can accept a beat this cycle; consumes the mapped input.
                if (stage_ready) begin
                    if (map_valid) begin
                        logic [255:0] nd; logic [31:0] ns;
                        if (has_data && !map_same_q) begin
                            stage_data  <= {1'b0, cur_addr, cur_data, cur_strb}; stage_valid <= 1'b1; stage_load = 1'b1;
                            cur_addr   <= map_beat;
                            cur_data   <= map_data;
                            cur_strb   <= map_strb;
                            has_data   <= 1'b1;
                        end else begin
                            nd = has_data ? cur_data : 256'd0;
                            ns = has_data ? cur_strb : 32'd0;
                            // Preserve the legacy packer's last-writer-wins behavior when two accepted
                            // fragments target one pixel in the same beat. map_data is sparse, so OR
                            // would corrupt a later color whose bit pattern overlaps the earlier one.
                            nd = (nd & ~map_mask) | map_data;
                            ns = ns | map_strb;
                            if (&ns) begin
                                stage_data  <= {1'b0, map_beat, nd, ns}; stage_valid <= 1'b1; stage_load = 1'b1;
                                has_data    <= 1'b0;
                            end else begin
                                cur_addr <= map_beat; cur_data <= nd; cur_strb <= ns; has_data <= 1'b1;
                            end
                        end
                    end else if (!in_valid && enable && arm_gs && flush_pending && has_data) begin
                        // partial-beat flush — retries here every cycle until stage_ready (guaranteed inside this if)
                        stage_data  <= {1'b0, cur_addr, cur_data, cur_strb}; stage_valid <= 1'b1; stage_load = 1'b1;
                        has_data    <= 1'b0;
                    end else if (!in_valid && enable && arm_gs && flush_pending && !has_data) begin
                        // ordered EOF marker — retries until accepted (in_valid empty => no pixel can be overtaken)
                        stage_data    <= {1'b1, cur_addr, cur_data, cur_strb}; stage_valid <= 1'b1; stage_load = 1'b1;
                        flush_pending <= 1'b0;
                    end
                end
                // overflow witness (accepted-write accounting): a stage load while the stage is occupied AND not
                // draining would DROP the previous beat. Gating on stage_ready makes this impossible; the witness
                // fires only if that invariant is ever violated. Must stay 0 (asserted by the saturation TB).
                if (stage_load && stage_valid && !stage_drain)
                    fifo_overflow_count <= fifo_overflow_count + 32'd1;
            end
        end
    end
    endgenerate

    // ============================ async FIFO (CDC) ============================
    logic [PW-1:0] fifo_rdata;
    logic          fifo_rempty;
    logic          fifo_rd;
    // Ch323 — reset BOTH FIFO pointers from the STABLE axi_rst_n (assert async, deassert
    // synced into gs_clk). gs_rst_n (= core reset) toggles on every CORE_CTRL re-render; if
    // the write pointer reset followed it while the read pointer stayed, the gray pointers
    // would desync → FIFO corruption (phantom beats, no commit). Same fix as gs_z_flush_writer.
    reg [1:0] wrst_sync;
    always_ff @(posedge gs_clk or negedge axi_rst_n) begin
        if (!axi_rst_n) wrst_sync <= 2'b00;
        else            wrst_sync <= {wrst_sync[0], 1'b1};
    end
    wire fifo_wrst_n = wrst_sync[1];
    gs_async_fifo #(.WIDTH(PW), .DEPTH(FIFO_DEPTH)) u_fifo (
        .wclk(gs_clk), .wrst_n(fifo_wrst_n), .wr(fifo_wr_ram), .wdata(fifo_wdata_ram), .wfull(fifo_wfull),
        .rclk(axi_clk), .rrst_n(axi_rst_n), .rd(fifo_rd), .rdata(fifo_rdata), .rempty(fifo_rempty)
    );

    // ============================ f2sdram-clock AXI FSM ============================
    localparam logic [1:0] S_IDLE=2'd0, S_AW=2'd1, S_W=2'd2, S_B=2'd3;
    logic [1:0]   state;
    logic [31:0]  beat_addr;
    logic [255:0] beat_data;
    logic [31:0]  beat_strb;
    logic [31:0]  awaddr_q;        // Ch352 — full AW address latched at admission, held stable AW->W->B

    assign awsize  = 3'd5;          // 32 bytes/beat (256-bit)
    assign awburst = 2'b01;         // INCR
    assign awid    = 5'd0;
    assign awlen   = 8'd0;          // single beat per line (tile-rows aren't contiguous)
    assign awaddr  = awaddr_q;      // Ch352 — latched at admission; STABLE through AW->W->B (AXI requires it)
    assign wdata   = beat_data;
    assign wstrb   = beat_strb;
    assign wlast   = 1'b1;          // 1-beat burst
    // Ch352 — AXI transaction stability (Codex): arm_axi/commit gate ADMISSION ONLY (S_IDLE pop). Once a beat is
    // admitted, awvalid/wvalid are driven by STATE alone and run to completion, so a later arm-deassert or a
    // fb_base commit can never drop VALID mid-handshake or move awaddr while AWVALID && !AWREADY.
    assign awvalid = (state == S_AW);
    assign wvalid  = (state == S_W);
    assign bready  = (state == S_B);
    // A commit and a FIFO admission may coincide safely: S_IDLE latches the full address and canary decision
    // from the OLD snapshot at that edge, then AW/W/B runs solely from those registered values. The next
    // admission sees the NEW snapshot. Do not gate this with the raw commit synchronizer: that turns the
    // control edge into a read-pointer/rempty timing cone inside the async FIFO.
    assign fifo_rd = (state == S_IDLE) && !fifo_rempty && arm_axi;
    assign idle    = (state == S_IDLE) && fifo_rempty;

    always_ff @(posedge axi_clk or negedge axi_rst_n) begin
        if (!axi_rst_n) begin
            state <= S_IDLE; beat_addr <= '0; beat_data <= '0; beat_strb <= '0; awaddr_q <= '0;
            beats_written <= '0; bursts_issued <= '0; bresp_err_count <= '0; frame_drained <= 1'b0;
        end else begin
            unique case (state)
                S_IDLE: if (!fifo_rempty && arm_axi) begin
                    if (fifo_rdata[MARK]) begin
                        // Ch353 — ordered EOF marker popped: every prior data beat's BRESP has completed (in-order
                        // FIFO + per-beat B). Assert the drain ack; consume the marker (fifo_rd pops it, no AXI).
                        frame_drained <= 1'b1;
                        state         <= S_IDLE;
                    end else begin
                        frame_drained <= 1'b0;              // new frame data in flight — drop the ack
                        beat_addr <= fifo_rdata[319:288];   // {marker, addr, data, strb}
                        beat_data <= fifo_rdata[287:32];
                        beat_strb <= fifo_rdata[31:0];
                        awaddr_q  <= fb_base_axi + fifo_rdata[319:288];  // latch FULL AW addr from the STABLE base
                        // canary: write ONLY the offset-0 (top-of-frame) 32-byte line; discard every other beat.
                        if (canary_axi && (fifo_rdata[319:288] != 32'd0))
                            state <= S_IDLE;
                        else
                            state <= S_AW;
                    end
                end
                S_AW: if (awready) begin
                    bursts_issued <= bursts_issued + 32'd1;
                    state <= S_W;
                end
                S_W: if (wready) begin
                    beats_written <= beats_written + 32'd1;
                    state <= S_B;
                end
                default: if (bvalid) begin   // S_B
                    if (bresp != 2'b00) bresp_err_count <= bresp_err_count + 32'd1;
                    state <= S_IDLE;
                end
            endcase
        end
    end
endmodule : gs_lpddr_axi_master
