diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..776861c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +name: Release + +on: + push: + tags: + - "*.*.*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Verify tag matches Rust package version + env: + TAG_NAME: ${{ github.ref_name }} + run: | + package_version="$(python3 - <<'PY' + import tomllib + with open("tool/Cargo.toml", "rb") as manifest: + print(tomllib.load(manifest)["package"]["version"]) + PY + )" + test "$TAG_NAME" = "$package_version" || { + echo "Tag $TAG_NAME does not match Rust package version $package_version" >&2 + exit 1 + } + + - uses: DeterminateSystems/nix-installer-action@v22 + + - name: Allow Gowin FHS sandbox + run: | + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true + sudo sysctl -w kernel.unprivileged_userns_clone=1 || true + + - name: Build FPGA bitstream + run: nix develop --command make build + + - name: Package release bitstream + env: + TAG_NAME: ${{ github.ref_name }} + run: | + cp impl/pnr/spi_flash.fs spi_flash.fs + cp impl/pnr/spi_flash.fs "norbert-${TAG_NAME}-tangprimer25k.fs" + sha256sum spi_flash.fs "norbert-${TAG_NAME}-tangprimer25k.fs" > SHA256SUMS + cat > release-notes.md < and choose either **Connect FT245 (WebUSB)** or **Connect UART (Web Serial)**. The UI uses the compiled-in rflasher database to search for and configure the emulated chip, and provides emulation control, verified SDRAM uploads and downloads, target-flash `#HOLD`, decoded live SPI -activity monitoring, and full TOCTOU trap configuration. Browser device APIs -are unavailable when opening `web/index.html` directly as a `file://` URL. +activity monitoring, and full TOCTOU trap configuration. + +The **Bitstream** tab can program a release or locally built Gowin `.fs` file +straight into volatile SRAM or persistent configuration flash. This is a Rust/ +WebAssembly port of the FT2232H MPSSE/JTAG and Gowin GW5A paths used by +openFPGALoader, built on the existing `ftdi-nusb`, `nusb`, and WebUSB support. +Disconnect an active FT245 connection and close native JTAG tools before +programming because they also claim FT2232H interface A. On Linux, unbind the +`ftdi_sio` driver from interface A if Chromium reports that it cannot claim the +interface. On Windows, interface A must use the WinUSB driver (for example, +configured with Zadig); leave interface B on its normal driver. + +Flash programming requires a readable JEDEC SFDP Basic Flash Parameter Table +and checks the write and sector-erase range against the reported capacity +(and the programmer's 24-bit address limit) before changing protection or +issuing an erase. Chips without supported SFDP data are rejected rather than +assuming a capacity. Incomplete `.fs` configuration data is rejected before +opening the JTAG adapter. + +Browser device APIs are unavailable when opening `web/index.html` directly as +a `file://` URL. WebUSB requires Chromium and a secure context (HTTPS or +localhost). ## Usage diff --git a/tool/Cargo.lock b/tool/Cargo.lock index 08c231e..170f13b 100644 --- a/tool/Cargo.lock +++ b/tool/Cargo.lock @@ -2423,7 +2423,7 @@ dependencies = [ [[package]] name = "spi-flash-tool" -version = "0.2.0" +version = "0.1.0" dependencies = [ "anyhow", "async-trait", diff --git a/tool/Cargo.toml b/tool/Cargo.toml index ca13e41..a996bfa 100644 --- a/tool/Cargo.toml +++ b/tool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "spi-flash-tool" -version = "0.2.0" +version = "0.1.0" edition = "2024" description = "Tool for loading data into the Tang Primer 25K SPI Flash Emulator (UART + FT245)" diff --git a/tool/src/gowin.rs b/tool/src/gowin.rs new file mode 100644 index 0000000..bbb6c90 --- /dev/null +++ b/tool/src/gowin.rs @@ -0,0 +1,1212 @@ +// SPDX-License-Identifier: Apache-2.0 +// This implementation is derived from openFPGALoader's Gowin and FTDI JTAG +// paths. Copyright (C) 2019 Gwenhael Goavec-Merou and openFPGALoader contributors. + +//! Experimental Gowin GW5A FPGA programmer for the browser WebUSB path. +//! +//! This module intentionally implements the small openFPGALoader subset needed +//! by NORbert's Tang Primer 25K target: FT2232H MPSSE/JTAG, Gowin `.fs` files, +//! volatile SRAM programming, and external SPI flash programming through the +//! GW5A JTAG-SPI bridge. + +use crate::gowin_validation::{GowinBitstream, flash_capacity, validate_flash_range}; +use ftdi_nusb::constants::mpsse; +use ftdi_nusb::mpsse::MpsseContext; +use ftdi_nusb::{FtdiDevice, Interface}; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::JsFuture; +use web_sys::{UsbDevice, UsbDeviceFilter, UsbDeviceRequestOptions}; + +const FTDI_VID: u16 = 0x0403; +const FT2232H_PID: u16 = 0x6010; +const TANG_PRIMER_25K_IDCODE: u32 = 0x0001_281b; +const GOWIN_IR_LEN: usize = 8; + +const NOOP: u8 = 0x02; +const ERASE_SRAM: u8 = 0x05; +const XFER_DONE: u8 = 0x09; +const INIT_ADDR: u8 = 0x12; +const CONFIG_ENABLE: u8 = 0x15; +const XFER_WRITE: u8 = 0x17; +const CONFIG_DISABLE: u8 = 0x3a; +const RELOAD: u8 = 0x3c; +const STATUS_REGISTER: u8 = 0x41; + +const STATUS_BAD_COMMAND: u32 = 1 << 1; +const STATUS_TIMEOUT: u32 = 1 << 3; +const STATUS_MEMORY_ERASE: u32 = 1 << 5; +const STATUS_SYSTEM_EDIT_MODE: u32 = 1 << 7; +const STATUS_DONE_FINAL: u32 = 1 << 13; + +const FLASH_WRSR: u8 = 0x01; +const FLASH_PP: u8 = 0x02; +const FLASH_READ: u8 = 0x03; +const FLASH_RDSR: u8 = 0x05; +const FLASH_WREN: u8 = 0x06; +const FLASH_SE: u8 = 0x20; +const FLASH_POWER_UP: u8 = 0xab; +const FLASH_CE: u8 = 0xc7; +const FLASH_RSTEN: u8 = 0x66; +const FLASH_RST: u8 = 0x99; +const FLASH_RDID: u8 = 0x9f; +const FLASH_RDSR_WIP: u8 = 0x01; +const FLASH_RDSR_WEL: u8 = 0x02; +const FLASH_BP_MASK: u8 = 0x7c; + +/// Describes a programming progress update. +#[derive(Debug, Clone)] +pub struct ProgramProgress { + /// Human-readable stage name. + pub stage: &'static str, + /// Bytes or bits completed in the current stage. + pub done: usize, + /// Total bytes or bits in the current stage. + pub total: usize, +} + +/// Summarizes metadata parsed from a Gowin `.fs` file. +#[derive(Debug, Clone)] +pub struct BitstreamInfo { + /// Gowin target IDCODE embedded in the bitstream. + pub idcode: u32, + /// Parsed checksum used by the SRAM programming flow. + pub checksum: u32, + /// Number of bytes shifted for SRAM programming. + pub sram_bytes: usize, + /// Number of bytes written for external flash programming. + pub flash_bytes: usize, +} + +/// Parses Gowin `.fs` metadata without opening WebUSB. +pub fn inspect_bitstream(fs: &[u8]) -> Result { + let bitstream = GowinBitstream::parse(fs, true)?; + Ok(BitstreamInfo { + idcode: bitstream.idcode, + checksum: bitstream.checksum, + sram_bytes: bitstream.sram_data.len(), + flash_bytes: bitstream.flash_data.len(), + }) +} + +/// Controls external flash programming behavior. +#[derive(Debug, Clone, Copy)] +pub struct FlashOptions { + /// Start address in the external SPI flash. + pub offset: u32, + /// Verify flash contents after programming. + pub verify: bool, + /// Allow clearing SPI flash block-protect bits before erase/program. + pub unprotect: bool, + /// Erase the entire flash chip instead of only touched 4 KiB sectors. + pub chip_erase: bool, +} + +/// Summarizes a successful programming operation. +#[derive(Debug, Clone)] +pub struct ProgramReport { + /// JTAG IDCODE read from the FPGA. + pub idcode: u32, + /// Gowin `.fs` IDCODE embedded in the bitstream. + pub bitstream_idcode: u32, + /// Parsed bitstream checksum. + pub checksum: u32, + /// Number of bytes written to SRAM or flash. + pub bytes_programmed: usize, + /// Optional external SPI flash JEDEC ID. + pub flash_jedec_id: Option, +} + +/// Parses and programs a Gowin `.fs` bitstream into volatile FPGA SRAM. +pub async fn program_sram_from_webusb( + fs: &[u8], + mut progress: impl FnMut(ProgramProgress), +) -> Result { + let bitstream = GowinBitstream::parse(fs, true)?; + let mut programmer = GowinProgrammer::open().await?; + let idcode = programmer.detect_idcode().await?; + validate_idcode(idcode, bitstream.idcode)?; + programmer.program_sram(&bitstream, &mut progress).await?; + Ok(ProgramReport { + idcode, + bitstream_idcode: bitstream.idcode, + checksum: bitstream.checksum, + bytes_programmed: bitstream.sram_data.len(), + flash_jedec_id: None, + }) +} + +/// Parses and programs a Gowin `.fs` bitstream into the external SPI flash. +pub async fn program_flash_from_webusb( + fs: &[u8], + options: FlashOptions, + mut progress: impl FnMut(ProgramProgress), +) -> Result { + let bitstream = GowinBitstream::parse(fs, false)?; + let mut programmer = GowinProgrammer::open().await?; + let idcode = programmer.detect_idcode().await?; + validate_idcode(idcode, bitstream.idcode)?; + let jedec = programmer + .program_external_flash(&bitstream, options, &mut progress) + .await?; + Ok(ProgramReport { + idcode, + bitstream_idcode: bitstream.idcode, + checksum: bitstream.checksum, + bytes_programmed: bitstream.flash_data.len(), + flash_jedec_id: Some(jedec), + }) +} + +fn validate_idcode(jtag_idcode: u32, bitstream_idcode: u32) -> Result<(), String> { + if (jtag_idcode & 0x0fff_ffff) != (TANG_PRIMER_25K_IDCODE & 0x0fff_ffff) { + return Err(format!( + "unsupported FPGA IDCODE 0x{jtag_idcode:08x}; expected Tang Primer 25K/GW5A-25 0x{TANG_PRIMER_25K_IDCODE:08x}" + )); + } + if (bitstream_idcode & 0x0fff_ffff) != (jtag_idcode & 0x0fff_ffff) { + return Err(format!( + "bitstream IDCODE 0x{bitstream_idcode:08x} does not match FPGA IDCODE 0x{jtag_idcode:08x}" + )); + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TapState { + TestLogicReset, + RunTestIdle, + SelectDrScan, + CaptureDr, + ShiftDr, + Exit1Dr, + PauseDr, + Exit2Dr, + UpdateDr, + SelectIrScan, + CaptureIr, + ShiftIr, + Exit1Ir, + PauseIr, + Exit2Ir, + UpdateIr, +} + +struct GowinProgrammer { + jtag: FtdiJtag, +} + +impl GowinProgrammer { + async fn open() -> Result { + let jtag = FtdiJtag::open_tang_primer_25k().await?; + Ok(Self { jtag }) + } + + async fn detect_idcode(&mut self) -> Result { + self.jtag.go_test_logic_reset().await?; + self.jtag.set_state(TapState::ShiftDr, 1).await?; + let rx = self.jtag.shift_bits(&[0xff; 4], 32, true, true).await?; + self.jtag.set_state(TapState::RunTestIdle, 1).await?; + let bytes: [u8; 4] = rx + .get(..4) + .ok_or("short JTAG IDCODE read")? + .try_into() + .map_err(|_| "invalid JTAG IDCODE read".to_string())?; + Ok(u32::from_le_bytes(bytes)) + } + + async fn program_sram( + &mut self, + bitstream: &GowinBitstream, + progress: &mut impl FnMut(ProgramProgress), + ) -> Result<(), String> { + self.reset_gowin().await?; + self.jtag.set_state(TapState::RunTestIdle, 1).await?; + self.jtag.toggle_clk(1_000_000).await?; + self.erase_sram().await?; + self.write_sram(bitstream, progress).await + } + + async fn program_external_flash( + &mut self, + bitstream: &GowinBitstream, + options: FlashOptions, + progress: &mut impl FnMut(ProgramProgress), + ) -> Result { + self.prepare_flash_access().await?; + let result = self + .program_spi_flash(&bitstream.flash_data, options, progress) + .await; + let post_result = self.post_flash_access().await; + match (result, post_result) { + (Ok(jedec), Ok(())) => Ok(jedec), + (Err(err), _) => Err(err), + (Ok(_), Err(err)) => Err(err), + } + } + + async fn send_command(&mut self, cmd: u8) -> Result<(), String> { + self.jtag.shift_ir(&[cmd], GOWIN_IR_LEN).await?; + self.jtag.toggle_clk(6).await + } + + async fn read_reg32(&mut self, cmd: u8) -> Result { + self.send_command(cmd).await?; + let rx = self.jtag.shift_dr(&[0xff; 4], 32, true).await?; + let bytes: [u8; 4] = rx + .get(..4) + .ok_or("short Gowin register read")? + .try_into() + .map_err(|_| "invalid Gowin register read".to_string())?; + Ok(u32::from_le_bytes(bytes)) + } + + async fn read_status(&mut self) -> Result { + self.read_reg32(STATUS_REGISTER).await + } + + async fn reset_gowin(&mut self) -> Result<(), String> { + self.send_command(RELOAD).await?; + self.send_command(NOOP).await + } + + async fn enable_cfg(&mut self) -> Result<(), String> { + self.send_command(CONFIG_ENABLE).await?; + self.poll_status( + STATUS_SYSTEM_EDIT_MODE, + STATUS_SYSTEM_EDIT_MODE, + "enable configuration", + ) + .await + } + + async fn disable_cfg(&mut self) -> Result<(), String> { + self.send_command(CONFIG_DISABLE).await?; + self.send_command(NOOP).await?; + self.poll_status(STATUS_SYSTEM_EDIT_MODE, 0, "disable configuration") + .await + } + + async fn poll_status(&mut self, mask: u32, value: u32, context: &str) -> Result<(), String> { + for attempt in 0..100_000u32 { + let status = self.read_status().await?; + if (status & mask) == value { + return Ok(()); + } + if attempt % 128 == 0 { + sleep_ms(0).await; + } + } + Err(format!("timeout waiting for {context}")) + } + + async fn erase_sram(&mut self) -> Result<(), String> { + let status = self.read_status().await?; + if (status & ((1 << 4) | STATUS_TIMEOUT | STATUS_BAD_COMMAND)) != 0 { + self.send_command(CONFIG_ENABLE).await?; + self.send_command(0x3f).await?; + self.send_command(CONFIG_DISABLE).await?; + self.send_command(NOOP).await?; + self.send_command(0x11).await?; + self.send_command(NOOP).await?; + self.jtag.toggle_clk(125 * 8).await?; + } + + const MAX_ERASE_ATTEMPTS: u8 = 10; + let mut last_status = 0; + for attempt in 1..=MAX_ERASE_ATTEMPTS { + self.enable_cfg().await?; + self.send_command(ERASE_SRAM).await?; + self.send_command(NOOP).await?; + self.poll_status(STATUS_MEMORY_ERASE, STATUS_MEMORY_ERASE, "SRAM erase") + .await?; + self.send_command(XFER_DONE).await?; + self.send_command(NOOP).await?; + self.disable_cfg().await?; + + last_status = self.read_status().await?; + if attempt >= 2 && (last_status & STATUS_DONE_FINAL) == 0 { + return Ok(()); + } + } + Err(format!( + "SRAM erase did not clear DONE after {MAX_ERASE_ATTEMPTS} attempts; status=0x{last_status:08x}" + )) + } + + async fn write_sram( + &mut self, + bitstream: &GowinBitstream, + progress: &mut impl FnMut(ProgramProgress), + ) -> Result<(), String> { + self.send_command(CONFIG_ENABLE).await?; + self.send_command(INIT_ADDR).await?; + self.send_command(XFER_WRITE).await?; + + let total_bits = bitstream.sram_data.len() * 8; + let mut bit_offset = 0usize; + const CHUNK_BITS: usize = 0x80000; + while bit_offset < total_bits { + let remaining = total_bits - bit_offset; + let chunk_bits = remaining.min(CHUNK_BITS); + let byte_offset = bit_offset / 8; + let byte_len = chunk_bits.div_ceil(8); + let end_state = remaining == chunk_bits; + self.jtag + .shift_dr_keep_state( + &bitstream.sram_data[byte_offset..byte_offset + byte_len], + chunk_bits, + end_state, + ) + .await?; + bit_offset += chunk_bits; + progress(ProgramProgress { + stage: "Writing SRAM", + done: bit_offset, + total: total_bits, + }); + sleep_ms(0).await; + } + + self.send_command(0x0a).await?; + let checksum = bitstream.checksum.to_le_bytes(); + self.jtag.shift_dr(&checksum, 32, false).await?; + self.send_command(0x08).await?; + self.send_command(CONFIG_DISABLE).await?; + self.send_command(NOOP).await?; + + let status = self.read_status().await?; + if (status & STATUS_DONE_FINAL) == 0 { + return Err(format!( + "FPGA did not assert DONE after SRAM program; status=0x{status:08x}" + )); + } + Ok(()) + } + + async fn prepare_flash_access(&mut self) -> Result<(), String> { + self.reset_gowin().await?; + self.jtag.set_state(TapState::RunTestIdle, 1).await?; + self.jtag.toggle_clk(1_000_000).await?; + self.erase_sram().await?; + sleep_ms(100).await; + self.gw5a_enable_spi().await?; + sleep_ms(100).await; + self.jtag.set_clock(10_000_000).await + } + + async fn post_flash_access(&mut self) -> Result<(), String> { + self.gw5a_disable_spi().await?; + self.reset_gowin().await + } + + async fn gw5a_enable_spi(&mut self) -> Result<(), String> { + self.enable_cfg().await?; + self.send_command(0x3f).await?; + self.disable_cfg().await?; + self.send_command(NOOP).await?; + self.jtag.set_state(TapState::RunTestIdle, 1).await?; + self.jtag.toggle_clk(126 * 8).await?; + self.jtag.set_state(TapState::RunTestIdle, 1).await?; + self.send_command(0x16).await?; + self.send_command(0x00).await?; + self.jtag.set_state(TapState::RunTestIdle, 1).await?; + self.jtag.toggle_clk(625 * 8).await?; + self.jtag.set_state(TapState::TestLogicReset, 1).await + } + + async fn gw5a_disable_spi(&mut self) -> Result<(), String> { + self.jtag.set_state(TapState::SelectDrScan, 1).await?; + self.jtag.set_state(TapState::CaptureDr, 1).await?; + self.jtag.set_state(TapState::Exit1Dr, 1).await?; + self.jtag.set_state(TapState::Exit2Dr, 1).await?; + for _ in 0..6 { + self.jtag.set_state(TapState::PauseDr, 1).await?; + self.jtag.set_state(TapState::Exit2Dr, 1).await?; + } + self.jtag.set_state(TapState::Exit1Dr, 1).await?; + self.jtag.set_state(TapState::PauseDr, 1).await?; + self.jtag.set_state(TapState::TestLogicReset, 1).await?; + self.jtag.toggle_clk(5).await + } + + async fn spi_transfer( + &mut self, + cmd: u8, + write_after_cmd: &[u8], + read_len: usize, + ) -> Result, String> { + let transfer_len = write_after_cmd.len() + read_len; + let rx_enabled = read_len > 0; + let k_len = transfer_len + usize::from(rx_enabled); + let bit_len = transfer_len * 8 + if rx_enabled { 3 } else { 0 }; + let mut jtx = vec![cmd & 1; k_len.max(1)]; + let mut plain = Vec::with_capacity(transfer_len); + plain.extend_from_slice(write_after_cmd); + plain.resize(transfer_len, 0); + let curr_tdi = plain.last().copied().unwrap_or(cmd) & 1; + for (dst, src) in jtx.iter_mut().zip(plain.iter().copied()) { + *dst = reverse_byte(src); + } + + let mut shifted_cmd = reverse_byte(cmd); + self.jtag + .set_state(TapState::RunTestIdle, shifted_cmd & 1) + .await?; + shifted_cmd >>= 1; + self.jtag + .read_write_bits(&[shifted_cmd], 7, false, false) + .await?; + let jrx = self + .jtag + .read_write_bits(&jtx, bit_len, rx_enabled, false) + .await?; + self.jtag + .set_state(TapState::TestLogicReset, curr_tdi) + .await?; + self.jtag.toggle_clk(5).await?; + + if !rx_enabled { + return Ok(Vec::new()); + } + let mut rx = vec![0u8; transfer_len]; + for (i, byte) in rx.iter_mut().enumerate() { + let a = *jrx.get(i).unwrap_or(&0); + let b = *jrx.get(i + 1).unwrap_or(&0); + *byte = reverse_byte((a >> 3) | ((b & 0x07) << 5)); + } + Ok(rx[write_after_cmd.len()..].to_vec()) + } + + async fn spi_wait( + &mut self, + cmd: u8, + mask: u8, + value: u8, + timeout: u32, + context: &str, + ) -> Result<(), String> { + for attempt in 0..timeout { + let status = self.spi_transfer(cmd, &[], 1).await?[0]; + if (status & mask) == value { + return Ok(()); + } + if attempt % 32 == 0 { + sleep_ms(0).await; + } + } + Err(format!("timeout waiting for SPI flash {context}")) + } + + async fn program_spi_flash( + &mut self, + data: &[u8], + options: FlashOptions, + progress: &mut impl FnMut(ProgramProgress), + ) -> Result { + self.spi_reset().await?; + self.spi_transfer(FLASH_POWER_UP, &[], 0).await?; + let id = self.spi_transfer(FLASH_RDID, &[], 4).await?; + if id.len() < 3 || id[..3] == [0, 0, 0] || id[..3] == [0xff, 0xff, 0xff] { + return Err(format!("failed to read SPI flash JEDEC ID: {id:02x?}")); + } + let jedec = (u32::from(id[0]) << 16) | (u32::from(id[1]) << 8) | u32::from(id[2]); + // Establish physical capacity before changing protection or erasing flash. + // Fail closed rather than guessing a capacity for unknown/non-SFDP chips. + let capacity = self.spi_flash_capacity().await?; + let end_addr = validate_flash_range(options.offset, data.len(), capacity)?; + + let status = self.spi_read_status().await?; + if (status & FLASH_BP_MASK) != 0 { + if !options.unprotect { + return Err(format!( + "SPI flash block protection is set (status=0x{status:02x}); enable unprotect to clear it" + )); + } + self.spi_write_status(status & !FLASH_BP_MASK).await?; + } + + if options.chip_erase { + self.spi_write_enable().await?; + self.spi_transfer(FLASH_CE, &[], 0).await?; + self.spi_wait(FLASH_RDSR, FLASH_RDSR_WIP, 0, 2_000_000, "chip erase") + .await?; + } else { + let start = options.offset & !0xfff; + let end = (end_addr + 0xfff) & !0xfff; + let total = (end - start) as usize; + let mut erased = 0usize; + for addr in (start..end).step_by(0x1000) { + self.spi_write_enable().await?; + self.spi_transfer(FLASH_SE, &addr24(addr), 0).await?; + self.spi_wait(FLASH_RDSR, FLASH_RDSR_WIP, 0, 200_000, "sector erase") + .await?; + erased += 0x1000; + progress(ProgramProgress { + stage: "Erasing flash", + done: erased.min(total), + total, + }); + sleep_ms(0).await; + } + } + + let mut written = 0usize; + while written < data.len() { + let addr = options.offset + written as u32; + let page_remaining = 256 - (addr as usize & 0xff); + let chunk_len = page_remaining.min(data.len() - written); + let chunk = &data[written..written + chunk_len]; + let mut payload = Vec::with_capacity(3 + chunk.len()); + payload.extend_from_slice(&addr24(addr)); + payload.extend_from_slice(chunk); + self.spi_write_enable().await?; + self.spi_transfer(FLASH_PP, &payload, 0).await?; + self.spi_wait(FLASH_RDSR, FLASH_RDSR_WIP, 0, 1000, "page program") + .await?; + written += chunk_len; + progress(ProgramProgress { + stage: "Writing flash", + done: written, + total: data.len(), + }); + sleep_ms(0).await; + } + + if options.verify { + let mut verified = 0usize; + for (chunk_index, expected) in data.chunks(256).enumerate() { + let addr = options.offset + (chunk_index * 256) as u32; + let actual = self + .spi_transfer(FLASH_READ, &addr24(addr), expected.len()) + .await?; + if actual != expected { + return Err(format!( + "flash verification failed at address 0x{addr:06x}: expected {:02x?}, got {:02x?}", + &expected[..expected.len().min(16)], + &actual[..actual.len().min(16)] + )); + } + verified += expected.len(); + progress(ProgramProgress { + stage: "Verifying flash", + done: verified, + total: data.len(), + }); + sleep_ms(0).await; + } + } + + Ok(jedec) + } + + async fn read_sfdp(&mut self, address: u32, len: usize) -> Result, String> { + if u64::from(address) + len as u64 > 0x0100_0000 { + return Err("SFDP table exceeds the 24-bit address range".to_string()); + } + let mut payload = addr24(address).to_vec(); + payload.push(0); // READ SFDP requires one dummy byte after the address. + self.spi_transfer(0x5a, &payload, len).await + } + + async fn spi_flash_capacity(&mut self) -> Result { + let header = self.read_sfdp(0, 8).await?; + if &header[..4] != b"SFDP" || header[5] != 1 { + return Err( + "flash has no supported SFDP header; refusing to guess its capacity".to_string(), + ); + } + for index in 0..=u32::from(header[6]) { + let parameter = self.read_sfdp(8 + index * 8, 8).await?; + // JEDEC Basic Flash Parameter Table, major version 1, >= 2 DWORDs. + if parameter[0] != 0 || parameter[7] != 0xff || parameter[2] != 1 || parameter[3] < 2 { + continue; + } + let table = u32::from_le_bytes([parameter[4], parameter[5], parameter[6], 0]); + let density = self.read_sfdp(table + 4, 4).await?; + return flash_capacity(u32::from_le_bytes( + density.try_into().map_err(|_| "short SFDP density read")?, + )); + } + Err("flash has no supported SFDP Basic Flash Parameter Table".to_string()) + } + + async fn spi_reset(&mut self) -> Result<(), String> { + self.spi_transfer(0xff, &[0xff; 8], 0).await?; + self.spi_transfer(FLASH_RSTEN, &[], 0).await?; + self.spi_transfer(FLASH_RST, &[], 0).await?; + sleep_ms(1).await; + Ok(()) + } + + async fn spi_read_status(&mut self) -> Result { + Ok(self.spi_transfer(FLASH_RDSR, &[], 1).await?[0]) + } + + async fn spi_write_enable(&mut self) -> Result<(), String> { + self.spi_transfer(FLASH_WREN, &[], 0).await?; + self.spi_wait( + FLASH_RDSR, + FLASH_RDSR_WEL, + FLASH_RDSR_WEL, + 1000, + "write enable", + ) + .await + } + + async fn spi_write_status(&mut self, status: u8) -> Result<(), String> { + self.spi_write_enable().await?; + self.spi_transfer(FLASH_WRSR, &[status], 0).await?; + self.spi_wait(FLASH_RDSR, FLASH_RDSR_WIP, 0, 1000, "write status") + .await + } +} + +fn addr24(addr: u32) -> [u8; 3] { + [ + ((addr >> 16) & 0xff) as u8, + ((addr >> 8) & 0xff) as u8, + (addr & 0xff) as u8, + ] +} + +struct WebUsbIdentity { + product: String, + serial: Option, +} + +impl WebUsbIdentity { + fn linux_unbind_hint(&self) -> String { + let Some(serial) = self.serial.as_deref() else { + return "The selected device has no USB serial number, so the browser cannot generate a safe per-device Linux unbind command".to_string(); + }; + let serial = shell_quote(serial); + format!( + "To unbind only this selected device's interface A on Linux, run: SERIAL={serial}; for dev in /sys/bus/usb/devices/*; do [ -r \"$dev/serial\" ] && [ \"$(cat \"$dev/serial\")\" = \"$SERIAL\" ] && [ -e \"$dev:1.0/driver/unbind\" ] && echo \"${{dev##*/}}:1.0\" | sudo tee \"$dev:1.0/driver/unbind\"; done" + ) + } +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +async fn request_ft2232h() -> Result<(nusb::Device, WebUsbIdentity), String> { + let window = web_sys::window().ok_or("window is unavailable")?; + let filter = UsbDeviceFilter::new(); + filter.set_vendor_id(FTDI_VID); + filter.set_product_id(FT2232H_PID); + let options = UsbDeviceRequestOptions::new(&[filter]); + let selected = JsFuture::from(window.navigator().usb().request_device(&options)) + .await + .map_err(|error| format!("WebUSB FTDI request failed: {error:?}"))?; + let usb_device: UsbDevice = selected + .dyn_into() + .map_err(|_| "browser returned an invalid USB device".to_string())?; + let identity = WebUsbIdentity { + product: usb_device + .product_name() + .unwrap_or_else(|| "FT2232H".to_string()), + serial: usb_device.serial_number(), + }; + let device = nusb::Device::from_js(usb_device) + .await + .map_err(|error| format!("failed to open WebUSB device: {error}"))?; + Ok((device, identity)) +} + +struct FtdiJtag { + dev: FtdiDevice, + ctx: MpsseContext, + state: TapState, +} + +impl FtdiJtag { + async fn open_tang_primer_25k() -> Result { + let (info, identity) = request_ft2232h().await?; + let descriptor = info.device_descriptor(); + if descriptor.vendor_id() != FTDI_VID || descriptor.product_id() != FT2232H_PID { + return Err(format!( + "selected USB device is {:04x}:{:04x}; choose the Tang Primer 25K FT2232H ({FTDI_VID:04x}:{FT2232H_PID:04x})", + descriptor.vendor_id(), + descriptor.product_id(), + )); + } + let mut dev = FtdiDevice::open_wasm(info, Interface::A) + .await + .map_err(|e| { + let serial = identity.serial.as_deref().unwrap_or("unavailable"); + format!( + "failed to open FT2232H interface A on selected device '{}' (serial {}): {e}. Disconnect active USB/JTAG tools. {}. On Windows, bind only this device's interface A to WinUSB", + identity.product, + serial, + identity.linux_unbind_hint(), + ) + })?; + if dev.chip_type().is_h_type() && dev.max_packet_size() == 0 { + return Err(format!( + "invalid FTDI device {FTDI_VID:04x}:{FT2232H_PID:04x}: zero max packet size" + )); + } + let ctx = MpsseContext::init(&mut dev, 2_500_000) + .await + .map_err(|e| format!("failed to initialize FTDI MPSSE: {e}"))?; + let mut this = Self { + dev, + ctx, + state: TapState::TestLogicReset, + }; + this.sync_mpsse().await?; + // openFPGALoader's `ft2232` cable config for Tang Primer boards. + this.ctx + .session(&mut this.dev) + .map_err(|e| format!("failed to start MPSSE GPIO session: {e}"))? + .set_gpio_low(0x08, 0x0b) + .await + .map_err(|e| format!("failed to configure FTDI low GPIO: {e}"))?; + this.ctx + .session(&mut this.dev) + .map_err(|e| format!("failed to start MPSSE GPIO session: {e}"))? + .set_gpio_high(0x08, 0x0b) + .await + .map_err(|e| format!("failed to configure FTDI high GPIO: {e}"))?; + this.go_test_logic_reset().await?; + Ok(this) + } + + async fn sync_mpsse(&mut self) -> Result<(), String> { + const SYNC_COMMAND: u8 = 0xaa; + + self.dev + .flush_rx() + .await + .map_err(|e| format!("failed to flush FTDI MPSSE input: {e}"))?; + self.dev + .write_all(&[SYNC_COMMAND, mpsse::SEND_IMMEDIATE]) + .await + .map_err(|e| format!("failed to write FTDI MPSSE sync: {e}"))?; + let mut response = Vec::with_capacity(64); + for _ in 0..64 { + response.push(self.read_exact(1).await?[0]); + if response.ends_with(&[0xfa, SYNC_COMMAND]) { + return Ok(()); + } + } + Err(format!( + "FTDI MPSSE sync did not return [fa, aa]; received {:02x?}", + response + )) + } + + async fn set_clock(&mut self, hz: u32) -> Result<(), String> { + self.ctx + .session(&mut self.dev) + .map_err(|e| format!("failed to start MPSSE clock session: {e}"))? + .set_clock(hz) + .await + .map_err(|e| format!("failed to set JTAG clock: {e}")) + } + + async fn go_test_logic_reset(&mut self) -> Result<(), String> { + self.write_tms_bits(&[true; 6], 1).await?; + self.state = TapState::TestLogicReset; + Ok(()) + } + + async fn shift_ir(&mut self, tdi: &[u8], bit_len: usize) -> Result, String> { + self.set_state(TapState::ShiftIr, 1).await?; + let tdo = self.shift_bits(tdi, bit_len, true, false).await?; + self.set_state(TapState::RunTestIdle, 1).await?; + Ok(tdo) + } + + async fn shift_dr( + &mut self, + tdi: &[u8], + bit_len: usize, + read: bool, + ) -> Result, String> { + if self.state != TapState::ShiftDr { + self.set_state(TapState::ShiftDr, 1).await?; + } + let tdo = self.shift_bits(tdi, bit_len, true, read).await?; + self.set_state(TapState::RunTestIdle, 1).await?; + Ok(tdo) + } + + async fn shift_dr_keep_state( + &mut self, + tdi: &[u8], + bit_len: usize, + exit_to_idle: bool, + ) -> Result<(), String> { + if self.state != TapState::ShiftDr { + self.set_state(TapState::ShiftDr, 1).await?; + } + self.shift_bits(tdi, bit_len, exit_to_idle, false).await?; + if exit_to_idle { + self.set_state(TapState::RunTestIdle, 1).await?; + } + Ok(()) + } + + async fn read_write_bits( + &mut self, + tdi: &[u8], + bit_len: usize, + read: bool, + last: bool, + ) -> Result, String> { + self.shift_bits(tdi, bit_len, last, read).await + } + + async fn shift_bits( + &mut self, + tdi_data: &[u8], + bit_count: usize, + exit_shift: bool, + read: bool, + ) -> Result, String> { + if bit_count == 0 { + return Ok(Vec::new()); + } + let byte_count = bit_count.div_ceil(8); + let mut tdi = vec![0u8; byte_count]; + let copy_len = tdi_data.len().min(byte_count); + tdi[..copy_len].copy_from_slice(&tdi_data[..copy_len]); + let normal_bits = if exit_shift { bit_count - 1 } else { bit_count }; + let full_bytes = normal_bits / 8; + let remaining_bits = normal_bits % 8; + let rw_cmd = + mpsse::DO_WRITE | if read { mpsse::DO_READ } else { 0 } | mpsse::WRITE_NEG | mpsse::LSB; + let mut cmd = Vec::with_capacity(byte_count + 32); + + let mut offset = 0usize; + while offset < full_bytes { + let chunk = (full_bytes - offset).min(65_536); + let len_field = (chunk - 1) as u16; + cmd.push(rw_cmd); + cmd.push(len_field as u8); + cmd.push((len_field >> 8) as u8); + cmd.extend_from_slice(&tdi[offset..offset + chunk]); + offset += chunk; + } + if remaining_bits > 0 { + cmd.push(rw_cmd | mpsse::BITMODE); + cmd.push((remaining_bits - 1) as u8); + cmd.push(tdi[full_bytes]); + } + if exit_shift { + let last_bit_idx = bit_count - 1; + let last_tdi = (tdi[last_bit_idx / 8] >> (last_bit_idx % 8)) & 1; + cmd.push( + mpsse::WRITE_TMS + | if read { mpsse::DO_READ } else { 0 } + | mpsse::WRITE_NEG + | mpsse::BITMODE + | mpsse::LSB, + ); + cmd.push(0); + cmd.push(0x01 | (last_tdi << 7)); + self.state = match self.state { + TapState::ShiftDr => TapState::Exit1Dr, + TapState::ShiftIr => TapState::Exit1Ir, + other => other, + }; + } + if read { + cmd.push(mpsse::SEND_IMMEDIATE); + } + self.dev + .write_all(&cmd) + .await + .map_err(|e| format!("JTAG shift write failed: {e}"))?; + + if !read { + return Ok(Vec::new()); + } + let mut expected = full_bytes; + if remaining_bits > 0 { + expected += 1; + } + if exit_shift { + expected += 1; + } + let response = self.read_exact(expected).await?; + let mut tdo = vec![0u8; byte_count]; + let mut tdo_bit = 0usize; + let mut resp_idx = 0usize; + if full_bytes > 0 { + tdo[..full_bytes].copy_from_slice(&response[..full_bytes]); + tdo_bit += full_bytes * 8; + resp_idx += full_bytes; + } + if remaining_bits > 0 { + let shifted = response[resp_idx] >> (8 - remaining_bits); + for bit in 0..remaining_bits { + if (shifted & (1 << bit)) != 0 { + tdo[tdo_bit / 8] |= 1 << (tdo_bit % 8); + } + tdo_bit += 1; + } + resp_idx += 1; + } + if exit_shift && (response[resp_idx] & 0x80) != 0 { + tdo[tdo_bit / 8] |= 1 << (tdo_bit % 8); + } + Ok(tdo) + } + + async fn set_state(&mut self, new_state: TapState, tdi: u8) -> Result<(), String> { + let mut bits = Vec::new(); + while self.state != new_state { + let tms = match self.state { + TapState::TestLogicReset => { + if new_state == TapState::TestLogicReset { + true + } else { + self.state = TapState::RunTestIdle; + false + } + } + TapState::RunTestIdle => { + if new_state == TapState::RunTestIdle { + false + } else { + self.state = TapState::SelectDrScan; + true + } + } + TapState::SelectDrScan => match new_state { + TapState::CaptureDr + | TapState::ShiftDr + | TapState::Exit1Dr + | TapState::PauseDr + | TapState::Exit2Dr + | TapState::UpdateDr => { + self.state = TapState::CaptureDr; + false + } + _ => { + self.state = TapState::SelectIrScan; + true + } + }, + TapState::SelectIrScan => match new_state { + TapState::CaptureIr + | TapState::ShiftIr + | TapState::Exit1Ir + | TapState::PauseIr + | TapState::Exit2Ir + | TapState::UpdateIr => { + self.state = TapState::CaptureIr; + false + } + _ => { + self.state = TapState::TestLogicReset; + true + } + }, + TapState::CaptureDr => { + if new_state == TapState::ShiftDr { + self.state = TapState::ShiftDr; + false + } else { + self.state = TapState::Exit1Dr; + true + } + } + TapState::ShiftDr => { + if new_state == TapState::ShiftDr { + false + } else { + self.state = TapState::Exit1Dr; + true + } + } + TapState::Exit1Dr => match new_state { + TapState::PauseDr + | TapState::Exit2Dr + | TapState::ShiftDr + | TapState::Exit1Dr => { + self.state = TapState::PauseDr; + false + } + _ => { + self.state = TapState::UpdateDr; + true + } + }, + TapState::PauseDr => { + if new_state == TapState::PauseDr { + false + } else { + self.state = TapState::Exit2Dr; + true + } + } + TapState::Exit2Dr => match new_state { + TapState::ShiftDr | TapState::Exit1Dr | TapState::PauseDr => { + self.state = TapState::ShiftDr; + false + } + _ => { + self.state = TapState::UpdateDr; + true + } + }, + TapState::UpdateDr | TapState::UpdateIr => { + if new_state == TapState::RunTestIdle { + self.state = TapState::RunTestIdle; + false + } else { + self.state = TapState::SelectDrScan; + true + } + } + TapState::CaptureIr => { + if new_state == TapState::ShiftIr { + self.state = TapState::ShiftIr; + false + } else { + self.state = TapState::Exit1Ir; + true + } + } + TapState::ShiftIr => { + if new_state == TapState::ShiftIr { + false + } else { + self.state = TapState::Exit1Ir; + true + } + } + TapState::Exit1Ir => match new_state { + TapState::PauseIr + | TapState::Exit2Ir + | TapState::ShiftIr + | TapState::Exit1Ir => { + self.state = TapState::PauseIr; + false + } + _ => { + self.state = TapState::UpdateIr; + true + } + }, + TapState::PauseIr => { + if new_state == TapState::PauseIr { + false + } else { + self.state = TapState::Exit2Ir; + true + } + } + TapState::Exit2Ir => match new_state { + TapState::ShiftIr | TapState::Exit1Ir | TapState::PauseIr => { + self.state = TapState::ShiftIr; + false + } + _ => { + self.state = TapState::UpdateIr; + true + } + }, + }; + bits.push(tms); + } + if !bits.is_empty() { + self.write_tms_bits(&bits, tdi).await?; + } + Ok(()) + } + + async fn write_tms_bits(&mut self, bits: &[bool], tdi: u8) -> Result<(), String> { + if bits.is_empty() { + return Ok(()); + } + let mut cmd = Vec::with_capacity(bits.len().div_ceil(6) * 3); + let mut offset = 0usize; + while offset < bits.len() { + let chunk_len = (bits.len() - offset).min(6); + let mut data = (tdi & 1) << 7; + let mut last_tms = false; + for i in 0..chunk_len { + last_tms = bits[offset + i]; + if last_tms { + data |= 1 << i; + } + } + if last_tms { + data |= 1 << chunk_len; + } + cmd.push(mpsse::WRITE_TMS | mpsse::WRITE_NEG | mpsse::BITMODE | mpsse::LSB); + cmd.push((chunk_len - 1) as u8); + cmd.push(data); + offset += chunk_len; + } + self.dev + .write_all(&cmd) + .await + .map_err(|e| format!("JTAG TMS write failed: {e}")) + } + + async fn toggle_clk(&mut self, cycles: u32) -> Result<(), String> { + let mut remaining = cycles; + let mut cmd = Vec::new(); + while remaining >= 8 { + let bytes = (remaining / 8).min(65_536); + let field = (bytes - 1) as u16; + cmd.push(mpsse::CLK_BYTES); + cmd.push(field as u8); + cmd.push((field >> 8) as u8); + remaining -= bytes * 8; + } + if remaining > 0 { + cmd.push(mpsse::CLK_BITS); + cmd.push((remaining - 1) as u8); + } + self.dev + .write_all(&cmd) + .await + .map_err(|e| format!("JTAG clock toggle failed: {e}")) + } + + async fn read_exact(&mut self, len: usize) -> Result, String> { + const MAX_STATUS_ONLY_READS: usize = 64; + + let mut buf = vec![0u8; len]; + let mut offset = 0usize; + let mut status_only_reads = 0usize; + while offset < len { + let n = self + .dev + .read_data(&mut buf[offset..]) + .await + .map_err(|e| format!("JTAG read failed: {e}"))?; + if n == 0 { + status_only_reads += 1; + if status_only_reads >= MAX_STATUS_ONLY_READS { + return Err("JTAG read timed out waiting for MPSSE data".to_string()); + } + continue; + } + status_only_reads = 0; + offset += n; + } + Ok(buf) + } +} + +async fn sleep_ms(ms: i32) { + let promise = js_sys::Promise::new(&mut |resolve, _| { + if let Some(window) = web_sys::window() { + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms); + } + }); + let _ = wasm_bindgen_futures::JsFuture::from(promise).await; +} + +fn reverse_byte(byte: u8) -> u8 { + byte.reverse_bits() +} diff --git a/tool/src/gowin_validation.rs b/tool/src/gowin_validation.rs new file mode 100644 index 0000000..c990190 --- /dev/null +++ b/tool/src/gowin_validation.rs @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: Apache-2.0 +// Derived from openFPGALoader's Gowin bitstream parser. +// Copyright (C) 2019 Gwenhael Goavec-Merou and openFPGALoader contributors. + +#[derive(Debug, Clone)] +pub(crate) struct GowinBitstream { + pub(crate) idcode: u32, + pub(crate) checksum: u32, + pub(crate) sram_data: Vec, + pub(crate) flash_data: Vec, +} + +impl GowinBitstream { + pub(crate) fn parse(input: &[u8], sram_reverse_bytes: bool) -> Result { + let text = + std::str::from_utf8(input).map_err(|e| format!("bitstream is not UTF-8: {e}"))?; + let mut lines = Vec::new(); + let mut in_header = true; + let mut end_header = None; + let mut idcode = 0u32; + let mut checksum_from_header = 0u32; + let mut conf_data_len = None; + let mut compressed = false; + let mut crc_check = false; + let mut zero8 = 0xffu8; + let mut zero4 = 0xffu8; + let mut zero2 = 0xffu8; + + for raw in text.lines() { + let line = raw.trim_end_matches('\r'); + if line.is_empty() { + break; + } + if line.starts_with('/') { + continue; + } + if !line.bytes().all(|b| b == b'0' || b == b'1') { + return Err("bitstream contains a non-binary data line".to_string()); + } + if line.len() % 8 != 0 { + return Err("bitstream line is not byte aligned".to_string()); + } + + let line_index = lines.len(); + lines.push(line.to_string()); + + if !in_header { + continue; + } + if line.len() < 8 { + return Err("truncated Gowin .fs header line".to_string()); + } + let key = bit_to_val(&line.as_bytes()[..8]) as u8 & 0x7f; + let val = bit_to_val(line.as_bytes()); + match key { + 0x06 => { + if line.len() != 64 { + return Err("invalid Gowin .fs IDCODE header length".to_string()); + } + idcode = val as u32; + } + 0x0a => checksum_from_header = val as u32, + 0x10 => { + if line.len() != 64 { + return Err("invalid Gowin .fs options header length".to_string()); + } + compressed = ((val >> 13) & 1) != 0; + } + 0x51 => { + if line.len() != 64 { + return Err("invalid Gowin .fs compression header length".to_string()); + } + zero8 = ((val >> 16) & 0xff) as u8; + zero4 = ((val >> 8) & 0xff) as u8; + zero2 = (val & 0xff) as u8; + } + 0x3b => { + if line.len() != 32 { + return Err("invalid Gowin .fs configuration length header".to_string()); + } + crc_check = ((val >> 23) & 1) != 0; + conf_data_len = Some((val & 0xffff) as usize); + end_header = Some(line_index); + in_header = false; + } + _ => {} + } + } + + if idcode == 0 { + return Err("Gowin .fs file does not contain an IDCODE header".to_string()); + } + let end_header = end_header + .ok_or("Gowin .fs file does not contain a configuration header terminator")?; + let conf_data_len = + conf_data_len.ok_or("Gowin .fs file does not contain ConfDataLength")?; + + let sram_data = pack_lines(&lines, sram_reverse_bytes)?; + let flash_data = if sram_reverse_bytes { + pack_lines(&lines, false)? + } else { + sram_data.clone() + }; + let checksum = compute_checksum( + &lines, + ChecksumOptions { + end_header, + conf_data_len, + compressed, + crc_check, + zero8, + zero4, + zero2, + }, + )?; + + Ok(Self { + idcode, + checksum: if checksum == 0 { + checksum_from_header + } else { + checksum + }, + sram_data, + flash_data, + }) + } +} + +fn bit_to_val(bits: &[u8]) -> u64 { + bits.iter() + .fold(0u64, |acc, &bit| (acc << 1) | u64::from(bit == b'1')) +} + +fn pack_lines(lines: &[String], reverse_bytes: bool) -> Result, String> { + let mut out = Vec::new(); + for line in lines { + if line.len() % 8 != 0 { + return Err("bitstream line is not byte aligned".to_string()); + } + for chunk in line.as_bytes().as_chunks::<8>().0 { + let byte = bit_to_val(chunk) as u8; + out.push(if reverse_bytes { + reverse_byte(byte) + } else { + byte + }); + } + } + Ok(out) +} + +struct ChecksumOptions { + end_header: usize, + conf_data_len: usize, + compressed: bool, + crc_check: bool, + zero8: u8, + zero4: u8, + zero2: u8, +} + +fn compute_checksum(lines: &[String], options: ChecksumOptions) -> Result { + if options.conf_data_len == 0 { + return Err("Gowin .fs file declares no configuration data".to_string()); + } + let data_lines = lines + .get(options.end_header + 1..) + .and_then(|lines| lines.get(..options.conf_data_len)) + .ok_or("truncated Gowin .fs configuration data: fewer lines than ConfDataLength")?; + let drop_bits = if options.crc_check { 8 * 8 } else { 6 * 8 }; + let mut checksum_bits = String::new(); + + for line in data_lines { + if line.len() <= drop_bits { + return Err("truncated Gowin .fs configuration line".to_string()); + } + if options.compressed { + let payload = &line[..line.len() - drop_bits]; + if payload.len() % 8 != 0 { + return Err("compressed Gowin .fs line is not byte aligned".to_string()); + } + for chunk in payload.as_bytes().as_chunks::<8>().0 { + let byte = bit_to_val(chunk) as u8; + if byte == options.zero8 { + checksum_bits.push_str( + "0000000000000000000000000000000000000000000000000000000000000000", + ); + } else if byte == options.zero4 { + checksum_bits.push_str("00000000000000000000000000000000"); + } else if byte == options.zero2 { + checksum_bits.push_str("0000000000000000"); + } else { + checksum_bits.push_str(std::str::from_utf8(chunk).map_err(|e| e.to_string())?); + } + } + } else { + checksum_bits.push_str(&line[..line.len() - drop_bits]); + } + } + + if checksum_bits.is_empty() { + return Ok(0); + } + if !checksum_bits.len().is_multiple_of(16) { + return Err("Gowin .fs checksum data is not 16-bit aligned".to_string()); + } + let mut checksum = 0u32; + for chunk in checksum_bits.as_bytes().as_chunks::<16>().0 { + checksum = checksum.wrapping_add(bit_to_val(chunk) as u32 & 0xffff); + } + Ok(checksum & 0xffff) +} + +fn reverse_byte(byte: u8) -> u8 { + byte.reverse_bits() +} + +/// Decode DWORD 2 of the JEDEC SFDP Basic Flash Parameter Table. +pub(crate) fn flash_capacity(density: u32) -> Result { + let bits = if density & (1 << 31) == 0 { + u64::from(density) + 1 + } else { + 1u64.checked_shl(density & 0x7fff_ffff) + .ok_or("invalid SFDP flash density exponent")? + }; + if bits < 8 || !bits.is_multiple_of(8) { + return Err("invalid SFDP flash density".to_string()); + } + Ok(bits / 8) +} + +pub(crate) fn validate_flash_range(offset: u32, len: usize, capacity: u64) -> Result { + // This programmer only sends three-byte addresses and erases 4 KiB sectors. + let limit = capacity.min(0x0100_0000); + let end = u64::from(offset) + .checked_add(len as u64) + .ok_or("flash range overflow")?; + let erase_end = end.checked_add(0xfff).ok_or("flash erase range overflow")? & !0xfff; + if len == 0 || u64::from(offset) >= limit || erase_end > limit { + return Err(format!( + "flash range 0x{offset:06x}..0x{end:06x} (including sector erase) exceeds usable capacity 0x{limit:x}" + )); + } + Ok(end as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn image(declared: u16, rows: usize) -> String { + let header = format!( + "{:064b}\n{:032b}\n", + 0x060000000001281bu64, + 0x3b000000u32 | u32::from(declared) + ); + header + &format!("{:064b}\n", 1u64 << 48).repeat(rows) + } + + #[test] + fn rejects_missing_configuration_rows() { + for reverse in [false, true] { + for text in [ + image(10, 0), + image(10, 9), + image(0, 0), + image(1, 0) + "\n" + &image(1, 1), + ] { + assert!(GowinBitstream::parse(text.as_bytes(), reverse).is_err()); + } + } + } + + #[test] + fn accepts_complete_image_with_trailer() { + let text = image(2, 2) + "11111111\n"; + for reverse in [false, true] { + let parsed = GowinBitstream::parse(text.as_bytes(), reverse).unwrap(); + assert_eq!(parsed.idcode, 0x0001281b); + assert_eq!(parsed.checksum, 2); + assert_eq!(parsed.flash_data.len(), 29); + assert_eq!(parsed.flash_data.last(), Some(&0xff)); + assert_eq!(parsed.sram_data[0], if reverse { 0x60 } else { 0x06 }); + } + } + + #[test] + fn decodes_sfdp_density_encodings() { + assert_eq!(flash_capacity(0x03ff_ffff).unwrap(), 8 * 1024 * 1024); + assert_eq!(flash_capacity(0x8000_001a).unwrap(), 8 * 1024 * 1024); + assert!(flash_capacity(0).is_err()); + assert!(flash_capacity(0x8000_0040).is_err()); + } + + #[test] + fn bounds_writes_and_erases_by_capacity_and_address_width() { + let capacity = 8 * 1024 * 1024; + assert_eq!( + validate_flash_range(0x7fff00, 256, capacity).unwrap(), + 0x800000 + ); + assert!(validate_flash_range(0x800000, 256, capacity).is_err()); + assert!(validate_flash_range(0x7fff00, 257, capacity).is_err()); + assert!(validate_flash_range(0, 0, capacity).is_err()); + assert!(validate_flash_range(0x1000000, 1, 32 * 1024 * 1024).is_err()); + assert!(validate_flash_range(0, 1, 1024).is_err()); + assert!(validate_flash_range(u32::MAX, usize::MAX, capacity).is_err()); + } +} diff --git a/tool/src/lib.rs b/tool/src/lib.rs index 1dc0bac..00534a6 100644 --- a/tool/src/lib.rs +++ b/tool/src/lib.rs @@ -9,6 +9,12 @@ pub mod chip; #[cfg(all(feature = "wasm", target_arch = "wasm32"))] pub mod sfdp; +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +pub mod gowin; + +#[cfg(any(test, all(feature = "wasm", target_arch = "wasm32")))] +mod gowin_validation; + #[cfg(all(feature = "wasm", target_arch = "wasm32"))] #[allow(dead_code)] mod device; diff --git a/tool/src/web_main.rs b/tool/src/web_main.rs index 9eccd34..fc45130 100644 --- a/tool/src/web_main.rs +++ b/tool/src/web_main.rs @@ -5,6 +5,7 @@ use gloo_timers::future::TimeoutFuture; use js_sys::{Array, Function, Uint8Array}; use spi_flash_tool::WebFlashDevice; use spi_flash_tool::chip::{FlashChip, FlashChipExt}; +use spi_flash_tool::gowin::{self, FlashOptions, ProgramProgress}; use spi_flash_tool::sfdp::generate_sfdp; use std::cell::RefCell; use std::collections::HashMap; @@ -20,6 +21,7 @@ use web_sys::{ enum Panel { #[default] Device, + Bitstream, Activity, Toctou, Wiring, @@ -46,6 +48,7 @@ struct SharedState { status: String, status_error: bool, pending_file: Option<(String, File)>, + pending_bitstream_file: Option<(String, Vec)>, read_data: Option<(u32, Vec)>, log_output: String, log_pending: Vec, @@ -72,6 +75,7 @@ impl Default for SharedState { status: "Choose a transport to connect to NORbert".to_owned(), status_error: false, pending_file: None, + pending_bitstream_file: None, read_data: None, log_output: String::new(), log_pending: Vec::new(), @@ -103,6 +107,13 @@ struct NorbertWebApp { upload_name: String, upload_file: Option, verify_upload: bool, + bitstream_name: String, + bitstream_data: Option>, + bitstream_info: String, + bitstream_flash_offset: String, + bitstream_verify_flash: bool, + bitstream_unprotect_flash: bool, + bitstream_chip_erase: bool, toctou_index: String, toctou_start: String, toctou_mask: String, @@ -135,6 +146,13 @@ impl NorbertWebApp { upload_name: String::new(), upload_file: None, verify_upload: false, + bitstream_name: String::new(), + bitstream_data: None, + bitstream_info: String::new(), + bitstream_flash_offset: "0".to_owned(), + bitstream_verify_flash: true, + bitstream_unprotect_flash: false, + bitstream_chip_erase: false, toctou_index: "0".to_owned(), toctou_start: "0x001000".to_owned(), toctou_mask: "0xFFF000".to_owned(), @@ -420,6 +438,125 @@ impl NorbertWebApp { input.click(); } + fn select_bitstream_file(&self) { + let document = web_sys::window().unwrap().document().unwrap(); + let input: HtmlInputElement = document + .create_element("input") + .unwrap() + .dyn_into() + .unwrap(); + input.set_type("file"); + input.set_accept(".fs,*/*"); + let state = self.state.clone(); + let onchange = Closure::wrap(Box::new(move |event: web_sys::Event| { + let input: HtmlInputElement = event.target().unwrap().dyn_into().unwrap(); + if let Some(file) = input.files().and_then(|files| files.get(0)) { + let name = file.name(); + let state = state.clone(); + spawn_local(async move { + match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await { + Ok(buffer) => { + let data = Uint8Array::new(&buffer).to_vec(); + state.borrow_mut().pending_bitstream_file = Some((name, data)); + } + Err(error) => set_error( + &mut state.borrow_mut(), + format!("Failed to read bitstream: {}", js_error(error)), + ), + } + }); + } + }) as Box); + input.set_onchange(Some(onchange.as_ref().unchecked_ref())); + onchange.forget(); + input.click(); + } + + fn program_bitstream_sram(&self, ctx: &egui::Context) { + let Some(data) = self.bitstream_data.clone() else { + set_error( + &mut self.state.borrow_mut(), + "Select a Gowin .fs bitstream first".to_owned(), + ); + return; + }; + self.run_bitstream_program(data, None, ctx); + } + + fn program_bitstream_flash(&self, ctx: &egui::Context) { + let Some(data) = self.bitstream_data.clone() else { + set_error( + &mut self.state.borrow_mut(), + "Select a Gowin .fs bitstream first".to_owned(), + ); + return; + }; + let Some(offset) = parse_number(&self.bitstream_flash_offset) else { + set_error( + &mut self.state.borrow_mut(), + "Invalid flash offset".to_owned(), + ); + return; + }; + self.run_bitstream_program( + data, + Some(FlashOptions { + offset, + verify: self.bitstream_verify_flash, + unprotect: self.bitstream_unprotect_flash, + chip_erase: self.bitstream_chip_erase, + }), + ctx, + ); + } + + fn run_bitstream_program( + &self, + data: Vec, + flash_options: Option, + ctx: &egui::Context, + ) { + let state = self.state.clone(); + let repaint = ctx.clone(); + { + let mut shared = state.borrow_mut(); + shared.busy = true; + shared.status = if flash_options.is_some() { + "Requesting JTAG adapter to program FPGA flash..." + } else { + "Requesting JTAG adapter to program FPGA SRAM..." + } + .to_owned(); + shared.status_error = false; + } + spawn_local(async move { + let progress_state = state.clone(); + let progress_repaint = repaint.clone(); + let progress = move |update: ProgramProgress| { + let mut shared = progress_state.borrow_mut(); + shared.status = format!("{}: {}/{}", update.stage, update.done, update.total); + progress_repaint.request_repaint(); + }; + let result = match flash_options { + Some(options) => gowin::program_flash_from_webusb(&data, options, progress).await, + None => gowin::program_sram_from_webusb(&data, progress).await, + }; + let mut shared = state.borrow_mut(); + shared.busy = false; + match result { + Ok(report) => { + shared.status = format!( + "FPGA programmed: {} bytes, JTAG IDCODE 0x{:08X}", + report.bytes_programmed, report.idcode + ); + shared.status_error = false; + } + Err(error) => set_error(&mut shared, format!("FPGA programming failed: {error}")), + } + repaint.request_repaint(); + }); + } + fn write_memory(&self, address: u32, file: File, verify: bool, ctx: &egui::Context) { let state = self.state.clone(); let repaint = ctx.clone(); @@ -631,6 +768,88 @@ impl NorbertWebApp { state.status = "Stopping SPI activity capture...".to_owned(); } + fn bitstream_panel(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + ui.heading("FPGA Bitstream"); + ui.separator(); + ui.label( + "Program the Tang Primer 25K directly from the browser using its FT2232H JTAG adapter. This ports the Gowin GW5A subset of openFPGALoader to Rust/WASM on top of ftdi-nusb and WebUSB.", + ); + ui.label( + "Download spi_flash.fs from the latest GitHub release, or choose a locally built .fs file.", + ); + ui.hyperlink_to( + "Download latest release bitstream", + "https://github.com/ArthurHeymans/NORbert/releases/latest/download/spi_flash.fs", + ); + + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui + .add_enabled( + !self.state.borrow().busy, + egui::Button::new("Choose .fs File..."), + ) + .clicked() + { + self.select_bitstream_file(); + } + if self.bitstream_name.is_empty() { + ui.label("No bitstream selected"); + } else { + ui.label(&self.bitstream_name); + } + }); + if !self.bitstream_info.is_empty() { + ui.label(&self.bitstream_info); + } + + let (busy, transport_connected) = { + let state = self.state.borrow(); + (state.busy, state.device.is_some()) + }; + let can_program = self.bitstream_data.is_some() && !busy && !transport_connected; + + ui.add_space(12.0); + ui.heading("Volatile SRAM"); + ui.label("Loads immediately and is lost when the board is powered off."); + if ui + .add_enabled(can_program, egui::Button::new("Program SRAM via WebUSB")) + .clicked() + { + self.program_bitstream_sram(ctx); + } + + ui.add_space(12.0); + ui.heading("Persistent Flash"); + ui.label("Writes the board's external configuration flash. The FPGA loads it at power-on."); + ui.horizontal(|ui| { + ui.label("Flash offset:"); + ui.add( + egui::TextEdit::singleline(&mut self.bitstream_flash_offset).desired_width(100.0), + ); + ui.checkbox(&mut self.bitstream_verify_flash, "Verify"); + ui.checkbox(&mut self.bitstream_unprotect_flash, "Clear protection bits"); + ui.checkbox(&mut self.bitstream_chip_erase, "Erase whole chip"); + }); + if ui + .add_enabled(can_program, egui::Button::new("Program Flash via WebUSB")) + .clicked() + { + self.program_bitstream_flash(ctx); + } + + ui.add_space(12.0); + if transport_connected { + ui.colored_label( + Color32::YELLOW, + "Disconnect the current NORbert transport before programming. An FT245 connection already owns FT2232H interface A.", + ); + } + ui.label( + "WebUSB requires Chromium and HTTPS or localhost. On Linux, FT2232H interface A must not be claimed by ftdi_sio; on Windows, it must use the WinUSB driver.", + ); + } + fn device_panel(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { ui.heading("Device"); ui.separator(); @@ -1081,11 +1300,33 @@ impl eframe::App for NorbertWebApp { self.upload_file = Some(file); } + let pending_bitstream = self.state.borrow_mut().pending_bitstream_file.take(); + if let Some((name, data)) = pending_bitstream { + match gowin::inspect_bitstream(&data) { + Ok(info) => { + self.bitstream_info = format!( + "{} SRAM bytes, {} flash bytes, target IDCODE 0x{:08X}, checksum 0x{:08X}", + info.sram_bytes, info.flash_bytes, info.idcode, info.checksum + ); + self.bitstream_name = name; + self.bitstream_data = Some(data); + let mut state = self.state.borrow_mut(); + state.status = "Bitstream loaded".to_owned(); + state.status_error = false; + } + Err(error) => set_error( + &mut self.state.borrow_mut(), + format!("Invalid bitstream: {error}"), + ), + } + } + egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { ui.horizontal(|ui| { ui.heading("NORbert Web Interface"); ui.separator(); ui.selectable_value(&mut self.panel, Panel::Device, "Device"); + ui.selectable_value(&mut self.panel, Panel::Bitstream, "Bitstream"); ui.selectable_value(&mut self.panel, Panel::Activity, "Activity"); ui.selectable_value(&mut self.panel, Panel::Toctou, "TOCTOU"); ui.selectable_value(&mut self.panel, Panel::Wiring, "Wiring"); @@ -1103,6 +1344,7 @@ impl eframe::App for NorbertWebApp { }); egui::CentralPanel::default().show(ctx, |ui| match self.panel { Panel::Device => self.device_panel(ui, ctx), + Panel::Bitstream => self.bitstream_panel(ui, ctx), Panel::Activity => self.activity_panel(ui, ctx), Panel::Toctou => self.toctou_panel(ui, ctx), Panel::Wiring => {}