Initial commit: retroDE_ps2 — first-of-its-kind PS2 GS FPGA core (DE25-Nano / Agilex 5)

RTL (GS rasterizer, EE core stub, platform bridge, LPDDR4B path), sim regression
(272 TBs), docs, and tooling. Copyrighted PS2 content (BIOS, game code, GS dumps,
and all dump-derived textures/traces) is excluded via .gitignore and stays local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 20:10:50 -04:00
commit ec82764bef
2462 changed files with 2174303 additions and 0 deletions
@@ -0,0 +1,454 @@
# 0010 — On-chip tile-local renderer core (first tiled-VRAM rung)
**Status:** proven in sim (Ch303), board-pending. One 16×16 tile, on-chip color+Z,
flush to VRAM. Texture still BRAM-VRAM. NO LPDDR, NO multi-tile binning yet.
## Why
doc 0009 established the per-pixel requirement for a combined textured+alpha+depth
primitive: **hidden = 1 read / 0 writes; visible = 3 reads / 2 writes.** doc 0008 §6
sets the target architecture: on-chip tile color+Z (RMW resolved on-chip), LPDDR as
backing VRAM, texture streamed/cached. This rung builds the **first piece**: the
on-chip tile color+Z scratchpad with flush, so the combined RMW happens on-chip and
only the texture fetch + the tile flush cross to VRAM. (Codex framing: build the
tile-local core first; stage LPDDR integration later.)
## What was built
- **`gs_tile_ram`** (rtl/gif_gs/gs_tile_ram.sv): generic 1W1R on-chip tile RAM,
registered read (1-cycle, matching the VRAM read2 contract). Instantiated twice
in gs_stub (gated by `TILE_LOCAL`): `u_tile_color` (256×32) + `u_tile_z` (256×32)
— one 16×16 tile, ~2 KiB total.
- **gs_stub `TILE_LOCAL` mode** (default 0 → byte-identical): a combined TME+ABE+ZTE
triangle renders into the tile via a CLEAR → RENDER → FLUSH sequence overlaid on
the existing R_IDLE/R_SCAN/R_DRAIN FSM.
## The tile memory schedule (the deliverable)
```
CLEAR : 256 cycles → write tile_color = TILE_CLEAR_COLOR, tile_z = TILE_CLEAR_Z
(every entry initialized; the "background")
RENDER (per inside pixel, tile index = {y[3:0], x[3:0]}):
beat0 read tile_z
beat1 Z-test (GEQUAL: frag_z vs tile_z). FAIL → STOP (no texture read,
no tile_color read, no tile_color/tile_z write). PASS → read texture (VRAM)
beat2 texel ready (Cs/As) ; read tile_color (dest)
beat3 blend ; WRITE tile_color
beat4 WRITE tile_z (skip on ZMSK)
FLUSH : 256 cycles → read tile_color[idx] (registered) → framebuffer write
(raster_pixel_emit → VRAM at the linear FB address). ~70 MB/s class
per doc 0008 — trivial.
```
In tile terms:
- **hidden pixel:** tile_z read only. No texture, no tile_color read/write, no tile_z write.
- **visible pixel:** tile_z read + texture read + tile_color read + tile_color write + tile_z write.
- **flush:** tile_color read → framebuffer write, ×256.
Texture stays on the VRAM read2 path (unchanged). Only color/Z moved on-chip.
## Verification (tb_top_psmct32_tile_demo)
Combined triangle (interpolated Z crossing the clear Z) over a CLEAR'd green tile.
A tracer on the tile-RAM ports + the emit port asserts the schedule:
- CLEAR wrote 256 color + 256 Z entries.
- hidden (depth-fail) pixels: no texture read, no tile_color write, no tile_z write.
- visible (depth-pass) pixels: texture read + tile_color write + tile_z write; rendered
color = blend(texel, clear-green); occluded/outside = clear green.
- FLUSH emitted 256 framebuffer writes; final scanout matches the Ch302 image.
Result: clear 256/256, flush 256, 35 visible / 7 hidden, errors=0. `TILE_LOCAL=0`
keeps every prior demo byte-identical.
## External LPDDR bandwidth model (documented, not yet exercised)
Per doc 0008: framebuffer flush is **trivial** (640×480×4 B ≈ 1.2 MB/frame ≈ 70 MB/s
@60fps, noise vs the measured 8.4 GB/s). Texture fetch + tile-reload from primitive
disorder are the real DDR consumers and are **locality-driven** — to be measured
against real GS traces (doc 0008 §45), not synthesized. This rung does NOT touch
LPDDR: texture is BRAM-VRAM, one tile, no eviction.
## Ch304 — 2×2 multi-tile grid (extension)
The single-tile core generalizes to a `TILE_COLS×TILE_ROWS` grid with minimal
change, because (a) `tile_idx = {y[3:0],x[3:0]}` is already the tile-local address
for any 16-aligned tile, and (b) attribute interpolation is screen-space → seams
are continuous by construction. Added: an outer tile loop (the popped primitive +
solved gradients persist across all tiles), per-tile walker-bbox clip to
`primitive_bbox ∩ tile` (skip render if no overlap → tile shows clear color), and
a flush FB-address offset by the tile origin. `TILE_COLS=TILE_ROWS=1` is byte-
identical to the single-tile path. Codex scope: fixed primitive list (one
primitive re-rendered per tile), re-test-against-each-tile, NO external bin memory.
Proven (tb_top_psmct32_tile2x2_demo): one triangle spanning a 2×2 grid (32×32,
crossing x=16 & y=16) — all 4 tiles clear independently (256 each), 1024 flush
emits, and the **whole 32×32 scanout matches a single screen-space reference**
(718/718, 67 seam-region pixels continuous) → no visible seams. This is the
re-test-each-primitive-against-each-tile architecture; a real binning engine /
command buffer is a later optimization, not needed for the architectural proof.
## Ch305 — MULTI-PRIMITIVE tiled scene (extension)
Generalizes the grid from re-rendering ONE primitive per tile to compositing a
LIST of primitives per tile, in order, so later primitives depth-test/alpha-blend
over earlier ones within each tile. Gated by `TILE_MULTIPRIM` (default 0 →
byte-identical) + `TILE_PRIM_COUNT` (batch size). The primitive FIFO IS the list
store (its slots already hold each primitive's pre-solved gradients), so re-reading
a slot is free. Per tile: CLEAR → load+render prim 0 → (pipeline-flush) → load+render
prim 1 → … → FLUSH. The grid starts only once the whole batch is buffered
(`fifo_count >= TILE_PRIM_COUNT && all_grad_done`) — the demo-honest stand-in for a
future GIF-EOP/kick. Empty-clip primitives are skipped per tile; the whole FIFO is
drained at grid end (streaming/partial-drain is future work). The inter-primitive
advance waits for the per-pixel pipeline to fully flush (`comb_pipe_empty`), not just
the walker reaching R_DRAIN, so a primitive's in-flight color/Z writes commit before
the next primitive's `ras_*` load.
Proven (tb_top_psmct32_tile_multiprim_demo): 3 combined prims over the 2×2 grid —
opaque blue bg (Z=0x5000), opaque red (Z=0x6000, in front), translucent white
(Z=0x5800, blends but is OCCLUDED by red where 0x5800 < 0x6000). The whole 32×32
scanout matches a software integer-Z-buffer + source-over replay (514/514), with all
interaction regions exercised (blue 24 / red 48 / light-blue 26 / occlusion 19 /
green 416) and seam continuity (50 seam matches). This is the architecture a real
command-stream replay needs; a per-tile bin buffer is a later optimization.
DEBUG NOTES (two non-obvious bugs surfaced): (1) the per-primitive clip wires indexed
a FIFO array through a function inside continuous `assign`s — iverilog-12 mis-reads
that as 0 (silent hang, sim-time frozen); fixed by computing them in `always_comb`
(legal SV, Quartus-clean; sim-only workaround). (2) The first failing image was a
FIXTURE bug, not RTL: three solid 4×4 textures placed 0x100 apart aliased, because a
PSMCT32 texture with TBW=1 has a 0x100-byte row stride so a 4-tall texture spans
0x400 bytes; spacing them 0x400 apart (TBP0 32/36/40) fixed it. The depth/RMW path
was correct all along.
## Ch306 — GS SCISSOR clipping (extension)
Bakes the GS SCISSOR_1 rectangle into the tile-traversal walker bounds (param
`SCISSOR_ENABLE`, default 0 → byte-identical). Because the scissor is a rectangle and
the walker scans a rectangle, the effective draw region = primitive bbox ∩ tile bbox ∩
scissor rect is itself rectangular — so the scissor is just intersected into the
walker bbox at all clip sites (single-prim `clip_*`, multiprim `always_comb`, and the
`mp_next_nonempty` empty-test). NO per-pixel scissor test: pixels outside the scissor
are never visited, so color and Z writes are both suppressed for free. SCISSOR_1 (GIF
reg 0x40) is parsed into a GLOBAL `scissor_1_q` (reset full-range); decoded fields →
12-bit `eff_sc*` gated by SCISSOR_ENABLE (0/0xFFF when off → max/min no-op). Per-
primitive (FIFO-snapshot) scissor is a future extension if a command stream varies it.
Proven (tb_top_psmct32_tile_scissor_demo): the Ch305 3-prim scene + SCISSOR_1
[9..22]×[6..20] (crossing both seams) — 514/514 match, clipped=39 (would-be-scene
pixels outside the rect are clear green), inside=59 (kept scene matches the unclipped
ref), exact boundary (edgePairs=6), seam=50. Regression 209→210, byte-identical.
## Ch307 — texture WRAP modes (REPEAT + CLAMP) (extension)
Adds GS texture wrap (CLAMP_1 WMS/WMT: REPEAT/CLAMP) for u/v, inside `gs_texture_unit`
(param `TEX_WRAP_ENABLE`, default 0 → pass-through byte-identical). Applied to u/v
BEFORE texel-address gen, so it covers the linear and swizzle paths and all callers at
one point. REPEAT = `u & (2^TW - 1)`; CLAMP = `min(u, 2^TW-1)`. gs_stub parses CLAMP_1
(reg 0x48) and snapshots wrap mode + TW/TH per primitive (FIFO, like ras_tbw), so
REPEAT and CLAMP primitives coexist in one scene. Codex sequencing: wrap/clamp before
bilinear, because it determines which edge neighbours a future bilinear filter samples.
Proven: a standalone sampler TB (tb_gs_texture_wrap) covers PSMCT32 + PSMT8 +
PSMT4-swizzle (wrap happens before swizzle); the board TB (tb_top_psmct32_tile_wrap_demo,
557/557) renders two textured tris sampling a striped 4×4 texture with UV 0..8 — REPEAT
tiles 2× (two white stripes), CLAMP sticks (one white stripe + edge-stretched blue).
Regression 210→212, byte-identical. (Fixture lesson: the first NON-solid tile texture
exposed an upload-giftag REGS nibble-count bug that solid textures had masked.)
## Ch308 — PSMCT16 tile color buffer (extension)
The on-chip tile COLOR RAM can be PSMCT16 (RGB5A1, 16-bit) instead of PSMCT32, via
param `TILE_COLOR_PSMCT16` (default 0 → byte-identical). It HALVES the color tile RAM
(`TILE_COLOR_W` = 16; Z RAM stays 32-bit) — the first answer to "can tile color be
narrower than the 32-bit blend width when the frame format allows it?" (yes). The RMW
packs ABGR8888→pix16 on write/clear, unpacks pix16→ABGR (bit-replicate) for the blend
dest, and the FLUSH emits PSMCT16 framebuffer writes (mirroring the proven S2 PSMCT16
emit; vram_normalize keys the halfword off byte_addr[1]). Scanout reads PSMCT16 via
DISPFB1.PSM=0x02.
CONSTRAINT discovered: the combined tile path's primitive eligibility requires
FRAME.PSM==PSMCT32 (the combined RMW was built PSMCT32-only). So the PSMCT16-ness lives
in the tile RAM + flush + DISPFB, NOT in FRAME.PSM — the demo keeps FRAME PSMCT32 (so
the prims classify as combined) while DISPFB + tile + flush are PSMCT16. A fully-PSMCT16
FRAME would need the combined gate relaxed to accept PSMCT16 dest (future work).
Proven (tb_top_psmct32_tile_psmct16_demo, 514/514): the Ch305 scene in PSMCT16, matched
against a software reference that applies the SAME per-step 5-bit quantization
(q(c)=(c&0xF8)|(c>>5)) the on-chip RMW does — each primitive blends over the quantized
dest. Clear green 0x80→0x84, light-blue 0x7F→0x7B, pure blue/red unchanged, red
occlusion intact. Regression 212→213, byte-identical.
## Ch309 — generic GS ALPHA blend modes (extension)
Generalizes the combined blender from the single hardcoded source-over to the GS
selector machinery `Cv = clamp(((A-B)*C)>>7 + D)` (A/B/C/D from ALPHA_1, FIX=[39:32]),
param `ALPHA_MODES_ENABLE` (default 0 → source-over, byte-identical). gs_alpha_blend
gains a_sel/b_sel/c_sel/d_sel/ad/fix inputs + a generic datapath; gs_stub FIFO-snapshots
the per-primitive selectors+FIX and wires them to u_comb_blend. The combined-eligibility
gate `close_combined` (which hardcoded source-over) is relaxed to accept any ABE
primitive when ALPHA_MODES_ENABLE — the generic blender handles any config. (Same class
of "eligibility gate too strict" as Ch308's PSMCT32-FRAME requirement: when you add a
per-pixel mode to the combined path, check the datapath AND close_combined.)
Proven (tb_top_psmct32_tile_alpha_demo, 514/514): the Ch305 scene with P1 ADDITIVE
(A=Cs,B=0,C=FIX=0x80,D=Cd → Cs+Cd) → magenta over the blue bg (glow/particle add), while
P0/P2 stay source-over (light-blue intact) and P2 is still depth-occluded by P1. Two
blend modes coexist. Regression 213→214, byte-identical.
## Ch310 — bilinear texture filtering (extension, 2-phase)
4-tap bilinear (PSMCT32), staged per Codex. PHASE 1: a multi-beat bilinear sampler in
gs_texture_unit (param BILINEAR_ENABLE, default 0): reads the 4 neighbours (each via the
Ch307 wrap), lerps by fractional U/V; schedule = 4·(1+RD_LATENCY)+1 ≈ 9 cyc/sample (the
architectural number for the future texture cache). Proven by a standalone TB
(tb_gs_texture_bilinear) — all 6 cases exact (center=nearest, halfway=4-tap avg, clamp
edge no-OOB, repeat edge wraps, nearest unchanged). PHASE 2: integrated into the COMBINED
tile path — TEX1.MMAG (GIF 0x14 bit5) per-primitive selects nearest vs linear; a runtime
`filter_lin` input gates the 4-tap; the affine interp gains a frac sibling
(interp_affine_uv_frac → step[15:12]); a new CB_TWAIT beat stalls the per-pixel FSM on the
LEVEL !tex_busy until the ~9-cycle sample completes (the FSM steps half-rate on z_advance,
so a level wait can't miss the 1-cycle out_valid), then CB_T latches the HELD filtered texel.
Depth/Z/blend/tile-RMW unchanged; bilinear did NOT touch close_combined (the prim is still
source-over ABE). Proven (tb_top_psmct32_tile_bilinear_demo): a magnified 4×4 blue/white
checker, nearest tri blocky (0 midtones) vs bilinear tri smoothed (all midtones), same
coverage (stall dropped nothing). Regression 215→216, byte-identical.
## Ch311 — per-tile BIN BUFFER (extension)
Replaces Ch305's render-time re-test (mp_next_nonempty: each tile re-scans all prims) with
a real precomputed bin buffer (param BIN_BUFFER_ENABLE, default 0). A new TP_BIN phase runs
a (prim,tile) double-loop counter FSM (prim_count×NTILES cycles) that tests each prim's
bbox∩tile∩scissor (the same overlap math) and appends the prim index to bin_prim[tile][] /
bin_n[tile], in ascending draw order. The render then walks each tile's bin (CLEAR-done loads
bin slot 0; RENDER-drain steps through bin_n; FLUSH at end) — no re-scan. Equivalent image
to the re-test path (same overlap test + order). This is the primitive-ROUTING machinery for
command-stream replay; the grid stays 2×2 (prove the mechanism, scale later). Proven
(tb_top_psmct32_tile_bin_demo): bins read back exactly (t0={0,1} t1={0,1} t2={0} t3={0,2} for
an all-tiles/2-tiles/1-tile prim trio) + image 594/594 vs the re-test reference. Regression
216→217, byte-identical.
## Next (staged, per Codex)
1. Multiple tiles / tile grid (primitive→tile binning). [DONE: Ch304 grid, Ch305 list, Ch311 bin buffer]
Scissor/window clipping. [DONE: Ch306]
Texture clamp/repeat. [DONE: Ch307]
PSMCT16 tile color. [DONE: Ch308]
ALPHA mode expansion. [DONE: Ch309]
Bilinear filtering. [DONE: Ch310 — sampler + combined-path integration]
Larger grid sweep. [DONE: Ch312 — 2x2→4x4 (16 tiles, 64x64) via the bin buffer; no new RTL logic, COLS/ROWS/NTILES already parameterized]
## Ch312 — 4x4 grid (extension)
Scales the tiled renderer to a 4×4 grid (16 tiles, 16×16 each = 64×64) by setting
TILE_COLS=TILE_ROWS=4 — NO new RTL logic, since the grid loop + bin buffer (NTILES,
CUR_T_W/BIN_T_W via $clog2) were already parameterized. 64×64 PSMCT32 FB fills 16 KiB so
the demo uses VRAM 32 KiB (textures @ 0x4000). Proven (tb_top_psmct32_tile_bin4x4_demo):
3 prims (P0 4-tile / P1 6-tile cross-seam / P2 1-tile, + 6 empty tiles), all 16 bin_n
read back exactly (1100 1211 0111 0001, empty=0), t5={0,1} order preserved, image 3240/3240
vs the re-test reference, seam continuity across x=16/32/48 + y=16/32. Regression 217→218,
byte-identical. The fit (owner) gives the resource-scaling answer: bin storage grows 4×
(60→240 register bits, still tiny) — a hard ALM/register jump would signal the register-bins
should go BRAM/MLAB-backed before larger scenes.
## Ch313 — full PSMCT16 framebuffer mode (extension)
Relaxes the `close_combined` eligibility gate so the combined/tiled path accepts a
PSMCT16 dest (`frame_1_q[29:24]==6'h02`) — but ONLY when `TILE_COLOR_PSMCT16=1`, so a
PSMCT16 FRAME never pairs with a PSMCT32 flush. This was the LAST place forcing a
PSMCT32 FRAME: the tile color RAM, the dest-color unpack for blending, and the flush
emit (be=`4'b0011`, psm=`0x02`, `<<1` byte addr) were ALL already PSMCT16 from Ch308,
keyed off `TILE_COLOR_PSMCT16` and independent of `FRAME.PSM`. So Ch308's PSMCT32-FRAME
workaround is gone — render/flush/scanout are now consistently RGB5A1. One-term RTL
change; at `TILE_COLOR_PSMCT16=0` (default) the new disjunct is constant-0 and the gate
collapses to the original PSMCT32-only test (byte-identical). Demo = the Ch312 4×4
(64×64) scene with `FRAME.PSM=PSMCT16` + DISPFB PSMCT16. A 64×64 PSMCT16 FB is 8 KiB —
HALF the 16 KiB PSMCT32 FB — so the demo runs in **16 KiB VRAM vs Ch312's 32 KiB**: the
direct framebuffer-memory saving that motivates the LPDDR-backed FB phase. Proven
(tb_top_psmct32_tile_psmct16fb_demo): flush 4096/4096 carry psm=0x02 + be=`0011`, ZERO
PSMCT32 flushes (whole FB is 16-bit); image 3240/3240 vs a re-test reference replayed
with per-step RGB5A1 quantization `q5(c)=(c&0xF8)|(c>>5)` (EXACT); 2875 matched pixels
differ from the would-be PSMCT32 value (proves the FB is genuinely RGB5A1, not PSMCT32);
bin_n/scissor/depth identical to Ch312 (1100 1211 0111 0001, t5={0,1}, seam 464).
FIT-CLEARED + VISUALLY VERIFIED on Agilex 5 (2026-06-01): vs Ch312 (4×4 PSMCT32, 32 KiB),
RAM blocks 45→29 (16), block-mem 688,128→421,888 (256 Kbit), ALMs 159, regs 555 — the
PSMCT16 FB recovered ALL of Ch312's 4×-scale-up memory cost, landing back on the Ch311 2×2
PSMCT32 baseline of 29 RAM blocks. A 4× grid in PSMCT16 costs ZERO extra framebuffer memory
vs the 2×2 PSMCT32 grid: hard proof the framebuffer (not bins/logic) is the on-chip memory
consumer and pixel format trades directly against it. Board image matches (blue/red/teal
tris + green, RGB5A1-quantized, no seams).
## Ch314 — bilinear for palettized (PSMT8/PSMT4) textures (extension)
Extends bilinear to INDEXED textures with the CLUT-BEFORE-INTERPOLATE rule: each of the 4
taps fetches an index, CLUTs it to RGBA, then the 4 COLORS interpolate (NOT the indices —
that would round to one palette entry). The sampler core is ~6 lines: the bilinear FSM tap
capture changes from `tap[beat] <= tex_rd_data` to `tap[beat] <= near_color` (already
`(PSMT8||PSMT4)?clut_rd_data:tex_rd_data`), so PSMCT32 is byte-identical and indexed taps
capture the CLUT'd color. New param PALETTE_BILINEAR (default 0) widens `do_lin` to admit
PSMT8(0x13)/PSMT4(0x14). The per-tap addr-gen (linear/swizzle + wrap/clamp) already runs
BEFORE the CLUT lookup, so "swizzle-before-CLUT" + edge wrap/clamp are free. For the BOARD
demo (bilinear lives only in the combined path), `close_combined`'s texture-PSM gate also
widens to admit PSMT8/PSMT4 when PALETTE_BILINEAR; the shared gs_texture_unit already had
the CLUT port wired and CLUT is a combinational 3rd port (no read2-arbitration change).
Proven: tb_gs_texture_bilinear (unit) CASE7 PSMT8 red↔blue halfway → 0xFF7F007F (purple,
neither endpoint = colors interpolated), CASE10 PSMT4 nibble across a byte boundary, CASE11
repeat / CASE12 clamp edges + no OOB, CASE1-6 PSMCT32 byte-identical;
tb_top_psmct32_tile_palbilinear_demo (board, combined path + CLUT load) nearMid=0 /
bilMid=58 — the on-board CLUT-before-interp proof. Regression 219→220 byte-identical, board
elab EXIT 0. FIT-CLEARED + VISUALLY VERIFIED on Agilex 5 (2026-06-01): LEFT tri blocky
blue/white indexed checker, RIGHT tri smoothed blue↔white midtones (CLUT-before-interp on
silicon). RESOURCE DELTA vs Ch310 (2×2 PSMCT32 bilinear, 16KB): ALMs 30,229→30,101 (flat),
DSP 122→122 (0), block-mem 425,984→425,984 (0), RAM blocks 29→29 (0) — palettized bilinear
is essentially FREE: zero extra DSP (reuses the same lerp multipliers), zero extra memory
(reuses the CLUT port from Ch296), the CLUT-before-interp restructure is just a mux on the
tap-capture path.
## Ch315 — primitive/bin capacity scaling (extension)
Parameterizes the primitive FIFO depth (was a hardcoded `FIFO_DEPTH=4`) as `TILE_FIFO_DEPTH`
(default 4 → byte-identical; power-of-2). In the bin-buffer renderer this depth sizes BOTH
the prim-list capacity N AND the per-tile bin depth M (bins are `[NTILES][FIFO_DEPTH]`),
so they're coupled (M=N: a tile's bin can hold every queued prim). Adds sim-visible
diagnostics (`raster_overflow_count_r`, `bin_occ_max_r`, defensive `bin_overflow_r`).
ARCHITECTURAL ANSWER to "where do register bins stop being reasonable": the dominant cost
is the ~40 `fifo_*` per-prim attribute arrays (hundreds of register bits/slot); the bins
add only `NTILES*FIFO_CNT_W` index bits per depth (~48 bits/depth at 4×4 — negligible), so
register bins stay cheap far past the FIFO's practical limit. OVERFLOW nuance: the batched
tile path triggers at `TILE_PRIM_COUNT` and drains the FIFO, so excess prims are CLAMPED
(visible as capped bin occupancy), not push-dropped — `raster_overflow` (the streaming
push-while-full flag, now counted) doesn't fire in the batched path; and `TILE_PRIM_COUNT`
must be `<= FIFO_DEPTH`. Proven: tb_top_psmct32_tile_cap_demo (depth 8, 7 prims) — bin t0
holds 6 (occ_max=6 > old 4), draw order {0..5}, image 3873/3873, no overflow;
tb_top_psmct32_tile_cap_overflow_demo (depth 4, same payload) — occ_max CLAMPS to 4
(capacity ceiling) and still renders all 16 tiles gracefully. Regression 220→222
byte-identical, board elab EXIT 0. (Demo puts the deep bin in t0 to dodge an orthogonal
latent bug: empty tiles preceding the first non-empty tile flush black — to be fixed
separately.) FIT-CLEARED + VISUALLY VERIFIED on Agilex 5 (2026-06-01). RESOURCE SLOPE
(depth-8 vs Ch312 depth-4): ALMs 29,682→32,072 (+2,390), regs 33,356→37,486 (+4,130),
block-mem + RAM-blocks UNCHANGED (688,128 / 45). So +4 FIFO slots = ~1,033 regs + ~600 ALMs
PER primitive slot, ZERO block RAM — dominated by the ~40 fifo_* attribute arrays; the bins
add ~80 regs/slot (negligible). The bins never stop being reasonable; the per-prim attribute
FIFO is the ALM-bound scaling wall (~16-prim headroom at this grid). Beyond that, move the
per-prim attribute storage (not the bins) to block RAM.
## Ch316 — leading-empty-tile traversal fix (correctness)
Fixes the latent bug found in Ch315: tiles that are EMPTY and PRECEDE the first non-empty
tile flushed BLACK instead of the clear colour. ROOT CAUSE: the per-tile flush row-stride
is `flush_pixel_index_w = flush_y*(ras_fbw<<6)+flush_x` (gs_stub ~line 3408), and `ras_fbw`
(FRAME width) was loaded ONLY by `mp_load_prim` (on primitive load). A leading-empty tile
loads no prim, so it used the reset `ras_fbw=0` → stride 0 → every row collapsed onto row
0's FB addresses → the tile's real screen rows kept the FB-init value (black). Empties AFTER
a render inherited that render's ras_fbw, hence were fine — the exact asymmetry observed.
FIX: in the `mp_grid_start` branch (~line 5588) load `ras_fbp/ras_fbw/ras_psm/ras_bpp_shift`
from the batch's oldest FIFO entry (`fifo_*[fifo_rptr]`) at GRID-RENDER START, so the flush
address is valid for EVERY tile. A batch shares one FRAME, so this equals what `mp_load_prim`
sets at render → byte-identical for any batch whose first tile is non-empty. Proven:
tb_top_psmct32_tile_late_demo (1 prim only in t15, t0..t14 empty) — ZERO black pixels, all
empty tiles green-cleared, bin_n[15]=1 (renderer reached the last tile, no premature done),
image 3990/3990; Ch315 cap_demo still 3873/3873. Regression 222→223 byte-identical, board
elab EXIT 0 (GS_TILE_LATE_DEMO). Root-caused with a direct VRAM probe (FB at empty tiles
0x0 → 0xFF008000 after the fix). FIT-CLEARED + VISUALLY VERIFIED on Agilex 5 (2026-06-01):
whole 64×64 green (all 15 leading empties clear correctly) + one blue triangle in t15;
resources on the Ch312 baseline (ALMs 29,801, regs 32,442, block-mem/RAM unchanged) — the
fix adds zero storage (loads 4 existing ras_* regs at grid start), pure control-flow.
## Ch317 — LPDDR-backed framebuffer, tile-flush only (sim write/readback proof)
First external-framebuffer step, deliberately tight: ONLY the PSMCT16 tile FLUSH is
redirected to an LPDDR framebuffer; tile color/Z + texture stay on-chip. The proven LPDDR
path (doc 0008, 8.4 GB/s, 256-bit AXI4 → EMIF hard-IP) lives in a SEPARATE diagnostic core,
not the GS top — so this rung proves the write/readback path against a behavioral LPDDR
MODEL (no board fit; wiring the real EMIF master + LPDDR scanout into the GS top is the next
rung). New module `gs_lpddr_fb_writer.sv`: a staging FIFO + burst engine (coalesces a
contiguous +2 run into one burst, 4 KiB cap per the doc 0008 AXI lesson) + byte-addressed
backing FB + bandwidth/over-underflow counters. Consumes the existing flush stream
(`raster_pixel_fb_addr_q` is already the linear `fb_base+(y*pitch+x)*2`). Integrated into
the bram top generate-guarded by `LPDDR_FB_ENABLE` (default 0 → not instantiated,
byte-identical), as a transitional ADDITIVE mirror (BRAM FB still feeds scanout; LPDDR is
the readback-proof target). Proven: tb_gs_lpddr_fb_writer (256-px tile → 512 B / 16 bursts;
2049-px run → 2 bursts via the 4 KiB cap; enable=0 inert) and tb_top_psmct32_tile_lpddrfb_demo
(Ch313 PSMCT16 scene → LPDDR FB == BRAM FB for all 4096 px; 8192 bytes; 256× 32-B bursts; no
over/underflow; ~0.20 GB/s @100 MHz model). Regression 223→225 byte-identical, board elab
EXIT 0 (writer pruned at default). FIX worth noting: a `PTR_W'(FIFO_DEPTH)` truncation read
the FIFO empty-as-full; use `count[PTR_W]`.
## Ch318 — LPDDR framebuffer write path on hardware (RTL sim-proven + fit-ready; board gated)
Connects the Ch317 write path to the real fabric→LPDDR port. qsys_top exposes an
`f2sdram` AXI4-256 port (was tied off); the GS runs on design_clk, f2sdram on CLOCK2_50 —
genuinely async. New `gs_async_fifo` (gray-code CDC) + `gs_lpddr_axi_master` (thin wrapper,
per Codex — does NOT touch the proven writer): GS-domain packer (16 px → one 256-bit
tile-row beat {addr,data,strb}) → async FIFO → f2sdram AXI burst FSM (single-beat INCR,
AWSIZE=5, AWLEN=0, full WSTRB). A HARD `write_enable` gate (packer + awvalid/wvalid + FSM)
makes an LPDDR write impossible unless explicitly enabled — Linux-safety. de25 top exposes
the PSMCT16 flush stream and, under `ifdef GS_LPDDR_FB`, drives the f2sdram write channel
(default = legacy inert tie-off → byte-identical; with the macro, write_enable=0 + FB_BASE=0
placeholder, so the fitted core boots inert). Proven: tb_gs_lpddr_axi_master (gate-off →
zero AXI activity; gate-on → 16 INCR beats, 0 protocol/bresp/FIFO errors, slave readback ==
source, under AW/W/B backpressure + async clocks); de25 elaborates EXIT 0 both ways;
regression byte-identical. fifo_full gotcha: use `count[PTR_W]` (a PTR_W-wide literal
truncates DEPTH→0). The BOARD run is GATED on a Linux-safe LPDDR address (owner: /proc/iomem
→ reserved region → FB_BASE → raise write_enable → write 8 KiB → HPS devmem readback/hash).
HW acceptance = write/readback + fitter snapshot. Ch319 = LPDDR scanout.
## Ch319 — LPDDR4B framebuffer write + HPS-bridge readback (SILICON-VERIFIED)
The f2sdram/HPS-DRAM path of Ch318 was CLOSED as platform-rejected (BRESP 256/256 on the
secure reserved region — /dev/mem reads of 0x80000000 crash the board). The GS framebuffer
pivots to **FPGA-private LPDDR4B** via the EMIF_Qsys IP (cloned from de25_lpddr4_bw/ao486,
same device): emif_clk ~310 MHz, emif_reset_n = cal-ready. Reuse the Ch318 writer chain
(`gs_lpddr_axi_master` + `gs_async_fifo` + counters), just retargeted onto the EMIF AXI
write port instead of f2sdram. New `gs_lpddr_rd_probe.sv` lets the HPS read any FB word back
over the bridge (`LPDDR_RDADDR` @ 0x03C: write byte-addr → poll `LPDDR_STATUS[3]` rd_pending
→ read the 32-bit word); the `lpddr_dump` tool walks this to pull a whole frame to a PPM.
**Crucially the FB is FPGA-private, NOT Linux RAM** — so verification is via the bridge probe
+ bridge COUNTERS (bytes/bursts/bresp_err), never /dev/mem. SILICON-VERIFIED: write 8 KiB →
bridge readback hash matches the source (md5 3b12baffc00bb6419fa66272c75b2cc7), BRESP_ERRS=0.
## Ch320 — LPDDR4B scanout to HDMI (SILICON-VERIFIED)
Display the LPDDR4B framebuffer on HDMI. `gs_lpddr_scanout.sv`: a whole-frame cache (an 8 KiB
M20K copy of the frame, NOT an ao486-style line buffer) filled from LPDDR via single-beat
reads (arlen=0 — the ONLY AXI read pattern proven on this EMIF; multi-beat bursts garble),
indexed by the PCRTC `vram_read_addr`. `gs_lpddr_rd_arb.sv`: a 2:1 read arbiter sharing the
EMIF read channel (port0 scanout = priority, port1 Ch319 probe). de25 top muxes the video
source (BRAM default / LPDDR scanout) on `LPDDR_CTRL[2]` video_src, gated by the PCRTC
display-window (`pix_window_o`). SILICON-VERIFIED at 64×64: scanout pixel-identical to BRAM.
**Bug found+fixed on silicon:** the scanout ignored the PCRTC display window → 10 sheared
tiles; fixed by exposing `pix_window_o` and gating the scanout mux. The whole-frame cache
DOES NOT SCALE — see Ch321: at 1024 beats (32 KiB) it never finishes loading on this EMIF.
## Ch321 — larger FB (128×128 PSMCT16) + LINE-BUFFER scanout (SILICON-VERIFIED) — ACCEPTED ARCHITECTURE
Two bricks. **Brick 1 (render):** new 128×128 PSMCT16 fixture (32 KiB frame) +
`GS_TILE_LPDDR128_DEMO` profile (VRAM grown 8→64 KiB so a 32 KiB frame fits, TILE grid 8×8 of
16×16 tiles). **Brick 2 (scanout) — the real deliverable:** `gs_lpddr_scanout_lb.sv`, a
double-buffered LINE-BUFFER reader that holds just TWO scanlines (displays row L from buf[L&1]
while prefetching the next row), O(width) on-chip not O(width×height). **DECISION: the
whole-frame cache is REFERENCE/FALLBACK only, NOT the architecture** — a cache that "fits"
still MIRRORS the FB in M20K, defeating the move to LPDDR, and empirically it won't even load
a 32 KiB frame on this EMIF (frame-cache `0x4` → cache_valid never sets, blank). The
line-buffer is THE scanout path going forward. SILICON-VERIFIED: render BURSTS=0x400/BRESP=0;
line-buffer `LPDDR_CTRL=0xC` → STATUS line_valid=1/rd_errs=0, HDMI matches the lpddr_dump PPM
pixel-for-pixel (no col-1 band — the sim TB's residual 1px/line was confirmed a checker
leading-edge artifact, not hardware). **Three real HARDWARE bugs fixed** (the first board
attempt garbled): (1) multi-beat burst → single-beat reads (arlen=0); (2) miss-prone request
toggle → free-running sequential prefetcher; (3) vsync-mid-read AXI abort → deferred reset
(`fs_pending`, never abort an in-flight read). Fit clean: 31,683 ALMs (68%), 117 RAM (33%).
## Next (per Codex)
The framebuffer now lives off-chip (write + line-buffer scanout, silicon-proven). Make
TEXTURE storage external next, correctness-first, before any performance sizing:
1. **Ch322 — LPDDR-backed texture fetch/cache (correctness-first).** One known texture in
LPDDR4B; a small read-only texture cache behind the sampler; BRAM texture path stays as
fallback. Acceptance: LPDDR-textured image == BRAM-textured image, rd_errs=0, counters
prove LPDDR fills happened. NOTE (prereq-check finding): the nearest-path sampler assumes
FIXED 1-cycle texel latency (no stall on the default path — CB_TWAIT only exists for
bilinear/combined), so a naive demand-miss stall would corrupt output. Resolve via
prefetch-warm (fill the cache fully before raster → every read a 1-cycle hit) OR add a
sampler/walker stall — see the Ch322 framing.
2. Framebuffer/Z backing to LPDDR with tile flush/reload.
3. Command-stream ingestion (defer until both FB and texture memory are off-chip).
Only after a real-trace texture-format histogram (doc 0008 §4) is performance LPDDR sizing
honest; Ch322 is correctness-only and does NOT pretend to know real-game cache sizing.