diff --git a/mujina-miner/src/asic/bzm2/error.rs b/mujina-miner/src/asic/bzm2/error.rs new file mode 100644 index 0000000..591c85e --- /dev/null +++ b/mujina-miner/src/asic/bzm2/error.rs @@ -0,0 +1,40 @@ +//! Error types for BZM2 protocol operations. + +use thiserror::Error; + +/// Validation failures detected while encoding or decoding BZM2 frames. +#[derive(Error, Debug)] +pub enum ProtocolError { + /// A register write command was constructed without any payload bytes. + #[error("register write payload cannot be empty")] + EmptyWritePayload, + + /// A register write payload exceeded the 8-bit on-wire length field. + #[error("register write payload too large: {0} bytes")] + WritePayloadTooLarge(usize), + + /// READREG only supports 1-, 2-, or 4-byte responses. + #[error("invalid read register byte count: {0} (expected 1, 2, or 4)")] + InvalidReadRegCount(u8), + + /// WRITEJOB only accepts `job_ctl` values that the hardware understands. + #[error("invalid job control value: {0} (expected 1 or 3)")] + InvalidJobControl(u8), + + /// The codec was asked to decode a READREG response size it does not + /// implement. + #[error("unsupported read register response size: {0} (expected 1 or 4)")] + UnsupportedReadRegResponseSize(usize), + + /// A frame exceeded what the bridge format can encode in one command. + #[error("frame too large to encode: {0} bytes")] + FrameTooLarge(usize), + + /// A NOOP response did not return the expected `2ZB` signature bytes. + #[error("invalid NOOP signature: {0:02x?}")] + InvalidNoopSignature([u8; 3]), + + /// The decoder saw a response opcode that is not currently supported. + #[error("unsupported response opcode: 0x{0:02x}")] + UnsupportedResponseOpcode(u8), +} diff --git a/mujina-miner/src/asic/bzm2/init.rs b/mujina-miner/src/asic/bzm2/init.rs new file mode 100644 index 0000000..7c43d8e --- /dev/null +++ b/mujina-miner/src/asic/bzm2/init.rs @@ -0,0 +1,223 @@ +//! BZM2 data-port initialization helpers. +//! +//! This module performs the board-time transport probe that happens before the +//! hashing thread takes ownership of the UART. Initialization here uses the +//! real protocol codec and returns a ready-to-use framed transport on success. + +use anyhow::{Context, Result, anyhow, bail}; +use futures::SinkExt; +use tokio::io::AsyncReadExt; +use tokio::time::{self, Duration}; +use tokio_stream::StreamExt; +use tokio_util::codec::{FramedRead, FramedWrite}; + +use super::{ + Bzm2Protocol, FrameCodec, HexBytes, ReadRegData, Response, + protocol::{DEFAULT_ASIC_ID, NOOP_STRING}, +}; +use crate::transport::serial::{SerialControl, SerialReader, SerialStream, SerialWriter}; + +/// Default BZM2 UART baud rate used by the BIRDS data port. +pub const DEFAULT_BZM2_DATA_BAUD: u32 = 5_000_000; + +/// Default timeout for each initialization request/response step. +pub const DEFAULT_IO_TIMEOUT: Duration = Duration::from_secs(2); + +/// Result of probing one ASIC during board initialization. +#[derive(Debug, Clone, Copy)] +pub struct ProbeResult { + /// Logical ASIC index that was probed. + pub logical_asic: u8, + /// Hardware UART ID observed on the response path. + pub asic_hw_id: u8, + /// Raw `ASIC_ID` register value returned by the chip. + pub asic_id: u32, +} + +/// Framed BZM2 data-port transport that has already passed initialization. +pub struct InitializedDataPort { + /// Probe metadata collected during initialization. + pub probe: ProbeResult, + /// Decoded response stream for subsequent hashing logic. + pub reader: FramedRead, + /// Encoded command sink for subsequent hashing logic. + pub writer: FramedWrite, + /// Control handle associated with the serial data port. + pub control: SerialControl, +} + +fn expect_noop_response(response: Response) -> Result { + match response { + Response::Noop { + asic_hw_id, + signature, + } if signature == *NOOP_STRING => Ok(asic_hw_id), + Response::Noop { signature, .. } => { + bail!("NOOP signature mismatch: got {:02x?}", signature) + } + other => bail!("expected NOOP response, got {:?}", other), + } +} + +fn expect_asic_id_response(expected_asic_hw_id: u8, response: Response) -> Result { + match response { + Response::ReadReg { + asic_hw_id, + data: ReadRegData::U32(asic_id), + } if asic_hw_id == expected_asic_hw_id => Ok(asic_id), + Response::ReadReg { asic_hw_id, data } => bail!( + "READREG(ASIC_ID) response mismatch: expected ASIC 0x{expected_asic_hw_id:02X}, got ASIC 0x{asic_hw_id:02X} with payload {:?}", + data + ), + other => bail!("expected READREG(ASIC_ID) response, got {:?}", other), + } +} + +async fn next_response( + reader: &mut FramedRead, + timeout: Duration, + context: &str, +) -> Result { + let response = time::timeout(timeout, reader.next()) + .await + .with_context(|| format!("timeout waiting for {context}"))? + .transpose() + .with_context(|| format!("read error while waiting for {context}"))? + .ok_or_else(|| anyhow!("BZM2 response stream closed while waiting for {context}"))?; + Ok(response) +} + +/// Open, probe, and return an initialized BZM2 data port using default +/// transport settings. +pub async fn initialize_data_port( + serial_port: &str, + logical_asic: u8, +) -> Result { + initialize_data_port_with_options( + serial_port, + logical_asic, + DEFAULT_BZM2_DATA_BAUD, + DEFAULT_IO_TIMEOUT, + ) + .await +} + +/// Open, probe, and return an initialized BZM2 data port using explicit +/// transport settings. +pub async fn initialize_data_port_with_options( + serial_port: &str, + logical_asic: u8, + baud: u32, + timeout: Duration, +) -> Result { + let protocol = Bzm2Protocol::new(); + let serial = SerialStream::new(serial_port, baud) + .with_context(|| format!("failed to open serial port {}", serial_port))?; + let (mut raw_reader, writer, control) = serial.split(); + + // Reset/power-up can leave transient bytes on the data UART. Drain any + // pending bytes before issuing the first command. + drain_input_noise(&mut raw_reader).await; + + let mut reader = FramedRead::new(raw_reader, FrameCodec::default()); + let mut writer = FramedWrite::new(writer, FrameCodec::default()); + + writer + .send(protocol.noop(DEFAULT_ASIC_ID)) + .await + .context("failed to send NOOP")?; + let noop_response = next_response(&mut reader, timeout, "NOOP response").await?; + let asic_hw_id = expect_noop_response(noop_response)?; + + writer + .send(protocol.read_asic_id(asic_hw_id)) + .await + .context("failed to send READREG(ASIC_ID)")?; + let asic_id_response = next_response(&mut reader, timeout, "READREG(ASIC_ID) response").await?; + let asic_id = expect_asic_id_response(asic_hw_id, asic_id_response)?; + + Ok(InitializedDataPort { + probe: ProbeResult { + logical_asic, + asic_hw_id, + asic_id, + }, + reader, + writer, + control, + }) +} + +async fn drain_input_noise(reader: &mut SerialReader) { + let mut scratch = [0u8; 256]; + loop { + match time::timeout(Duration::from_millis(20), reader.read(&mut scratch)).await { + Ok(Ok(0)) => break, + Ok(Ok(n)) => { + tracing::debug!( + bytes = n, + rx = %HexBytes(&scratch[..n]), + "BZM2 init drained residual input" + ); + continue; + } + Ok(Err(_)) => break, + Err(_elapsed) => break, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_expect_noop_response_accepts_expected_signature() { + let asic_hw_id = expect_noop_response(Response::Noop { + asic_hw_id: DEFAULT_ASIC_ID, + signature: *NOOP_STRING, + }) + .unwrap(); + assert_eq!(asic_hw_id, DEFAULT_ASIC_ID); + } + + #[test] + fn test_expect_noop_response_rejects_non_noop_response() { + let error = expect_noop_response(Response::ReadReg { + asic_hw_id: DEFAULT_ASIC_ID, + data: ReadRegData::U32(0x1234_5678), + }) + .expect_err("non-NOOP response must fail"); + assert!(error.to_string().contains("expected NOOP response")); + } + + #[test] + fn test_expect_asic_id_response_accepts_matching_u32_payload() { + let asic_id = expect_asic_id_response( + DEFAULT_ASIC_ID, + Response::ReadReg { + asic_hw_id: DEFAULT_ASIC_ID, + data: ReadRegData::U32(0x1234_5678), + }, + ) + .unwrap(); + assert_eq!(asic_id, 0x1234_5678); + } + + #[test] + fn test_expect_asic_id_response_rejects_mismatched_payload_type() { + let error = expect_asic_id_response( + DEFAULT_ASIC_ID, + Response::ReadReg { + asic_hw_id: DEFAULT_ASIC_ID, + data: ReadRegData::U8(0x12), + }, + ) + .expect_err("unexpected payload type must fail"); + assert!( + error + .to_string() + .contains("READREG(ASIC_ID) response mismatch") + ); + } +} diff --git a/mujina-miner/src/asic/bzm2/mod.rs b/mujina-miner/src/asic/bzm2/mod.rs new file mode 100644 index 0000000..d2f9868 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/mod.rs @@ -0,0 +1,37 @@ +//! BZM2 ASIC family support. +//! +//! The BZM2 implementation is split into focused modules: +//! - [`protocol`] owns wire-format types and the Tokio codec. +//! - [`thread`] owns the `HashThread` actor and chip bring-up sequence. +//! - [`init`] owns board-time transport probing before the hash thread takes +//! over the UART. +//! - [`error`] contains protocol-specific validation errors. +//! +//! BIRDS boards use this module for both board-time initialization and +//! production hashing. Keeping the low-level helpers centralized avoids board +//! code having to duplicate protocol details. + +use std::fmt; + +/// Wrapper for formatting byte slices as space-separated uppercase hex. +pub(crate) struct HexBytes<'a>(pub(crate) &'a [u8]); + +impl fmt::Display for HexBytes<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, byte) in self.0.iter().enumerate() { + if i > 0 { + write!(f, " ")?; + } + write!(f, "{:02X}", byte)?; + } + Ok(()) + } +} + +pub mod error; +pub mod init; +pub mod protocol; +pub mod thread; + +pub use error::ProtocolError; +pub use protocol::{Bzm2Protocol, Command, FrameCodec, Opcode, ReadRegData, Response}; diff --git a/mujina-miner/src/asic/bzm2/protocol.rs b/mujina-miner/src/asic/bzm2/protocol.rs new file mode 100644 index 0000000..7df487c --- /dev/null +++ b/mujina-miner/src/asic/bzm2/protocol.rs @@ -0,0 +1,1402 @@ +//! BZM2 wire protocol and frame codec. +//! +//! This module defines the wire-format types used by the BIRDS BZM2 transport: +//! - Command encoding for `NOOP`, `READREG`, `WRITEREG`, multicast writes, and +//! `WRITEJOB` +//! - Response decoding for `NOOP`, `READREG`, `READRESULT`, and `DTS/VS` +//! - 9-bit transmit framing via the BIRDS USB bridge format +//! +//! The API surface is intentionally narrow: higher-level chip initialization and +//! mining logic live in [`super::thread`], while this module stays responsible +//! for translating typed commands and responses to bytes. + +use std::io; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use strum::FromRepr; +use tokio_util::codec::{Decoder, Encoder}; + +use super::{HexBytes, error::ProtocolError}; +use crate::transport::nine_bit::nine_bit_encode_frame; + +/// ASCII marker returned by the ASIC in identification contexts. +pub const ASIC_STRING: &[u8; 3] = b"BZ2"; +/// Signature returned by a successful `NOOP` response. +pub const NOOP_STRING: &[u8; 3] = b"2ZB"; +/// Default hardware ASIC ID before per-chip addressing is assigned. +pub const DEFAULT_ASIC_ID: u8 = 0xfa; + +/// Distance between logical ASIC indices and hardware UART IDs. +pub const ASIC_HW_ID_STRIDE: u8 = 10; +/// Total engine count exposed by one BZM2 ASIC package. +pub const ENGINES_PER_ASIC: usize = 240; + +/// Engine selector used for local-register accesses. +pub const NOTCH_REG: u16 = 0x0fff; +/// Engine selector used for BIST register accesses. +pub const BIST_REG: u16 = 0x0fc0; +/// Broadcast ASIC target used by commands that address every device. +pub const BROADCAST_ASIC: u8 = 0xff; +/// Broadcast engine/group target used for wide writes. +pub const BROADCAST_ENGINE: u16 = 0x00ff; + +/// Terminator byte appended to transmit frames by the bridge format. +pub const TERM_BYTE: u8 = 0xa5; +/// Target selector used by `READREG` requests. +pub const TAR_BYTE: u8 = 0x08; +/// Register offset encoded in `WRITEJOB` headers. +pub const WRITEJOB_OFFSET: u16 = 41; + +/// Engine-local register offsets used by job dispatch and result handling. +pub mod engine_reg { + pub const STATUS: u16 = 0x00; + pub const CONFIG: u16 = 0x01; + pub const DELAY: u16 = 0x0c; + pub const MIDSTATE: u16 = 0x10; + pub const MRRESIDUE: u16 = 0x30; + pub const START_TIMESTAMP: u16 = 0x34; + pub const SEQUENCE_ID: u16 = 0x38; + pub const JOB_CONTROL: u16 = 0x39; + pub const START_NONCE: u16 = 0x3c; + pub const END_NONCE: u16 = 0x40; + pub const TARGET: u16 = 0x44; + pub const TIMESTAMP_COUNT: u16 = 0x48; + pub const ZEROS_TO_FIND: u16 = 0x49; + pub const RESULT_VALID: u16 = 0x70; + pub const RESULT_SEQUENCE: u16 = 0x71; + pub const RESULT_TIME: u16 = 0x72; + pub const RESULT_NONCE: u16 = 0x73; + pub const RESULT_POP: u16 = 0x77; +} + +/// Local control and sensor register offsets for one ASIC. +pub mod local_reg { + pub const RESULT_STS_CTL: u16 = 0x00; + pub const ERROR_LOG0: u16 = 0x01; + pub const ERROR_LOG1: u16 = 0x02; + pub const ERROR_LOG2: u16 = 0x03; + pub const ERROR_LOG3: u16 = 0x04; + pub const SPI_STS_CTL: u16 = 0x05; + pub const UART_LINE_CTL: u16 = 0x06; + pub const UART_TDM_CTL: u16 = 0x07; + pub const SLOW_CLK_DIV: u16 = 0x08; + pub const TDM_DELAY: u16 = 0x09; + pub const UART_TX: u16 = 0x0a; + pub const ASIC_ID: u16 = 0x0b; + pub const PLL_CNTRL: u16 = 0x0f; + pub const PLL_POSTDIV: u16 = 0x10; + pub const PLL_FBDIV: u16 = 0x11; + pub const PLL_ENABLE: u16 = 0x12; + pub const PLL_MISC: u16 = 0x13; + pub const ENG_SOFT_RESET: u16 = 0x16; + pub const PLL1_CNTRL: u16 = 0x19; + pub const PLL1_POSTDIV: u16 = 0x1a; + pub const PLL1_FBDIV: u16 = 0x1b; + pub const PLL1_ENABLE: u16 = 0x1c; + pub const PLL1_MISC: u16 = 0x1d; + pub const UART_SPI_TAP: u16 = 0x20; + pub const SENS_TDM_GAP_CNT: u16 = 0x2d; + pub const DTS_SRST_PD: u16 = 0x2e; + pub const DTS_CFG: u16 = 0x2f; + pub const TEMPSENSOR_TUNE_CODE: u16 = 0x30; + pub const THERMAL_TRIP_STATUS: u16 = 0x31; + pub const THERMAL_TEMP_CODE: u16 = 0x32; + pub const THERMAL_SAR_COUNT_LOAD: u16 = 0x34; + pub const THERMAL_SAR_STATE_RESET: u16 = 0x35; + pub const SENSOR_THRS_CNT: u16 = 0x3c; + pub const SENSOR_CLK_DIV: u16 = 0x3d; + pub const VSENSOR_SRST_PD: u16 = 0x3e; + pub const VSENSOR_CFG: u16 = 0x3f; + pub const VOLTAGE_SENSOR_ENABLE: u16 = 0x40; + pub const VOLTAGE_SENSOR_STATUS: u16 = 0x41; + pub const VOLTAGE_SENSOR_MISC: u16 = 0x42; + pub const VOLTAGE_SENSOR_DFT: u16 = 0x43; + pub const BANDGAP: u16 = 0x45; + pub const LDO_0_CTL_STS: u16 = 0x46; + pub const LDO_1_CTL_STS: u16 = 0x47; + pub const IO_PEPS: u16 = 0x50; + pub const IO_PEPS_DS: u16 = 0x51; + pub const IO_PUPDST: u16 = 0x52; + pub const IO_NON_CLK_DS: u16 = 0x53; + pub const CKDCCR_0_0: u16 = 0x54; + pub const CKDCCR_1_0: u16 = 0x55; + pub const CKDCCR_2_0: u16 = 0x56; + pub const CKDCCR_3_0: u16 = 0x57; + pub const CKDCCR_4_0: u16 = 0x58; + pub const CKDCCR_5_0: u16 = 0x59; + pub const CKDLLR_0_0: u16 = 0x5a; + pub const CKDLLR_1_0: u16 = 0x5b; + pub const CKDCCR_0_1: u16 = 0x5c; + pub const CKDCCR_1_1: u16 = 0x5d; + pub const CKDCCR_2_1: u16 = 0x5e; + pub const CKDCCR_3_1: u16 = 0x5f; + pub const CKDCCR_4_1: u16 = 0x60; + pub const CKDCCR_5_1: u16 = 0x61; + pub const CKDLLR_0_1: u16 = 0x62; + pub const CKDLLR_1_1: u16 = 0x63; +} + +/// BIST register offsets used during engine self-test programming. +pub mod bist_reg { + pub const RESULT_FSM_CTL: u16 = 0x00; + pub const ERROR_LOG0: u16 = 0x01; + pub const ERROR_LOG1: u16 = 0x02; + pub const ERROR_LOG2: u16 = 0x03; + pub const ERROR_LOG3: u16 = 0x04; + pub const ENABLE: u16 = 0x06; + pub const CONTROL: u16 = 0x07; + pub const RESULT_TIMEOUT: u16 = 0x08; + pub const STATUS: u16 = 0x09; + pub const JOB_COUNT: u16 = 0x0a; + pub const GAP_COUNT: u16 = 0x0b; + pub const ENG_CLK_GATE: u16 = 0x0c; + pub const INT_START_NONCE: u16 = 0x0d; + pub const INT_END_NONCE: u16 = 0x0e; + pub const RESULT_SEL: u16 = 0x17; + pub const EXPECTED_RES_REG0: u16 = 0x18; + pub const EXPECTED_RES_REG1: u16 = 0x19; + pub const EXPECTED_RES_REG2: u16 = 0x1a; + pub const EXPECTED_RES_REG3: u16 = 0x1b; + pub const EXP_PAT_REG0: u16 = 0x1c; + pub const EXP_PAT_REG1: u16 = 0x1d; + pub const EXP_PAT_REG2: u16 = 0x1e; + pub const EXP_PAT_REG3: u16 = 0x1f; + + pub const fn exp_pat_subjob0(n: u16) -> u16 { + 0x20 + n + } + + pub const fn exp_pat_subjob1(n: u16) -> u16 { + 0x80 + n + } + + pub const fn exp_pat_subjob2(n: u16) -> u16 { + 0x94 + n + } + + pub const fn exp_pat_subjob3(n: u16) -> u16 { + 0xa8 + n + } + + pub const fn job_tce_row(j: u16, t: u16, r: u16) -> u16 { + 0x30 + (0x50 * j) + (0x14 * t) + r + } +} + +/// Supported BZM2 opcodes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, FromRepr)] +#[repr(u8)] +pub enum Opcode { + /// Push one micro-job into an engine. + WriteJob = 0x0, + /// Read a completed result from an engine. + ReadResult = 0x1, + /// Write one or more bytes to a register region. + WriteReg = 0x2, + /// Read one, two, or four bytes from a register region. + ReadReg = 0x3, + /// Write one or more bytes to a register group or broadcast target. + MulticastWrite = 0x4, + /// Read temperature/voltage sensor telemetry. + DtsVs = 0x0d, + /// Loopback command used by bring-up and diagnostics. + Loopback = 0x0e, + /// Lightweight connectivity check that returns `NOOP_STRING`. + Noop = 0x0f, +} + +/// Translate logical ASIC index (0..N) to hardware ASIC ID used on UART. +pub fn logical_to_hw_asic_id(logical_asic: u8) -> u8 { + logical_asic + .saturating_add(1) + .saturating_mul(ASIC_HW_ID_STRIDE) +} + +/// Translate hardware ASIC ID from UART into logical ASIC index. +pub fn hw_to_logical_asic_id(hw_asic_id: u8) -> Option { + if hw_asic_id < ASIC_HW_ID_STRIDE || !hw_asic_id.is_multiple_of(ASIC_HW_ID_STRIDE) { + return None; + } + + Some((hw_asic_id / ASIC_HW_ID_STRIDE) - 1) +} + +/// Commands that can be sent to one or more BZM2 ASICs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + /// Push a job payload to one engine. + WriteJob { + asic_hw_id: u8, + engine: u16, + midstate: [u8; 32], + merkle_residue: u32, + timestamp: u32, + sequence: u8, + job_ctl: u8, + }, + + /// Send NOOP command. + Noop { asic_hw_id: u8 }, + + /// Read register value (1/2/4 bytes). + ReadReg { + asic_hw_id: u8, + engine: u16, + offset: u16, + count: u8, + }, + + /// Write register value (1-255 bytes). + WriteReg { + asic_hw_id: u8, + engine: u16, + offset: u16, + value: Bytes, + }, + + /// Write one or more bytes using opcode 0x4 (row/group write). + MulticastWrite { + asic_hw_id: u8, + group: u16, + offset: u16, + value: Bytes, + }, +} + +impl Command { + /// Build a single `WRITEJOB` command containing one midstate variant. + pub fn write_job_single_midstate( + asic_hw_id: u8, + engine: u16, + midstate: [u8; 32], + merkle_residue: u32, + timestamp: u32, + sequence: u8, + job_ctl: u8, + ) -> Self { + Self::WriteJob { + asic_hw_id, + engine, + midstate, + merkle_residue, + timestamp, + sequence, + job_ctl, + } + } + + /// Build the 4-command WRITEJOB burst. + /// + /// Sequence mapping: + /// `seq_start = (sequence_id % 2) * 4`, then `seq_start + [0,1,2,3]`. + /// The first three commands carry `job_ctl=0`; the final command carries + /// the requested `job_ctl` (must be 1 or 3). + pub fn write_job( + asic_hw_id: u8, + engine: u16, + midstates: [[u8; 32]; 4], + merkle_residue: u32, + timestamp: u32, + sequence_id: u8, + job_ctl: u8, + ) -> Result<[Self; 4], ProtocolError> { + if !matches!(job_ctl, 1 | 3) { + return Err(ProtocolError::InvalidJobControl(job_ctl)); + } + + let seq_start = (sequence_id % 2) * 4; + Ok([ + Self::write_job_single_midstate( + asic_hw_id, + engine, + midstates[0], + merkle_residue, + timestamp, + seq_start, + 0, + ), + Self::write_job_single_midstate( + asic_hw_id, + engine, + midstates[1], + merkle_residue, + timestamp, + seq_start + 1, + 0, + ), + Self::write_job_single_midstate( + asic_hw_id, + engine, + midstates[2], + merkle_residue, + timestamp, + seq_start + 2, + 0, + ), + Self::write_job_single_midstate( + asic_hw_id, + engine, + midstates[3], + merkle_residue, + timestamp, + seq_start + 3, + job_ctl, + ), + ]) + } + + /// Build a `READREG` command for a four-byte little-endian register. + pub fn read_reg_u32(asic_hw_id: u8, engine: u16, offset: u16) -> Self { + Self::ReadReg { + asic_hw_id, + engine, + offset, + count: 4, + } + } + + /// Build a single-byte `WRITEREG` command. + pub fn write_reg_u8(asic_hw_id: u8, engine: u16, offset: u16, value: u8) -> Self { + Self::WriteReg { + asic_hw_id, + engine, + offset, + value: Bytes::copy_from_slice(&[value]), + } + } + + /// Build a four-byte little-endian `WRITEREG` command. + pub fn write_reg_u32_le(asic_hw_id: u8, engine: u16, offset: u16, value: u32) -> Self { + Self::WriteReg { + asic_hw_id, + engine, + offset, + value: Bytes::copy_from_slice(&value.to_le_bytes()), + } + } + + /// Build a single-byte multicast register write. + pub fn multicast_write_u8(asic_hw_id: u8, group: u16, offset: u16, value: u8) -> Self { + Self::MulticastWrite { + asic_hw_id, + group, + offset, + value: Bytes::copy_from_slice(&[value]), + } + } + + fn encode_raw(&self) -> Result { + let mut raw = BytesMut::new(); + + match self { + Self::WriteJob { + asic_hw_id, + engine, + midstate, + merkle_residue, + timestamp, + sequence, + job_ctl, + } => { + // WRITEJOB command: + // [header:u32_be][midstate:32][merkle_residue:u32_le] + // [timestamp:u32_le][sequence:u8][job_ctl:u8] + raw.reserve(46); + raw.put_u32(build_full_header( + *asic_hw_id, + Opcode::WriteJob, + *engine, + WRITEJOB_OFFSET, + )); + raw.extend_from_slice(midstate); + raw.put_u32_le(*merkle_residue); + raw.put_u32_le(*timestamp); + raw.put_u8(*sequence); + raw.put_u8(*job_ctl); + } + Self::Noop { asic_hw_id } => { + // NOOP command: + // [asic_hw_id][opcode<<4] + raw.reserve(2); + raw.put_u16(build_short_header(*asic_hw_id, Opcode::Noop)); + } + Self::ReadReg { + asic_hw_id, + engine, + offset, + count, + } => { + if !matches!(*count, 1 | 2 | 4) { + return Err(ProtocolError::InvalidReadRegCount(*count)); + } + + // READREG command + // [header:u32_be][count-1][TAR_BYTE] + raw.reserve(6); + raw.put_u32(build_full_header( + *asic_hw_id, + Opcode::ReadReg, + *engine, + *offset, + )); + raw.put_u8(count.saturating_sub(1)); + raw.put_u8(TAR_BYTE); + } + Self::WriteReg { + asic_hw_id, + engine, + offset, + value, + } => { + if value.is_empty() { + return Err(ProtocolError::EmptyWritePayload); + } + if value.len() > usize::from(u8::MAX) { + return Err(ProtocolError::WritePayloadTooLarge(value.len())); + } + + // WRITEREG command (no length prefix): + // [header:u32_be][count-1][data...] + raw.reserve(5 + value.len()); + raw.put_u32(build_full_header( + *asic_hw_id, + Opcode::WriteReg, + *engine, + *offset, + )); + raw.put_u8((value.len() as u8).saturating_sub(1)); + raw.extend_from_slice(value); + } + Self::MulticastWrite { + asic_hw_id, + group, + offset, + value, + } => { + if value.is_empty() { + return Err(ProtocolError::EmptyWritePayload); + } + if value.len() > usize::from(u8::MAX) { + return Err(ProtocolError::WritePayloadTooLarge(value.len())); + } + + raw.reserve(5 + value.len()); + raw.put_u32(build_full_header( + *asic_hw_id, + Opcode::MulticastWrite, + *group, + *offset, + )); + raw.put_u8((value.len() as u8).saturating_sub(1)); + raw.extend_from_slice(value); + } + } + + Ok(raw) + } +} + +/// Typed payload returned by a `READREG` response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReadRegData { + /// One-byte register payload. + U8(u8), + /// Two-byte little-endian register payload. + U16(u16), + /// Four-byte little-endian register payload. + U32(u32), +} + +/// Responses decoded from the BZM2 UART receive stream. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Response { + /// Response to a `NOOP` request. + Noop { asic_hw_id: u8, signature: [u8; 3] }, + /// Response to a `READREG` request. + ReadReg { asic_hw_id: u8, data: ReadRegData }, + /// Completed result from one engine. + ReadResult { + asic_hw_id: u8, + engine_id: u16, + status: u8, + nonce: u32, + sequence: u8, + timecode: u8, + }, + /// Temperature and voltage sensor telemetry. + DtsVs { asic_hw_id: u8, data: DtsVsData }, +} + +/// Decoded payload for the `DTS/VS` response family. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DtsVsData { + /// Generation-1 payload (`uart_dts_vs_msg`) represented as a big-endian `u32`. + Gen1(u32), + /// Generation-2 payload (`uart_gen2_dts_vs_msg`) represented as a big-endian `u64`. + Gen2(u64), +} + +/// BZM2 frame codec. +/// +/// Encoder emits 9-bit-translated TX bytes (`[data, flag]` pairs) using +/// `nine_bit_encode_frame`. Decoder expects plain 8-bit RX bytes in TDM mode. +#[derive(Debug, Clone)] +pub struct FrameCodec { + readreg_response_size: usize, + dts_vs_payload_size: Option, +} + +impl FrameCodec { + /// Create codec with explicit READREG response payload size (1, 2, or 4 bytes). + pub fn new(readreg_response_size: usize) -> Result { + if !matches!(readreg_response_size, 1 | 2 | 4) { + return Err(ProtocolError::UnsupportedReadRegResponseSize( + readreg_response_size, + )); + } + + Ok(Self { + readreg_response_size, + dts_vs_payload_size: None, + }) + } + + fn io_error(err: ProtocolError) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, err) + } +} + +impl Default for FrameCodec { + fn default() -> Self { + Self { + readreg_response_size: 4, + dts_vs_payload_size: None, + } + } +} + +impl FrameCodec { + const DTS_VS_GEN1_PAYLOAD_LEN: usize = 4; + const DTS_VS_GEN2_PAYLOAD_LEN: usize = 8; + + fn is_plausible_asic_hw_id(asic_hw_id: u8) -> bool { + asic_hw_id == BROADCAST_ASIC + || asic_hw_id == DEFAULT_ASIC_ID + || hw_to_logical_asic_id(asic_hw_id).is_some() + } + + fn response_opcode(opcode: u8) -> Option { + match opcode { + 0x01 => Some(Opcode::ReadResult), + 0x03 => Some(Opcode::ReadReg), + 0x0d => Some(Opcode::DtsVs), + 0x0f => Some(Opcode::Noop), + _ => None, + } + } + + fn is_plausible_response_header(buf: &[u8]) -> bool { + if buf.len() < 2 { + return false; + } + Self::is_plausible_asic_hw_id(buf[0]) && Self::response_opcode(buf[1]).is_some() + } + + fn echoed_tx_prefix_len(buf: &[u8]) -> Option { + if buf.len() < 6 || buf[1] != 0x01 { + return None; + } + + for offset in (4..=buf.len().saturating_sub(2)).step_by(2) { + if buf[offset - 1] != 0x00 { + return None; + } + + if Self::is_plausible_response_header(&buf[offset..]) { + return Some(offset); + } + } + + None + } +} + +impl Encoder for FrameCodec { + type Error = io::Error; + + fn encode(&mut self, item: Command, dst: &mut BytesMut) -> Result<(), Self::Error> { + let raw = item.encode_raw().map_err(Self::io_error)?; + let encoded = nine_bit_encode_frame(&raw); + tracing::debug!( + raw = %HexBytes(&raw), + encoded = %HexBytes(&encoded), + "BZM2 tx frame" + ); + dst.extend_from_slice(&encoded); + Ok(()) + } +} + +impl Decoder for FrameCodec { + type Item = Response; + type Error = io::Error; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + loop { + // Minimum frame is [asic_hw_id, opcode] + if src.len() < 2 { + return Ok(None); + } + + if let Some(prefix_len) = Self::echoed_tx_prefix_len(src) { + src.advance(prefix_len); + continue; + } + + let opcode = match Self::response_opcode(src[1]) { + Some(op) => op, + None => { + // Byte-level resync when stream is misaligned. + // tracing::debug!( + // dropped = format_args!("0x{:02X}", src[0]), + // next = format_args!("0x{:02X}", src[1]), + // "BZM2 rx resync: dropping byte" + // ); + src.advance(1); + continue; + } + }; + if !Self::is_plausible_asic_hw_id(src[0]) { + // tracing::debug!( + // dropped = format_args!("0x{:02X}", src[0]), + // next = format_args!("0x{:02X}", src[1]), + // "BZM2 rx resync: dropping byte" + // ); + src.advance(1); + continue; + } + + match opcode { + Opcode::Noop => { + if src.len() < 5 { + return Ok(None); + } + tracing::debug!(rx = %HexBytes(&src[..5]), "BZM2 rx NOOP frame"); + + let asic_hw_id = src[0]; + let signature = [src[2], src[3], src[4]]; + if signature != *NOOP_STRING { + // tracing::debug!( + // asic_hw_id, + // signature = %format_hex(&signature), + // buffer_len = src.len(), + // "BZM2 rx NOOP signature mismatch, resync by dropping one byte" + // ); + src.advance(1); + continue; + } + + src.advance(5); + return Ok(Some(Response::Noop { + asic_hw_id, + signature, + })); + } + Opcode::ReadReg => { + let frame_len = 2 + self.readreg_response_size; + if src.len() < frame_len { + return Ok(None); + } + tracing::debug!( + rx = %HexBytes(&src[..frame_len]), + "BZM2 rx READREG frame" + ); + + let mut frame = src.split_to(frame_len); + let asic_hw_id = frame.get_u8(); + let _opcode = frame.get_u8(); + let data = match self.readreg_response_size { + 1 => ReadRegData::U8(frame.get_u8()), + 2 => ReadRegData::U16(frame.get_u16_le()), + 4 => ReadRegData::U32(frame.get_u32_le()), + n => { + return Err(Self::io_error( + ProtocolError::UnsupportedReadRegResponseSize(n), + )); + } + }; + + return Ok(Some(Response::ReadReg { asic_hw_id, data })); + } + Opcode::ReadResult => { + const FRAME_LEN: usize = 10; // [asic:u8][opcode:u8][engine+status:2][nonce:4][sequence:1][time:1] + if src.len() < FRAME_LEN { + return Ok(None); + } + + let engine_status = u16::from_be_bytes([src[2], src[3]]); + // BIRDS/bzm2 layout packs [status:4 | engine_id:12] in network byte order. + let engine_id = engine_status & 0x0fff; + let status = ((engine_status >> 12) & 0x000f) as u8; + tracing::trace!(rx = %HexBytes(&src[..FRAME_LEN]), "BZM2 rx READRESULT frame"); + + let asic_hw_id = src[0]; + let nonce = u32::from_le_bytes([src[4], src[5], src[6], src[7]]); + let sequence = src[8]; + let timecode = src[9]; + src.advance(FRAME_LEN); + + return Ok(Some(Response::ReadResult { + asic_hw_id, + engine_id, + status, + nonce, + sequence, + timecode, + })); + } + Opcode::DtsVs => { + let gen1_frame_len = 2 + Self::DTS_VS_GEN1_PAYLOAD_LEN; + let gen2_frame_len = 2 + Self::DTS_VS_GEN2_PAYLOAD_LEN; + if src.len() < gen1_frame_len { + return Ok(None); + } + + let payload_len = if let Some(payload_len) = self.dts_vs_payload_size { + payload_len + } else { + // Don't lock to gen1 on a fragmented gen2 frame. + // Wait until we have enough bytes to disambiguate. + if src.len() < gen2_frame_len { + return Ok(None); + } + + let gen1_boundary_ok = + Self::is_plausible_response_header(&src[gen1_frame_len..]); + let gen2_boundary_ok = + Self::is_plausible_response_header(&src[gen2_frame_len..]); + + let chosen = match (gen1_boundary_ok, gen2_boundary_ok) { + (true, false) => Self::DTS_VS_GEN1_PAYLOAD_LEN, + (false, true) => Self::DTS_VS_GEN2_PAYLOAD_LEN, + // Prefer gen2 in ambiguous cases: this aligns with existing boards. + _ => Self::DTS_VS_GEN2_PAYLOAD_LEN, + }; + self.dts_vs_payload_size = Some(chosen); + chosen + }; + + let frame_len = 2 + payload_len; + if src.len() < frame_len { + return Ok(None); + } + // tracing::trace!( + // payload_len, + // rx = %format_hex(&src[..frame_len]), + // "BZM2 rx DTS/VS frame" + // ); + + let mut frame = src.split_to(frame_len); + let asic_hw_id = frame.get_u8(); + let _opcode = frame.get_u8(); + let data = match payload_len { + Self::DTS_VS_GEN1_PAYLOAD_LEN => DtsVsData::Gen1(frame.get_u32()), + Self::DTS_VS_GEN2_PAYLOAD_LEN => DtsVsData::Gen2(frame.get_u64()), + n => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unsupported DTS/VS payload size: {n}"), + )); + } + }; + + return Ok(Some(Response::DtsVs { asic_hw_id, data })); + } + _other => { + // tracing::debug!( + // opcode = format_args!("0x{:02X}", _other as u8), + // "BZM2 rx non-response opcode after header check, resync by dropping one byte" + // ); + src.advance(1); + continue; + } + } + } + } +} + +fn build_short_header(asic_hw_id: u8, opcode: Opcode) -> u16 { + ((asic_hw_id as u16) << 8) | ((opcode as u16) << 4) +} + +fn build_full_header(asic_hw_id: u8, opcode: Opcode, engine: u16, offset: u16) -> u32 { + ((asic_hw_id as u32) << 24) | ((opcode as u32) << 20) | ((engine as u32) << 8) | (offset as u32) +} + +/// Stateless command factory for BZM2 protocol operations. +/// +/// Callers that want a protocol-centric entry point can build common commands +/// through this type instead of constructing enum variants directly. +pub struct Bzm2Protocol; + +impl Default for Bzm2Protocol { + fn default() -> Self { + Self::new() + } +} + +impl Bzm2Protocol { + /// Create a new protocol helper. + pub fn new() -> Self { + Self + } + + /// Create a `NOOP` command for one ASIC. + pub fn noop(&self, asic_hw_id: u8) -> Command { + Command::Noop { asic_hw_id } + } + + /// Create a `READREG` command. + pub fn read_register(&self, asic_hw_id: u8, engine: u16, offset: u16, count: u8) -> Command { + Command::ReadReg { + asic_hw_id, + engine, + offset, + count, + } + } + + /// Create a four-byte `READREG` command for the `ASIC_ID` local register. + pub fn read_asic_id(&self, asic_hw_id: u8) -> Command { + self.read_register(asic_hw_id, NOTCH_REG, local_reg::ASIC_ID, 4) + } + + /// Create a `WRITEREG` command. + pub fn write_register( + &self, + asic_hw_id: u8, + engine: u16, + offset: u16, + value: Bytes, + ) -> Command { + Command::WriteReg { + asic_hw_id, + engine, + offset, + value, + } + } + + /// Create a multicast write command. + pub fn multicast_write( + &self, + asic_hw_id: u8, + group: u16, + offset: u16, + value: Bytes, + ) -> Command { + Command::MulticastWrite { + asic_hw_id, + group, + offset, + value, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_asic_id_translation() { + assert_eq!(logical_to_hw_asic_id(0), 10); + assert_eq!(logical_to_hw_asic_id(1), 20); + assert_eq!(hw_to_logical_asic_id(10), Some(0)); + assert_eq!(hw_to_logical_asic_id(20), Some(1)); + assert_eq!(hw_to_logical_asic_id(9), None); + assert_eq!(hw_to_logical_asic_id(11), None); + } + + #[test] + fn test_response_header_rejects_command_opcode() { + assert!(!FrameCodec::is_plausible_response_header(&[ + 0x0a, + Opcode::WriteReg as u8 + ])); + assert!(FrameCodec::is_plausible_response_header(&[ + 0x0a, + Opcode::ReadReg as u8 + ])); + } + + #[test] + fn test_encode_noop_frame() { + let cmd = Command::Noop { asic_hw_id: 0xfa }; + let raw = cmd.encode_raw().expect("encode should succeed"); + assert_eq!(raw.as_ref(), &[0xfa, 0xf0]); + + let mut codec = FrameCodec::default(); + let mut encoded = BytesMut::new(); + codec + .encode(cmd, &mut encoded) + .expect("encode should succeed"); + + assert_eq!(encoded.as_ref(), &[0xfa, 0x01, 0xf0, 0x00]); + } + + #[test] + fn test_encode_readreg_u32_frame() { + let cmd = Command::read_reg_u32(0x0a, NOTCH_REG, local_reg::ASIC_ID); + let raw = cmd.encode_raw().expect("encode should succeed"); + + // header = (0x0a << 24) | (0x3 << 20) | (0x0fff << 8) | 0x0b + assert_eq!(raw.as_ref(), &[0x0a, 0x3f, 0xff, 0x0b, 0x03, TAR_BYTE]); + } + + #[test] + fn test_encode_writereg_u32_frame() { + let cmd = Command::write_reg_u32_le(0x0a, NOTCH_REG, local_reg::UART_TX, 0x1234_5678); + let raw = cmd.encode_raw().expect("encode should succeed"); + + // count byte = 4 - 1 = 3 + assert_eq!( + raw.as_ref(), + &[0x0a, 0x2f, 0xff, 0x0a, 0x03, 0x78, 0x56, 0x34, 0x12,] + ); + } + + #[test] + fn test_encode_multicast_write_u8_frame() { + let cmd = Command::multicast_write_u8(0x0a, 0x0012, engine_reg::CONFIG, 0x04); + let raw = cmd.encode_raw().expect("encode should succeed"); + assert_eq!(raw.as_ref(), &[0x0a, 0x40, 0x12, 0x01, 0x00, 0x04]); + } + + #[test] + fn test_encode_writejob_single_midstate_frame() { + let mut midstate = [0u8; 32]; + for (i, byte) in midstate.iter_mut().enumerate() { + *byte = i as u8; + } + + let cmd = Command::write_job_single_midstate( + 0x0a, + 0x0123, + midstate, + 0x1122_3344, + 0x5566_7788, + 0xfe, + 0x03, + ); + + let raw = cmd.encode_raw().expect("encode should succeed"); + assert_eq!(&raw[..4], [0x0a, 0x01, 0x23, 0x29]); + assert_eq!(&raw[4..36], midstate); + assert_eq!(&raw[36..40], 0x1122_3344u32.to_le_bytes()); + assert_eq!(&raw[40..44], 0x5566_7788u32.to_le_bytes()); + assert_eq!(raw[44], 0xfe); + assert_eq!(raw[45], 0x03); + } + + #[test] + fn test_writejob_builds_four_commands() { + let mut midstates = [[0u8; 32]; 4]; + midstates[0][0] = 0x10; + midstates[1][0] = 0x20; + midstates[2][0] = 0x30; + midstates[3][0] = 0x40; + + let cmds = Command::write_job(0x0a, 0x0123, midstates, 0x1122_3344, 0x5566_7788, 0xff, 3) + .expect("writejob should build"); + + let raw0 = cmds[0].clone().encode_raw().expect("encode should succeed"); + let raw1 = cmds[1].clone().encode_raw().expect("encode should succeed"); + let raw2 = cmds[2].clone().encode_raw().expect("encode should succeed"); + let raw3 = cmds[3].clone().encode_raw().expect("encode should succeed"); + + assert_eq!(raw0[44], 4); + assert_eq!(raw1[44], 5); + assert_eq!(raw2[44], 6); + assert_eq!(raw3[44], 7); + assert_eq!(raw0[45], 0); + assert_eq!(raw1[45], 0); + assert_eq!(raw2[45], 0); + assert_eq!(raw3[45], 3); + assert_eq!(raw0[4], 0x10); + assert_eq!(raw1[4], 0x20); + assert_eq!(raw2[4], 0x30); + assert_eq!(raw3[4], 0x40); + } + + #[test] + fn test_writejob_rejects_invalid_job_ctl() { + let midstates = [[0u8; 32]; 4]; + let err = Command::write_job(0x0a, 0x0123, midstates, 0, 0, 0, 0x02) + .expect_err("invalid job_ctl should fail"); + assert!(matches!(err, ProtocolError::InvalidJobControl(0x02))); + } + + #[test] + fn test_decode_noop_response() { + let mut codec = FrameCodec::default(); + let mut src = BytesMut::from(&[0x0a, Opcode::Noop as u8, b'2', b'Z', b'B'][..]); + + let response = codec.decode(&mut src).expect("decode should succeed"); + assert_eq!( + response, + Some(Response::Noop { + asic_hw_id: 0x0a, + signature: *NOOP_STRING, + }) + ); + assert!(src.is_empty()); + } + + #[test] + fn test_decode_readreg_u32_response() { + let mut codec = FrameCodec::default(); + let mut src = BytesMut::from(&[0x0a, Opcode::ReadReg as u8, 0x78, 0x56, 0x34, 0x12][..]); + + let response = codec.decode(&mut src).expect("decode should succeed"); + assert_eq!( + response, + Some(Response::ReadReg { + asic_hw_id: 0x0a, + data: ReadRegData::U32(0x1234_5678), + }) + ); + assert!(src.is_empty()); + } + + #[test] + fn test_decode_readreg_u8_response() { + let mut codec = FrameCodec::new(1).expect("codec should construct"); + let mut src = BytesMut::from(&[0x0a, Opcode::ReadReg as u8, 0xab][..]); + + let response = codec.decode(&mut src).expect("decode should succeed"); + assert_eq!( + response, + Some(Response::ReadReg { + asic_hw_id: 0x0a, + data: ReadRegData::U8(0xab), + }) + ); + assert!(src.is_empty()); + } + + #[test] + fn test_decode_readreg_u16_response() { + let mut codec = FrameCodec::new(2).expect("codec should construct"); + let mut src = BytesMut::from(&[0x0a, Opcode::ReadReg as u8, 0x34, 0x12][..]); + + let response = codec.decode(&mut src).expect("decode should succeed"); + assert_eq!( + response, + Some(Response::ReadReg { + asic_hw_id: 0x0a, + data: ReadRegData::U16(0x1234), + }) + ); + assert!(src.is_empty()); + } + + #[test] + fn test_decode_readresult_response() { + let mut codec = FrameCodec::default(); + let mut src = BytesMut::from( + &[ + 0x0a, + Opcode::ReadResult as u8, + 0x40, + 0xe3, // engine_status: status=0x4, engine_id=0x0e3 + 0x78, + 0x56, + 0x34, + 0x12, // nonce LE + 0x07, // sequence + 0x2a, // timecode + ][..], + ); + + let response = codec.decode(&mut src).expect("decode should succeed"); + assert_eq!( + response, + Some(Response::ReadResult { + asic_hw_id: 0x0a, + engine_id: 0x0e3, + status: 0x4, + nonce: 0x1234_5678, + sequence: 0x07, + timecode: 0x2a, + }) + ); + assert!(src.is_empty()); + } + + #[test] + fn test_decode_resync_from_garbage() { + let mut codec = FrameCodec::default(); + let mut src = BytesMut::from(&[0xaa, 0xbb, 0x0a, Opcode::Noop as u8, b'2', b'Z', b'B'][..]); + + let response = codec.decode(&mut src).expect("decode should succeed"); + assert_eq!( + response, + Some(Response::Noop { + asic_hw_id: 0x0a, + signature: *NOOP_STRING, + }) + ); + assert!(src.is_empty()); + } + + #[test] + fn test_decode_resyncs_from_invalid_noop_signature() { + let mut codec = FrameCodec::default(); + let mut src = BytesMut::from( + &[ + 0x0a, + Opcode::Noop as u8, + 0xfd, + 0x7f, + 0x0a, // bogus NOOP-like frame + 0x0a, + Opcode::Noop as u8, + b'2', + b'Z', + b'B', // valid NOOP frame + ][..], + ); + + let response = codec.decode(&mut src).expect("decode should succeed"); + assert_eq!( + response, + Some(Response::Noop { + asic_hw_id: 0x0a, + signature: *NOOP_STRING, + }) + ); + assert!(src.is_empty()); + } + + #[test] + fn test_decode_resyncs_from_implausible_asic_id() { + let mut codec = FrameCodec::default(); + let mut src = BytesMut::from( + &[ + 0x6b, + Opcode::ReadResult as u8, + 0x8f, + 0xff, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, // bogus frame with impossible ASIC ID + 0x0a, + Opcode::Noop as u8, + b'2', + b'Z', + b'B', + ][..], + ); + + let response = codec.decode(&mut src).expect("decode should succeed"); + assert_eq!( + response, + Some(Response::Noop { + asic_hw_id: 0x0a, + signature: *NOOP_STRING, + }) + ); + assert!(src.is_empty()); + } + + #[test] + fn test_decode_accepts_high_readresult_engine_id() { + let mut codec = FrameCodec::default(); + let mut src = BytesMut::from( + &[ + 0x0a, + Opcode::ReadResult as u8, + 0x8f, + 0xff, // engine_id=0x0fff + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x0a, + Opcode::ReadResult as u8, + 0x80, + 0xe3, // engine_id=0x0e3, status=0x8 + 0x78, + 0x56, + 0x34, + 0x12, + 0x07, + 0x2a, + ][..], + ); + + let response = codec.decode(&mut src).expect("decode should succeed"); + assert_eq!( + response, + Some(Response::ReadResult { + asic_hw_id: 0x0a, + engine_id: 0x0fff, + status: 0x8, + nonce: 0x0000_0000, + sequence: 0x01, + timecode: 0x00, + }) + ); + + let response = codec.decode(&mut src).expect("decode should succeed"); + assert_eq!( + response, + Some(Response::ReadResult { + asic_hw_id: 0x0a, + engine_id: 0x0e3, + status: 0x8, + nonce: 0x1234_5678, + sequence: 0x07, + timecode: 0x2a, + }) + ); + assert!(src.is_empty()); + } + + #[test] + fn test_decode_drops_echoed_tx_pairs_before_noop() { + let mut codec = FrameCodec::default(); + + let echoed_raw = [0x0a, 0x2f, 0xff, 0x00, 0x00, 0xaa, 0xbb, 0xcc]; + let mut src = nine_bit_encode_frame(&echoed_raw); + src.extend_from_slice(&[0x0a, Opcode::Noop as u8, b'2', b'Z', b'B']); + + let response = codec.decode(&mut src).expect("decode should succeed"); + assert_eq!( + response, + Some(Response::Noop { + asic_hw_id: 0x0a, + signature: *NOOP_STRING, + }) + ); + assert!(src.is_empty()); + } + + #[test] + fn test_decode_realigns_and_drops_echoed_tx_pairs() { + let mut codec = FrameCodec::default(); + + let echoed_raw = [0xff, 0x40, 0x12, 0x01, 0x00, 0x04, 0x08]; + let mut src = BytesMut::from(&[0x99][..]); // misalignment byte + src.extend_from_slice(&nine_bit_encode_frame(&echoed_raw)); + src.extend_from_slice(&[0x0a, Opcode::Noop as u8, b'2', b'Z', b'B']); + + let response = codec.decode(&mut src).expect("decode should succeed"); + assert_eq!( + response, + Some(Response::Noop { + asic_hw_id: 0x0a, + signature: *NOOP_STRING, + }) + ); + assert!(src.is_empty()); + } + + #[test] + fn test_decode_dts_vs_gen1_before_noop() { + let mut codec = FrameCodec::default(); + let mut src = BytesMut::from( + &[ + 0x0a, + Opcode::DtsVs as u8, + 0x12, + 0x34, + 0x56, + 0x78, + 0x0a, + Opcode::Noop as u8, + b'2', + b'Z', + b'B', + ][..], + ); + + let response = codec.decode(&mut src).expect("decode should succeed"); + assert_eq!( + response, + Some(Response::DtsVs { + asic_hw_id: 0x0a, + data: DtsVsData::Gen1(0x1234_5678), + }) + ); + + let response = codec.decode(&mut src).expect("decode should succeed"); + assert_eq!( + response, + Some(Response::Noop { + asic_hw_id: 0x0a, + signature: *NOOP_STRING, + }) + ); + assert!(src.is_empty()); + } + + #[test] + fn test_decode_dts_vs_gen2_response() { + let mut codec = FrameCodec::default(); + let mut src = BytesMut::from( + &[ + 0x0a, + Opcode::DtsVs as u8, + 0x01, + 0x02, + 0x03, + 0x04, + 0x05, + 0x06, + 0x07, + 0x08, + ][..], + ); + + let response = codec.decode(&mut src).expect("decode should succeed"); + assert_eq!( + response, + Some(Response::DtsVs { + asic_hw_id: 0x0a, + data: DtsVsData::Gen2(0x0102_0304_0506_0708), + }) + ); + assert!(src.is_empty()); + } + + #[test] + fn test_decode_dts_vs_gen2_fragmented_does_not_lock_gen1() { + let mut codec = FrameCodec::default(); + let mut src = BytesMut::from(&[0x0a, Opcode::DtsVs as u8, 0x01, 0x02, 0x03, 0x04][..]); + + assert!( + codec + .decode(&mut src) + .expect("decode should succeed") + .is_none() + ); + + src.extend_from_slice(&[0x05, 0x06, 0x07, 0x08]); + let response = codec.decode(&mut src).expect("decode should succeed"); + assert_eq!( + response, + Some(Response::DtsVs { + asic_hw_id: 0x0a, + data: DtsVsData::Gen2(0x0102_0304_0506_0708), + }) + ); + assert!(src.is_empty()); + } +} diff --git a/mujina-miner/src/asic/bzm2/thread.rs b/mujina-miner/src/asic/bzm2/thread.rs new file mode 100644 index 0000000..223ecd0 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/thread.rs @@ -0,0 +1,823 @@ +//! BZM2 HashThread implementation. +//! +//! This module uses an actor-style `HashThread` implementation and performs +//! full BZM2 bring-up before the first task is accepted. +//! +//! A `Bzm2Thread` represents the hashing worker for one BIRDS board data path. +//! It is responsible for: +//! - asserting and releasing ASIC reset through board-provided peripherals +//! - programming the chip register set needed for mining +//! - translating scheduler work into BZM2 micro-jobs +//! - validating returned results before forwarding shares upstream + +use std::{ + io, + sync::{Arc, RwLock}, +}; + +use async_trait::async_trait; +use futures::{sink::Sink, stream::Stream}; +use tokio::sync::{mpsc, oneshot, watch}; +use tokio::time::{self, Duration}; +use tokio_stream::StreamExt; + +use super::protocol; +use crate::{ + asic::hash_thread::{ + BoardPeripherals, HashTask, HashThread, HashThreadCapabilities, HashThreadError, + HashThreadEvent, HashThreadStatus, Share, ThreadRemovalSignal, + }, + tracing::prelude::*, + types::{Difficulty, HashRate}, +}; +use bringup::{initialize_chip, write_reg_u8, write_reg_u32}; +use hashing::{Bzm2CheckResult, task_midstate_versions}; +#[cfg(test)] +use hashing::{ + bzm2_double_sha_from_midstate_and_tail, bzm2_tail16_bytes, check_result, hash_bytes_bzm2_order, +}; +use tracker::{AssignmentTracker, SelectedReadResultCandidate}; +use work::{AssignedTask, is_invalid_engine, logical_engine_index, send_task_to_all_engines}; +#[cfg(test)] +use work::{EngineAssignment, task_to_bzm2_payload}; + +mod bringup; +mod hashing; +mod tracker; +mod work; + +const ENGINE_ROWS: u16 = 20; +const ENGINE_COLS: u16 = 12; +// Invalid or non-existent engine coordinates. +const INVALID_ENGINE_0_ROW: u16 = 0; +const INVALID_ENGINE_0_COL: u16 = 4; +const INVALID_ENGINE_1_ROW: u16 = 0; +const INVALID_ENGINE_1_COL: u16 = 5; +const INVALID_ENGINE_2_ROW: u16 = 19; +const INVALID_ENGINE_2_COL: u16 = 5; +const INVALID_ENGINE_3_ROW: u16 = 19; +const INVALID_ENGINE_3_COL: u16 = 11; +const INVALID_ENGINE_COUNT: usize = 4; +const WORK_ENGINE_COUNT: usize = + (ENGINE_ROWS as usize * ENGINE_COLS as usize) - INVALID_ENGINE_COUNT; +const ENGINE_EN2_OFFSET_START: u64 = 1; + +const SENSOR_REPORT_INTERVAL: u32 = 63; +const THERMAL_TRIP_C: f32 = 115.0; +const VOLTAGE_TRIP_MV: f32 = 500.0; + +const PLL_LOCK_MASK: u32 = 0x4; +const REF_CLK_MHZ: f32 = 50.0; +const REF_DIVIDER: u32 = 2; +const POST2_DIVIDER: u32 = 1; +const POST1_DIVIDER: u8 = 1; +const TARGET_FREQ_MHZ: f32 = 800.0; +const DRIVE_STRENGTH_STRONG: u32 = 0x4446_4444; +const ENGINE_CONFIG_ENHANCED_MODE_BIT: u8 = 1 << 2; + +const INIT_NOOP_TIMEOUT: Duration = Duration::from_millis(500); +const INIT_READREG_TIMEOUT: Duration = Duration::from_millis(500); +const PLL_LOCK_TIMEOUT: Duration = Duration::from_secs(3); +const PLL_POLL_DELAY: Duration = Duration::from_millis(100); +const SOFT_RESET_DELAY: Duration = Duration::from_millis(1); +const MIDSTATE_COUNT: usize = 4; +const WRITEJOB_CTL_REPLACE: u8 = 3; +const MIN_LEADING_ZEROS: u8 = 32; +const ENGINE_LEADING_ZEROS: u8 = 36; +const ENGINE_ZEROS_TO_FIND: u8 = ENGINE_LEADING_ZEROS - MIN_LEADING_ZEROS; +// Timestamp register uses bit7 for AUTO_CLOCK_UNGATE, so max counter value is 0x7f. +const ENGINE_TIMESTAMP_COUNT: u8 = 0x7f; +const AUTO_CLOCK_UNGATE: u8 = 1; +// Runtime nonce gap value. +const BZM2_NONCE_MINUS: u32 = 0x4c; +// Per-ASIC nonce assignment: each active ASIC searches the full +// nonce space (except 0xffff_ffff). +const BZM2_START_NONCE: u32 = 0x0000_0000; +const BZM2_END_NONCE: u32 = 0xffff_fffe; +const READRESULT_SEQUENCE_SPACE: usize = 64; // sequence byte carries 4 micro-jobs => 6 visible sequence bits +const READRESULT_SLOT_HISTORY: usize = 16; +const READRESULT_ASSIGNMENT_HISTORY_LIMIT: usize = + READRESULT_SEQUENCE_SPACE * READRESULT_SLOT_HISTORY; +const ZERO_LZ_DIAGNOSTIC_LIMIT: u64 = 24; + +#[derive(Debug)] +enum ThreadCommand { + UpdateTask { + new_task: HashTask, + response_tx: oneshot::Sender, HashThreadError>>, + }, + ReplaceTask { + new_task: HashTask, + response_tx: oneshot::Sender, HashThreadError>>, + }, + GoIdle { + response_tx: oneshot::Sender, HashThreadError>>, + }, + #[expect(unused)] + Shutdown, +} + +/// `HashThread` wrapper for a BZM2 board worker. +/// +/// This is a thin handle around a spawned actor task. The actor owns the +/// serial transport and emits [`HashThreadEvent`] updates as it initializes +/// the ASICs and processes work. +pub struct Bzm2Thread { + name: String, + command_tx: mpsc::Sender, + event_rx: Option>, + capabilities: HashThreadCapabilities, + status: Arc>, +} + +impl Bzm2Thread { + /// Create a new BZM2 hashing worker. + /// + /// The thread starts in an uninitialized state. Hardware bring-up happens + /// lazily when the first task is assigned so board discovery can complete + /// without immediately programming the ASICs. + pub fn new( + name: String, + chip_responses: R, + chip_commands: W, + peripherals: BoardPeripherals, + removal_rx: watch::Receiver, + asic_count: u8, + ) -> Self + where + R: Stream> + Unpin + Send + 'static, + W: Sink + Unpin + Send + 'static, + W::Error: std::fmt::Debug, + { + let (cmd_tx, cmd_rx) = mpsc::channel(10); + let (evt_tx, evt_rx) = mpsc::channel(100); + + let status = Arc::new(RwLock::new(HashThreadStatus::default())); + let status_clone = Arc::clone(&status); + + tokio::spawn(async move { + bzm2_thread_actor(Bzm2ThreadActor { + cmd_rx, + evt_tx, + removal_rx, + status: status_clone, + chip_responses, + chip_commands, + peripherals, + asic_count, + }) + .await; + }); + + Self { + name, + command_tx: cmd_tx, + event_rx: Some(evt_rx), + capabilities: HashThreadCapabilities { + hashrate_estimate: HashRate::from_terahashes(1.0), // Stub + }, + status, + } + } +} + +#[async_trait] +impl HashThread for Bzm2Thread { + fn name(&self) -> &str { + &self.name + } + + fn capabilities(&self) -> &HashThreadCapabilities { + &self.capabilities + } + + async fn update_task( + &mut self, + new_task: HashTask, + ) -> std::result::Result, HashThreadError> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::UpdateTask { + new_task, + response_tx, + }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + + response_rx + .await + .map_err(|_| HashThreadError::WorkAssignmentFailed("no response from thread".into()))? + } + + async fn replace_task( + &mut self, + new_task: HashTask, + ) -> std::result::Result, HashThreadError> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::ReplaceTask { + new_task, + response_tx, + }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + + response_rx + .await + .map_err(|_| HashThreadError::WorkAssignmentFailed("no response from thread".into()))? + } + + async fn go_idle(&mut self) -> std::result::Result, HashThreadError> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::GoIdle { response_tx }) + .await + .map_err(|_| HashThreadError::ChannelClosed("command channel closed".into()))?; + + response_rx + .await + .map_err(|_| HashThreadError::WorkAssignmentFailed("no response from thread".into()))? + } + + fn take_event_receiver(&mut self) -> Option> { + self.event_rx.take() + } + + fn status(&self) -> HashThreadStatus { + self.status.read().expect("status lock poisoned").clone() + } +} + +struct Bzm2ThreadActor { + cmd_rx: mpsc::Receiver, + evt_tx: mpsc::Sender, + removal_rx: watch::Receiver, + status: Arc>, + chip_responses: R, + chip_commands: W, + peripherals: BoardPeripherals, + asic_count: u8, +} + +#[derive(Clone, Copy)] +enum TaskAssignmentMode { + Update, + Replace, +} + +impl TaskAssignmentMode { + fn transition_message(self, had_old_task: bool) -> &'static str { + match (self, had_old_task) { + (Self::Update, true) => "Updating work", + (Self::Update, false) => "Updating work from idle", + (Self::Replace, true) => "Replacing work", + (Self::Replace, false) => "Replacing work from idle", + } + } + + fn send_failure_context(self) -> &'static str { + match self { + Self::Update => "update_task", + Self::Replace => "replace_task", + } + } + + fn sent_message(self) -> &'static str { + match self { + Self::Update => "Sent BZM2 work to chip", + Self::Replace => "Sent BZM2 work to chip (old work invalidated)", + } + } +} + +async fn assign_task( + chip_responses: &mut R, + chip_commands: &mut W, + peripherals: &mut BoardPeripherals, + asic_count: u8, + chip_initialized: &mut bool, + current_task: &mut Option, + assignment_tracker: &mut AssignmentTracker, + status: &Arc>, + new_task: HashTask, + mode: TaskAssignmentMode, +) -> Result, HashThreadError> +where + R: Stream> + Unpin, + W: Sink + Unpin, + W::Error: std::fmt::Debug, +{ + if let Some(old) = current_task.as_ref() { + debug!( + old_job = %old.template.id, + new_job = %new_task.template.id, + "{}", + mode.transition_message(true) + ); + } else { + debug!( + new_job = %new_task.template.id, + "{}", + mode.transition_message(false) + ); + } + + if !*chip_initialized { + match initialize_chip(chip_responses, chip_commands, peripherals, asic_count).await { + Ok(ids) => { + *chip_initialized = true; + info!(asic_ids = ?ids, "BZM2 initialization completed"); + } + Err(e) => { + error!(error = %e, "BZM2 chip initialization failed"); + return Err(e); + } + } + } + + let microjob_versions = task_midstate_versions(&new_task); + let write_sequence_id = assignment_tracker.current_write_sequence_id(); + + let engine_assignments = send_task_to_all_engines( + chip_commands, + &new_task, + microjob_versions, + write_sequence_id, + ENGINE_ZEROS_TO_FIND, + ENGINE_TIMESTAMP_COUNT, + ) + .await + .map_err(|e| { + error!( + error = %e, + command = mode.send_failure_context(), + "Failed to send BZM2 work" + ); + e + })?; + + let Some(default_assignment) = engine_assignments.first().cloned() else { + let e = HashThreadError::WorkAssignmentFailed(format!( + "no engine assignments produced for {}", + mode.send_failure_context() + )); + error!( + error = %e, + command = mode.send_failure_context(), + "Failed to send BZM2 work" + ); + return Err(e); + }; + + let new_assigned_task = AssignedTask { + task: new_task.clone(), + merkle_root: default_assignment.merkle_root, + engine_assignments: Arc::from(engine_assignments.into_boxed_slice()), + microjob_versions, + sequence_id: write_sequence_id, + timestamp_count: ENGINE_TIMESTAMP_COUNT, + leading_zeros: ENGINE_LEADING_ZEROS, + nonce_minus_value: BZM2_NONCE_MINUS, + }; + assignment_tracker.retain(new_assigned_task); + + debug!( + job_id = %new_task.template.id, + write_sequence_id, + "{}", + mode.sent_message() + ); + assignment_tracker.advance_sequence(); + + let old_task = current_task.replace(new_task); + { + let mut s = status.write().expect("status lock poisoned"); + s.is_active = true; + } + + Ok(old_task) +} + +async fn bzm2_thread_actor(actor: Bzm2ThreadActor) +where + R: Stream> + Unpin, + W: Sink + Unpin, + W::Error: std::fmt::Debug, +{ + let Bzm2ThreadActor { + mut cmd_rx, + evt_tx, + mut removal_rx, + status, + mut chip_responses, + mut chip_commands, + mut peripherals, + asic_count, + } = actor; + + if let Some(ref mut asic_enable) = peripherals.asic_enable + && let Err(e) = asic_enable.disable().await + { + warn!(error = %e, "Failed to disable BZM2 ASIC on thread startup"); + } + + let mut chip_initialized = false; + let mut current_task: Option = None; + let mut assignment_tracker = AssignmentTracker::new(); + let mut zero_lz_diagnostic_samples: u64 = 0; + let mut status_ticker = time::interval(Duration::from_secs(5)); + status_ticker.set_missed_tick_behavior(time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = removal_rx.changed() => { + let signal = removal_rx.borrow().clone(); + if signal != ThreadRemovalSignal::Running { + { + let mut s = status.write().expect("status lock poisoned"); + s.is_active = false; + } + break; + } + } + + Some(cmd) = cmd_rx.recv() => { + match cmd { + ThreadCommand::UpdateTask { new_task, response_tx } => { + let result = assign_task( + &mut chip_responses, + &mut chip_commands, + &mut peripherals, + asic_count, + &mut chip_initialized, + &mut current_task, + &mut assignment_tracker, + &status, + new_task, + TaskAssignmentMode::Update, + ) + .await; + let _ = response_tx.send(result); + } + ThreadCommand::ReplaceTask { new_task, response_tx } => { + let result = assign_task( + &mut chip_responses, + &mut chip_commands, + &mut peripherals, + asic_count, + &mut chip_initialized, + &mut current_task, + &mut assignment_tracker, + &status, + new_task, + TaskAssignmentMode::Replace, + ) + .await; + let _ = response_tx.send(result); + } + ThreadCommand::GoIdle { response_tx } => { + debug!("Going idle"); + + let old_task = current_task.take(); + assignment_tracker.clear(); + { + let mut s = status.write().expect("status lock poisoned"); + s.is_active = false; + } + let _ = response_tx.send(Ok(old_task)); + } + ThreadCommand::Shutdown => { + info!("Shutdown command received"); + break; + } + } + } + + Some(result) = chip_responses.next() => { + match result { + Ok(protocol::Response::Noop { .. }) => {} + Ok(protocol::Response::ReadReg { .. }) => {} + Ok(protocol::Response::DtsVs { asic_hw_id, data }) => { + // Temporarily suppress noisy DTS/VS logging while debugging share flow. + let _ = (asic_hw_id, data); + } + Ok(protocol::Response::ReadResult { + asic_hw_id, + engine_id, + status: result_status, + nonce, + sequence, + timecode, + }) => { + // status bit3 indicates a valid nonce candidate. + if (result_status & 0x8) == 0 { + continue; + } + + let row = engine_id & 0x3f; + let column = engine_id >> 6; + if row >= ENGINE_ROWS || column >= ENGINE_COLS { + continue; + } + if is_invalid_engine(row, column) { + continue; + } + let Some(logical_engine_id) = logical_engine_index(row, column) else { + continue; + }; + + let nonce_raw = nonce; + let Some(SelectedReadResultCandidate { + assigned, + sequence_id, + micro_job_id, + timecode_effective, + slot_candidate_count, + share_version, + ntime_offset, + share_ntime, + nonce_adjusted, + nonce_submit, + hash_bytes, + hash, + check_result, + observed_leading_zeros, + }) = assignment_tracker + .resolve_candidate(logical_engine_id, sequence, timecode, nonce_raw) + else { + continue; + }; + + if check_result == Bzm2CheckResult::Error + && observed_leading_zeros == 0 + && zero_lz_diagnostic_samples < ZERO_LZ_DIAGNOSTIC_LIMIT + { + zero_lz_diagnostic_samples = + zero_lz_diagnostic_samples.saturating_add(1); + warn!( + asic_hw_id, + engine_hw_id = engine_id, + logical_engine_id, + sequence_id, + matched_sequence_id = assigned.sequence_id, + micro_job_id, + timecode_effective = format_args!("{:#04x}", timecode_effective), + slot_candidate_count, + nonce_raw = format_args!("{:#010x}", nonce_raw), + nonce_adjusted = format_args!("{:#010x}", nonce_adjusted), + nonce_submit = format_args!("{:#010x}", nonce_submit), + nonce_minus_value = format_args!("{:#x}", assigned.nonce_minus_value), + ntime_offset, + ntime = format_args!("{:#010x}", share_ntime), + version = format_args!("{:#010x}", share_version.to_consensus() as u32), + observed_leading_zeros_bits = observed_leading_zeros, + required_leading_zeros_bits = assigned.leading_zeros, + hash_msb = format_args!("{:#04x}", hash_bytes[31]), + "BZM2 READRESULT valid-flag nonce reconstructed with zero leading zeros" + ); + } + + if check_result == Bzm2CheckResult::Correct { + let share = Share { + nonce: nonce_submit, + hash, + version: share_version, + ntime: share_ntime, + extranonce2: assigned.task.en2, + expected_work: assigned.task.share_target.to_work(), + }; + + if assigned.task.share_tx.send(share).await.is_err() { + debug!("Share channel closed (task replaced)"); + } else { + let mut s = status.write().expect("status lock poisoned"); + s.chip_shares_found = s.chip_shares_found.saturating_add(1); + } + } + } + Err(e) => { + warn!(error = %e, "Error reading BZM2 response stream"); + } + } + } + + _ = status_ticker.tick() => { + let snapshot = status.read().expect("status lock poisoned").clone(); + let _ = evt_tx.send(HashThreadEvent::StatusUpdate(snapshot)).await; + } + } + } + + debug!("BZM2 thread actor exiting"); +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use bitcoin::block::Header as BlockHeader; + use bytes::BytesMut; + use serde_json::json; + use tokio::sync::mpsc; + use tokio_util::codec::Decoder as _; + + use crate::{ + asic::hash_thread::HashTask, + job_source::{ + Extranonce2, Extranonce2Range, GeneralPurposeBits, JobTemplate, MerkleRootKind, + MerkleRootTemplate, VersionTemplate, + }, + stratum_v1::JobNotification, + types::Difficulty, + }; + + use super::{ + AssignedTask, BZM2_NONCE_MINUS, Bzm2CheckResult, ENGINE_LEADING_ZEROS, + ENGINE_TIMESTAMP_COUNT, EngineAssignment, MIDSTATE_COUNT, WORK_ENGINE_COUNT, + bzm2_double_sha_from_midstate_and_tail, bzm2_tail16_bytes, check_result, + hash_bytes_bzm2_order, protocol, task_midstate_versions, task_to_bzm2_payload, + }; + + #[test] + fn test_readresult_hash_check_with_known_good_bzm2_share() { + // Job + accepted share captured from known working messages + // - notify: job_id=18965aa3c6b2c4cf, ntime=0x699a9733, version mask 0x1fffe000 + // - accepted submit: en2=7200000000000000, ntime=699a9735, nonce=1c1a2bff, vmask=1fff0000 + let notify_params = json!([ + "18965aa3c6b2c4cf", + "fe207277906478ce38c2ea1089c75d1da29c36ff0000a8a70000000000000000", + "02000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2c03304f0e01000438979a69041dd270030c", + "0e6879647261706f6f6c2f32353666ffffffff02e575a31200000000160014c64b1b9283ba1ea86bb9e7b696b0c8f68dad04000000000000000000266a24aa21a9ed413814acda23cadaad2f189d0dd7794ab6892d1eaad4b1a1433156a31ccb62a800000000", + [ + "be51038f82c6f95e407ff56a88a85e179935927e20ec26994e453c858c52b2d5", + "41f1b3ef96540488c96e6a53ca5156541082ab6d670e87069d84ca600fe32323", + "ef3b47f15c4e98960b53cbd23c6bc6ce29ffcfa6d5c23b869db0a8e5699e7b0d", + "48653d2575674cfd6417dee08bafd2de5246ff615c8b3af9829d21d972ad4e73", + "2013f4b7781327c760228203e073a252ed48547770c7033fb283e521dbf062d2", + "a83041e9c9bdc76e5fe2be707c6b114d6f33a4e42632fe8d79f1015e1a0c8caf", + "8f136aca72f1f36a1e7ac1a40b3a2dd0cf7fc8e36be6a8c1f520933b1511cdf0", + "93dc2365dce4dece9d317654715c0a7bcfa6a175afba9693199dd0dacb9bab15", + "3f11ffc73e9f01af072a495c47b03bec824eeab3fc7e92e1f52907d16516764d" + ], + "20000000", + "1701f303", + "699a9733", + true + ]); + let job = JobNotification::from_stratum_params( + notify_params + .as_array() + .expect("notify_params must be an array"), + ) + .expect("notify params should parse"); + + let en2_size = 8u8; + let en2_bytes = hex::decode("7200000000000000").expect("en2 hex should parse"); + let en2_value = + u64::from_le_bytes(en2_bytes.as_slice().try_into().expect("en2 size must be 8")); + let en2 = Extranonce2::new(en2_value, en2_size).expect("en2 should construct"); + + let template = Arc::new(JobTemplate { + id: job.job_id, + prev_blockhash: job.prev_hash, + version: VersionTemplate::new( + job.version, + GeneralPurposeBits::from(&0x1fffe000u32.to_be_bytes()), + ) + .expect("version template should construct"), + bits: job.nbits, + share_target: Difficulty::from(1000u64).to_target(), + time: job.ntime, + merkle_root: MerkleRootKind::Computed(MerkleRootTemplate { + coinbase1: job.coinbase1, + extranonce1: hex::decode("e1a253ac").expect("extranonce1 hex should parse"), + extranonce2_range: Extranonce2Range::new(en2_size) + .expect("en2 range should construct"), + coinbase2: job.coinbase2, + merkle_branches: job.merkle_branches, + }), + }); + + let (share_tx, _share_rx) = mpsc::channel(1); + let task = HashTask { + template: Arc::clone(&template), + en2_range: Some(Extranonce2Range::new(en2_size).expect("en2 range should construct")), + en2: Some(en2), + share_target: Difficulty::from(1000u64).to_target(), + ntime: template.time, + share_tx, + }; + + let merkle_root = template + .compute_merkle_root(&en2) + .expect("merkle root should compute"); + let microjob_versions = task_midstate_versions(&task); + let payload = task_to_bzm2_payload(&task, merkle_root, microjob_versions) + .expect("payload should derive"); + let engine_assignments = vec![ + EngineAssignment { + merkle_root, + extranonce2: task.en2, + midstates: payload, + }; + WORK_ENGINE_COUNT + ]; + let assigned = AssignedTask { + task, + merkle_root, + engine_assignments: Arc::from(engine_assignments.into_boxed_slice()), + microjob_versions, + sequence_id: 0, + timestamp_count: ENGINE_TIMESTAMP_COUNT, + leading_zeros: ENGINE_LEADING_ZEROS, + nonce_minus_value: BZM2_NONCE_MINUS, + }; + + // Reconstruct an on-wire READRESULT frame for the accepted share: + // status=0x8 (valid), engine_id=0x001, sequence=2 (micro-job 2), timecode=0x3a. + // READRESULT adjusted nonce is byte-swapped before Stratum submit. + let expected_nonce_submit = 0x1c1a_2bffu32; + let expected_nonce_adjusted = expected_nonce_submit.swap_bytes(); + let expected_ntime = 0x699a_9735u32; + let expected_version = 0x3fff_0000u32; + let ntime_delta = expected_ntime.wrapping_sub(assigned.task.ntime); + assert_eq!( + ntime_delta, 2, + "test fixture ntime delta must match capture" + ); + + let raw_nonce = expected_nonce_adjusted.wrapping_add(BZM2_NONCE_MINUS); + let raw_frame = [ + 0x0a, + protocol::Opcode::ReadResult as u8, + 0x80, + 0x01, + (raw_nonce & 0xff) as u8, + ((raw_nonce >> 8) & 0xff) as u8, + ((raw_nonce >> 16) & 0xff) as u8, + ((raw_nonce >> 24) & 0xff) as u8, + 0x02, + ENGINE_TIMESTAMP_COUNT.wrapping_sub(ntime_delta as u8), + ]; + + let mut codec = protocol::FrameCodec::default(); + let mut src = BytesMut::from(&raw_frame[..]); + let response = codec + .decode(&mut src) + .expect("decode should succeed") + .expect("frame should decode"); + + let protocol::Response::ReadResult { + engine_id, + status, + nonce: nonce_raw, + sequence, + timecode, + .. + } = response + else { + panic!("expected READRESULT response"); + }; + assert_eq!(engine_id, 0x001); + assert_eq!(status, 0x8); + + let sequence_id = sequence / (MIDSTATE_COUNT as u8); + let micro_job_id = sequence % (MIDSTATE_COUNT as u8); + assert_eq!(sequence_id, assigned.sequence_id); + + let share_version = assigned.microjob_versions[micro_job_id as usize]; + let ntime_offset = u32::from(assigned.timestamp_count.wrapping_sub(timecode)); + let share_ntime = assigned.task.ntime.wrapping_add(ntime_offset); + let nonce_adjusted = nonce_raw.wrapping_sub(assigned.nonce_minus_value); + let nonce_submit = nonce_adjusted.swap_bytes(); + + assert_eq!(share_version.to_consensus() as u32, expected_version); + assert_eq!(share_ntime, expected_ntime); + assert_eq!(nonce_adjusted, expected_nonce_adjusted); + assert_eq!(nonce_submit, expected_nonce_submit); + + let header = BlockHeader { + version: share_version, + prev_blockhash: assigned.task.template.prev_blockhash, + merkle_root: assigned.merkle_root, + time: share_ntime, + bits: assigned.task.template.bits, + nonce: nonce_submit, + }; + let hash = header.block_hash(); + let hash_bytes = hash_bytes_bzm2_order(&hash); + let tail16 = bzm2_tail16_bytes(&assigned, share_ntime, nonce_submit); + let bzm2_hash_bytes = bzm2_double_sha_from_midstate_and_tail( + &assigned.engine_assignments[0].midstates[micro_job_id as usize], + &tail16, + ); + let target_bytes = assigned.task.share_target.to_le_bytes(); + + assert_eq!(hash_bytes, bzm2_hash_bytes); + assert_eq!( + check_result(&hash_bytes, &target_bytes, assigned.leading_zeros), + Bzm2CheckResult::Correct + ); + assert!(assigned.task.share_target.is_met_by(hash)); + } +} diff --git a/mujina-miner/src/asic/bzm2/thread/bringup.rs b/mujina-miner/src/asic/bzm2/thread/bringup.rs new file mode 100644 index 0000000..7ab0f9a --- /dev/null +++ b/mujina-miner/src/asic/bzm2/thread/bringup.rs @@ -0,0 +1,767 @@ +use std::io; + +use futures::{SinkExt, sink::Sink, stream::Stream}; +use tokio::time::{self, Duration, Instant}; +use tokio_stream::StreamExt; + +use crate::{ + asic::hash_thread::{BoardPeripherals, HashThreadError}, + tracing::prelude::*, +}; + +use super::{ + BZM2_END_NONCE, BZM2_START_NONCE, DRIVE_STRENGTH_STRONG, ENGINE_COLS, + ENGINE_CONFIG_ENHANCED_MODE_BIT, ENGINE_ROWS, INIT_NOOP_TIMEOUT, INIT_READREG_TIMEOUT, + PLL_LOCK_MASK, PLL_LOCK_TIMEOUT, PLL_POLL_DELAY, POST1_DIVIDER, POST2_DIVIDER, REF_CLK_MHZ, + REF_DIVIDER, SENSOR_REPORT_INTERVAL, SOFT_RESET_DELAY, TARGET_FREQ_MHZ, THERMAL_TRIP_C, + VOLTAGE_TRIP_MV, protocol, work::engine_id, work::is_invalid_engine, +}; + +fn init_failed(msg: impl Into) -> HashThreadError { + HashThreadError::InitializationFailed(msg.into()) +} + +async fn send_command( + chip_commands: &mut W, + command: protocol::Command, + context: &str, +) -> Result<(), HashThreadError> +where + W: Sink + Unpin, + W::Error: std::fmt::Debug, +{ + chip_commands + .send(command) + .await + .map_err(|e| init_failed(format!("{context}: {e:?}"))) +} + +async fn drain_input(chip_responses: &mut R) +where + R: Stream> + Unpin, +{ + while let Ok(Some(_)) = time::timeout(Duration::from_millis(20), chip_responses.next()).await {} +} + +async fn wait_for_noop( + chip_responses: &mut R, + expected_asic_id: u8, + timeout: Duration, +) -> Result<(), HashThreadError> +where + R: Stream> + Unpin, +{ + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(init_failed(format!( + "timeout waiting for NOOP response from ASIC 0x{expected_asic_id:02x}" + ))); + } + + match time::timeout(remaining, chip_responses.next()).await { + Ok(Some(Ok(protocol::Response::Noop { asic_hw_id, .. }))) + if asic_hw_id == expected_asic_id => + { + return Ok(()); + } + Ok(Some(Ok(_))) => continue, + Ok(Some(Err(e))) => { + return Err(init_failed(format!("failed while waiting for NOOP: {e}"))); + } + Ok(None) => { + return Err(init_failed("response stream closed while waiting for NOOP")); + } + Err(_) => { + return Err(init_failed(format!( + "timeout waiting for NOOP response from ASIC 0x{expected_asic_id:02x}" + ))); + } + } + } +} + +async fn read_reg_u32( + chip_responses: &mut R, + chip_commands: &mut W, + asic_id: u8, + engine: u16, + offset: u16, + timeout: Duration, + context: &str, +) -> Result +where + R: Stream> + Unpin, + W: Sink + Unpin, + W::Error: std::fmt::Debug, +{ + send_command( + chip_commands, + protocol::Command::read_reg_u32(asic_id, engine, offset), + context, + ) + .await?; + + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(init_failed(format!( + "{context}: timeout waiting for READREG response" + ))); + } + + match time::timeout(remaining, chip_responses.next()).await { + Ok(Some(Ok(protocol::Response::ReadReg { asic_hw_id, data }))) + if asic_hw_id == asic_id => + { + return match data { + protocol::ReadRegData::U32(value) => Ok(value), + protocol::ReadRegData::U16(value) => Ok(value as u32), + protocol::ReadRegData::U8(value) => Ok(value as u32), + }; + } + Ok(Some(Ok(_))) => continue, + Ok(Some(Err(e))) => { + return Err(init_failed(format!("{context}: stream read error: {e}"))); + } + Ok(None) => { + return Err(init_failed(format!("{context}: response stream closed"))); + } + Err(_) => { + return Err(init_failed(format!( + "{context}: timeout waiting for response" + ))); + } + } + } +} + +pub(super) async fn write_reg_u32( + chip_commands: &mut W, + asic_id: u8, + engine: u16, + offset: u16, + value: u32, + context: &str, +) -> Result<(), HashThreadError> +where + W: Sink + Unpin, + W::Error: std::fmt::Debug, +{ + send_command( + chip_commands, + protocol::Command::write_reg_u32_le(asic_id, engine, offset, value), + context, + ) + .await +} + +pub(super) async fn write_reg_u8( + chip_commands: &mut W, + asic_id: u8, + engine: u16, + offset: u16, + value: u8, + context: &str, +) -> Result<(), HashThreadError> +where + W: Sink + Unpin, + W::Error: std::fmt::Debug, +{ + send_command( + chip_commands, + protocol::Command::write_reg_u8(asic_id, engine, offset, value), + context, + ) + .await +} + +async fn group_write_u8( + chip_commands: &mut W, + asic_id: u8, + group: u16, + offset: u16, + value: u8, + context: &str, +) -> Result<(), HashThreadError> +where + W: Sink + Unpin, + W::Error: std::fmt::Debug, +{ + send_command( + chip_commands, + protocol::Command::multicast_write_u8(asic_id, group, offset, value), + context, + ) + .await +} + +fn thermal_c_to_tune_code(thermal_c: f32) -> u32 { + let tune_code = (2048.0 / 4096.0) + (4096.0 * (thermal_c + 293.8) / 631.8); + tune_code.max(0.0) as u32 +} + +fn voltage_mv_to_tune_code(voltage_mv: f32) -> u32 { + let tune_code = (16384.0 / 6.0) * (2.5 * voltage_mv / 706.7 + 3.0 / 16384.0 + 1.0); + tune_code.max(0.0) as u32 +} + +fn calc_pll_dividers(freq_mhz: f32, post1_divider: u8) -> (u32, u32) { + let fb = + REF_DIVIDER as f32 * (post1_divider as f32 + 1.0) * (POST2_DIVIDER as f32 + 1.0) * freq_mhz + / REF_CLK_MHZ; + let mut fb_div = fb as u32; + if fb - fb_div as f32 > 0.5 { + fb_div += 1; + } + + let post_div = (1 << 12) | (POST2_DIVIDER << 9) | ((post1_divider as u32) << 6) | REF_DIVIDER; + (post_div, fb_div) +} + +async fn configure_sensors( + chip_responses: &mut R, + chip_commands: &mut W, + read_asic_id: u8, +) -> Result<(), HashThreadError> +where + R: Stream> + Unpin, + W: Sink + Unpin, + W::Error: std::fmt::Debug, +{ + let thermal_trip_code = thermal_c_to_tune_code(THERMAL_TRIP_C); + let voltage_trip_code = voltage_mv_to_tune_code(VOLTAGE_TRIP_MV); + + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::UART_TX, + 0xF, + "enable sensors: UART_TX", + ) + .await?; + + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::SLOW_CLK_DIV, + 2, + "enable sensors: SLOW_CLK_DIV", + ) + .await?; + + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::SENSOR_CLK_DIV, + (8 << 5) | 8, + "enable sensors: SENSOR_CLK_DIV", + ) + .await?; + + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::DTS_SRST_PD, + 1 << 8, + "enable sensors: DTS_SRST_PD", + ) + .await?; + + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::SENS_TDM_GAP_CNT, + SENSOR_REPORT_INTERVAL, + "enable sensors: SENS_TDM_GAP_CNT", + ) + .await?; + + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::DTS_CFG, + 0, + "enable sensors: DTS_CFG", + ) + .await?; + + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::SENSOR_THRS_CNT, + (10 << 16) | 10, + "enable sensors: SENSOR_THRS_CNT", + ) + .await?; + + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::TEMPSENSOR_TUNE_CODE, + 0x8001 | (thermal_trip_code << 1), + "enable sensors: TEMPSENSOR_TUNE_CODE", + ) + .await?; + + let bandgap = read_reg_u32( + chip_responses, + chip_commands, + read_asic_id, + protocol::NOTCH_REG, + protocol::local_reg::BANDGAP, + INIT_READREG_TIMEOUT, + "enable sensors: read BANDGAP", + ) + .await?; + let bandgap_updated = (bandgap & !0xF) | 0x3; + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::BANDGAP, + bandgap_updated, + "enable sensors: write BANDGAP", + ) + .await?; + + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::VSENSOR_SRST_PD, + 1 << 8, + "enable sensors: VSENSOR_SRST_PD", + ) + .await?; + + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::VSENSOR_CFG, + (8 << 28) | (1 << 24), + "enable sensors: VSENSOR_CFG", + ) + .await?; + + let vs_enable = (voltage_trip_code << 16) | (voltage_trip_code << 1) | 1; + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::VOLTAGE_SENSOR_ENABLE, + vs_enable, + "enable sensors: VOLTAGE_SENSOR_ENABLE", + ) + .await?; + + Ok(()) +} + +async fn set_frequency( + chip_responses: &mut R, + chip_commands: &mut W, + read_asic_id: u8, +) -> Result<(), HashThreadError> +where + R: Stream> + Unpin, + W: Sink + Unpin, + W::Error: std::fmt::Debug, +{ + let (post_div, fb_div) = calc_pll_dividers(TARGET_FREQ_MHZ, POST1_DIVIDER); + + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::PLL_FBDIV, + fb_div, + "set frequency: PLL_FBDIV", + ) + .await?; + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::PLL_POSTDIV, + post_div, + "set frequency: PLL_POSTDIV", + ) + .await?; + + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::PLL1_FBDIV, + fb_div, + "set frequency: PLL1_FBDIV", + ) + .await?; + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::PLL1_POSTDIV, + post_div, + "set frequency: PLL1_POSTDIV", + ) + .await?; + + time::sleep(Duration::from_millis(1)).await; + + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::PLL_ENABLE, + 1, + "set frequency: PLL_ENABLE", + ) + .await?; + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::PLL1_ENABLE, + 1, + "set frequency: PLL1_ENABLE", + ) + .await?; + + let deadline = Instant::now() + PLL_LOCK_TIMEOUT; + for pll_enable_offset in [ + protocol::local_reg::PLL_ENABLE, + protocol::local_reg::PLL1_ENABLE, + ] { + loop { + let lock = read_reg_u32( + chip_responses, + chip_commands, + read_asic_id, + protocol::NOTCH_REG, + pll_enable_offset, + INIT_READREG_TIMEOUT, + "set frequency: wait PLL lock", + ) + .await?; + if (lock & PLL_LOCK_MASK) != 0 { + break; + } + + if Instant::now() >= deadline { + return Err(init_failed(format!( + "set frequency: PLL at offset 0x{pll_enable_offset:02x} failed to lock" + ))); + } + + time::sleep(PLL_POLL_DELAY).await; + } + } + + Ok(()) +} + +async fn soft_reset(chip_commands: &mut W, asic_id: u8) -> Result<(), HashThreadError> +where + W: Sink + Unpin, + W::Error: std::fmt::Debug, +{ + write_reg_u32( + chip_commands, + asic_id, + protocol::NOTCH_REG, + protocol::local_reg::ENG_SOFT_RESET, + 0, + "soft reset assert", + ) + .await?; + time::sleep(SOFT_RESET_DELAY).await; + write_reg_u32( + chip_commands, + asic_id, + protocol::NOTCH_REG, + protocol::local_reg::ENG_SOFT_RESET, + 1, + "soft reset release", + ) + .await?; + time::sleep(SOFT_RESET_DELAY).await; + Ok(()) +} + +async fn set_all_clock_gates(chip_commands: &mut W, asic_id: u8) -> Result<(), HashThreadError> +where + W: Sink + Unpin, + W::Error: std::fmt::Debug, +{ + for group_id in 0..ENGINE_ROWS { + group_write_u8( + chip_commands, + asic_id, + group_id, + protocol::engine_reg::CONFIG, + ENGINE_CONFIG_ENHANCED_MODE_BIT, + "set all clock gates", + ) + .await?; + } + Ok(()) +} + +async fn set_asic_nonce_range(chip_commands: &mut W, asic_id: u8) -> Result<(), HashThreadError> +where + W: Sink + Unpin, + W::Error: std::fmt::Debug, +{ + for col in 0..ENGINE_COLS { + for row in 0..ENGINE_ROWS { + if is_invalid_engine(row, col) { + continue; + } + let engine = engine_id(row, col); + write_reg_u32( + chip_commands, + asic_id, + engine, + protocol::engine_reg::START_NONCE, + BZM2_START_NONCE, + "set nonce range: START_NONCE", + ) + .await?; + write_reg_u32( + chip_commands, + asic_id, + engine, + protocol::engine_reg::END_NONCE, + BZM2_END_NONCE, + "set nonce range: END_NONCE", + ) + .await?; + } + } + + Ok(()) +} + +async fn start_warm_up_jobs(chip_commands: &mut W, asic_id: u8) -> Result<(), HashThreadError> +where + W: Sink + Unpin, + W::Error: std::fmt::Debug, +{ + for col in 0..ENGINE_COLS { + for row in 0..ENGINE_ROWS { + if is_invalid_engine(row, col) { + continue; + } + let engine = engine_id(row, col); + + write_reg_u8( + chip_commands, + asic_id, + engine, + protocol::engine_reg::TIMESTAMP_COUNT, + 0xff, + "warm-up: TIMESTAMP_COUNT", + ) + .await?; + + for seq in [0xfc, 0xfd, 0xfe, 0xff] { + write_reg_u8( + chip_commands, + asic_id, + engine, + protocol::engine_reg::SEQUENCE_ID, + seq, + "warm-up: SEQUENCE_ID", + ) + .await?; + } + + write_reg_u8( + chip_commands, + asic_id, + engine, + protocol::engine_reg::JOB_CONTROL, + 1, + "warm-up: JOB_CONTROL", + ) + .await?; + } + } + Ok(()) +} + +pub(super) async fn initialize_chip( + chip_responses: &mut R, + chip_commands: &mut W, + peripherals: &mut BoardPeripherals, + asic_count: u8, +) -> Result, HashThreadError> +where + R: Stream> + Unpin, + W: Sink + Unpin, + W::Error: std::fmt::Debug, +{ + if asic_count == 0 { + return Err(init_failed("asic_count must be > 0")); + } + + if let Some(ref mut asic_enable) = peripherals.asic_enable { + asic_enable + .enable() + .await + .map_err(|e| init_failed(format!("failed to release reset for BZM2 bring-up: {e}")))?; + } + time::sleep(Duration::from_millis(200)).await; + + drain_input(chip_responses).await; + + send_command( + chip_commands, + protocol::Command::Noop { + asic_hw_id: protocol::DEFAULT_ASIC_ID, + }, + "default ping", + ) + .await?; + wait_for_noop(chip_responses, protocol::DEFAULT_ASIC_ID, INIT_NOOP_TIMEOUT).await?; + debug!("BZM2 default ASIC ID ping succeeded"); + + let mut asic_ids = Vec::with_capacity(asic_count as usize); + for index in 0..asic_count { + let asic_id = protocol::logical_to_hw_asic_id(index); + if protocol::hw_to_logical_asic_id(asic_id) != Some(index) { + return Err(init_failed(format!( + "invalid ASIC ID mapping for logical index {} -> 0x{:02x}", + index, asic_id + ))); + } + + write_reg_u32( + chip_commands, + protocol::DEFAULT_ASIC_ID, + protocol::NOTCH_REG, + protocol::local_reg::ASIC_ID, + asic_id as u32, + "program chain IDs", + ) + .await?; + time::sleep(Duration::from_millis(50)).await; + + let readback = read_reg_u32( + chip_responses, + chip_commands, + asic_id, + protocol::NOTCH_REG, + protocol::local_reg::ASIC_ID, + INIT_READREG_TIMEOUT, + "verify programmed ASIC ID", + ) + .await?; + + if (readback & 0xff) as u8 != asic_id { + return Err(init_failed(format!( + "ASIC ID verify mismatch for 0x{asic_id:02x}: read 0x{readback:08x}" + ))); + } + + asic_ids.push(asic_id); + } + debug!(asic_ids = ?asic_ids, "BZM2 chain IDs programmed"); + + drain_input(chip_responses).await; + for &asic_id in &asic_ids { + send_command( + chip_commands, + protocol::Command::Noop { + asic_hw_id: asic_id, + }, + "per-ASIC ping", + ) + .await?; + wait_for_noop(chip_responses, asic_id, INIT_NOOP_TIMEOUT).await?; + } + debug!("BZM2 per-ASIC ping succeeded"); + + let first_asic = *asic_ids + .first() + .ok_or_else(|| init_failed("no ASIC IDs programmed"))?; + + debug!("Configuring BZM2 sensors"); + configure_sensors(chip_responses, chip_commands, first_asic).await?; + debug!("Configuring BZM2 PLL"); + set_frequency(chip_responses, chip_commands, first_asic).await?; + + write_reg_u8( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::CKDCCR_5_0, + 0x00, + "disable DLL0", + ) + .await?; + write_reg_u8( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::CKDCCR_5_1, + 0x00, + "disable DLL1", + ) + .await?; + + let uart_tdm_control = (0x7f << 9) | (100 << 1) | 1; + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::UART_TDM_CTL, + uart_tdm_control, + "enable UART TDM mode", + ) + .await?; + + write_reg_u32( + chip_commands, + first_asic, + protocol::NOTCH_REG, + protocol::local_reg::IO_PEPS_DS, + DRIVE_STRENGTH_STRONG, + "set drive strength", + ) + .await?; + + for &asic_id in &asic_ids { + debug!(asic_id, "BZM2 soft reset + clock gate + warm-up start"); + soft_reset(chip_commands, asic_id).await?; + set_all_clock_gates(chip_commands, asic_id).await?; + set_asic_nonce_range(chip_commands, asic_id).await?; + start_warm_up_jobs(chip_commands, asic_id).await?; + debug!(asic_id, "BZM2 warm-up complete"); + } + + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + protocol::NOTCH_REG, + protocol::local_reg::RESULT_STS_CTL, + 0x10, + "enable TDM results", + ) + .await?; + + Ok(asic_ids) +} diff --git a/mujina-miner/src/asic/bzm2/thread/hashing.rs b/mujina-miner/src/asic/bzm2/thread/hashing.rs new file mode 100644 index 0000000..aa47bd2 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/thread/hashing.rs @@ -0,0 +1,337 @@ +use bitcoin::{ + TxMerkleNode, + block::{Header as BlockHeader, Version as BlockVersion}, + consensus, + hashes::{Hash as _, HashEngine as _, sha256}, +}; + +use crate::asic::hash_thread::{HashTask, HashThreadError}; + +use super::{AssignedTask, MIDSTATE_COUNT}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Bzm2CheckResult { + Correct, + NotMeetTarget, + Error, +} + +pub(super) fn midstate_version_mask_variants(version_mask: u32) -> [u32; MIDSTATE_COUNT] { + if version_mask == 0 { + return [0, 0, 0, 0]; + } + + let mut mask = version_mask; + let mut cnt: u32 = 0; + while mask.is_multiple_of(16) { + cnt = cnt.saturating_add(1); + mask /= 16; + } + + let mut tmp_mask = 0u32; + if !mask.is_multiple_of(16) { + tmp_mask = mask % 16; + } else if !mask.is_multiple_of(8) { + tmp_mask = mask % 8; + } else if !mask.is_multiple_of(4) { + tmp_mask = mask % 4; + } else if !mask.is_multiple_of(2) { + tmp_mask = mask % 2; + } + + for _ in 0..cnt { + tmp_mask = tmp_mask.saturating_mul(16); + } + + [ + 0, + tmp_mask, + version_mask.saturating_sub(tmp_mask), + version_mask, + ] +} + +pub(super) fn task_midstate_versions(task: &HashTask) -> [BlockVersion; MIDSTATE_COUNT] { + let template = task.template.as_ref(); + let base = template.version.base().to_consensus() as u32; + let gp_mask = u16::from_be_bytes(*template.version.gp_bits_mask().as_bytes()) as u32; + let version_mask = gp_mask << 13; + let variants = midstate_version_mask_variants(version_mask); + + variants.map(|variant| BlockVersion::from_consensus((base | variant) as i32)) +} + +pub(super) fn check_result( + sha256_le: &[u8; 32], + target_le: &[u8; 32], + leading_zeros: u8, +) -> Bzm2CheckResult { + let mut i: usize = 31; + while i > 0 && sha256_le[i] == 0 { + i -= 1; + } + + let threshold = 31i32 - i32::from(leading_zeros / 8); + if (i as i32) > threshold { + return Bzm2CheckResult::Error; + } + if (i as i32) == threshold { + let mut bit_count = leading_zeros % 8; + let mut bit_index = 7u8; + while bit_count > 0 { + if (sha256_le[i] & (1u8 << bit_index)) != 0 { + return Bzm2CheckResult::Error; + } + bit_count -= 1; + bit_index = bit_index.saturating_sub(1); + } + } + + for k in (1..=31).rev() { + if sha256_le[k] < target_le[k] { + return Bzm2CheckResult::Correct; + } + if sha256_le[k] > target_le[k] { + return Bzm2CheckResult::NotMeetTarget; + } + } + + Bzm2CheckResult::Correct +} + +pub(super) fn leading_zero_bits(sha256_le: &[u8; 32]) -> u16 { + let mut bits = 0u16; + for byte in sha256_le.iter().rev() { + if *byte == 0 { + bits = bits.saturating_add(8); + continue; + } + bits = bits.saturating_add(byte.leading_zeros() as u16); + return bits; + } + bits +} + +fn bzm2_midstate_to_sha256_midstate(midstate_le: &[u8; 32]) -> sha256::Midstate { + let mut midstate_be = [0u8; 32]; + for (be_chunk, le_chunk) in midstate_be + .chunks_exact_mut(4) + .zip(midstate_le.chunks_exact(4)) + { + let word = u32::from_le_bytes(le_chunk.try_into().expect("chunk size is 4")); + be_chunk.copy_from_slice(&word.to_be_bytes()); + } + + sha256::Midstate::from_byte_array(midstate_be) +} + +fn sha256_midstate_to_bzm2_le(midstate: sha256::Midstate) -> [u8; 32] { + let midstate_be = midstate.to_byte_array(); + let mut midstate_le = [0u8; 32]; + for (le_chunk, be_chunk) in midstate_le + .chunks_exact_mut(4) + .zip(midstate_be.chunks_exact(4)) + { + let word = u32::from_be_bytes(be_chunk.try_into().expect("chunk size is 4")); + le_chunk.copy_from_slice(&word.to_le_bytes()); + } + + midstate_le +} + +pub(super) fn bzm2_double_sha_from_midstate_and_tail( + midstate_le: &[u8; 32], + tail16: &[u8; 16], +) -> [u8; 32] { + let mut engine = + sha256::HashEngine::from_midstate(bzm2_midstate_to_sha256_midstate(midstate_le), 64); + engine.input(tail16); + + sha256::Hash::from_engine(engine) + .hash_again() + .to_byte_array() +} + +pub(super) fn bzm2_tail16_bytes( + assigned: &AssignedTask, + ntime: u32, + nonce_submit: u32, +) -> [u8; 16] { + let merkle_root_bytes = consensus::serialize(&assigned.merkle_root); + let mut tail16 = [0u8; 16]; + tail16[0..4].copy_from_slice(&merkle_root_bytes[28..32]); + tail16[4..8].copy_from_slice(&ntime.to_le_bytes()); + tail16[8..12].copy_from_slice(&assigned.task.template.bits.to_consensus().to_le_bytes()); + tail16[12..16].copy_from_slice(&nonce_submit.to_le_bytes()); + tail16 +} + +pub(super) fn build_header_bytes( + task: &HashTask, + version: BlockVersion, + merkle_root: TxMerkleNode, +) -> Result<[u8; 80], HashThreadError> { + let template = task.template.as_ref(); + let header = BlockHeader { + version, + prev_blockhash: template.prev_blockhash, + merkle_root, + time: task.ntime, + bits: template.bits, + nonce: 0, + }; + + let bytes = consensus::serialize(&header); + let len = bytes.len(); + bytes.try_into().map_err(|_| { + HashThreadError::WorkAssignmentFailed(format!("unexpected serialized header size: {len}")) + }) +} + +pub(super) fn compute_midstate_le(header_prefix_64: &[u8; 64]) -> [u8; 32] { + let mut engine = sha256::HashEngine::default(); + engine.input(header_prefix_64); + sha256_midstate_to_bzm2_le(engine.midstate()) +} + +#[cfg(test)] +pub(super) fn hash_bytes_bzm2_order(hash: &bitcoin::BlockHash) -> [u8; 32] { + *hash.as_byte_array() +} + +#[cfg(test)] +mod tests { + use bitcoin::hashes::{Hash as _, HashEngine as _, sha256d}; + + use super::{ + Bzm2CheckResult, bzm2_double_sha_from_midstate_and_tail, + bzm2_midstate_to_sha256_midstate, check_result, compute_midstate_le, + hash_bytes_bzm2_order, midstate_version_mask_variants, sha256_midstate_to_bzm2_le, + }; + + #[test] + fn test_midstate_version_mask_variants_for_full_mask() { + assert_eq!( + midstate_version_mask_variants(0x1fff_e000), + [0x0000_0000, 0x0000_e000, 0x1fff_0000, 0x1fff_e000] + ); + } + + #[test] + fn test_midstate_version_mask_variants_for_zero_mask() { + assert_eq!(midstate_version_mask_variants(0), [0, 0, 0, 0]); + } + + #[test] + fn test_check_result_leading_zeros_error() { + let mut hash = [0u8; 32]; + let target = [0xffu8; 32]; + hash[31] = 0x80; + assert_eq!(check_result(&hash, &target, 32), Bzm2CheckResult::Error); + } + + #[test] + fn test_check_result_accepts_required_leading_zeros() { + let mut hash = [0u8; 32]; + let target = [0xffu8; 32]; + hash[27] = 0x3f; + assert_eq!(check_result(&hash, &target, 34), Bzm2CheckResult::Correct); + } + + #[test] + fn test_check_result_rejects_missing_partial_zero_bits() { + let mut hash = [0u8; 32]; + let target = [0xffu8; 32]; + hash[27] = 0x40; + assert_eq!(check_result(&hash, &target, 34), Bzm2CheckResult::Error); + } + + #[test] + fn test_check_result_target_compare() { + let mut hash = [0u8; 32]; + let mut target = [0u8; 32]; + + hash[1] = 0x10; + target[1] = 0x20; + assert_eq!(check_result(&hash, &target, 32), Bzm2CheckResult::Correct); + + hash[1] = 0x30; + target[1] = 0x20; + assert_eq!( + check_result(&hash, &target, 32), + Bzm2CheckResult::NotMeetTarget + ); + } + + #[test] + fn test_hash_bytes_bzm2_order_keeps_digest_order() { + let src = core::array::from_fn(|i| i as u8); + let hash = bitcoin::BlockHash::from_byte_array(src); + assert_eq!(hash_bytes_bzm2_order(&hash), src); + } + + #[test] + fn test_midstate_conversion_round_trip_preserves_words() { + let sha256_midstate = bitcoin::hashes::sha256::Midstate::from_byte_array( + core::array::from_fn(|i| i as u8), + ); + let bzm2_midstate = sha256_midstate_to_bzm2_le(sha256_midstate); + + assert_eq!( + bzm2_midstate_to_sha256_midstate(&bzm2_midstate).to_byte_array(), + sha256_midstate.to_byte_array() + ); + } + + #[test] + fn test_compute_midstate_le_matches_bitcoin_sha256_engine() { + let header_prefix = core::array::from_fn(|i| i as u8); + let mut engine = bitcoin::hashes::sha256::HashEngine::default(); + engine.input(&header_prefix); + + assert_eq!( + compute_midstate_le(&header_prefix), + sha256_midstate_to_bzm2_le(engine.midstate()) + ); + } + + #[test] + fn test_bzm2_double_sha_matches_bitcoin_double_sha_for_full_header() { + let header_bytes: [u8; 80] = core::array::from_fn(|i| i as u8); + let header_prefix: [u8; 64] = header_bytes[..64] + .try_into() + .expect("header prefix must be 64 bytes"); + let header_tail: [u8; 16] = header_bytes[64..] + .try_into() + .expect("header tail must be 16 bytes"); + + assert_eq!( + bzm2_double_sha_from_midstate_and_tail( + &compute_midstate_le(&header_prefix), + &header_tail, + ), + sha256d::Hash::hash(&header_bytes).to_byte_array() + ); + } + + #[test] + fn test_bzm2_double_sha_matches_known_trace_sample() { + let midstate = + hex::decode("07348faef527b8ec3733171cb0781bc545efb4220d71e0a5b54af23de2106bfd") + .expect("midstate hex should parse"); + let tail16 = + hex::decode("ef70e3ac38979a6903f301176467a52b").expect("tail16 hex should parse"); + let expected_double_sha = + hex::decode("25ef6a2327c5304bd263126a6a38ad16c3b27cd8b647085624a7130000000000") + .expect("double sha hex should parse"); + let midstate: [u8; 32] = midstate.try_into().expect("midstate must be 32 bytes"); + let tail16: [u8; 16] = tail16.try_into().expect("tail16 must be 16 bytes"); + let expected_double_sha: [u8; 32] = expected_double_sha + .try_into() + .expect("double sha must be 32 bytes"); + assert_eq!( + bzm2_double_sha_from_midstate_and_tail(&midstate, &tail16), + expected_double_sha + ); + } +} diff --git a/mujina-miner/src/asic/bzm2/thread/tracker.rs b/mujina-miner/src/asic/bzm2/thread/tracker.rs new file mode 100644 index 0000000..32cd094 --- /dev/null +++ b/mujina-miner/src/asic/bzm2/thread/tracker.rs @@ -0,0 +1,256 @@ +use std::collections::VecDeque; + +use bitcoin::block::Version as BlockVersion; +use bitcoin::hashes::Hash as _; + +use super::{ + Bzm2CheckResult, MIDSTATE_COUNT, READRESULT_ASSIGNMENT_HISTORY_LIMIT, READRESULT_SLOT_HISTORY, + hashing::bzm2_double_sha_from_midstate_and_tail, hashing::bzm2_tail16_bytes, + hashing::check_result, hashing::leading_zero_bits, work::AssignedTask, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ReadResultFields { + sequence: u8, + timecode: u8, + sequence_id: u8, + micro_job_id: u8, + used_masked_fields: bool, +} + +pub(super) struct SelectedReadResultCandidate { + pub(super) assigned: AssignedTask, + pub(super) sequence_id: u8, + pub(super) micro_job_id: u8, + pub(super) timecode_effective: u8, + pub(super) slot_candidate_count: usize, + pub(super) share_version: BlockVersion, + pub(super) ntime_offset: u32, + pub(super) share_ntime: u32, + pub(super) nonce_adjusted: u32, + pub(super) nonce_submit: u32, + pub(super) hash_bytes: [u8; 32], + pub(super) hash: bitcoin::BlockHash, + pub(super) check_result: Bzm2CheckResult, + pub(super) observed_leading_zeros: u16, +} + +pub(super) struct AssignmentTracker { + assignments: VecDeque, + next_sequence_id: u8, +} + +impl AssignmentTracker { + pub(super) fn new() -> Self { + Self { + assignments: VecDeque::with_capacity(READRESULT_ASSIGNMENT_HISTORY_LIMIT), + next_sequence_id: 0, + } + } + + pub(super) fn current_write_sequence_id(&self) -> u8 { + writejob_effective_sequence_id(self.next_sequence_id) + } + + pub(super) fn retain(&mut self, new_task: AssignedTask) { + let slot = readresult_sequence_slot(new_task.sequence_id); + self.assignments.push_back(new_task); + + let mut slot_count = self + .assignments + .iter() + .filter(|task| readresult_sequence_slot(task.sequence_id) == slot) + .count(); + while slot_count > READRESULT_SLOT_HISTORY { + if let Some(index) = self + .assignments + .iter() + .position(|task| readresult_sequence_slot(task.sequence_id) == slot) + { + let _ = self.assignments.remove(index); + slot_count = slot_count.saturating_sub(1); + } else { + break; + } + } + + while self.assignments.len() > READRESULT_ASSIGNMENT_HISTORY_LIMIT { + let _ = self.assignments.pop_front(); + } + } + + pub(super) fn advance_sequence(&mut self) { + self.next_sequence_id = self.next_sequence_id.wrapping_add(1); + } + + pub(super) fn clear(&mut self) { + self.assignments.clear(); + } + + pub(super) fn resolve_candidate( + &self, + logical_engine_id: usize, + sequence: u8, + timecode: u8, + nonce_raw: u32, + ) -> Option { + let resolved_fields = resolve_readresult_fields(sequence, timecode, |slot| { + self.assignments + .iter() + .rev() + .any(|task| readresult_sequence_slot(task.sequence_id) == slot) + })?; + let sequence_id = resolved_fields.sequence_id; + let micro_job_id = resolved_fields.micro_job_id; + let timecode_effective = resolved_fields.timecode; + let sequence_slot = readresult_sequence_slot(sequence_id); + let slot_candidates: Vec = self + .assignments + .iter() + .rev() + .filter(|task| readresult_sequence_slot(task.sequence_id) == sequence_slot) + .cloned() + .collect(); + let slot_candidate_count = slot_candidates.len(); + if slot_candidate_count == 0 { + return None; + } + + let mut selected_candidate: Option = None; + let mut selected_rank = 0u8; + + for mut candidate in slot_candidates { + let Some(engine_assignment) = + candidate.engine_assignments.get(logical_engine_id).cloned() + else { + continue; + }; + candidate.merkle_root = engine_assignment.merkle_root; + candidate.task.en2 = engine_assignment.extranonce2; + let share_version = candidate.microjob_versions[micro_job_id as usize]; + let selected_midstate = engine_assignment.midstates[micro_job_id as usize]; + let ntime_offset = + u32::from(candidate.timestamp_count.wrapping_sub(timecode_effective)); + let share_ntime = candidate.task.ntime.wrapping_add(ntime_offset); + let nonce_adjusted = nonce_raw.wrapping_sub(candidate.nonce_minus_value); + let nonce_submit = nonce_adjusted.swap_bytes(); + + let tail16 = bzm2_tail16_bytes(&candidate, share_ntime, nonce_submit); + let hash_bytes = bzm2_double_sha_from_midstate_and_tail(&selected_midstate, &tail16); + let hash = bitcoin::BlockHash::from_byte_array(hash_bytes); + let target_bytes = candidate.task.share_target.to_le_bytes(); + let check_result = check_result(&hash_bytes, &target_bytes, candidate.leading_zeros); + let observed_leading_zeros = leading_zero_bits(&hash_bytes); + let rank = match check_result { + Bzm2CheckResult::Correct => 3, + Bzm2CheckResult::NotMeetTarget => 2, + Bzm2CheckResult::Error => 1, + }; + + if selected_candidate.is_none() || rank > selected_rank { + selected_rank = rank; + selected_candidate = Some(SelectedReadResultCandidate { + assigned: candidate, + sequence_id, + micro_job_id, + timecode_effective, + slot_candidate_count, + share_version, + ntime_offset, + share_ntime, + nonce_adjusted, + nonce_submit, + hash_bytes, + hash, + check_result, + observed_leading_zeros, + }); + if rank == 3 { + break; + } + } + } + + selected_candidate + } +} + +fn readresult_sequence_slot(sequence_id: u8) -> u8 { + sequence_id & 0x3f +} + +fn writejob_effective_sequence_id(sequence_id: u8) -> u8 { + sequence_id % 2 +} + +fn resolve_readresult_fields( + sequence_raw: u8, + timecode_raw: u8, + has_sequence_slot: impl Fn(u8) -> bool, +) -> Option { + let sequence_id_raw = sequence_raw / (MIDSTATE_COUNT as u8); + let sequence_slot_raw = readresult_sequence_slot(sequence_id_raw); + if has_sequence_slot(sequence_slot_raw) { + return Some(ReadResultFields { + sequence: sequence_raw, + timecode: timecode_raw, + sequence_id: sequence_id_raw, + micro_job_id: sequence_raw % (MIDSTATE_COUNT as u8), + used_masked_fields: false, + }); + } + + let sequence_masked = sequence_raw & 0x7f; + let timecode_masked = timecode_raw & 0x7f; + let sequence_id_masked = sequence_masked / (MIDSTATE_COUNT as u8); + let sequence_slot_masked = readresult_sequence_slot(sequence_id_masked); + if (sequence_masked != sequence_raw || timecode_masked != timecode_raw) + && has_sequence_slot(sequence_slot_masked) + { + return Some(ReadResultFields { + sequence: sequence_masked, + timecode: timecode_masked, + sequence_id: sequence_id_masked, + micro_job_id: sequence_masked % (MIDSTATE_COUNT as u8), + used_masked_fields: true, + }); + } + + None +} + +#[cfg(test)] +mod tests { + use super::resolve_readresult_fields; + + #[test] + fn test_resolve_readresult_fields_prefers_raw_when_slot_exists() { + let active_slots = [32u8, 0u8]; + let fields = resolve_readresult_fields(0x80, 0xbc, |slot| active_slots.contains(&slot)) + .expect("raw slot should resolve"); + assert_eq!(fields.sequence, 0x80); + assert_eq!(fields.timecode, 0xbc); + assert_eq!(fields.sequence_id, 32); + assert_eq!(fields.micro_job_id, 0); + assert!(!fields.used_masked_fields); + } + + #[test] + fn test_resolve_readresult_fields_uses_masked_fallback() { + let active_slots = [0u8]; + let fields = resolve_readresult_fields(0x82, 0xbc, |slot| active_slots.contains(&slot)) + .expect("masked slot should resolve"); + assert_eq!(fields.sequence, 0x02); + assert_eq!(fields.timecode, 0x3c); + assert_eq!(fields.sequence_id, 0); + assert_eq!(fields.micro_job_id, 2); + assert!(fields.used_masked_fields); + } + + #[test] + fn test_resolve_readresult_fields_none_when_no_slot_matches() { + let active_slots = [0u8]; + let fields = resolve_readresult_fields(0xfd, 0x7f, |slot| active_slots.contains(&slot)); + assert!(fields.is_none()); + } +} diff --git a/mujina-miner/src/asic/bzm2/thread/work.rs b/mujina-miner/src/asic/bzm2/thread/work.rs new file mode 100644 index 0000000..2a0ac1c --- /dev/null +++ b/mujina-miner/src/asic/bzm2/thread/work.rs @@ -0,0 +1,290 @@ +use std::sync::Arc; + +use bitcoin::{TxMerkleNode, block::Version as BlockVersion}; +use futures::{SinkExt, sink::Sink}; + +use crate::{ + asic::hash_thread::{HashTask, HashThreadError}, + job_source::{Extranonce2, MerkleRootKind}, +}; + +use super::{ + AUTO_CLOCK_UNGATE, ENGINE_COLS, ENGINE_EN2_OFFSET_START, ENGINE_ROWS, INVALID_ENGINE_0_COL, + INVALID_ENGINE_0_ROW, INVALID_ENGINE_1_COL, INVALID_ENGINE_1_ROW, INVALID_ENGINE_2_COL, + INVALID_ENGINE_2_ROW, INVALID_ENGINE_3_COL, INVALID_ENGINE_3_ROW, MIDSTATE_COUNT, + WORK_ENGINE_COUNT, WRITEJOB_CTL_REPLACE, hashing::build_header_bytes, + hashing::compute_midstate_le, protocol, write_reg_u8, write_reg_u32, +}; + +struct TaskJobPayload { + midstates: [[u8; 32]; MIDSTATE_COUNT], + merkle_residue: u32, + timestamp: u32, +} + +#[derive(Clone)] +pub(super) struct EngineAssignment { + pub(super) merkle_root: TxMerkleNode, + pub(super) extranonce2: Option, + pub(super) midstates: [[u8; 32]; MIDSTATE_COUNT], +} + +#[derive(Clone)] +pub(super) struct AssignedTask { + pub(super) task: HashTask, + pub(super) merkle_root: TxMerkleNode, + pub(super) engine_assignments: Arc<[EngineAssignment]>, + pub(super) microjob_versions: [BlockVersion; MIDSTATE_COUNT], + pub(super) sequence_id: u8, + pub(super) timestamp_count: u8, + pub(super) leading_zeros: u8, + pub(super) nonce_minus_value: u32, +} + +pub(super) fn engine_id(row: u16, col: u16) -> u16 { + ((col & 0x3f) << 6) | (row & 0x3f) +} + +pub(super) fn is_invalid_engine(row: u16, col: u16) -> bool { + (row == INVALID_ENGINE_0_ROW && col == INVALID_ENGINE_0_COL) + || (row == INVALID_ENGINE_1_ROW && col == INVALID_ENGINE_1_COL) + || (row == INVALID_ENGINE_2_ROW && col == INVALID_ENGINE_2_COL) + || (row == INVALID_ENGINE_3_ROW && col == INVALID_ENGINE_3_COL) +} + +pub(super) fn logical_engine_index(row: u16, col: u16) -> Option { + if row >= ENGINE_ROWS || col >= ENGINE_COLS || is_invalid_engine(row, col) { + return None; + } + + let mut logical = 0usize; + for r in 0..ENGINE_ROWS { + for c in 0..ENGINE_COLS { + if is_invalid_engine(r, c) { + continue; + } + if r == row && c == col { + return Some(logical); + } + logical = logical.saturating_add(1); + } + } + + None +} + +fn engine_extranonce2_for_logical_engine( + task: &HashTask, + logical_engine: usize, +) -> Option { + let base = task.en2?; + let offset = (logical_engine as u64).saturating_add(ENGINE_EN2_OFFSET_START); + + if let Some(range) = task.en2_range.as_ref() + && range.size == base.size() + { + let value = if range.min == 0 && range.max == u64::MAX { + base.value().wrapping_add(offset) + } else { + let span = range.max.saturating_sub(range.min).saturating_add(1); + let base_value = if base.value() < range.min || base.value() > range.max { + range.min + } else { + base.value() + }; + let rel = base_value.saturating_sub(range.min); + range + .min + .saturating_add((rel.saturating_add(offset % span)) % span) + }; + return Extranonce2::new(value, base.size()).ok(); + } + + let width_bits = u32::from(base.size()).saturating_mul(8); + let max = if width_bits >= 64 { + u64::MAX + } else { + (1u64 << width_bits) - 1 + }; + let value = if max == u64::MAX { + base.value().wrapping_add(offset) + } else { + base.value().wrapping_add(offset) & max + }; + Extranonce2::new(value, base.size()).ok() +} + +fn compute_task_merkle_root(task: &HashTask) -> Result { + let template = task.template.as_ref(); + match &template.merkle_root { + MerkleRootKind::Computed(_) => { + let en2 = task.en2.as_ref().ok_or_else(|| { + HashThreadError::WorkAssignmentFailed( + "EN2 is required for computed merkle roots".into(), + ) + })?; + template.compute_merkle_root(en2).map_err(|e| { + HashThreadError::WorkAssignmentFailed(format!("failed to compute merkle root: {e}")) + }) + } + MerkleRootKind::Fixed(merkle_root) => Ok(*merkle_root), + } +} + +#[cfg(test)] +pub(super) fn task_to_bzm2_payload( + task: &HashTask, + merkle_root: TxMerkleNode, + versions: [BlockVersion; MIDSTATE_COUNT], +) -> Result<[[u8; 32]; MIDSTATE_COUNT], HashThreadError> { + Ok(build_task_job_payload(task, merkle_root, versions)?.midstates) +} + +fn build_task_job_payload( + task: &HashTask, + merkle_root: TxMerkleNode, + versions: [BlockVersion; MIDSTATE_COUNT], +) -> Result { + let mut midstates = [[0u8; 32]; MIDSTATE_COUNT]; + let mut merkle_residue = 0u32; + let mut timestamp = 0u32; + + for (idx, midstate) in midstates.iter_mut().enumerate() { + let header = build_header_bytes(task, versions[idx], merkle_root)?; + let header_prefix: [u8; 64] = header[..64] + .try_into() + .expect("header prefix length is fixed"); + + *midstate = compute_midstate_le(&header_prefix); + + if idx == 0 { + merkle_residue = u32::from_be_bytes( + header[64..68] + .try_into() + .expect("slice length is exactly 4 bytes"), + ); + timestamp = u32::from_be_bytes( + header[68..72] + .try_into() + .expect("slice length is exactly 4 bytes"), + ); + } + } + + Ok(TaskJobPayload { + midstates, + merkle_residue, + timestamp, + }) +} + +pub(super) async fn send_task_to_all_engines( + chip_commands: &mut W, + task: &HashTask, + versions: [BlockVersion; MIDSTATE_COUNT], + sequence_id: u8, + zeros_to_find: u8, + timestamp_count: u8, +) -> Result, HashThreadError> +where + W: Sink + Unpin, + W::Error: std::fmt::Debug, +{ + let target = task.template.bits.to_consensus().swap_bytes(); + let timestamp_reg_value = ((AUTO_CLOCK_UNGATE & 0x1) << 7) | (timestamp_count & 0x7f); + let mut engine_assignments = Vec::with_capacity(WORK_ENGINE_COUNT); + + for row in 0..ENGINE_ROWS { + for col in 0..ENGINE_COLS { + if is_invalid_engine(row, col) { + continue; + } + + let Some(logical_engine_id) = logical_engine_index(row, col) else { + continue; + }; + let engine = engine_id(row, col); + let mut engine_task = task.clone(); + engine_task.en2 = engine_extranonce2_for_logical_engine(task, logical_engine_id); + let merkle_root = compute_task_merkle_root(&engine_task).map_err(|e| { + HashThreadError::WorkAssignmentFailed(format!( + "failed to derive per-engine merkle root for logical engine {logical_engine_id} (row {row} col {col}): {e}" + )) + })?; + let payload = + build_task_job_payload(&engine_task, merkle_root, versions).map_err(|e| { + HashThreadError::WorkAssignmentFailed(format!( + "failed to derive per-engine payload for logical engine {logical_engine_id} (row {row} col {col}): {e}" + )) + })?; + + write_reg_u8( + chip_commands, + protocol::BROADCAST_ASIC, + engine, + protocol::engine_reg::ZEROS_TO_FIND, + zeros_to_find, + "task assign: ZEROS_TO_FIND", + ) + .await?; + + write_reg_u8( + chip_commands, + protocol::BROADCAST_ASIC, + engine, + protocol::engine_reg::TIMESTAMP_COUNT, + timestamp_reg_value, + "task assign: TIMESTAMP_COUNT", + ) + .await?; + + write_reg_u32( + chip_commands, + protocol::BROADCAST_ASIC, + engine, + protocol::engine_reg::TARGET, + target, + "task assign: TARGET", + ) + .await?; + + let commands = protocol::Command::write_job( + protocol::BROADCAST_ASIC, + engine, + payload.midstates, + payload.merkle_residue, + payload.timestamp, + sequence_id, + WRITEJOB_CTL_REPLACE, + ) + .map_err(|e| { + HashThreadError::WorkAssignmentFailed(format!( + "failed to build WRITEJOB payload for engine 0x{engine:03x}: {e}" + )) + })?; + + for command in commands { + chip_commands.send(command).await.map_err(|e| { + HashThreadError::WorkAssignmentFailed(format!( + "failed to send WRITEJOB to engine 0x{engine:03x}: {e:?}" + )) + })?; + } + engine_assignments.push(EngineAssignment { + merkle_root, + extranonce2: engine_task.en2, + midstates: payload.midstates, + }); + } + } + + if engine_assignments.len() != WORK_ENGINE_COUNT { + return Err(HashThreadError::WorkAssignmentFailed(format!( + "unexpected BZM2 engine assignment count: got {}, expected {}", + engine_assignments.len(), + WORK_ENGINE_COUNT + ))); + } + + Ok(engine_assignments) +} diff --git a/mujina-miner/src/asic/mod.rs b/mujina-miner/src/asic/mod.rs index 062ee8f..d5a74cb 100644 --- a/mujina-miner/src/asic/mod.rs +++ b/mujina-miner/src/asic/mod.rs @@ -1,4 +1,5 @@ pub mod bm13xx; +pub mod bzm2; pub mod hash_thread; use async_trait::async_trait; diff --git a/mujina-miner/src/board/birds.rs b/mujina-miner/src/board/birds.rs new file mode 100644 index 0000000..193d0b2 --- /dev/null +++ b/mujina-miner/src/board/birds.rs @@ -0,0 +1,519 @@ +//! BIRDS mining board support. +//! +//! The BIRDS board is a mining board with 4 BZM2 ASIC chips, communicating via +//! USB using two serial ports: a control UART for GPIO/I2C and a data UART for +//! ASIC communication with 8-bit to 9-bit serial translation. +//! +//! This module follows the same split of responsibilities as the BM13xx-backed +//! boards: +//! - the board owns USB discovery, power sequencing, and reset control +//! - the [`Bzm2Thread`] owns chip bring-up after the data path is handed off +//! - board-only protocol helpers stay local so they can be unit tested without +//! requiring attached hardware + +use async_trait::async_trait; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::watch; +use tokio::time::{Duration, sleep}; +use tokio_serial::SerialPortBuilderExt; +use tokio_util::codec::{FramedRead, FramedWrite}; + +use super::{ + Board, BoardDescriptor, BoardError, BoardInfo, + pattern::{BoardPattern, Match, StringMatch}, +}; +use crate::{ + api_client::types::BoardState, + asic::{ + bzm2::{FrameCodec, HexBytes, init, thread::Bzm2Thread}, + hash_thread::{AsicEnable, BoardPeripherals, HashThread, ThreadRemovalSignal}, + }, + error::Error, + transport::{ + UsbDeviceInfo, + serial::{SerialControl, SerialReader, SerialWriter}, + }, +}; + +/// Number of BZM2 ASICs on a BIRDS board. +const ASICS_PER_BOARD: usize = 4; + +/// Default baud rate for the BIRDS data UART (5 Mbps). +const DATA_UART_BAUD: u32 = 5_000_000; + +/// Baud rate for the BIRDS control UART. +const CONTROL_UART_BAUD: u32 = 115_200; + +/// BIRDS control GPIO: 5V power enable. +const GPIO_5V_EN: u8 = 1; +/// BIRDS control GPIO: VR power enable. +const GPIO_VR_EN: u8 = 0; +/// BIRDS control GPIO: ASIC reset (active-low). +const GPIO_ASIC_RST: u8 = 2; +/// BIRDS control board ID for 5V/ASIC reset GPIO operations. +const CTRL_ID_POWER_RESET: u8 = 0xAB; +/// BIRDS control board ID for VR GPIO operations. +const CTRL_ID_VR: u8 = 0xAA; +/// Control protocol page for GPIO. +const CTRL_PAGE_GPIO: u8 = 0x06; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct BirdsPorts { + control_port: String, + data_port: String, +} + +impl BirdsPorts { + fn from_slice(serial_ports: &[String]) -> Result { + if serial_ports.len() != 2 { + return Err(BoardError::InitializationFailed(format!( + "BIRDS requires exactly 2 serial ports, found {}", + serial_ports.len() + ))); + } + + Ok(Self { + control_port: serial_ports[0].clone(), + data_port: serial_ports[1].clone(), + }) + } + + fn from_device_info(device_info: &UsbDeviceInfo) -> Result { + let serial_ports = device_info.serial_ports().map_err(|e| { + BoardError::InitializationFailed(format!("Failed to enumerate serial ports: {}", e)) + })?; + Self::from_slice(serial_ports) + } +} + +fn build_gpio_write_packet(dev_id: u8, pin: u8, value_high: bool) -> [u8; 7] { + [ + 0x07, + 0x00, + dev_id, + 0x00, + CTRL_PAGE_GPIO, + pin, + if value_high { 0x01 } else { 0x00 }, + ] +} + +fn validate_gpio_ack(dev_id: u8, pin: u8, ack: [u8; 4]) -> Result<(), BoardError> { + if ack[2] != dev_id { + return Err(BoardError::HardwareControl(format!( + "GPIO ack ID mismatch for pin {}: expected 0x{:02x}, got 0x{:02x}", + pin, dev_id, ack[2] + ))); + } + + Ok(()) +} + +/// BIRDS mining board. +pub struct BirdsBoard { + device_info: UsbDeviceInfo, + state_tx: watch::Sender, + control_port: Option, + data_reader: Option>, + data_writer: Option>, + data_control: Option, + thread_shutdown: Option>, +} + +impl BirdsBoard { + /// Create a new BIRDS board instance. + pub fn new(device_info: UsbDeviceInfo) -> Result { + let serial = device_info.serial_number.clone(); + let initial_state = BoardState { + name: format!("birds-{}", serial.as_deref().unwrap_or("unknown")), + model: "BIRDS".into(), + serial, + ..Default::default() + }; + let (state_tx, _) = watch::channel(initial_state); + + Ok(Self { + device_info, + state_tx, + control_port: None, + data_reader: None, + data_writer: None, + data_control: None, + thread_shutdown: None, + }) + } + + /// Early bring-up init path. + /// + /// During board initialization we verify that control sequencing works and + /// that at least one ASIC answers protocol-level initialization traffic + /// before exposing the data channel to a hashing thread. + pub async fn initialize(&mut self) -> Result<(), BoardError> { + let BirdsPorts { + control_port, + data_port, + } = BirdsPorts::from_device_info(&self.device_info)?; + + tracing::info!( + serial = ?self.device_info.serial_number, + control_port = %control_port, + data_port = %data_port, + data_baud = DATA_UART_BAUD, + control_baud = CONTROL_UART_BAUD, + asics = ASICS_PER_BOARD, + "Running BIRDS ASIC data-port initialization" + ); + + // Match known-good bring-up sequence from birds_asyncio.py: + // 1) VR off and settle + // 2) Enable 5V rail + // 3) Enable VR + // 4) Pulse ASIC reset low/high + // 5) Wait for UART startup + self.bringup_power_and_reset(&control_port).await?; + self.control_port = Some(control_port); + + let initialized_data_port = + init::initialize_data_port(&data_port, 0) + .await + .map_err(|e| { + BoardError::InitializationFailed(format!( + "BIRDS ASIC data-port initialization failed: {:#}", + e + )) + })?; + let result = initialized_data_port.probe; + + tracing::info!( + logical_asic = result.logical_asic, + asic_hw_id = result.asic_hw_id, + asic_id = format_args!("0x{:08x}", result.asic_id), + "BIRDS ASIC data-port initialization succeeded" + ); + + self.data_reader = Some(initialized_data_port.reader); + self.data_writer = Some(initialized_data_port.writer); + self.data_control = Some(initialized_data_port.control); + + Ok(()) + } + + fn open_control_stream(control_port: &str) -> Result { + tokio_serial::new(control_port, CONTROL_UART_BAUD) + .open_native_async() + .map_err(|e| { + BoardError::InitializationFailed(format!( + "Failed to open BIRDS control port {}: {}", + control_port, e + )) + }) + } + + async fn set_asic_reset(control_port: &str, value_high: bool) -> Result<(), BoardError> { + let mut control_stream = Self::open_control_stream(control_port)?; + Self::control_gpio_write( + &mut control_stream, + CTRL_ID_POWER_RESET, + GPIO_ASIC_RST, + value_high, + ) + .await + } + + fn thread_name_for_serial(serial_number: Option<&str>) -> String { + match serial_number { + Some(serial) => format!("BIRDS-{}", &serial[..8.min(serial.len())]), + None => "BIRDS".to_string(), + } + } + + async fn bringup_power_and_reset(&self, control_port: &str) -> Result<(), BoardError> { + let mut control_stream = Self::open_control_stream(control_port)?; + + Self::control_gpio_write(&mut control_stream, CTRL_ID_VR, GPIO_VR_EN, false).await?; + sleep(Duration::from_millis(2000)).await; + + Self::control_gpio_write(&mut control_stream, CTRL_ID_POWER_RESET, GPIO_5V_EN, true) + .await?; + sleep(Duration::from_millis(100)).await; + + Self::control_gpio_write(&mut control_stream, CTRL_ID_VR, GPIO_VR_EN, true).await?; + sleep(Duration::from_millis(100)).await; + + Self::control_gpio_write( + &mut control_stream, + CTRL_ID_POWER_RESET, + GPIO_ASIC_RST, + false, + ) + .await?; + sleep(Duration::from_millis(100)).await; + + Self::control_gpio_write( + &mut control_stream, + CTRL_ID_POWER_RESET, + GPIO_ASIC_RST, + true, + ) + .await?; + sleep(Duration::from_millis(1000)).await; + + Ok(()) + } + + async fn control_gpio_write( + stream: &mut tokio_serial::SerialStream, + dev_id: u8, + pin: u8, + value_high: bool, + ) -> Result<(), BoardError> { + // Packet format: [len:u16_le][id][bus][page][cmd=pin][value]. + let packet = build_gpio_write_packet(dev_id, pin, value_high); + tracing::debug!( + dev_id = format_args!("0x{:02X}", dev_id), + pin, + value = if value_high { 1 } else { 0 }, + tx = %HexBytes(&packet), + "BIRDS ctrl gpio tx" + ); + stream.write_all(&packet).await.map_err(|e| { + BoardError::HardwareControl(format!( + "Failed to write GPIO control packet (pin {}): {}", + pin, e + )) + })?; + + // Ack is 4 bytes. Byte[2] should echo board id. + let mut ack = [0u8; 4]; + stream.read_exact(&mut ack).await.map_err(|e| { + BoardError::HardwareControl(format!( + "Failed to read GPIO control ack (pin {}): {}", + pin, e + )) + })?; + tracing::debug!( + dev_id = format_args!("0x{:02X}", dev_id), + pin, + rx = %HexBytes(&ack), + "BIRDS ctrl gpio rx" + ); + validate_gpio_ack(dev_id, pin, ack) + } + + async fn hold_in_reset(&self) -> Result<(), BoardError> { + let control_port = self.control_port.as_ref().ok_or_else(|| { + BoardError::InitializationFailed("BIRDS control port not initialized".into()) + })?; + + Self::set_asic_reset(control_port, false).await + } +} + +struct BirdsAsicEnable { + control_port: String, +} + +#[async_trait] +impl AsicEnable for BirdsAsicEnable { + async fn enable(&mut self) -> anyhow::Result<()> { + BirdsBoard::set_asic_reset(&self.control_port, true) + .await + .map_err(|e| anyhow::anyhow!("failed to release BZM2 reset: {}", e)) + } + + async fn disable(&mut self) -> anyhow::Result<()> { + BirdsBoard::set_asic_reset(&self.control_port, false) + .await + .map_err(|e| anyhow::anyhow!("failed to assert BZM2 reset: {}", e)) + } +} + +#[async_trait] +impl Board for BirdsBoard { + fn board_info(&self) -> BoardInfo { + BoardInfo { + model: "BIRDS".to_string(), + firmware_version: None, + serial_number: self.device_info.serial_number.clone(), + } + } + + async fn shutdown(&mut self) -> Result<(), BoardError> { + if let Some(ref tx) = self.thread_shutdown + && let Err(e) = tx.send(ThreadRemovalSignal::Shutdown) + { + tracing::warn!("Failed to send shutdown signal to BIRDS thread: {}", e); + } + + self.hold_in_reset().await?; + Ok(()) + } + + async fn create_hash_threads(&mut self) -> Result>, BoardError> { + let (removal_tx, removal_rx) = watch::channel(ThreadRemovalSignal::Running); + self.thread_shutdown = Some(removal_tx); + + let data_reader = self + .data_reader + .take() + .ok_or(BoardError::InitializationFailed( + "No BIRDS data reader available".into(), + ))?; + let data_writer = self + .data_writer + .take() + .ok_or(BoardError::InitializationFailed( + "No BIRDS data writer available".into(), + ))?; + + let control_port = self + .control_port + .clone() + .ok_or(BoardError::InitializationFailed( + "No BIRDS control port available".into(), + ))?; + let asic_enable = BirdsAsicEnable { control_port }; + let peripherals = BoardPeripherals { + asic_enable: Some(Box::new(asic_enable)), + voltage_regulator: None, + }; + + let thread_name = Self::thread_name_for_serial(self.device_info.serial_number.as_deref()); + + let thread = Bzm2Thread::new( + thread_name, + data_reader, + data_writer, + peripherals, + removal_rx, + ASICS_PER_BOARD as u8, + ); + Ok(vec![Box::new(thread)]) + } +} + +// Factory function to create BIRDS board from USB device info +async fn create_from_usb( + device: UsbDeviceInfo, +) -> crate::error::Result<(Box, super::BoardRegistration)> { + let mut board = BirdsBoard::new(device) + .map_err(|e| Error::Hardware(format!("Failed to create board: {}", e)))?; + + board + .initialize() + .await + .map_err(|e| Error::Hardware(format!("Failed to initialize BIRDS board: {}", e)))?; + + let registration = super::BoardRegistration { + state_rx: board.state_tx.subscribe(), + }; + Ok((Box::new(board), registration)) +} + +// Register this board type with the inventory system +inventory::submit! { + BoardDescriptor { + pattern: BoardPattern { + vid: Match::Any, + pid: Match::Any, + manufacturer: Match::Specific(StringMatch::Exact("OSMU")), + product: Match::Specific(StringMatch::Exact("BIRDS")), + serial_pattern: Match::Any, + }, + name: "BIRDS", + create_fn: |device| Box::pin(create_from_usb(device)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_device(serial: Option<&str>) -> UsbDeviceInfo { + UsbDeviceInfo::new_for_test( + 0xc0de, + 0xcafe, + serial.map(str::to_string), + Some("BIRDS".to_string()), + Some("Mining Board".to_string()), + "/sys/devices/test".to_string(), + ) + } + + #[test] + fn test_board_creation() { + let board = BirdsBoard::new(test_device(Some("TEST001"))); + assert!(board.is_ok()); + + let board = board.unwrap(); + assert_eq!(board.board_info().model, "BIRDS"); + } + + #[test] + fn test_birds_ports_requires_exactly_two_serial_ports() { + let ports = vec!["/dev/ttyACM0".to_string()]; + let error = BirdsPorts::from_slice(&ports).expect_err("one port should be rejected"); + assert_eq!( + error.to_string(), + "Board initialization failed: BIRDS requires exactly 2 serial ports, found 1" + ); + } + + #[test] + fn test_birds_ports_preserves_control_and_data_order() { + let ports = vec!["/dev/ttyACM0".to_string(), "/dev/ttyACM1".to_string()]; + let birds_ports = BirdsPorts::from_slice(&ports).unwrap(); + assert_eq!(birds_ports.control_port, "/dev/ttyACM0"); + assert_eq!(birds_ports.data_port, "/dev/ttyACM1"); + } + + #[test] + fn test_build_gpio_write_packet_layout() { + let packet = build_gpio_write_packet(CTRL_ID_POWER_RESET, GPIO_ASIC_RST, true); + assert_eq!( + packet, + [ + 0x07, + 0x00, + CTRL_ID_POWER_RESET, + 0x00, + CTRL_PAGE_GPIO, + GPIO_ASIC_RST, + 0x01 + ] + ); + } + + #[test] + fn test_validate_gpio_ack_accepts_matching_device_id() { + let ack = [0x04, 0x00, CTRL_ID_VR, 0x00]; + assert!(validate_gpio_ack(CTRL_ID_VR, GPIO_VR_EN, ack).is_ok()); + } + + #[test] + fn test_validate_gpio_ack_rejects_mismatched_device_id() { + let ack = [0x04, 0x00, CTRL_ID_POWER_RESET, 0x00]; + let error = + validate_gpio_ack(CTRL_ID_VR, GPIO_VR_EN, ack).expect_err("mismatched ack must fail"); + assert_eq!( + error.to_string(), + format!( + "Hardware control error: GPIO ack ID mismatch for pin {}: expected 0x{:02x}, got 0x{:02x}", + GPIO_VR_EN, CTRL_ID_VR, CTRL_ID_POWER_RESET + ) + ); + } + + #[test] + fn test_thread_name_uses_serial_prefix() { + assert_eq!( + BirdsBoard::thread_name_for_serial(Some("1234567890")), + "BIRDS-12345678" + ); + } + + #[test] + fn test_thread_name_falls_back_when_serial_is_missing() { + assert_eq!(BirdsBoard::thread_name_for_serial(None), "BIRDS"); + } +} diff --git a/mujina-miner/src/board/mod.rs b/mujina-miner/src/board/mod.rs index d8ce752..331db23 100644 --- a/mujina-miner/src/board/mod.rs +++ b/mujina-miner/src/board/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod birds; pub(crate) mod bitaxe; pub mod cpu; pub(crate) mod emberone; diff --git a/mujina-miner/src/transport/mod.rs b/mujina-miner/src/transport/mod.rs index 45457d9..0ea83ca 100644 --- a/mujina-miner/src/transport/mod.rs +++ b/mujina-miner/src/transport/mod.rs @@ -6,6 +6,7 @@ //! events when devices are connected or disconnected. pub mod cpu; +pub mod nine_bit; pub mod serial; pub mod usb; diff --git a/mujina-miner/src/transport/nine_bit.rs b/mujina-miner/src/transport/nine_bit.rs new file mode 100644 index 0000000..446d2f9 --- /dev/null +++ b/mujina-miner/src/transport/nine_bit.rs @@ -0,0 +1,105 @@ +//! 9-bit serial TX encoding for BZM2 ASIC communication. +//! +//! The BZM2 ASIC uses 9-bit serial (9N1), where the 9th bit marks the start +//! of a new command frame (address byte). When communicating through a USB-CDC +//! bridge (like bitaxe-raw firmware on RP2350), each outgoing 9-bit word is +//! encoded as a pair of bytes over USB: +//! +//! - First byte: lower 8 bits of the 9-bit word (data) +//! - Second byte: bit 8 (0x00 = data, 0x01 = address/frame start) +//! +//! The firmware strips the 9th bit on RX, so responses come back as plain +//! 8-bit bytes and no decoding is needed on the read path. + +use bytes::{BufMut, BytesMut}; + +/// Encode a complete command frame into 9-bit serial format. +/// +/// The first byte of the frame gets flag=0x01 (address byte, 9th bit set), +/// all subsequent bytes get flag=0x00 (data bytes). This matches the encoding +/// expected by the bitaxe-raw firmware's PIO UART bridge. +/// +/// # Arguments +/// +/// * `frame` - Raw protocol bytes for one complete command frame +/// +/// # Returns +/// +/// Encoded bytes with interleaved flag bytes (2x the input length). +pub fn nine_bit_encode_frame(frame: &[u8]) -> BytesMut { + let mut encoded = BytesMut::with_capacity(frame.len() * 2); + for (i, &byte) in frame.iter().enumerate() { + encoded.put_u8(byte); + encoded.put_u8(if i == 0 { 0x01 } else { 0x00 }); + } + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_frame_single_byte() { + let encoded = nine_bit_encode_frame(&[0xAA]); + assert_eq!(encoded.as_ref(), &[0xAA, 0x01]); + } + + #[test] + fn test_encode_frame_multi_byte() { + let encoded = nine_bit_encode_frame(&[0xFA, 0x0F, 0x42, 0x00]); + assert_eq!( + encoded.as_ref(), + &[ + 0xFA, 0x01, // first byte: flag=0x01 (address) + 0x0F, 0x00, // subsequent: flag=0x00 (data) + 0x42, 0x00, 0x00, 0x00, + ] + ); + } + + #[test] + fn test_encode_frame_empty() { + let encoded = nine_bit_encode_frame(&[]); + assert!(encoded.is_empty()); + } + + #[test] + fn test_encode_noop_command() { + // BZM2 NOOP command (non-EHL): [length_lo, length_hi, header_hi, header_lo] + // Example: asic_id=0xFA, opcode=NOOP(0xF) + // header = (0xFA << 8) | (0xF << 4) = 0xFAF0 + // length = 4 + let frame = [0x04, 0x00, 0xFA, 0xF0]; + let encoded = nine_bit_encode_frame(&frame); + assert_eq!( + encoded.as_ref(), + &[ + 0x04, 0x01, // length LSB: address byte + 0x00, 0x00, // length MSB: data byte + 0xFA, 0x00, // header byte 1: data byte + 0xF0, 0x00, // header byte 2: data byte + ] + ); + } + + #[test] + fn test_roundtrip() { + // Encode a frame, then verify the raw pairs match expected format + let original = vec![0x07, 0x00, 0xFA, 0x20, 0x00, 0x03, 0xFF]; + let encoded = nine_bit_encode_frame(&original); + + // Verify length doubled + assert_eq!(encoded.len(), original.len() * 2); + + // Verify first pair has flag=0x01 + assert_eq!(encoded[0], original[0]); + assert_eq!(encoded[1], 0x01); + + // Verify remaining pairs have flag=0x00 + for i in 1..original.len() { + assert_eq!(encoded[i * 2], original[i]); + assert_eq!(encoded[i * 2 + 1], 0x00); + } + } +}