# Ch353 audit log — full SH3 draw on an LPDDR-only direct PSMCT32 framebuffer

Purpose: complete, auditable trail of every change + verification for Codex review.
Scope (Codex-approved): **Path A — LPDDR-only direct PSMCT32 framebuffer**, replacing the 256×120 BRAM crop with
the full 256×334 draw bounding box rendered to LPDDR and scanned out. Path B (tile-spill) deferred.
Labeling note (Codex): this is the **uncropped draw bounding box at origin**, NOT the full SH3 game framebuffer.

## Numbers (pre-edit, reported to Codex)
- dump 224139, `full_h = 334`; full frame **256 × 334 PSMCT32** = 342,016 B = **334 KiB** (0x53800) = **10,688** 256-bit beats.
- row stride 1024 B. LPDDR ranges (disjoint): **FB 0x000000..0x053800 (334 KiB)**; **TEX 0x200000..0x240000 (256 KiB)**.
- 334 KiB > 128 KiB BRAM and > 256 KiB read2 cap → LPDDR required.

---

## Brick 1a — widen `gs_lpddr_axi_master` to PSMCT32 + end-of-scene flush  [DONE, verified]

RTL: `rtl/gif_gs/gs_lpddr_axi_master.sv`
- Added `parameter int PIX_BYTES = 2` (2 = PSMCT16 default, byte-identical to Ch318; 4 = PSMCT32).
- Derived: `PIX_BITS = PIX_BYTES*8`, `LANE_LO = (PIX_BYTES==4)?2:1`, `STRB1 = '1` (per-pixel byte-strobe mask).
- Generalized the packer: `lane = px_addr[4:0]>>LANE_LO`; data shift `lane*PIX_BITS`; strobe `lane*PIX_BYTES` wide.
  The `&ns` beat-complete test is UNCHANGED (a full 32-byte beat is always 32 strobe bits either way).
- Port `px_pix16[15:0]` → `px_pix32[31:0]` (PSMCT16 callers drive `{16'd0, pix16}`).
- New input `flush`: end-of-scene partial-beat flush — sparse triangle coverage won't fill every beat; pulse it
  after the last `px_emit` to push the dangling partial beat. PSMCT16 tile path ties it to 0 (tile-rows always
  complete beats). Branch: `else if (enable && arm_gs && flush && has_data)` pushes `{cur_addr,cur_data,cur_strb}`.

Callers updated: `rtl/top/de25_nano_psmct32_raster_demo_top.sv` (2 instantiations) → `.px_pix32({16'd0, demo_flush_pix16}), .flush(1'b0)`.

TB: `sim/tb/gif_gs/tb_gs_lpddr_axi_master.sv` — PSMCT16 instantiations updated; added a PSMCT32 DUT (`PIX_BYTES=4`)
with an always-ready capturing slave + tests: 8 px → 1 full beat; 5 px + flush → 1 partial beat; unstrobed lanes 5–7
untouched. Result: `[axi32] PSMCT32 ok ...`, `[axi] DISARMED/CANARY/STABILITY ok` (PSMCT16 byte-identical), **PASS**.

**Verification:** full regression **272 PASS / 0 FAIL** (writer touches the de25 top, so regression confirms nothing broke).

---

## Brick 1c — LPDDR-only direct-FB proof  [DONE, verified]

Recon finding (big de-risk): the bram-top ALREADY has the pieces — no new bram-top logic:
- `FB_LPDDR_ONLY` (Ch326, `top_psmct32_raster_demo_bram.sv:1170`): `vram_we_pre = xfer_busy ? xfer_we : (raster_pixel_emit && !FB_LPDDR_ONLY)` — suppresses the BRAM raster mirror, keeps xfer/CLUT writes. = Codex's "suppress raster, keep CLUT".
- `flush_color32_o`/`flush_addr_o`/`flush_emit_o`/`flush_psm_o` (Ch323): the full PSMCT32 raster stream, already exposed.
- `feeder_ready_o` (C_READY): render-done, for the flush pulse.

TB: `sim/tb/top/tb_top_psmct32_sh3_lpddr_fb.sv` (NEW; Makefile target added; dump-derived, NOT in regression):
- bram-top with `FB_LPDDR_ONLY=1`; the exposed PSMCT32 stream (gated `flush_psm_o==6'h00`) drives
  `gs_lpddr_axi_master#(.FIFO_DEPTH(64),.PIX_BYTES(4))` → a behavioral, strobe-honouring LPDDR FB.
- Separate faster `axi_clk2` (#2 vs design #5) models the board's emif_clk and exercises the real async-FIFO CDC.
- "ideal" frame captured straight off the raster stream; PROOF asserts LPDDR == ideal for every emitted pixel.
- End-of-scene `flush` pulse + a STABLE-idle drain gate before asserting.

**Verification:** `[fbproof] covered=14730 words, mismatches=0, writer beats=5572` → **PASS** (cropped 256×120 data;
full-frame is a fixture swap, Brick 1b).

Two findings worth auditing:
1. Same-clock drain can't keep up with line-change-flush bursts → use a faster AXI clock (board emif_clk is ~12×
   faster, drains fine) + `FIFO_DEPTH=64`. (No RTL backpressure added; the board clock ratio makes it unnecessary.)
2. **Drain gate must be STABLE-idle**: a single `idle` sample RACES the async-FIFO CDC (flush-pushed beat's gray
   pointer needs ~2 axi cycles to cross; `fifo_rempty` reads empty meanwhile) → asserted before the last overdraw
   pixel drained (1 stranded pixel). Fix: settle + require idle for 8 consecutive cycles. **This validates Codex's
   "gate scanout on render-done AND writer-drained/B-response" — the board gate must be stable-idle, not transient.**

---

## Brick 1b — full-frame fixture (256×334) + re-run proof  [DONE, verified]

Tooling: `tools/gs_make_sh3_real_draw_fixture.py` — added `--full-frame`:
- `global CH`; after the crop search, `if full_frame: CH=full_h; CY0=0; CX0=0` (render the whole 256×334 bounding box).
- Crop-dependent outputs written with a `tag` (`full` vs `real`): `feeder_sh3_{tag}.mem`, `sh3_{tag}_params.vh`,
  `sh3_{tag}_refmap*.mem`, `bios_sh3_{tag}.mem`, `payload_sh3_{tag}.mem`. Shared texture/CLUT/idx/pal stay `sh3_real_*`.
  → the Ch352 cropped build (`sh3_real_*`) is byte-untouched.
- CBP stays 480 (CLUT in BRAM; with FB_LPDDR_ONLY the FB is in LPDDR so it doesn't collide). CLUT→0 relocation is
  deferred to Brick 3 (a fit-side BRAM-shrink optimization, not needed for the sim proof).

Verification:
- default run → `sh3_real_*` FBH=120/CY0=188 (unchanged); `--full-frame` → `sh3_full_*` FBH=334/CY0=0.
- **Cropped oracle still bit-identical: `tb_top_psmct32_sh3_real_draw_demo` 96.2% PASS** (Ch352 build intact).
- proof TB: `\`ifdef SH3_FULL_FRAME` selects `sh3_full_params.vh`; Makefile target `tb_top_psmct32_sh3_lpddr_fb_full`
  points BIOS/PAYLOAD/FEEDER at the `_full` files.
- **Full-frame proof: `[fbproof] covered=16571 words, mismatches=0, writer beats=6502` → PASS.**

## Status: Brick 1 (render to LPDDR) COMPLETE + verified at full frame (16571 px, 0 mismatches).

---

## Codex audit corrections (all 5 resolved before Brick 2)

**#1 (High) — full-frame oracle distinction.** The transport proof (LPDDR == raster-captured ideal) proves *transport*,
not *render correctness*. Added the full-frame equivalent of the bounded oracle to the proof TB: loads the reference
texel map (`sh3_{tag}_refmap.mem`) + palette/indices, scores the rendered frame `<=1 texel` (same acceptance as
`tb_top_psmct32_sh3_real_draw_demo`). Cropped reads **96.2% / INT 97.5% / clut_bad=0 — EXACTLY matching the standalone
oracle**; full-frame **96.1% / INT 97.6% / clut_bad=0**. (Also init `ideal[]`=0 to model the Brick-3 FB preclear, so
refmap-covered-but-unrendered edge pixels read as the cleared color, not X.)

**#2 (High) — production-safe drain ack (replaces the stable-idle heuristic).** `gs_lpddr_axi_master`: FIFO word
widened to 321 (marker bit). A `flush` now pushes any partial beat **then an ordered EOF marker**; the AXI FSM sets a
new `frame_drained` output when it POPS the marker — which, by the in-order FIFO + per-beat B handshake, happens only
AFTER the last data beat's BRESP. Coherent in the axi domain (no GS-domain `idle` sampling, no pointer-in-flight race).
The proof TB now waits on `frame_drained` (a stable level), not the heuristic. PSMCT16 path byte-identical (flush tied 0).

**#3 (Medium) — PSMCT32 AXI backpressure.** `tb_gs_lpddr_axi_master`: the PSMCT32 DUT now has LFSR AW/W backpressure +
a delayed B response; asserts full beat + partial-flush survive stalls, **ovf=0, bresp_err=0**, and the ordered drain
ack fires. PASS.

**#4 (Medium) — tagged generated PNGs.** `gs_make_sh3_real_draw_fixture.py` now writes `sh3_{tag}_ref.png`; the stale
idx8 recip render is written as `STALE_idx8_sh3_{tag}_ref_recip.png` (clearly marked). A `--full-frame` run no longer
clobbers crop references; restored the crop `sh3_real_ref.png` (256×120) and created `sh3_full_ref.png` (256×334).

**#5 (Hygiene) — gitignore.** Added `/synth/**/tmp-clearbox/`, `/synth/**/sta_*.txt`, `*.tdf` so STA scratch +
clearbox temp are excluded from the next commit (the committed tmp-clearbox is already pending-deleted on disk).

## Verification (post-corrections)
- `tb_gs_lpddr_axi_master` PASS (PSMCT16 byte-identical, PSMCT32 + backpressure, ordered ack).
- `tb_top_psmct32_sh3_lpddr_fb` (cropped) PASS: transport 0 mism + oracle 96.2%/97.5%/clut_bad=0.
- `tb_top_psmct32_sh3_lpddr_fb_full` (256×334) PASS: transport 0 mism + oracle 96.1%/97.6%/clut_bad=0.
- Cropped oracle `tb_top_psmct32_sh3_real_draw_demo` still 96.2% PASS (Ch352 build intact).
- Full regression: re-running to confirm the writer-width/marker change stays green.

## Codex audit round 2 (two real items + integration note)

**(High) EOF/partial dropped when FIFO full.** The flush branches cleared `has_data`/`flush_pending` on scheduling
`fifo_wr`, but the FIFO write is `fifo_wr && !fifo_wfull` — if full, the partial/marker vanished. Fix in
`gs_lpddr_axi_master`: (a) gate the flush branches on `!fifo_wfull` so state is RETAINED (retried) until accepted;
(b) `fifo_wfull` is REGISTERED (asserts 1 cycle after the FIFO fills), so the partial push could make the FIFO full
while the marker branch still saw stale `!wfull` and fired early → its write then dropped. Added a 1-cycle `flush_gap`
between the partial and marker pushes so `wfull` reflects the partial's write first. **Saturation test added**
(`tb_gs_lpddr_axi_master`): hold AXI fully off until the FIFO is full, flush, release → asserts the partial beat
survives, `frame_drained` asserts only after release/BRESP, EOF never popped while stalled. PASS.

**(Hygiene) tracked tmp-clearbox deletions.** `git rm --cached` on the 51 tracked `tmp-clearbox/*.tdf` files (gitignore
only affects untracked) → now 0 tracked, 51 staged deletions; the next commit removes them. No other STA/tdf scratch
tracked.

**Integration note (Codex, for Brick 2).** `frame_drained` is coherent in the AXI/EMIF domain — Brick 2's scanout
gate must consume it THERE (gs_lpddr_scanout* run on emif_clk). Any design-clock use needs a synchronizer. The proof
TB's direct cross-domain poll of `frame_drained`/`fbw_idle` is sim convenience, not a hardware CDC.

## Codex audit round 3 (enqueue edge case)

**(High) a prior NORMAL `fifo_wr` leaves `fifo_wfull` stale at a flush branch.** Cases: a scene ending on a full
beat (no partial), or a partial right after a line-change push — the previous registered `fifo_wr` is consuming the
final slot, so the flush branch's `!fifo_wfull` check is stale. The round-2 `flush_gap` only covered partial→marker.
Fix: gate BOTH flush enqueue branches on **`!fifo_wr && !fifo_wfull`** — no push in flight AND not full, so the
`!wfull` check is accurate; state is retained/retried until accepted. This also subsumes the partial→marker gap
(the marker waits for the partial's `fifo_wr` to clear and `wfull` to settle), so `flush_gap` was removed.

**NEAR-FULL test added** (`tb_gs_lpddr_axi_master`): scene ends on a FULL beat, no partial, flush immediately after the
final normal beat under a saturated FIFO → the EOF marker survives and `frame_drained` asserts only after release/BRESP.
PASS (alongside the saturation test).

## Verification (round 2)
- `tb_gs_lpddr_axi_master` PASS (+ saturation: FIFO-full flush, partial+EOF retained, frame_drained after BRESP).
- both proofs still PASS (cropped 96.2%/clut_bad=0; full-frame 96.1%/clut_bad=0; transport 0 mism).
- full regression re-running (writer gap fix).

Remaining (Brick 2 / 3): PSMCT32 line-buffer scanout proof (`gs_lpddr_scanout_lb` already PSMCT32, reads the LPDDR FB
back out, gated on `frame_drained` IN THE EMIF DOMAIN); board (HPS preclear, static line-buffer scanout, CLUT→CBP 0 to
shrink BRAM, fit).

## Brick 2 — PSMCT32 LPDDR-FB line-buffer scanout proof (`tb_gs_lpddr_scanout_fb`)

Full write→read loop: the Ch353 writer (`gs_lpddr_axi_master #(.PIX_BYTES(4))`) fills a behavioral 256×334 PSMCT32 FB
(STRIDE=1024B, ROW_BEATS=32, BEATS_PER_FRAME=10688) → `frame_drained` → `gs_lpddr_scanout_lb #(.STRIDE_BYTES(1024),
.ROW_BEATS(32),.N_ROWS(334),.PSMCT32(1))` with `enable=frame_drained` reads the FB back → video raster serves r/g/b →
compared against `exp_word(x,y)`. Three independent clocks (gs #5 / emif #2 / video #7). Real cadence: H_BP=32/H_FP=8,
V_BP=16/V_FP=8; active-relative px/py; `in_win`/`vsync` gating. FB precleared to 0 (models HPS preclear). Read model
has variable AR/R latency (LFSR). All 256×334 pixels compared **including the x≥128 black half**.

Codex acceptance gates — all met:
- **All pixels correct incl. black**: `errors=0` (checked=105968 ≥ 256×334 active).
- **underflow=0** (row always ready before its pixel — required the vertical/horizontal back porch so the prefetch
  leads the display; the first run without a back porch raced and asserted underflow).
- **rd_errs=0** (no read-response errors).
- **valid through the final row**: `checked ≥ H_ACT*V_ACT`.
- **frame_drained gate consumed in the EMIF domain** (scanout runs on emif_clk).

Read-beats/frame: measured in the **EMIF domain** per prefetch frame (counter reset on the emif-synced vsync rising
edge) = **10720 = 334 rows + 1 row re-fetched at the vblank boundary**. Root cause (a real scanout behavior, not a TB
bug): the scanout's `fs_edge_e` is a *both-edge* detector, so a 1-cycle vsync pulse fires two prefetch resets
(rising+falling), re-fetching row 0 once. Harmless — every row is read correctly (pixels match). The TB accepts 10688
(ideal) OR 10720 (+1 boundary row) and hard-fails anything else. **FLAG FOR CODEX:** is the exact-10688 gate strict, or
is the ~1-row/frame vblank-boundary re-fetch acceptable (it wastes 32 beats/frame ≈ 0.3% of read bandwidth)? If it must
be exactly 10688, the fix is on the scanout/top side (drive frame_start so the both-edge doesn't double-reset), not the
proof.

Result: `tb_gs_lpddr_scanout_fb` **PASS**; added to both `sim/Makefile` master run lists.

Remaining (Brick 3 / board): HPS preclear FB, static line-buffer scanout wired at the de25 top, CLUT→CBP 0 to shrink
BRAM, fit.

## Codex round 4 — beat-count fix (production RTL) + Brick 3 start-ordering gate

**(Resolved) both-edge → rising-edge in `gs_lpddr_scanout_lb`.** The 10720-vs-10688 was a real production-RTL issue,
not test noise. Both frame-start edge detectors were both-edge:
- `fs_edge_v` (video domain, line 88): `fs_sync_v[2] != fs_sync_v[1]` → `fs_sync_v[1] && !fs_sync_v[2]`
- `fs_edge_e` (emif domain, line 132): `fs_sync_e[2] != fs_sync_e[1]` → `fs_sync_e[1] && !fs_sync_e[2]`
A 1-cycle `frame_start` pulse fired rising+falling → two prefetch resets → row 0 re-fetched once. Rising-edge only =
exactly one reset per frame. TB assertion tightened back to **exactly 10688** (`!== BEATS_PER_FRAME` is a hard fail).

Verification: `tb_gs_lpddr_scanout_fb` → **10688/frame, underflow=0, rd_errs=0, errors=0, PASS**. Focused scanout
suite all PASS (scanout_lb, scanout_lb_psm32, scanout_lb_psm32_256, scanout_concurrency, scanout, scanout_psm32).
Full regression re-run for the RTL change.

## Brick 3 — start-ordering gate (Codex, mandatory sequence)

The de25 top must enforce this host-start order; the boot CLUT payload must NOT auto-start rendering before 1–4:
1. HPS preclears the framebuffer.
2. HPS uploads the texture.
3. Texture cache fill completes successfully.
4. CLUT boot upload/load completes.
5. Arm the PSMCT32 writer.
6. Start the feeder render.
7. Send EOF; await EMIF-domain `frame_drained`.
8. Enable the static LPDDR scanout.

## Codex round 5 — Brick 3 build rulings (board path)

1. **No hardware preclear engine for v1.** Extend the HPS write-probe (`ps2_sh3_tex_upload.c`) to zero
   0x000000–0x0537FF (85,504 words) — only ~30% more traffic than the proven 65,536-word texture upload — polling
   `write_pending` and checking BRESP. A new AXI engine + arbiter are unjustified before measuring this path.
2. **Dedicated `GS_SH3_LPDDR_FB` profile**, separate from `GS_TILE_SPILL`, static PSMCT32 line-buffer scanout,
   profile-specific geometry (256×334).
3. **`frame_drained` as a HARD gate, no timeout.** A timeout can display a partial FB — recreating the bug the
   handshake fixes. Failure stays BLACK with diagnostics; a manual debug override is acceptable only if explicit.
4. **Feeder auto-start-once correction (CRITICAL).** `top_psmct32_raster_demo_bram.sv:807` C_SETUP asserts `fdr_start`
   when the boot upload finishes → the disarmed boot render leaves a pending EOF that would assert `frame_drained` the
   instant the host later arms the writer. Fix: **`FEEDER_AUTOSTART` param (default 1'b1), 0 only for this profile** →
   C_SETUP goes straight to C_READY (no boot render); every render then waits for an explicit 0x0E8 GO.
5. **Expose `frame_drained` as a host-readable diagnostic** if a spare status bit exists.

Required host sequence (GS_SH3_LPDDR_FB): (1) wait setup/CLUT complete + feeder ready → (2) preclear LPDDR FB →
(3) upload texture → (4) fill + verify texture cache → (5) configure base 0, full mode, arm writer → (6) issue 0x0E8 GO
→ (7) await ordered `frame_drained` → (8) enable scanout automatically from that ack.

### Landed this round
- **`FEEDER_AUTOSTART` param + FSM** in `top_psmct32_raster_demo_bram.sv` (param after FEEDER_STG_WORDS; C_SETUP
  branches on it → auto boot render when 1, straight to C_READY when 0). Additive, default 1'b1. Verified
  byte-identical: tb_top_psmct32_sh3_lpddr_fb / feeder_runtime_demo / feeder_scene_retrigger_demo all PASS.

### Design clarification (derived)
`GS_SH3_LPDDR_FB` is a **feeder-based** profile: the host streams the SH3 draw list into the feeder (0x0DC/0x0E4) and
GOes (0x0E8) — matching Codex's host sequence. So its inner-instance config mirrors `GS_SH3_REAL_DRAW_DEMO`
(FEEDER_ENABLE=1, CLUT_CSM1_ENABLE=1, PERSPECTIVE_CORRECT=1, PERSP_RECIP_IDX_BITS=11, GS_LPDDR_TEX cache) but with:
FEEDER_AUTOSTART=0, FB_LPDDR_ONLY=1 (Ch326, suppress BRAM FB mirror), the full 256×334 PSMCT32 FB in LPDDR (not the
128 KiB crop), and the line-buffer scanout at 256×334.

### Remaining Brick 3 build (precise)
- **(RTL, board top)** add `elsif GS_SH3_LPDDR_FB` inner-instance param block (SH3 config + FEEDER_AUTOSTART=0 +
  FB_LPDDR_ONLY=1); activate the GS_LPDDR4B_FB writer/scanout blocks for this profile; reconfigure the writer instance
  to PIX_BYTES=4 / px_pix32=demo_flush_color32 / px_emit on psm==6'h00 / flush=end-of-scene / route `frame_drained`;
  set scanout geometry (STRIDE=1024, ROW_BEATS=32, N_ROWS=334, PSMCT32); `frame_ready_emif = frame_drained` (hard gate,
  no timeout).
- **(RTL, bridge)** expose `frame_drained` as a status bit (mind the `.*`-port-add trap: update every bridge
  instantiation/TB).
- **(host)** `ps2_sh3_tex_upload.c` FB-preclear loop (zero 0x000000–0x0537FF via write-probe, poll write_pending + BRESP).
- **(build)** add the `GS_SH3_LPDDR_FB` profile to `select_de25_profile.sh` + QSF VERILOG_MACRO (also defines
  GS_LPDDR4B_FB + GS_LPDDR_TEX + the SH3 fixture macros).
- **(sim, MANDATORY before fit)** integration sim proving the sequence: FEEDER_AUTOSTART=0 → no boot render → arm →
  GO → EOF → `frame_drained` fires exactly once (NOT prematurely from a boot render) → scanout serves the correct
  full-frame. This is the correctness proof for Codex's round-5 item 4.

## Brick 3 — host-start SEQUENCE proof (integration sim) — PASS

`sim/tb/top/tb_top_psmct32_sh3_lpddr_fb_seq.sv` (full-frame fixtures; the mandatory pre-fit gate). Instantiates the
bram-top with **FEEDER_AUTOSTART=0** + FB_LPDDR_ONLY + the SH3 feeder config, the PSMCT32 writer, the behavioral
(precleared) LPDDR FB, and `gs_lpddr_scanout_lb` reading it back to a real video raster — with the **render-epoch**
flush logic (Codex round 5) driving the writer's EOF. Clocks: design/gs #5, emif #2, video #7, bridge #11.

Render epoch (mirrors what wires into the board top): `render_inflight` set by an accepted host GO (feeder_go while
ready); `flush = render_inflight && feeder_ready_rise`; cleared on flush. So the FEEDER_AUTOSTART=0 setup→C_READY edge
(render_inflight=0) can NOT enqueue a false EOF.

Codex acceptance — ALL met on the first run:
- Boot setup reaches C_READY. **Before GO: raster_emits=0, writer_beats=0, eof=0, frame_drained=0** (no boot render).
- Texture fill verifies (fill_crc=fbdeaa32, tex_rd_errs=0). FB precleared to 0; idle BRESP errors=0.
- Host sets base 0 / canary off / arms writer (latched via ctrl_commit).
- **GO → feeder_ready drops then re-rises** after render+drain.
- **Exactly ONE EOF → exactly ONE frame_drained.** Writer beats=6502, FIFO overflow=0, BRESP errors=0.
- Synchronized `frame_drained` reaches the bridge domain.
- Scanout auto-enabled from the ack: **exactly 10688 read beats/frame**, all 256×334 pixels match (incl. black),
  underflow=0, rd_errs=0.
- Bounded oracle: 96.1% ≤1-texel ALL / 97.6% interior / clut_bad=0 (correct SH3 render).
- `errors=0` → **PASS**.

The SH3 fixture TBs are dump-derived/LOCAL, so this is run manually (like its siblings) — NOT added to the master
`run:` list. Full regression re-run for the widely-used inner-module FEEDER_AUTOSTART change (additive, default 1'b1).

REMAINING Brick 3 (board plumbing, then owner fit): board-top GS_SH3_LPDDR_FB profile (param block + writer reconfig +
scanout geom + frame_ready_emif=frame_drained + the render-epoch logic wired in design_clk); bridge frame_drained
status bit; host preclear loop in ps2_sh3_tex_upload.c; select_de25_profile.sh + QSF profile. No fit until the board
build compiles clean under the profile and this sequence proof stays green.

## Brick 3 board plumbing — COMPLETE (awaiting owner fit)

All board-side wiring for GS_SH3_LPDDR_FB is landed and verified in sim; the fit itself is owner-gated.

**Board top (`de25_nano_psmct32_raster_demo_top.sv`), all gated on `GS_SH3_LPDDR_FB` (inert for every other profile):**
- Inner-instance param block `elsif GS_SH3_LPDDR_FB` — mirrors GS_SH3_REAL_DRAW_DEMO (FEEDER_ENABLE, CLUT_CSM1,
  PERSPECTIVE_CORRECT, PERSP_RECIP=11, VRAM 128 KiB, GRAD_SEQ_DIVIDER=0) + **FEEDER_AUTOSTART=0** + **FB_LPDDR_ONLY=1**.
- TEXC block: `elsif GS_SH3_LPDDR_FB` → the same 512×512 PSMT8 LPDDR texture geometry as the crop.
- Render epoch (design_clk): `sh3_render_inflight` set by an accepted feeder GO; `sh3_fb_flush = inflight && feeder_ready
  rise`; cleared on flush → exactly one EOF per render (Codex round 5 — the setup→C_READY edge can't leak an EOF).
- frame_ready gate: `frame_ready_emif = sh3_frame_drained` (EMIF prefetch enable) + `frame_ready_r` = design-synced
  sh3_frame_drained (HDMI mux blanks black until drained). HARD gate, no timeout.
- Writer (u_lpddr_axi, the active GS_LPDDR4B_FB instance): `PIX_BYTES(4)`, `px_emit` on psm==6'h00,
  `px_pix32=demo_flush_color32`, `flush=sh3_fb_flush`, `.frame_drained(sh3_frame_drained)`.
- Scanout: `SCANOUT_LB_NROWS=334` (STRIDE 1024 / ROW_BEATS 32), `SCANOUT_FB_BASE=0`, `SCANOUT_PSMCT32=1`,
  `scanout_lb_eff` forced 1 (line-buffer; a 256×334 frame-cache would be 334 KiB).
- Verified: patched-copy iverilog elaboration under GS_SH3_LPDDR_FB+GS_LPDDR4B_FB+GS_LPDDR_TEX = exit 0 (the only
  standalone-sim gap is the Quartus-only EMIF_Qsys IP + a pre-existing `lpddr_ctrl_commit_w` forward-ref, both
  fit-proven). Default board-top TB still builds+passes (edits inert for non-SH3).

**Drain diagnostic:** used existing `LPDDR_STATUS 0x02C[0]` (writer idle, stable post-render) + `LPDDR_BYTES 0x030`
(beats!=0) — the narrowest path (Codex), avoiding a bridge port-add that would break 4 `.*` regression TBs.
`frame_drained` still gates the scanout internally.

**Build profile:** `scripts/select_de25_profile.sh sh3_lpddr_fb` case added (SCENE = GS_SH3_LPDDR_FB + GS_LPDDR_TEX +
feeder_sh3_full; FIXTURE = bios/payload_sh3_full). Ran it myself + verified the QSF: SCENE/FIXTURE blocks correct,
markers balanced (2×), static GS_LPDDR4B_FB=1 / GS_LPDDR_FB=1 / USE_QSYS_TOP=1 / EMIF IP intact.

**Host app:** `tools/ps2_sh3_tex_upload.c` extended with `--lpddr-fb` mode (Codex's no-hw-clear path): wait feeder
ready → PRECLEAR FB 0x000000..0x0537FF (85504 words) via the write-probe (poll write_pending, check BRESP) → upload
texture → fill+verify cache → set base 0 / canary off / arm writer → feeder GO → await drain (idle + bytes!=0) →
raise video_src. Compiles clean (-Wall); dry-run OK (tex sum32=fbdeaa32). Crop path byte-unchanged.

**Verification:** seq proof `tb_top_psmct32_sh3_lpddr_fb_seq` still PASS (10688 beats, 1 EOF→1 frame_drained, oracle
96.1%). Regression re-running after the board-top edits.

**Owner fit order:** (1) `python3 tools/gs_make_sh3_real_draw_fixture.py --full-frame` (regenerate LOCAL fixtures if
absent); (2) `./scripts/select_de25_profile.sh sh3_lpddr_fb` (DONE — QSF already set); (3) fit in Quartus; (4) on the
board: `gcc -O2 -o ps2_sh3_tex_upload tools/ps2_sh3_tex_upload.c && sudo ./ps2_sh3_tex_upload sh3_real_tex_lpddr.mem
--lpddr-fb`.

## Ch353 follow-up — host-visible frame_drained diagnostic bit (Codex-approved after silicon)

Silicon showed the host's `idle`-based drain confirmation reads a mid-render count (idle pulses between the feeder's
render batches → the board printed "beats=44"). The DISPLAY was correct (scanout gated on the ordered frame_drained),
but the host couldn't READ a stable drain signal. Added it (the `.*`-port-add done carefully):

- **Bridge (`ps2_hps_bridge.sv`):** new `input frame_drained_i` + `frame_drained_sync` (2-FF into the bridge clock) +
  **LPDDR_STATUS 0x02C[6] = frame_drained_sync[1]** (STABLE — asserts only after the EOF marker's last BRESP).
- **Board top:** `sh3_frame_drained` now defined in both ifdef branches (driven by the writer under GS_SH3_LPDDR_FB,
  tied 0 otherwise); bridge `.frame_drained_i(sh3_frame_drained)`.
- **`.*`-trap handled:** the 4 `.*` bridge TBs (tb_ps2_hps_bridge, tb_bridge_iop_pad_input, tb_pad_state_via_sif_to_ee,
  tb_ee_pad_buffer_branch) each got a tied `logic frame_drained_i = 1'b0;` (matched by name). tb_lpddr_tex_staging is
  explicit/partial — the new input floats like its other unconnected inputs (no change). null bridge unaffected
  (its port list is a strict subset). All 5 bridge TBs PASS.
- **Host (`ps2_sh3_tex_upload.c --lpddr-fb`):** step 7 now polls **0x02C[6] frame_drained** (not [0]idle) and reports
  `frame_drained=1, FB beats written=~6500`. Compiles clean (-Wall).

Verified: board top elaborates clean under GS_SH3_LPDDR_FB+GS_LPDDR4B_FB+GS_LPDDR_TEX (exit 0); seq proof still PASS
(10688, 1 EOF→1 frame_drained); full regression re-running. This is a diagnostic-only change — the functional path
(silicon-verified) is unchanged; it re-fits into the same profile.
