From 5cdcfb97ab8e2775f33b79e2a3c2db2c4afba600 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Thu, 2 Jul 2026 12:21:26 -0500 Subject: [PATCH 01/38] docs: codify doc comment prose conventions The written style rules cover doc comment mechanics but never specified mood or punctuation for summary lines, and the tree has drifted into a mix of imperative and third-person summaries, with and without terminal periods. Adopt Rust's API documentation conventions (RFC 505 and RFC 1574): a summary is a complete sentence in third-person present tense, ending with a period. The essentials are summarized inline so a reader gets the basics without chasing the RFCs. --- CODE_STYLE.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/CODE_STYLE.md b/CODE_STYLE.md index ba677c14..4559d2b2 100644 --- a/CODE_STYLE.md +++ b/CODE_STYLE.md @@ -145,6 +145,38 @@ Use standard Rust documentation format: - Include examples for complex functionality - Document panics, errors, and safety requirements +Doc comment prose follows Rust's API documentation conventions +([RFC 1574], building on [RFC 505]), the style used throughout the +standard library. The basics: + +- Begin with a one-line summary. rustdoc shows this line by itself + in module indexes, so it must stand alone. +- End the summary with a period. This includes noun-phrase + summaries for types and fields: `/// Pool connection + configuration.` +- Describe functions and methods in third-person present tense: + `/// Returns the length in bytes.`, not `/// Return the length + in bytes`. A doc comment describes what the item does; it is not + a command to the reader. +- Separate the summary from any further detail with a blank `///` + line. + +```rust +/// Creates a frequency from a count of megahertz. +/// +/// Values are stored internally as integer Hz, so fractional +/// megahertz such as 62.5 are exact. +pub fn from_mhz(mhz: f32) -> Self { +``` + +Older code predates this rule and mixes imperative, period-less +summaries. Bring doc comments into conformance when you modify +them; do not restyle otherwise-untouched code in a functional +commit. + +[RFC 505]: https://rust-lang.github.io/rfcs/0505-api-comment-conventions.html +[RFC 1574]: https://rust-lang.github.io/rfcs/1574-more-api-documentation-conventions.html + See CODING_GUIDELINES.md for guidance on what to document and comment style. From 212e9f0ab14feacdfefc33190281471617b96ed6 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Wed, 22 Apr 2026 14:36:15 -0500 Subject: [PATCH 02/38] refactor(bm13xx): rename ChipType to ChipModel The BM1362/BM1366/BM1370/... values in this enum are chip models, not types in any broader sense. Rename the enum to ChipModel and the Register::ChipId.chip_type field to model for consistency. Pure mechanical rename; no behavior change. --- mujina-miner/src/asic/bm13xx/protocol.rs | 40 ++++++++++++------------ mujina-miner/src/board/bitaxe.rs | 6 ++-- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/protocol.rs b/mujina-miner/src/asic/bm13xx/protocol.rs index e6af515f..9b2cbd18 100644 --- a/mujina-miner/src/asic/bm13xx/protocol.rs +++ b/mujina-miner/src/asic/bm13xx/protocol.rs @@ -159,9 +159,9 @@ impl From for [u8; 4] { } } -/// Known chip types in the BM13xx family +/// Known chip models in the BM13xx family. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChipType { +pub enum ChipModel { /// BM1362 - Used in Antminer S19 J Pro (126 chips) /// Core count unknown BM1362, @@ -172,11 +172,11 @@ pub enum ChipType { BM1370, /// BM1397 - Previous generation chip BM1397, - /// Unknown chip type with raw ID bytes + /// Unknown chip model with raw ID bytes. Unknown([u8; 2]), } -impl ChipType { +impl ChipModel { /// Get the raw chip ID bytes pub fn id_bytes(&self) -> [u8; 2] { match self { @@ -188,7 +188,7 @@ impl ChipType { } } - /// Get expected hash engine count for this chip type, if known + /// Returns the expected hash engine count for this model, if known. pub fn core_count(&self) -> Option { match self { Self::BM1370 => Some(2048), // 128 x 16; esp-miner uses 2040 @@ -197,7 +197,7 @@ impl ChipType { } } -impl From<[u8; 2]> for ChipType { +impl From<[u8; 2]> for ChipModel { fn from(bytes: [u8; 2]) -> Self { match bytes { [0x13, 0x62] => Self::BM1362, @@ -209,9 +209,9 @@ impl From<[u8; 2]> for ChipType { } } -impl From for [u8; 2] { - fn from(chip_type: ChipType) -> Self { - chip_type.id_bytes() +impl From for [u8; 2] { + fn from(model: ChipModel) -> Self { + model.id_bytes() } } @@ -533,9 +533,9 @@ pub enum RegisterAddress { #[derive(Clone)] pub enum Register { ChipId { - chip_type: ChipType, // Chip type identifier - core_count: u8, // Core configuration byte - address: u8, // Assigned chip address + model: ChipModel, + core_count: u8, // Core configuration byte + address: u8, // Assigned chip address }, PllDivider(PllConfig), NonceRange(NonceRangeConfig), @@ -571,7 +571,7 @@ impl Register { let raw_value = u32::from_le_bytes(*bytes); match address { RegisterAddress::ChipId => Register::ChipId { - chip_type: ChipType::from([bytes[0], bytes[1]]), + model: ChipModel::from([bytes[0], bytes[1]]), core_count: bytes[2], address: bytes[3], }, @@ -641,11 +641,11 @@ impl Register { fn encode_data(&self, dst: &mut BytesMut) { match self { Register::ChipId { - chip_type, + model, core_count, address, } => { - dst.put_slice(&chip_type.id_bytes()); + dst.put_slice(&model.id_bytes()); dst.put_u8(*core_count); dst.put_u8(*address); } @@ -693,12 +693,12 @@ impl std::fmt::Debug for Register { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Register::ChipId { - chip_type, + model, core_count, address, } => f .debug_struct("ChipId") - .field("chip_type", chip_type) + .field("model", model) .field("core_count", core_count) .field("address", address) .finish(), @@ -1486,7 +1486,7 @@ mod command_tests { broadcast: false, chip_address: 0x01, register: Register::ChipId { - chip_type: ChipType::BM1370, + model: ChipModel::BM1370, core_count: 0x00, address: 0x01, }, @@ -1863,7 +1863,7 @@ mod response_tests { assert_eq!(chip_address, 0x00); let Register::ChipId { - chip_type, + model, core_count, address, } = register @@ -1871,7 +1871,7 @@ mod response_tests { panic!("Expected ChipId register, got {:?}", register); }; - assert_eq!(chip_type, ChipType::BM1370); + assert_eq!(model, ChipModel::BM1370); assert_eq!(core_count, 0x00); assert_eq!(address, 0x00); } diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 2108e665..218c535c 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -563,11 +563,11 @@ async fn discover_chips( match response { Some(Ok(bm13xx::Response::ReadRegister { chip_address: _, - register: bm13xx::Register::ChipId { chip_type, core_count, address } + register: bm13xx::Register::ChipId { model, core_count, address } })) => { - let chip_id = chip_type.id_bytes(); + let chip_id = model.id_bytes(); debug!("Discovered chip {:?} ({:02x}{:02x}) at address {address}", - chip_type, chip_id[0], chip_id[1]); + model, chip_id[0], chip_id[1]); chip_infos.push(ChipInfo { chip_id, From b62ead7f52645f187d4309ac2fc9304940ad1aa5 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Wed, 22 Apr 2026 14:42:08 -0500 Subject: [PATCH 03/38] feat(types): add unit-aware Frequency type Introduce a Frequency type storing Hz as u64 internally, following std::time::Duration's pattern. Provides from_hz, from_khz, from_mhz constructors and hz, khz, mhz accessors. --- mujina-miner/src/types/frequency.rs | 99 +++++++++++++++++++++++++++++ mujina-miner/src/types/mod.rs | 2 + 2 files changed, 101 insertions(+) create mode 100644 mujina-miner/src/types/frequency.rs diff --git a/mujina-miner/src/types/frequency.rs b/mujina-miner/src/types/frequency.rs new file mode 100644 index 00000000..ed0bd6cd --- /dev/null +++ b/mujina-miner/src/types/frequency.rs @@ -0,0 +1,99 @@ +//! Frequency type for representing clock rates and oscillation frequencies. +//! +//! Stores frequency internally as Hz (u64) for precision, following the +//! pattern of `std::time::Duration`. Convert on access via the unit- +//! specific methods. + +/// Frequency in Hz. +/// +/// A unit-aware frequency type. Stores Hz internally for precision; +/// convert on access. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub struct Frequency { + hz: u64, +} + +impl Frequency { + /// Creates a frequency from Hz. + pub fn from_hz(hz: u64) -> Self { + Self { hz } + } + + /// Creates a frequency from kHz. + pub fn from_khz(khz: u32) -> Self { + Self { + hz: khz as u64 * 1_000, + } + } + + /// Creates a frequency from MHz. + pub fn from_mhz(mhz: f32) -> Self { + Self { + hz: (mhz * 1_000_000.0) as u64, + } + } + + /// Returns the frequency in Hz. + pub fn hz(&self) -> u64 { + self.hz + } + + /// Returns the frequency in kHz. + pub fn khz(&self) -> u32 { + (self.hz / 1_000) as u32 + } + + /// Returns the frequency in MHz. + pub fn mhz(&self) -> f32 { + self.hz as f32 / 1_000_000.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_hz() { + let freq = Frequency::from_hz(25_000_000); + assert_eq!(freq.hz(), 25_000_000); + assert_eq!(freq.khz(), 25_000); + assert_eq!(freq.mhz(), 25.0); + } + + #[test] + fn from_khz() { + let freq = Frequency::from_khz(25_000); + assert_eq!(freq.hz(), 25_000_000); + assert_eq!(freq.khz(), 25_000); + assert_eq!(freq.mhz(), 25.0); + } + + #[test] + fn from_mhz() { + let freq = Frequency::from_mhz(400.0); + assert_eq!(freq.mhz(), 400.0); + assert_eq!(freq.khz(), 400_000); + assert_eq!(freq.hz(), 400_000_000); + } + + #[test] + fn fractional_mhz() { + let freq = Frequency::from_mhz(62.5); + assert_eq!(freq.hz(), 62_500_000); + assert_eq!(freq.mhz(), 62.5); + } + + #[test] + fn ordering() { + let low = Frequency::from_mhz(50.0); + let high = Frequency::from_mhz(600.0); + assert!(low < high); + } + + #[test] + fn default_is_zero() { + let freq = Frequency::default(); + assert_eq!(freq.hz(), 0); + } +} diff --git a/mujina-miner/src/types/mod.rs b/mujina-miner/src/types/mod.rs index 70ea0862..e3fa5f93 100644 --- a/mujina-miner/src/types/mod.rs +++ b/mujina-miner/src/types/mod.rs @@ -7,6 +7,7 @@ mod bitcoin_impls; mod debounced_alarm; mod difficulty; +mod frequency; mod hash_rate; mod hashrate_estimator; mod share_rate; @@ -19,6 +20,7 @@ pub use bitcoin::block::Header as BlockHeader; pub use bitcoin::{Amount, BlockHash, Network, Target, Transaction, TxOut, Work}; pub use debounced_alarm::{AlarmStatus, DebouncedAlarm}; pub use difficulty::Difficulty; +pub use frequency::Frequency; pub use hash_rate::HashRate; pub use hashrate_estimator::HashrateEstimator; pub use share_rate::ShareRate; From 4683e20b63d25d8851eaa8590dc30dec39290624 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Wed, 22 Apr 2026 14:49:03 -0500 Subject: [PATCH 04/38] refactor(bm13xx): move PLL calculation to ChipConfig Introduce a ChipConfig carrying per-chip-model defaults (model, frequency range, PLL search bounds) and factory functions for BM1362 and BM1370. calculate_pll becomes a ChipConfig method driven by the chip's PllParams. When multiple PLL configurations yield the same output frequency, prefer the one with the lowest VCO. S19 J Pro serial captures show VCO stays low during the ramp, whereas the prior first-match approach drifted higher. A ramp-walk test asserts our VCO is never higher than the captured VCO at any step. Remove the old protocol-level frequency struct and its out-of-range error; the PLL search now takes its place, rejecting targets outside the model's frequency range and reporting failure instead of silently falling back to a default divider configuration. Bitaxe's embedded PLL calculator is untouched here and migrates to ChipConfig later. --- mujina-miner/src/asic/bm13xx/chip_config.rs | 372 ++++++++++++++++++++ mujina-miner/src/asic/bm13xx/error.rs | 3 - mujina-miner/src/asic/bm13xx/mod.rs | 1 + mujina-miner/src/asic/bm13xx/protocol.rs | 210 ++--------- 4 files changed, 403 insertions(+), 183 deletions(-) create mode 100644 mujina-miner/src/asic/bm13xx/chip_config.rs diff --git a/mujina-miner/src/asic/bm13xx/chip_config.rs b/mujina-miner/src/asic/bm13xx/chip_config.rs new file mode 100644 index 00000000..f1b1fa5a --- /dev/null +++ b/mujina-miner/src/asic/bm13xx/chip_config.rs @@ -0,0 +1,372 @@ +//! Per-chip-model configuration for the BM13xx family. +//! +//! `ChipConfig` carries the defaults that vary by chip model: identity, +//! frequency range, IO driver strength, and the PLL search parameters. +//! Factory functions [`bm1362`] and [`bm1370`] return the values for +//! each supported model, validated against serial captures of an +//! S19 J Pro (BM1362) and an S21 Pro and Bitaxe Gamma (BM1370). + +use super::protocol::{ChipModel, PllConfig}; +use crate::types::Frequency; + +/// Per-chip-model configuration. +/// +/// Build via [`bm1362`] or [`bm1370`] and adjust fields as needed for +/// a specific board. +#[derive(Debug, Clone)] +pub struct ChipConfig { + /// Chip model identity. Verified during enumeration. + pub model: ChipModel, + /// Lowest frequency supported on this chip. + pub min_freq: Frequency, + /// Highest frequency supported on this chip. + pub max_freq: Frequency, + /// PLL search bounds for this chip model. + pub pll_params: PllParams, +} + +impl ChipConfig { + /// Returns true if `model` matches this chip's model. + pub fn verify_model(&self, model: ChipModel) -> bool { + self.model == model + } + + /// Calculates the optimal PLL configuration for `freq`. + /// + /// Searches the space defined by `pll_params` for dividers that + /// produce the target frequency with minimum error. Among + /// equal-error configurations, prefers the one with the lowest VCO + /// frequency to keep VCO within its optimal operating range. + /// Returns `None` when `freq` lies outside the chip's frequency + /// range or no divider configuration reaches it. + pub fn calculate_pll(&self, freq: Frequency) -> Option { + if freq < self.min_freq || freq > self.max_freq { + return None; + } + + let target_mhz = freq.mhz(); + let params = &self.pll_params; + + let mut best_config = None; + let mut min_error = f32::MAX; + let mut best_vco = f32::MAX; + + for ref_div in [2u8, 1u8] { + for post_div1 in (1..=7).rev() { + for post_div2 in (1..=post_div1).rev() { + let fb_div_f = + (post_div1 * post_div2) as f32 * target_mhz * ref_div as f32 / CRYSTAL_MHZ; + let fb_div = fb_div_f.round() as u8; + + if fb_div < params.fb_div_min || fb_div > params.fb_div_max { + continue; + } + + let actual_mhz = CRYSTAL_MHZ * fb_div as f32 + / (ref_div as f32 * post_div1 as f32 * post_div2 as f32); + let error = (target_mhz - actual_mhz).abs(); + let vco = CRYSTAL_MHZ * fb_div as f32 / ref_div as f32; + + if vco < params.vco_min_mhz || vco > params.vco_max_mhz { + continue; + } + + if error < 1.0 && (error < min_error || (error == min_error && vco < best_vco)) + { + min_error = error; + best_vco = vco; + let post_div = ((post_div1 - 1) << 4) | (post_div2 - 1); + best_config = Some(PllConfig::new(fb_div, ref_div, post_div)); + } + } + } + } + + best_config + } +} + +/// BM1362 defaults (EmberOne00, S19 J Pro). Frequency range and PLL +/// bounds derived from S19 J Pro serial captures. +pub fn bm1362() -> ChipConfig { + ChipConfig { + model: ChipModel::BM1362, + min_freq: Frequency::from_mhz(50.0), + max_freq: Frequency::from_mhz(525.0), + pll_params: PllParams { + fb_div_min: 0xa0, + fb_div_max: 0xef, + vco_min_mhz: 1600.0, + vco_max_mhz: 3200.0, + }, + } +} + +/// BM1370 defaults (Bitaxe Gamma, S21 Pro). Frequency range and PLL +/// bounds derived from S21 Pro serial captures and Bitaxe Gamma logic +/// analyzer captures. +pub fn bm1370() -> ChipConfig { + ChipConfig { + model: ChipModel::BM1370, + min_freq: Frequency::from_mhz(50.0), + max_freq: Frequency::from_mhz(600.0), + pll_params: PllParams { + fb_div_min: 0xa0, + fb_div_max: 0xef, + vco_min_mhz: 2000.0, + vco_max_mhz: 3000.0, + }, + } +} + +/// PLL search bounds for a BM13xx chip model. +/// +/// fb_div and VCO bounds vary by model (e.g., BM1362/BM1370 use +/// `fb_div in [0xa0, 0xef]`, while BM1366/BM1368 use `[0x90, 0xeb]`). +/// Other search parameters (ref_div range, postdiv ordering) are +/// shared across the family and hardcoded in the search loop. +#[derive(Debug, Clone, Copy)] +pub struct PllParams { + /// Minimum feedback divider considered during search. + pub fb_div_min: u8, + /// Maximum feedback divider considered during search. + pub fb_div_max: u8, + /// Minimum valid VCO frequency (MHz). + pub vco_min_mhz: f32, + /// Maximum valid VCO frequency (MHz). + pub vco_max_mhz: f32, +} + +/// Crystal oscillator frequency for BM13xx chips (25 MHz). +const CRYSTAL_MHZ: f32 = 25.0; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verify_model_matches() { + let config = bm1362(); + assert!(config.verify_model(ChipModel::BM1362)); + assert!(!config.verify_model(ChipModel::BM1370)); + } + + #[test] + fn pll_calculation_produces_valid_frequencies() { + // Reference PLL values from esp-miner (first-match algorithm). + // Format: (target_mhz, [fb_div, ref_div, post_div]). + let test_cases = [ + (62.5, [0xd2, 0x02, 0x65]), + (75.0, [0xd2, 0x02, 0x64]), + (100.0, [0xe0, 0x02, 0x63]), + (400.0, [0xe0, 0x02, 0x60]), + (500.0, [0xa2, 0x02, 0x30]), + ]; + + let config = bm1370(); + for (target_mhz, esp_raw) in test_cases { + let freq = Frequency::from_mhz(target_mhz); + let pll = config.calculate_pll(freq).unwrap(); + + let esp_post1 = ((esp_raw[2] >> 4) & 0xf) + 1; + let esp_post2 = (esp_raw[2] & 0xf) + 1; + let esp_actual = + CRYSTAL_MHZ * esp_raw[0] as f32 / (esp_raw[1] * esp_post1 * esp_post2) as f32; + + let our_post1 = ((pll.post_div >> 4) & 0xf) + 1; + let our_post2 = (pll.post_div & 0xf) + 1; + let our_actual = + CRYSTAL_MHZ * pll.fb_div as f32 / (pll.ref_div * our_post1 * our_post2) as f32; + + let esp_error = (target_mhz - esp_actual).abs(); + let our_error = (target_mhz - our_actual).abs(); + + assert!( + (0xa0..=0xef).contains(&pll.fb_div), + "fb_div out of range: {:#04x} at {} MHz", + pll.fb_div, + target_mhz + ); + assert!( + pll.ref_div == 1 || pll.ref_div == 2, + "ref_div invalid: {} at {} MHz", + pll.ref_div, + target_mhz + ); + assert!( + our_error < 1.0, + "error {:.4} MHz too large for target {} MHz", + our_error, + target_mhz + ); + assert!( + our_error <= esp_error + 0.01, + "worse than esp-miner at {} MHz (ours {:.4}, esp {:.4})", + target_mhz, + our_error, + esp_error + ); + } + } + + #[test] + fn rejects_out_of_range_frequencies() { + // 700 MHz has an exact divider solution (fb_div 0xa8, ref_div 2, + // post divs 3x1) within the search bounds, so only the frequency + // range check can reject it. + assert_eq!(bm1370().calculate_pll(Frequency::from_mhz(700.0)), None); + assert_eq!(bm1362().calculate_pll(Frequency::from_mhz(600.0)), None); + assert_eq!(bm1370().calculate_pll(Frequency::from_mhz(40.0)), None); + } + + #[test] + fn pll_vco_flag_set_correctly() { + // Flag 0x50 for vco >= 2400 MHz, otherwise 0x40. Both families + // use the same rule; VCO bounds differ but the threshold does not. + for config in [bm1362(), bm1370()] { + for freq_mhz in [100.0, 200.0, 300.0, 400.0, 500.0, 525.0] { + let pll = config.calculate_pll(Frequency::from_mhz(freq_mhz)).unwrap(); + let vco = pll.fb_div as f32 * CRYSTAL_MHZ / pll.ref_div as f32; + let expected = if vco >= 2400.0 { 0x50 } else { 0x40 }; + assert_eq!( + pll.flag, expected, + "{:?} {} MHz: VCO={:.1} should use flag=0x{:02X}, got 0x{:02X}", + config.model, freq_mhz, vco, expected, pll.flag + ); + } + } + } + + #[test] + fn bm1362_and_bm1370_pll_identical_in_shared_vco_range() { + // Within the VCO range both models accept (BM1362 [1600, 3200] + // intersected with BM1370 [2000, 3000]), the PLL search yields + // identical configurations. + let bm1362_config = bm1362(); + let bm1370_config = bm1370(); + for freq_mhz in [100.0, 200.0, 300.0, 400.0, 500.0] { + let freq = Frequency::from_mhz(freq_mhz); + assert_eq!( + bm1362_config.calculate_pll(freq).unwrap(), + bm1370_config.calculate_pll(freq).unwrap(), + "BM1362 and BM1370 should produce identical PLL at {} MHz", + freq_mhz + ); + } + } + + /// Every PLL write an S19 J Pro hashboard emits during its ramp, + /// in the order emitted. Each entry is the payload of a + /// `write_register(pll)` command captured on the serial bus. + /// 76 steps total. + #[rustfmt::skip] + const S19J_PRO_RAMP: &[PllConfig] = &[ + PllConfig { flag: 0x40, fb_div: 0xa2, ref_div: 0x02, post_div: 0x55 }, + PllConfig { flag: 0x40, fb_div: 0xaf, ref_div: 0x02, post_div: 0x64 }, + PllConfig { flag: 0x40, fb_div: 0xa5, ref_div: 0x02, post_div: 0x54 }, + PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x63 }, + PllConfig { flag: 0x40, fb_div: 0xb6, ref_div: 0x02, post_div: 0x63 }, + PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x53 }, + PllConfig { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x53 }, + PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x62 }, + PllConfig { flag: 0x40, fb_div: 0xaa, ref_div: 0x02, post_div: 0x43 }, + PllConfig { flag: 0x40, fb_div: 0xa2, ref_div: 0x02, post_div: 0x52 }, + PllConfig { flag: 0x40, fb_div: 0xab, ref_div: 0x02, post_div: 0x52 }, + PllConfig { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x52 }, + PllConfig { flag: 0x40, fb_div: 0xbd, ref_div: 0x02, post_div: 0x52 }, + PllConfig { flag: 0x40, fb_div: 0xa5, ref_div: 0x02, post_div: 0x42 }, + PllConfig { flag: 0x40, fb_div: 0xa1, ref_div: 0x02, post_div: 0x61 }, + PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x61 }, + PllConfig { flag: 0x40, fb_div: 0xaf, ref_div: 0x02, post_div: 0x61 }, + PllConfig { flag: 0x40, fb_div: 0xb6, ref_div: 0x02, post_div: 0x61 }, + PllConfig { flag: 0x40, fb_div: 0xa2, ref_div: 0x02, post_div: 0x51 }, + PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x51 }, + PllConfig { flag: 0x40, fb_div: 0xae, ref_div: 0x02, post_div: 0x51 }, + PllConfig { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x51 }, + PllConfig { flag: 0x40, fb_div: 0xba, ref_div: 0x02, post_div: 0x51 }, + PllConfig { flag: 0x40, fb_div: 0xa0, ref_div: 0x02, post_div: 0x41 }, + PllConfig { flag: 0x40, fb_div: 0xa5, ref_div: 0x02, post_div: 0x41 }, + PllConfig { flag: 0x40, fb_div: 0xaa, ref_div: 0x02, post_div: 0x41 }, + PllConfig { flag: 0x40, fb_div: 0xaf, ref_div: 0x02, post_div: 0x41 }, + PllConfig { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x41 }, + PllConfig { flag: 0x40, fb_div: 0xb9, ref_div: 0x02, post_div: 0x41 }, + PllConfig { flag: 0x40, fb_div: 0xbe, ref_div: 0x02, post_div: 0x41 }, + PllConfig { flag: 0x50, fb_div: 0xc3, ref_div: 0x02, post_div: 0x41 }, + PllConfig { flag: 0x40, fb_div: 0xa0, ref_div: 0x02, post_div: 0x31 }, + PllConfig { flag: 0x40, fb_div: 0xa4, ref_div: 0x02, post_div: 0x31 }, + PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x31 }, + PllConfig { flag: 0x40, fb_div: 0xac, ref_div: 0x02, post_div: 0x31 }, + PllConfig { flag: 0x40, fb_div: 0xb0, ref_div: 0x02, post_div: 0x31 }, + PllConfig { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x31 }, + PllConfig { flag: 0x40, fb_div: 0xa1, ref_div: 0x02, post_div: 0x60 }, + PllConfig { flag: 0x40, fb_div: 0xbc, ref_div: 0x02, post_div: 0x31 }, + PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x60 }, + PllConfig { flag: 0x50, fb_div: 0xc4, ref_div: 0x02, post_div: 0x31 }, + PllConfig { flag: 0x40, fb_div: 0xaf, ref_div: 0x02, post_div: 0x60 }, + PllConfig { flag: 0x50, fb_div: 0xcc, ref_div: 0x02, post_div: 0x31 }, + PllConfig { flag: 0x40, fb_div: 0xb6, ref_div: 0x02, post_div: 0x60 }, + PllConfig { flag: 0x50, fb_div: 0xd4, ref_div: 0x02, post_div: 0x31 }, + PllConfig { flag: 0x40, fb_div: 0xa2, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x40, fb_div: 0xa5, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x40, fb_div: 0xab, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x40, fb_div: 0xae, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x40, fb_div: 0xb1, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x40, fb_div: 0xb7, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x40, fb_div: 0xba, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x40, fb_div: 0xbd, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x40, fb_div: 0xa0, ref_div: 0x02, post_div: 0x40 }, + PllConfig { flag: 0x50, fb_div: 0xc3, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x40, fb_div: 0xa5, ref_div: 0x02, post_div: 0x40 }, + PllConfig { flag: 0x50, fb_div: 0xc9, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x40, fb_div: 0xaa, ref_div: 0x02, post_div: 0x40 }, + PllConfig { flag: 0x50, fb_div: 0xcf, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x40, fb_div: 0xaf, ref_div: 0x02, post_div: 0x40 }, + PllConfig { flag: 0x50, fb_div: 0xd5, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x40 }, + PllConfig { flag: 0x50, fb_div: 0xdb, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x40, fb_div: 0xb9, ref_div: 0x02, post_div: 0x40 }, + PllConfig { flag: 0x50, fb_div: 0xe1, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x40, fb_div: 0xbe, ref_div: 0x02, post_div: 0x40 }, + PllConfig { flag: 0x50, fb_div: 0xe7, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x50, fb_div: 0xc3, ref_div: 0x02, post_div: 0x40 }, + PllConfig { flag: 0x50, fb_div: 0xed, ref_div: 0x02, post_div: 0x50 }, + PllConfig { flag: 0x40, fb_div: 0xa0, ref_div: 0x02, post_div: 0x30 }, + PllConfig { flag: 0x40, fb_div: 0xa2, ref_div: 0x02, post_div: 0x30 }, + PllConfig { flag: 0x40, fb_div: 0xa4, ref_div: 0x02, post_div: 0x30 }, + PllConfig { flag: 0x40, fb_div: 0xa6, ref_div: 0x02, post_div: 0x30 }, + PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x30 }, + ]; + + /// Derives the target frequency of each captured PLL write in the + /// S19 J Pro ramp, runs `calculate_pll` on it, and asserts our VCO + /// is not higher than the firmware's at any step. Equality + /// counts as success (our_vco == captured_vco), so exact matches + /// pass trivially; mismatches must pick a lower VCO. + #[test] + fn pll_ramp_never_higher_vco_than_firmware() { + let config = bm1362(); + + for &captured in S19J_PRO_RAMP { + let post1 = ((captured.post_div >> 4) & 0xf) + 1; + let post2 = (captured.post_div & 0xf) + 1; + let captured_mhz = + CRYSTAL_MHZ * captured.fb_div as f32 / (captured.ref_div * post1 * post2) as f32; + let captured_vco = CRYSTAL_MHZ * captured.fb_div as f32 / captured.ref_div as f32; + + let pll = config + .calculate_pll(Frequency::from_mhz(captured_mhz)) + .unwrap(); + let our_vco = CRYSTAL_MHZ * pll.fb_div as f32 / pll.ref_div as f32; + + assert!( + our_vco <= captured_vco, + "at {:.4} MHz: captured VCO {:.1}, ours {:.1} (higher than firmware)", + captured_mhz, + captured_vco, + our_vco + ); + } + } +} diff --git a/mujina-miner/src/asic/bm13xx/error.rs b/mujina-miner/src/asic/bm13xx/error.rs index 3cf9c7c3..dd80e6cd 100644 --- a/mujina-miner/src/asic/bm13xx/error.rs +++ b/mujina-miner/src/asic/bm13xx/error.rs @@ -18,7 +18,4 @@ pub enum ProtocolError { #[error("Buffer too small: need {need} bytes, have {have}")] BufferTooSmall { need: usize, have: usize }, - - #[error("Invalid frequency: {mhz} MHz (must be between 50-800 MHz)")] - InvalidFrequency { mhz: u32 }, } diff --git a/mujina-miner/src/asic/bm13xx/mod.rs b/mujina-miner/src/asic/bm13xx/mod.rs index 853baa7c..54c1456c 100644 --- a/mujina-miner/src/asic/bm13xx/mod.rs +++ b/mujina-miner/src/asic/bm13xx/mod.rs @@ -3,6 +3,7 @@ //! This module provides protocol implementation and utilities for //! communicating with BM13xx series mining chips (BM1366, BM1370, etc). +pub mod chip_config; pub mod crc; pub mod error; pub mod protocol; diff --git a/mujina-miner/src/asic/bm13xx/protocol.rs b/mujina-miner/src/asic/bm13xx/protocol.rs index 9b2cbd18..7ead8da6 100644 --- a/mujina-miner/src/asic/bm13xx/protocol.rs +++ b/mujina-miner/src/asic/bm13xx/protocol.rs @@ -11,11 +11,12 @@ use std::{fmt, io}; use strum::FromRepr; use tokio_util::codec::{Decoder, Encoder}; +use super::chip_config::ChipConfig; use super::crc::{crc5, crc5_is_valid, crc16}; use super::error::ProtocolError; use crate::job_source::GeneralPurposeBits; use crate::tracing::prelude::*; -use crate::types::Difficulty; +use crate::types::{Difficulty, Frequency}; /// Wrapper for formatting byte slices as space-separated hex. struct HexBytes<'a>(&'a [u8]); @@ -32,107 +33,26 @@ impl fmt::Display for HexBytes<'_> { } } -/// Mining frequency with validation and PLL calculation -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Frequency { - mhz: f32, -} - -impl Frequency { - /// Minimum supported frequency in MHz - #[allow(dead_code)] - pub const MIN_MHZ: f32 = 50.0; - /// Maximum supported frequency in MHz - #[allow(dead_code)] - pub const MAX_MHZ: f32 = 800.0; - /// Base crystal frequency in MHz - const CRYSTAL_MHZ: f32 = 25.0; - - /// Create frequency from MHz value with validation - #[allow(dead_code)] - pub fn from_mhz(mhz: f32) -> Result { - if !(Self::MIN_MHZ..=Self::MAX_MHZ).contains(&mhz) { - return Err(ProtocolError::InvalidFrequency { mhz: mhz as u32 }); - } - Ok(Self { mhz }) - } - - /// Get frequency in MHz - #[allow(dead_code)] - pub fn mhz(&self) -> f32 { - self.mhz - } - - /// Calculate optimal PLL configuration for this frequency - pub fn calculate_pll(&self) -> PllConfig { - let target_freq = self.mhz; - let mut best_config = PllConfig::new(0xa0, 2, 0x55); // Default - let mut min_error = f32::MAX; - - // Search for optimal PLL settings - // ref_divider: 1 or 2 - // post_divider1: 1-7, must be >= post_divider2 - // post_divider2: 1-7 - // fb_divider: 0xa0-0xef (160-239) - - for ref_div in [2, 1] { - for post_div1 in (1..=7).rev() { - for post_div2 in (1..=7).rev() { - if post_div1 >= post_div2 { - // Calculate required feedback divider - let fb_div_f = - (post_div1 * post_div2) as f32 * target_freq * ref_div as f32 - / Self::CRYSTAL_MHZ; - let fb_div = fb_div_f.round() as u8; - - if (0xa0..=0xef).contains(&fb_div) { - // Calculate actual frequency with these settings - let actual_freq = Self::CRYSTAL_MHZ * fb_div as f32 - / (ref_div as f32 * post_div1 as f32 * post_div2 as f32); - let error = (target_freq - actual_freq).abs(); - - if error < min_error && error < 1.0 { - min_error = error; - // Encode post dividers as per hardware format - let post_div = ((post_div1 - 1) << 4) | (post_div2 - 1); - best_config = PllConfig::new(fb_div, ref_div, post_div); - } - } - } - } - } - } - - best_config - } -} - -/// PLL configuration for frequency control +/// PLL configuration for frequency control. #[derive(Debug, Clone, Copy, PartialEq)] pub struct PllConfig { - /// VCO control flag (0x40 for VCO < 2400 MHz, 0x50 for VCO >= 2400 MHz) + /// VCO control flag (0x40 for low VCO, 0x50 for high VCO). pub flag: u8, - /// Feedback divider (0xa0-0xef = 160-239) + /// Feedback divider. pub fb_div: u8, - /// Reference divider (1 or 2) + /// Reference divider (typically 1 or 2). pub ref_div: u8, - /// Post divider encoded value + /// Post divider, encoded as `((post_div1-1) << 4) | (post_div2-1)`. pub post_div: u8, } impl PllConfig { - /// Create a new PLL configuration - /// - /// Automatically calculates the VCO control flag based on frequency: - /// - 0x40 if VCO frequency < 2400 MHz - /// - 0x50 if VCO frequency >= 2400 MHz - /// - /// where VCO frequency = fb_div * 25.0 / ref_div + /// Create a PLL configuration, deriving the VCO flag from the + /// resulting VCO frequency: `vco >= 2400 MHz` picks flag `0x50`, + /// otherwise flag `0x40`. pub fn new(fb_div: u8, ref_div: u8, post_div: u8) -> Self { - // Calculate VCO frequency to determine flag - let vco_freq = (fb_div as f32) * 25.0 / (ref_div as f32); - let flag = if vco_freq >= 2400.0 { 0x50 } else { 0x40 }; - + let vco_mhz = fb_div as f32 * 25.0 / ref_div as f32; + let flag = if vco_mhz >= 2400.0 { 0x50 } else { 0x40 }; Self { flag, fb_div, @@ -1321,78 +1241,6 @@ mod init_tests { } } - #[test] - fn pll_calculation_produces_valid_frequencies() { - // Test cases from serial captures showing PLL values sent by esp-miner - // Note: esp-miner uses first-found algorithm while we find optimal settings - // Format: (target_mhz, [fb_div, ref_div, post_div] from esp-miner) - let test_cases = vec![ - (62.5, [0xd2, 0x02, 0x65]), // 62.50MHz - (75.0, [0xd2, 0x02, 0x64]), // 75.00MHz - (100.0, [0xe0, 0x02, 0x63]), // 100.00MHz - (400.0, [0xe0, 0x02, 0x60]), // 400.00MHz - (500.0, [0xa2, 0x02, 0x30]), // 500.00MHz -> esp-miner gives 506.25MHz - ]; - - for (target_mhz, esp_miner_raw) in test_cases { - let freq = Frequency::from_mhz(target_mhz).unwrap(); - let pll = freq.calculate_pll(); - - // Calculate actual frequencies for both esp-miner and our values - let esp_post_div1 = ((esp_miner_raw[2] >> 4) & 0xf) + 1; - let esp_post_div2 = (esp_miner_raw[2] & 0xf) + 1; - let esp_actual_mhz = 25.0 * esp_miner_raw[0] as f32 - / (esp_miner_raw[1] as f32 * esp_post_div1 as f32 * esp_post_div2 as f32); - - let our_post_div1 = ((pll.post_div >> 4) & 0xf) + 1; - let our_post_div2 = (pll.post_div & 0xf) + 1; - let our_actual_mhz = 25.0 * pll.fb_div as f32 - / (pll.ref_div as f32 * our_post_div1 as f32 * our_post_div2 as f32); - - // Calculate errors - let esp_error = (target_mhz - esp_actual_mhz).abs(); - let our_error = (target_mhz - our_actual_mhz).abs(); - - println!("Target: {:.2}MHz", target_mhz); - println!( - " esp-miner: fb={:#04x} ref={} post={:#04x} -> {:.2}MHz (error: {:.4}MHz)", - esp_miner_raw[0], esp_miner_raw[1], esp_miner_raw[2], esp_actual_mhz, esp_error - ); - println!( - " Our calc: fb={:#04x} ref={} post={:#04x} -> {:.2}MHz (error: {:.4}MHz)", - pll.fb_div, pll.ref_div, pll.post_div, our_actual_mhz, our_error - ); - - // Verify our calculation produces valid PLL parameters - assert!( - pll.fb_div >= 0xa0 && pll.fb_div <= 0xef, - "fb_div out of range: {:#04x}", - pll.fb_div - ); - assert!( - pll.ref_div == 1 || pll.ref_div == 2, - "ref_div invalid: {}", - pll.ref_div - ); - - // Verify our error is reasonable (within 1MHz) - assert!( - our_error < 1.0, - "Frequency error too large: {:.2}MHz for target {}MHz", - our_error, - target_mhz - ); - - // Our algorithm should produce equal or better results - // Allow small tolerance for floating point comparison - assert!( - our_error <= esp_error + 0.01, - "Our algorithm produced worse result than esp-miner for {}MHz", - target_mhz - ); - } - } - #[test] fn nonce_range_configuration() { let protocol = BM13xxProtocol::new(); @@ -2481,23 +2329,25 @@ impl BM13xxProtocol { } } - /// Get the initialization sequence for a single chip (e.g., Bitaxe). + /// Returns the initialization sequence for a single chip (e.g., Bitaxe). /// - /// Returns a vector of commands to configure the chip for mining: - /// 1. Set PLL parameters for desired frequency - /// 2. Enable version rolling if supported - /// 3. Configure other chip-specific settings - pub fn single_chip_init(&self, frequency: Frequency) -> Vec { - let mut commands = Vec::new(); - - // Enable version rolling with mask 0xFFFF - commands.push(self.broadcast_write(Register::VersionMask(VersionMask::full_rolling()))); - - // Configure PLL for desired frequency - let pll_config = frequency.calculate_pll(); - commands.push(self.broadcast_write(Register::PllDivider(pll_config))); - - commands + /// The commands configure the chip for mining: + /// 1. Enable version rolling + /// 2. Set PLL parameters for desired frequency using the chip's + /// family-specific PLL parameters + /// + /// Returns `None` when `frequency` is unreachable for this chip + /// model. + pub fn single_chip_init( + &self, + chip_config: &ChipConfig, + frequency: Frequency, + ) -> Option> { + let pll_config = chip_config.calculate_pll(frequency)?; + Some(vec![ + self.broadcast_write(Register::VersionMask(VersionMask::full_rolling())), + self.broadcast_write(Register::PllDivider(pll_config)), + ]) } /// Initialize a multi-chip chain (e.g., S21 Pro, S19 J Pro). From fc9c0b6ec05f535cc3866300a767af4cca38cb41 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Wed, 22 Apr 2026 15:24:40 -0500 Subject: [PATCH 05/38] fix(tps546): drop CP bit from ON_OFF_CONFIG With CP set, the regulator gates output on both the CONTROL pin and the OPERATION command; noise on the CONTROL pin can glitch the regulator off. Without CP, only the software OPERATION command drives on/off. --- mujina-miner/src/peripheral/tps546.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mujina-miner/src/peripheral/tps546.rs b/mujina-miner/src/peripheral/tps546.rs index be3cd625..9bd49388 100644 --- a/mujina-miner/src/peripheral/tps546.rs +++ b/mujina-miner/src/peripheral/tps546.rs @@ -150,11 +150,11 @@ impl Tps546 { .await?; debug!("Power output turned off"); - // Configure ON_OFF_CONFIG immediately after turning off (esp-miner sequence) - // Using same configuration as esp-miner - both CONTROL pin and OPERATION command + // Configure ON_OFF_CONFIG immediately after turning off. + // OPERATION command only: setting CP would also key output state + // to the CONTROL pin, where noise can glitch the regulator off. let on_off_config = pmbus::OnOffConfig::DELAY | pmbus::OnOffConfig::POLARITY - | pmbus::OnOffConfig::CP | pmbus::OnOffConfig::CMD | pmbus::OnOffConfig::PU; self.write_byte(PmbusCommand::OnOffConfig, on_off_config.bits()) From c57fdc10fc32f7d3505de4923f15a6822702dfca Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Wed, 22 Apr 2026 15:28:53 -0500 Subject: [PATCH 06/38] fix(tps546): use family name in doc comments and logs The driver resolves both D24A and D24S variants at runtime via device ID, so labeling it as a D24A-specific driver is misleading. Refer to the TPS546 family consistently in the module doc, struct doc, and log messages. --- mujina-miner/src/peripheral/tps546.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/mujina-miner/src/peripheral/tps546.rs b/mujina-miner/src/peripheral/tps546.rs index 9bd49388..7dc4691d 100644 --- a/mujina-miner/src/peripheral/tps546.rs +++ b/mujina-miner/src/peripheral/tps546.rs @@ -1,7 +1,7 @@ -//! TPS546D24A Power Management Controller Driver +//! TPS546 Power Management Controller Driver //! -//! This module provides a driver for the Texas Instruments TPS546D24A -//! synchronous buck converter with PMBus interface. +//! This module provides a driver for the Texas Instruments TPS546 +//! family of synchronous buck converters with PMBus interface. //! //! Datasheet: @@ -117,7 +117,7 @@ pub enum Tps546Error { FaultDetected(String), } -/// TPS546D24A driver +/// TPS546 driver pub struct Tps546 { i2c: I2C, config: Tps546Config, @@ -137,7 +137,7 @@ impl Tps546 { /// Initialize the TPS546 pub async fn init(&mut self) -> Result<()> { - debug!("Initializing TPS546D24A power regulator"); + debug!("Initializing TPS546 power regulator"); // First verify device ID to ensure I2C communication is working self.verify_device_id().await?; @@ -784,7 +784,7 @@ impl Tps546 { /// Dump the complete TPS546 configuration for debugging pub async fn dump_configuration(&mut self) -> Result<()> { - debug!("=== TPS546D24A Configuration Dump ==="); + debug!("=== TPS546 Configuration Dump ==="); // Voltage Configuration debug!("--- Voltage Configuration ---"); From 454a4c4ba21a366bfb391fff82258feac0e1e8e0 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Wed, 22 Apr 2026 15:30:51 -0500 Subject: [PATCH 07/38] refactor(tps546): add PMBus voltage control primitives Add three methods that map directly to individual PMBus operations: - set_vout_target: writes VOUT_COMMAND with range validation - enable_output: writes OPERATION = ON and fails unless it reads back - disable_output: writes OPERATION = OFF (immediate) and fails unless it reads back These let callers compose exactly the operations they need: board init calls set_vout_target then clear_faults then enable_output; shutdown calls disable_output; voltage-frequency ramping calls set_vout_target alone. The existing set_vout method stays; callers migrate next. --- mujina-miner/src/peripheral/tps546.rs | 52 +++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/mujina-miner/src/peripheral/tps546.rs b/mujina-miner/src/peripheral/tps546.rs index 7dc4691d..2485fde5 100644 --- a/mujina-miner/src/peripheral/tps546.rs +++ b/mujina-miner/src/peripheral/tps546.rs @@ -115,6 +115,8 @@ pub enum Tps546Error { VoltageOutOfRange(f32, f32, f32), #[error("PMBus fault detected: {0}")] FaultDetected(String), + #[error("OPERATION reads back 0x{readback:02x} after commanding 0x{commanded:02x}")] + OperationNotAccepted { commanded: u8, readback: u8 }, } /// TPS546 driver @@ -495,6 +497,56 @@ impl Tps546 { Ok(()) } + /// Sets the target voltage without changing the output state. + pub async fn set_vout_target(&mut self, volts: f32) -> Result<()> { + if volts < self.config.vout_min || volts > self.config.vout_max { + bail!(Tps546Error::VoltageOutOfRange( + volts, + self.config.vout_min, + self.config.vout_max + )); + } + + let value = self.encode_voltage(volts).await?; + self.write_word(PmbusCommand::VoutCommand, value).await?; + debug!("VOUT_COMMAND set to {:.2}V", volts); + Ok(()) + } + + /// Enables the output (OPERATION = ON). + /// + /// Fails unless the command reads back from the device. + pub async fn enable_output(&mut self) -> Result<()> { + self.write_operation(pmbus::Operation::On).await?; + debug!("Output enabled"); + Ok(()) + } + + /// Disables the output immediately (OPERATION = OFF). + /// + /// Fails unless the command reads back from the device. + pub async fn disable_output(&mut self) -> Result<()> { + self.write_operation(pmbus::Operation::OffImmediate).await?; + debug!("Output disabled"); + Ok(()) + } + + /// Writes OPERATION and confirms the device accepted it. + /// + /// A PMBus write can be acknowledged on the bus yet ignored by + /// the device, so read the register back and fail on mismatch. + async fn write_operation(&mut self, op: pmbus::Operation) -> Result<()> { + self.write_byte(PmbusCommand::Operation, op.as_u8()).await?; + let readback = self.read_byte(PmbusCommand::Operation).await?; + if readback != op.as_u8() { + bail!(Tps546Error::OperationNotAccepted { + commanded: op.as_u8(), + readback, + }); + } + Ok(()) + } + /// Set output voltage pub async fn set_vout(&mut self, volts: f32) -> Result<()> { if volts == 0.0 { From 3c3160e2f4bc4ebe68ef5a6d948b22756612fdae Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Wed, 22 Apr 2026 15:32:41 -0500 Subject: [PATCH 08/38] refactor(bitaxe): use TPS546 primitives directly Init now calls set_vout_target then clear_faults then enable_output in sequence; shutdown calls disable_output. The monolithic set_vout helper cleared faults implicitly and only logged the result of its post-enable readbacks; the primitives now confirm their own OPERATION writes and fail init when the enable does not take. The old status-word diagnostics are gone: a one-shot status peek during soft-start is ambiguous, and continuous power-state monitoring is future work. --- mujina-miner/src/board/bitaxe.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 218c535c..67ecf099 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -454,9 +454,9 @@ impl Bitaxe { warn!("Failed to hold chips in reset: {}", e); } - match self.regulator.lock().await.set_vout(0.0).await { + match self.regulator.lock().await.disable_output().await { Ok(()) => debug!("Core voltage turned off"), - Err(e) => warn!("Failed to turn off core voltage: {}", e), + Err(e) => warn!("Failed to disable output: {}", e), } } } @@ -523,9 +523,17 @@ async fn init_power_controller(i2c: BitaxeRawI2c) -> Result const DEFAULT_VOUT: f32 = 1.15; tps546 - .set_vout(DEFAULT_VOUT) + .set_vout_target(DEFAULT_VOUT) .await - .context("failed to set core voltage")?; + .context("failed to set core voltage target")?; + tps546 + .clear_faults() + .await + .context("failed to clear faults")?; + tps546 + .enable_output() + .await + .context("failed to enable output")?; debug!("Core voltage set to {DEFAULT_VOUT}V"); time::sleep(Duration::from_millis(500)).await; From 892e2f4d47775364a2e761d157b0c662645c9c1e Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Wed, 22 Apr 2026 15:37:48 -0500 Subject: [PATCH 09/38] refactor(tps546): remove obsolete set_vout helper The monolithic set_vout method has no callers after Bitaxe migrated to composing set_vout_target, clear_faults, and enable_output directly. Drop it. --- mujina-miner/src/peripheral/tps546.rs | 62 --------------------------- 1 file changed, 62 deletions(-) diff --git a/mujina-miner/src/peripheral/tps546.rs b/mujina-miner/src/peripheral/tps546.rs index 2485fde5..3c2d0b59 100644 --- a/mujina-miner/src/peripheral/tps546.rs +++ b/mujina-miner/src/peripheral/tps546.rs @@ -547,68 +547,6 @@ impl Tps546 { Ok(()) } - /// Set output voltage - pub async fn set_vout(&mut self, volts: f32) -> Result<()> { - if volts == 0.0 { - // Turn off output - self.write_byte( - PmbusCommand::Operation, - pmbus::Operation::OffImmediate.as_u8(), - ) - .await?; - debug!("Output voltage turned off"); - } else { - // Check voltage range - if volts < self.config.vout_min || volts > self.config.vout_max { - bail!(Tps546Error::VoltageOutOfRange( - volts, - self.config.vout_min, - self.config.vout_max - )); - } - - // Set voltage - let value = self.encode_voltage(volts).await?; - self.write_word(PmbusCommand::VoutCommand, value).await?; - debug!("Output voltage set to {:.2}V", volts); - - // Clear any faults before turning on - self.clear_faults().await?; - debug!("Cleared faults before turn-on"); - - // Turn on output - self.write_byte(PmbusCommand::Operation, pmbus::Operation::On.as_u8()) - .await?; - - // Verify operation - let op_val = self.read_byte(PmbusCommand::Operation).await?; - if op_val != pmbus::Operation::On.as_u8() { - error!("Failed to turn on output, OPERATION = 0x{:02X}", op_val); - } else { - debug!("Power turned ON successfully, OPERATION = 0x{:02X}", op_val); - } - - // Check immediate status after turn-on - let status = self.read_word(PmbusCommand::StatusWord).await?; - if pmbus::StatusWord::from_bits_truncate(status).contains(pmbus::StatusWord::OFF) { - error!( - "WARNING: Power is still OFF after turn-on command! STATUS_WORD = 0x{:04X}", - status - ); - // Read all status registers to understand why - if let Ok(vout_status) = self.read_byte(PmbusCommand::StatusVout).await { - error!(" STATUS_VOUT: 0x{:02X}", vout_status); - } - if let Ok(iout_status) = self.read_byte(PmbusCommand::StatusIout).await { - error!(" STATUS_IOUT: 0x{:02X}", iout_status); - } - } else { - debug!("Power is ON, STATUS_WORD = 0x{:04X}", status); - } - } - Ok(()) - } - /// Read input voltage in millivolts pub async fn get_vin(&mut self) -> Result { let value = self.read_word(PmbusCommand::ReadVin).await?; From 7d6ad3b90942dc980e192ac5b1334f5b63e399b4 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Wed, 22 Apr 2026 22:15:12 -0500 Subject: [PATCH 10/38] fix(bm13xx): correct JobFull length byte to match factory firmware Emit 54 (0x36) instead of 86 for the JobFull length byte. Factory firmware captures use 54 on both BM1362 (S19 J Pro) and BM1370 (S21 Pro), and BM1362 EmberOne hardware has mined successfully with 54 in prior prototypes. esp-miner on Bitaxe sends 86 instead; BM1370 seems to tolerate both. Record a structural hypothesis for this specific value in an inline comment. In the tests that compared full-frame bytes against esp-miner captures, compare only the job-data range (bytes 4..86), excluding the length byte and the trailing CRC16, which intentionally diverge. Add a job frame captured from S19 J Pro factory firmware as a fixture, and assert that encoding its fields reproduces the captured frame down to the length byte and CRC16. --- mujina-miner/src/asic/bm13xx/protocol.rs | 96 +++++++++++++++++------ mujina-miner/src/asic/bm13xx/test_data.rs | 24 +++++- 2 files changed, 94 insertions(+), 26 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/protocol.rs b/mujina-miner/src/asic/bm13xx/protocol.rs index 7ead8da6..1ed25888 100644 --- a/mujina-miner/src/asic/bm13xx/protocol.rs +++ b/mujina-miner/src/asic/bm13xx/protocol.rs @@ -882,13 +882,19 @@ impl Command { CommandFlagsCmd::WriteRegisterOrJob, )); - const JOB_DATA_LEN: u8 = 82; // Size of JobFullFormat - const FLAGS_LEN: u8 = 1; - const LENGTH_FIELD_LEN: u8 = 1; - const CRC_LEN: u8 = 2; // Jobs use CRC16, not CRC5 - const TOTAL_LEN: u8 = FLAGS_LEN + LENGTH_FIELD_LEN + JOB_DATA_LEN + CRC_LEN; - - dst.put_u8(TOTAL_LEN); + // Captures from factory firmware use this value on both BM1362 + // (S19 J Pro) and BM1370 (S21 Pro). esp-miner firmware on + // Bitaxe sends 86 instead; the BM1370 appears to tolerate + // both. + // + // Hypothesis for why 54: a single midstate JobMidstate frame + // is exactly 54 bytes on the wire (flags + length + header + + // midstate0 + crc16). JobFull transmits 88 bytes but declares + // its length as if the frame were in midstate format, where + // prev_block_hash is folded into midstate0 rather than sent + // raw. + const JOB_LENGTH_BYTE: u8 = 54; + dst.put_u8(JOB_LENGTH_BYTE); // Write job data // job_id is a 4-bit value (0-15), encode into bits 6-3 of job_header @@ -1314,6 +1320,11 @@ mod init_tests { #[cfg(test)] mod command_tests { use super::*; + use crate::asic::bm13xx::test_data::s19jpro_job; + use bitcoin::block::Version; + use bitcoin::hash_types::TxMerkleNode; + use bitcoin::hashes::Hash; + use bitcoin::{BlockHash, CompactTarget}; #[test] fn read_register() { @@ -1518,7 +1529,7 @@ mod command_tests { // Verify packet structure assert_eq!(&frame[0..2], &[0x55, 0xaa]); // Preamble assert_eq!(frame[2], 0x21); // TYPE_JOB | GROUP_SINGLE | CMD_WRITE - assert_eq!(frame[3], 86); // Total length + assert_eq!(frame[3], 54); // Length byte per factory captures (not a byte count) assert_eq!(frame[4], job.job_id); assert_eq!(frame[5], job.num_midstates); assert_eq!(&frame[6..10], &job.starting_nonce.to_le_bytes()); @@ -1593,12 +1604,52 @@ mod command_tests { ) .expect("Failed to encode job command"); - // Verify our encoding exactly matches the hardware capture - assert_eq!( - frame.as_ref(), - &esp_miner_job::wire_tx::FRAME, - "JobFull encoding doesn't match hardware capture" - ); + // Our body bytes match esp-miner's wire capture. Byte 3 (length byte) + // and bytes 86..88 (CRC16) intentionally differ; see JOB_LENGTH_BYTE. + assert_eq!(&frame[4..86], &esp_miner_job::wire_tx::FRAME[4..86]); + } + + #[test] + fn job_full_matches_s19jpro_factory_capture() { + let job = job_full_from_wire(&s19jpro_job::wire_tx::FRAME); + + let mut codec = FrameCodec; + let mut frame = BytesMut::new(); + codec + .encode(Command::JobFull { job_data: job }, &mut frame) + .expect("Failed to encode job command"); + + // Byte-for-byte match, length byte and CRC16 included: the + // encoder reproduces factory firmware exactly. + assert_eq!(&frame[..], &s19jpro_job::wire_tx::FRAME[..]); + } + + /// Builds a JobFullFormat from a captured JobFull wire frame. + fn job_full_from_wire(frame: &[u8; 88]) -> JobFullFormat { + // The wire sends each 32-byte hash as eight 4-byte words, most + // significant word first; internal order reverses the words. + fn hash_from_wire(wire: &[u8]) -> [u8; 32] { + let mut internal = [0u8; 32]; + for i in 0..8 { + internal[(7 - i) * 4..(8 - i) * 4].copy_from_slice(&wire[i * 4..(i + 1) * 4]); + } + internal + } + + JobFullFormat { + job_id: frame[4] >> 3, + num_midstates: frame[5], + starting_nonce: u32::from_le_bytes(frame[6..10].try_into().unwrap()), + nbits: CompactTarget::from_consensus(u32::from_le_bytes( + frame[10..14].try_into().unwrap(), + )), + ntime: u32::from_le_bytes(frame[14..18].try_into().unwrap()), + merkle_root: TxMerkleNode::from_byte_array(hash_from_wire(&frame[18..50])), + prev_block_hash: BlockHash::from_byte_array(hash_from_wire(&frame[50..82])), + version: Version::from_consensus( + u32::from_le_bytes(frame[82..86].try_into().unwrap()) as i32 + ), + } } fn assert_frame_eq(cmd: Command, expect: &[u8]) { @@ -1651,12 +1702,9 @@ mod command_tests { .encode(Command::JobFull { job_data: job }, &mut frame) .expect("Failed to encode job command"); - // Verify our encoding exactly matches the wire capture - assert_eq!( - frame.as_ref(), - &esp_miner_job::wire_tx::FRAME, - "JobFull encoding doesn't match hardware capture" - ); + // Our body bytes match esp-miner's wire capture. Byte 3 (length byte) + // and bytes 86..88 (CRC16) intentionally differ; see JOB_LENGTH_BYTE. + assert_eq!(&frame[4..86], &esp_miner_job::wire_tx::FRAME[4..86]); } } @@ -2139,11 +2187,9 @@ mod response_tests { ) .expect("Should encode JobFull command"); - assert_eq!( - tx_frame.as_ref(), - &esp_miner_job::wire_tx::FRAME, - "TX frame should match hardware capture" - ); + // Our body bytes match esp-miner's wire capture. Byte 3 (length byte) + // and bytes 86..88 (CRC16) intentionally differ; see JOB_LENGTH_BYTE. + assert_eq!(&tx_frame[4..86], &esp_miner_job::wire_tx::FRAME[4..86]); let rx_response = decode_frame(&esp_miner_job::wire_rx::FRAME).expect("Should decode RX frame"); diff --git a/mujina-miner/src/asic/bm13xx/test_data.rs b/mujina-miner/src/asic/bm13xx/test_data.rs index 1d67706f..e648bb16 100644 --- a/mujina-miner/src/asic/bm13xx/test_data.rs +++ b/mujina-miner/src/asic/bm13xx/test_data.rs @@ -1,7 +1,8 @@ //! Test data from real mining hardware captures. //! //! This module provides known-good test data extracted from actual chip -//! communication on Bitaxe Gamma (single BM1370). +//! communication on Bitaxe Gamma (single BM1370) and from S19 J Pro +//! factory firmware (BM1362 chain). //! //! This module serves as a rosetta stone between Stratum v1, Rust Bitcoin's //! internal format, and the BM13xx wire protocol. It demonstrates the correct @@ -702,3 +703,24 @@ pub mod esp_miner_job { } } } + +/// Job frame captured from S19 J Pro factory firmware (BM1362 chain). +pub mod s19jpro_job { + /// Wire protocol TX frame (job broadcast to chips). + pub mod wire_tx { + /// Complete wire frame (TX to chips). + /// + /// Factory firmware declares the JobFull length byte as 54 + /// (0x36), unlike esp-miner's 86. This frame pins the encoder's + /// length byte and CRC16 to factory behavior. + pub const FRAME: [u8; 88] = [ + 0x55, 0xAA, 0x21, 0x36, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x26, 0x77, 0x02, 0x17, + 0x6E, 0x49, 0xB6, 0x67, 0x90, 0x52, 0x3E, 0x5B, 0x37, 0xDF, 0x50, 0x4A, 0xE0, 0xA1, + 0x3F, 0xC0, 0xF2, 0xCB, 0x93, 0xB9, 0x4A, 0x6B, 0x42, 0x22, 0x4F, 0x75, 0x21, 0x63, + 0x14, 0x76, 0xB5, 0xD6, 0xDC, 0x20, 0xCC, 0x27, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xDD, 0x41, 0x00, 0x00, 0x7D, 0xF8, 0xA5, 0x87, 0xA3, 0x1D, 0xD9, 0xFF, + 0xE1, 0xCC, 0x3A, 0xAD, 0x8B, 0x1F, 0x17, 0xEE, 0xFA, 0x02, 0x84, 0x08, 0x00, 0x00, + 0x00, 0x20, 0x62, 0xB9, + ]; + } +} From 942afa3052292a9392d23e0ffc53e2c2462eb326 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Thu, 23 Apr 2026 09:16:27 -0500 Subject: [PATCH 11/38] test(bm13xx): drop duplicate job_full_encoding test Drop job_full_encoding_matches_hardware_capture, a longtime duplicate of job_full_matches_esp_miner_capture. --- mujina-miner/src/asic/bm13xx/protocol.rs | 31 ------------------------ 1 file changed, 31 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/protocol.rs b/mujina-miner/src/asic/bm13xx/protocol.rs index 1ed25888..ce5baf4d 100644 --- a/mujina-miner/src/asic/bm13xx/protocol.rs +++ b/mujina-miner/src/asic/bm13xx/protocol.rs @@ -1675,37 +1675,6 @@ mod command_tests { .collect::>() .join(" ") } - - #[test] - fn job_full_encoding_matches_hardware_capture() { - use crate::asic::bm13xx::test_data::esp_miner_job; - - // Build JobFullFormat from Bitcoin types and verify it encodes to exact wire bytes - let job = JobFullFormat { - job_id: *esp_miner_job::wire_tx::JOB_ID, - num_midstates: esp_miner_job::wire_tx::NUM_MIDSTATES_BYTE[0], - starting_nonce: u32::from_le_bytes( - (*esp_miner_job::wire_tx::STARTING_NONCE_BYTES) - .try_into() - .unwrap(), - ), - nbits: *esp_miner_job::wire_tx::NBITS, - ntime: *esp_miner_job::wire_tx::NTIME, - merkle_root: *esp_miner_job::wire_tx::MERKLE_ROOT, - prev_block_hash: *esp_miner_job::wire_tx::PREV_BLOCKHASH, - version: *esp_miner_job::wire_tx::VERSION, - }; - - let mut codec = FrameCodec; - let mut frame = BytesMut::new(); - codec - .encode(Command::JobFull { job_data: job }, &mut frame) - .expect("Failed to encode job command"); - - // Our body bytes match esp-miner's wire capture. Byte 3 (length byte) - // and bytes 86..88 (CRC16) intentionally differ; see JOB_LENGTH_BYTE. - assert_eq!(&frame[4..86], &esp_miner_job::wire_tx::FRAME[4..86]); - } } #[cfg(test)] From 412528a79d552de11c8bb4a12ca9fdaf3e62355e Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Thu, 23 Apr 2026 12:43:05 -0500 Subject: [PATCH 12/38] refactor(bm13xx): split Command by frame family The wire protocol has two frame families with different framing rules: TYPE=2 register ops use CRC5 with a self-describing length byte, and TYPE=1 jobs use CRC16 with a fixed conventional length. One Command enum covered both, forcing the encoder to re-inspect the variant at runtime to pick the right CRC. Give each family its own enum and FrameCodec an Encoder impl per type, so CRC selection becomes compile-time dispatch. The hash thread still drives register ops and jobs through a single underlying sink because Framed satisfies Sink and Sink simultaneously. --- mujina-miner/src/asic/bm13xx/protocol.rs | 195 +++++++++++++---------- mujina-miner/src/asic/bm13xx/thread.rs | 44 ++--- mujina-miner/src/board/bitaxe.rs | 4 +- 3 files changed, 137 insertions(+), 106 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/protocol.rs b/mujina-miner/src/asic/bm13xx/protocol.rs index ce5baf4d..a0e96fcb 100644 --- a/mujina-miner/src/asic/bm13xx/protocol.rs +++ b/mujina-miner/src/asic/bm13xx/protocol.rs @@ -705,8 +705,9 @@ pub fn hash_from_wire_bytes(wire_bytes: &[u8; 32]) -> [u8; 32] { hash } +/// TYPE=2 frames: register reads/writes and chain addressing. Use CRC5. #[derive(Debug)] -pub enum Command { +pub enum RegisterCommand { /// Assign an address to the first unaddressed chip via daisy-chain forwarding SetChipAddress { chip_address: u8 }, /// Put all chips into addressing mode (enables daisy-chain forwarding) @@ -723,6 +724,11 @@ pub enum Command { chip_address: u8, register: Register, }, +} + +/// TYPE=1 frames: mining jobs. Use CRC16. +#[derive(Debug)] +pub enum JobCommand { /// Send a job with full block header (BM1370/BM1362 style) /// Chip calculates midstates internally JobFull { job_data: JobFullFormat }, @@ -773,20 +779,20 @@ pub struct JobMidstateFormat { pub midstate3: Option<[u8; 32]>, // Optional for version rolling } -impl Command { - fn build_flags(typ: CommandFlagsType, broadcast: bool, cmd: CommandFlagsCmd) -> u8 { - let mut flags = 0u8; - let field = flags.view_bits_mut::(); - field[5..7].store(typ as u8); - field[4..5].store(broadcast as u8); - field[0..4].store(cmd as u8); - flags - } +fn build_flags(typ: CommandFlagsType, broadcast: bool, cmd: CommandFlagsCmd) -> u8 { + let mut flags = 0u8; + let field = flags.view_bits_mut::(); + field[5..7].store(typ as u8); + field[4..5].store(broadcast as u8); + field[0..4].store(cmd as u8); + flags +} +impl RegisterCommand { fn encode(&self, dst: &mut BytesMut) { match self { - Command::SetChipAddress { chip_address } => { - dst.put_u8(Self::build_flags( + Self::SetChipAddress { chip_address } => { + dst.put_u8(build_flags( CommandFlagsType::Command, false, // Never broadcast CommandFlagsCmd::SetChipAddress, @@ -804,8 +810,8 @@ impl Command { dst.put_u8(*chip_address); dst.put_u8(0x00); // Reserved byte (always 0x00) } - Command::ChainInactive => { - dst.put_u8(Self::build_flags( + Self::ChainInactive => { + dst.put_u8(build_flags( CommandFlagsType::Command, true, // Always broadcast CommandFlagsCmd::ChainInactive, @@ -823,12 +829,12 @@ impl Command { dst.put_u8(0x00); // Reserved byte dst.put_u8(0x00); // Reserved byte } - Command::ReadRegister { + Self::ReadRegister { broadcast, chip_address, register_address, } => { - dst.put_u8(Self::build_flags( + dst.put_u8(build_flags( CommandFlagsType::Command, *broadcast, CommandFlagsCmd::ReadRegister, @@ -846,12 +852,12 @@ impl Command { dst.put_u8(*chip_address); dst.put_u8(*register_address as u8); } - Command::WriteRegister { + Self::WriteRegister { broadcast, chip_address, register, } => { - dst.put_u8(Self::build_flags( + dst.put_u8(build_flags( CommandFlagsType::Command, *broadcast, CommandFlagsCmd::WriteRegisterOrJob, @@ -875,8 +881,15 @@ impl Command { dst.put_u8(register.address() as u8); register.encode_data(dst); } - Command::JobFull { job_data } => { - dst.put_u8(Self::build_flags( + } + } +} + +impl JobCommand { + fn encode(&self, dst: &mut BytesMut) { + match self { + Self::JobFull { job_data } => { + dst.put_u8(build_flags( CommandFlagsType::Job, false, // Jobs are never broadcast CommandFlagsCmd::WriteRegisterOrJob, @@ -915,8 +928,8 @@ impl Command { dst.put_u32_le(job_data.version.to_consensus() as u32); } - Command::JobMidstate { job_data } => { - dst.put_u8(Self::build_flags( + Self::JobMidstate { job_data } => { + dst.put_u8(build_flags( CommandFlagsType::Job, false, // Jobs are never broadcast CommandFlagsCmd::WriteRegisterOrJob, @@ -1046,32 +1059,41 @@ impl Response { #[derive(Default)] pub struct FrameCodec; -impl Encoder for FrameCodec { +impl Encoder for FrameCodec { type Error = io::Error; - fn encode(&mut self, command: Command, dst: &mut BytesMut) -> Result<(), Self::Error> { + fn encode(&mut self, command: RegisterCommand, dst: &mut BytesMut) -> Result<(), Self::Error> { const PREAMBLE: [u8; 2] = [0x55, 0xaa]; dst.put_slice(&PREAMBLE); let start_pos = dst.len(); command.encode(dst); + let crc = crc5(&dst[start_pos..]); + dst.put_u8(crc); - // Jobs use CRC16, other commands use CRC5 - match &command { - Command::JobFull { .. } | Command::JobMidstate { .. } => { - // Calculate CRC16 over flags + length + data - let crc = crc16(&dst[start_pos..]); - // Wire format: CRC transmitted big-endian (high byte, low byte) - dst.put_slice(&crc.to_be_bytes()); - } - _ => { - // Calculate CRC5 over everything after preamble - let crc = crc5(&dst[2..]); - dst.put_u8(crc); - } - } + trace!( + cmd = ?command, + bytes = dst.len(), + frame = %HexBytes(dst.as_ref()), + "TX BM13xx" + ); + + Ok(()) + } +} + +impl Encoder for FrameCodec { + type Error = io::Error; + + fn encode(&mut self, command: JobCommand, dst: &mut BytesMut) -> Result<(), Self::Error> { + const PREAMBLE: [u8; 2] = [0x55, 0xaa]; + dst.put_slice(&PREAMBLE); + + let start_pos = dst.len(); + command.encode(dst); + let crc = crc16(&dst[start_pos..]); + dst.put_slice(&crc.to_be_bytes()); - // Log the encoded frame for debugging trace!( cmd = ?command, bytes = dst.len(), @@ -1174,7 +1196,7 @@ mod init_tests { // Verify the sequence starts with version rolling enable assert!(matches!( &commands[0], - Command::WriteRegister { + RegisterCommand::WriteRegister { broadcast: true, chip_address: 0x00, register: Register::VersionMask(_), @@ -1184,7 +1206,7 @@ mod init_tests { // Verify chain inactive command let chain_inactive_pos = commands .iter() - .position(|c| matches!(c, Command::ChainInactive)) + .position(|c| matches!(c, RegisterCommand::ChainInactive)) .expect("ChainInactive command not found in initialization sequence"); assert!(chain_inactive_pos > 0); @@ -1192,7 +1214,7 @@ mod init_tests { let first_address_pos = chain_inactive_pos + 1; assert!(matches!( &commands[first_address_pos], - Command::SetChipAddress { chip_address: 0x00 } + RegisterCommand::SetChipAddress { chip_address: 0x00 } )); // Verify we have 65 address assignments @@ -1204,7 +1226,7 @@ mod init_tests { // Verify addresses increment by 2 for (i, cmd) in address_commands.iter().enumerate() { match cmd { - Command::SetChipAddress { chip_address } => { + RegisterCommand::SetChipAddress { chip_address } => { assert_eq!(*chip_address, (i * 2) as u8); } _ => panic!("Expected SetChipAddress command, got {:?}", cmd), @@ -1223,7 +1245,7 @@ mod init_tests { .filter(|c| { matches!( c, - Command::WriteRegister { + RegisterCommand::WriteRegister { register: Register::IoDriverStrength { .. }, .. } @@ -1234,7 +1256,7 @@ mod init_tests { // Check first domain boundary (chip 8 = address 0x08) let first_boundary = io_strength_commands[0]; - if let Command::WriteRegister { + if let RegisterCommand::WriteRegister { chip_address, register: Register::IoDriverStrength(strength), .. @@ -1254,7 +1276,7 @@ mod init_tests { // Test single chip - full range let commands = protocol.configure_nonce_ranges(1); assert_eq!(commands.len(), 1); - if let Command::WriteRegister { + if let RegisterCommand::WriteRegister { register: Register::NonceRange(config), broadcast: true, .. @@ -1267,7 +1289,7 @@ mod init_tests { // Test S21 Pro configuration (65 chips) let commands = protocol.configure_nonce_ranges(65); assert_eq!(commands.len(), 1); - if let Command::WriteRegister { + if let RegisterCommand::WriteRegister { register: Register::NonceRange(config), .. } = &commands[0] @@ -1278,7 +1300,7 @@ mod init_tests { // Test small chain let commands = protocol.configure_nonce_ranges(8); - if let Command::WriteRegister { + if let RegisterCommand::WriteRegister { register: Register::NonceRange(config), .. } = &commands[0] @@ -1297,7 +1319,7 @@ mod init_tests { let nonce_range_cmd = commands.iter().find(|c| { matches!( c, - Command::WriteRegister { + RegisterCommand::WriteRegister { register: Register::NonceRange { .. }, .. } @@ -1306,7 +1328,7 @@ mod init_tests { assert!(nonce_range_cmd.is_some()); - if let Some(Command::WriteRegister { + if let Some(RegisterCommand::WriteRegister { register: Register::NonceRange(config), .. }) = nonce_range_cmd @@ -1329,7 +1351,7 @@ mod command_tests { #[test] fn read_register() { assert_frame_eq( - Command::ReadRegister { + RegisterCommand::ReadRegister { broadcast: true, chip_address: 0, register_address: RegisterAddress::ChipId, @@ -1341,7 +1363,7 @@ mod command_tests { #[test] fn write_register_chip_address() { assert_frame_eq( - Command::WriteRegister { + RegisterCommand::WriteRegister { broadcast: false, chip_address: 0x01, register: Register::ChipId { @@ -1361,7 +1383,7 @@ mod command_tests { fn write_version_mask_from_capture() { // From S21 Pro capture: TX: 55 AA 51 09 00 A4 90 00 FF FF 1C assert_frame_eq( - Command::WriteRegister { + RegisterCommand::WriteRegister { broadcast: true, // 0x51 = broadcast chip_address: 0x00, register: Register::VersionMask(VersionMask::full_rolling()), @@ -1377,7 +1399,7 @@ mod command_tests { // From Bitaxe capture: TX: 55 AA 51 09 00 A8 00 07 00 00 03 // Value 0x00 07 00 00 in little-endian = 0x00000700 assert_frame_eq( - Command::WriteRegister { + RegisterCommand::WriteRegister { broadcast: true, chip_address: 0x00, register: Register::InitControl { @@ -1394,7 +1416,7 @@ mod command_tests { fn write_misc_control_from_capture() { // From Bitaxe capture: TX: 55 AA 51 09 00 18 F0 00 C1 00 04 assert_frame_eq( - Command::WriteRegister { + RegisterCommand::WriteRegister { broadcast: true, chip_address: 0x00, register: Register::MiscControl { @@ -1411,7 +1433,7 @@ mod command_tests { fn chain_inactive_from_capture() { // From S21 Pro capture: TX: 55 AA 53 05 00 00 03 assert_frame_eq( - Command::ChainInactive, + RegisterCommand::ChainInactive, &[0x55, 0xaa, 0x53, 0x05, 0x00, 0x00, 0x03], ); } @@ -1420,7 +1442,7 @@ mod command_tests { fn set_chip_address_from_capture() { // From S21 Pro capture: TX: 55 AA 40 05 04 00 03 (assign address 0x04) assert_frame_eq( - Command::SetChipAddress { chip_address: 0x04 }, + RegisterCommand::SetChipAddress { chip_address: 0x04 }, &[0x55, 0xaa, 0x40, 0x05, 0x04, 0x00, 0x03], ); } @@ -1430,7 +1452,7 @@ mod command_tests { // From Bitaxe capture: TX: 55 AA 51 09 00 3C 80 00 8B 00 12 // Core register uses big-endian encoding assert_frame_eq( - Command::WriteRegister { + RegisterCommand::WriteRegister { broadcast: true, chip_address: 0x00, register: Register::Core { @@ -1449,7 +1471,7 @@ mod command_tests { // Difficulty 256 = 8 zero_bits let log2_diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); assert_frame_eq( - Command::WriteRegister { + RegisterCommand::WriteRegister { broadcast: true, chip_address: 0x00, register: Register::TicketMask(TicketMask::new(log2_diff)), @@ -1464,7 +1486,7 @@ mod command_tests { fn write_nonce_range_from_capture() { // From S21 Pro capture: TX: 55 AA 51 09 00 10 00 00 1E B5 0F assert_frame_eq( - Command::WriteRegister { + RegisterCommand::WriteRegister { broadcast: true, chip_address: 0x00, register: Register::NonceRange(NonceRangeConfig::multi_chip(65)), @@ -1519,7 +1541,7 @@ mod command_tests { let mut frame = BytesMut::new(); codec .encode( - Command::JobFull { + JobCommand::JobFull { job_data: job.clone(), }, &mut frame, @@ -1597,7 +1619,7 @@ mod command_tests { let mut frame = BytesMut::new(); codec .encode( - Command::JobFull { + JobCommand::JobFull { job_data: job.clone(), }, &mut frame, @@ -1616,7 +1638,7 @@ mod command_tests { let mut codec = FrameCodec; let mut frame = BytesMut::new(); codec - .encode(Command::JobFull { job_data: job }, &mut frame) + .encode(JobCommand::JobFull { job_data: job }, &mut frame) .expect("Failed to encode job command"); // Byte-for-byte match, length byte and CRC16 included: the @@ -1652,7 +1674,10 @@ mod command_tests { } } - fn assert_frame_eq(cmd: Command, expect: &[u8]) { + fn assert_frame_eq(cmd: C, expect: &[u8]) + where + FrameCodec: Encoder, + { let mut codec = FrameCodec; let mut frame = BytesMut::new(); codec @@ -2149,7 +2174,7 @@ mod response_tests { let mut tx_frame = BytesMut::new(); codec .encode( - Command::JobFull { + JobCommand::JobFull { job_data: job.clone(), }, &mut tx_frame, @@ -2326,8 +2351,8 @@ impl BM13xxProtocol { } /// Helper to create a broadcast write command - fn broadcast_write(&self, register: Register) -> Command { - Command::WriteRegister { + fn broadcast_write(&self, register: Register) -> RegisterCommand { + RegisterCommand::WriteRegister { broadcast: true, chip_address: 0x00, register, @@ -2336,8 +2361,8 @@ impl BM13xxProtocol { /// Helper to create a targeted write command #[cfg_attr(not(test), allow(dead_code))] - fn write_to(&self, chip_address: u8, register: Register) -> Command { - Command::WriteRegister { + fn write_to(&self, chip_address: u8, register: Register) -> RegisterCommand { + RegisterCommand::WriteRegister { broadcast: false, chip_address, register, @@ -2357,7 +2382,7 @@ impl BM13xxProtocol { &self, chip_config: &ChipConfig, frequency: Frequency, - ) -> Option> { + ) -> Option> { let pll_config = chip_config.calculate_pll(frequency)?; Some(vec![ self.broadcast_write(Register::VersionMask(VersionMask::full_rolling())), @@ -2374,7 +2399,7 @@ impl BM13xxProtocol { /// 4. Configure domain boundaries /// 5. Ramp up frequency gradually #[cfg_attr(not(test), allow(dead_code))] - pub fn multi_chip_init(&self, chain_length: usize) -> Vec { + pub fn multi_chip_init(&self, chain_length: usize) -> Vec { // Multi-chip initialization register values const INIT_CONTROL_VALUE: u32 = 0x00000700; const MISC_CONTROL_MULTI_CHIP: u32 = 0x0000c1f0; @@ -2399,12 +2424,12 @@ impl BM13xxProtocol { })); // Step 4: Set chain inactive for address assignment - commands.push(Command::ChainInactive); + commands.push(RegisterCommand::ChainInactive); // Step 5: Assign addresses (increment by 2) for i in 0..chain_length { let address = (i as u8) * ADDRESS_INCREMENT; - commands.push(Command::SetChipAddress { + commands.push(RegisterCommand::SetChipAddress { chip_address: address, }); } @@ -2435,7 +2460,11 @@ impl BM13xxProtocol { /// Domains are groups of chips that share signal integrity settings. /// This configures IO driver strength and UART relay for domain boundaries. #[cfg_attr(not(test), allow(dead_code))] - pub fn configure_domains(&self, chain_length: usize, chips_per_domain: usize) -> Vec { + pub fn configure_domains( + &self, + chain_length: usize, + chips_per_domain: usize, + ) -> Vec { const UART_RELAY_BASE: u32 = 0x03000000; const ADDRESS_INCREMENT: u8 = 2; @@ -2488,7 +2517,7 @@ impl BM13xxProtocol { /// This distributes the 32-bit nonce space across all chips in the chain /// to avoid duplicate work. Each chip searches a unique portion of the nonce space. #[cfg_attr(not(test), allow(dead_code))] - pub fn configure_nonce_ranges(&self, chain_length: usize) -> Vec { + pub fn configure_nonce_ranges(&self, chain_length: usize) -> Vec { let mut commands = Vec::new(); // Calculate nonce range based on chain length @@ -2501,8 +2530,8 @@ impl BM13xxProtocol { } /// Create a command to read a register. - pub fn read_register(&self, chip_address: u8, register: RegisterAddress) -> Command { - Command::ReadRegister { + pub fn read_register(&self, chip_address: u8, register: RegisterAddress) -> RegisterCommand { + RegisterCommand::ReadRegister { broadcast: false, chip_address, register_address: register, @@ -2510,8 +2539,8 @@ impl BM13xxProtocol { } /// Set UART baud rate on all chips - pub fn set_baudrate(&self, baudrate: BaudRate) -> Command { - Command::WriteRegister { + pub fn set_baudrate(&self, baudrate: BaudRate) -> RegisterCommand { + RegisterCommand::WriteRegister { broadcast: true, chip_address: 0x00, register: Register::UartBaud(baudrate), @@ -2526,7 +2555,7 @@ impl BM13xxProtocol { chip_address: u8, register: RegisterAddress, value: u32, - ) -> Result { + ) -> Result { // TODO: Properly encode register based on type // For now, just handle RegA8 as an example let register_value = match register { @@ -2566,7 +2595,7 @@ impl BM13xxProtocol { RegisterAddress::MiscSettings => Register::MiscSettings { raw_value: value }, }; - Ok(Command::WriteRegister { + Ok(RegisterCommand::WriteRegister { broadcast: false, chip_address, register: register_value, @@ -2574,8 +2603,8 @@ impl BM13xxProtocol { } /// Create a broadcast command to discover all chips. - pub fn discover_chips() -> Command { - Command::ReadRegister { + pub fn discover_chips() -> RegisterCommand { + RegisterCommand::ReadRegister { broadcast: true, chip_address: 0, register_address: RegisterAddress::ChipId, diff --git a/mujina-miner/src/asic/bm13xx/thread.rs b/mujina-miner/src/asic/bm13xx/thread.rs index 6e874bdd..c8dd89d3 100644 --- a/mujina-miner/src/asic/bm13xx/thread.rs +++ b/mujina-miner/src/asic/bm13xx/thread.rs @@ -17,7 +17,7 @@ use futures::{SinkExt, sink::Sink, stream::Stream}; use tokio::sync::{mpsc, oneshot, watch}; use tokio_stream::StreamExt; -use super::protocol::{self, Log2Difficulty, TicketMask}; +use super::protocol::{self, JobCommand, Log2Difficulty, Register, RegisterCommand, TicketMask}; use crate::{ asic::hash_thread::{ BoardPeripherals, HashTask, HashThread, HashThreadCapabilities, HashThreadEvent, @@ -125,7 +125,7 @@ impl BM13xxThread { /// * `chip_commands` - Sink for sending encoded commands to chips /// * `peripherals` - Hardware interfaces from board (enable, regulator, etc.) /// * `removal_rx` - Watch channel for board-triggered removal - pub fn new( + pub fn new( name: String, chip_responses: R, chip_commands: W, @@ -134,8 +134,8 @@ impl BM13xxThread { ) -> Self where R: Stream> + Unpin + Send + 'static, - W: Sink + Unpin + Send + 'static, - W::Error: std::fmt::Debug, + W: Sink + Sink + Unpin + Send + 'static, + E: std::fmt::Debug + Send + 'static, { let (cmd_tx, cmd_rx) = mpsc::channel(10); let (evt_tx, evt_rx) = mpsc::channel(100); @@ -241,17 +241,15 @@ impl HashThread for BM13xxThread { /// Initialize BM13xx chip for mining. /// /// Enables chip, configures all registers, and ramps frequency to target. -async fn initialize_chip( +async fn initialize_chip( chip_commands: &mut W, peripherals: &mut BoardPeripherals, asic_difficulty: Log2Difficulty, ) -> Result<()> where - W: Sink + Unpin, - W::Error: std::fmt::Debug, + W: Sink + Sink + Unpin, + E: std::fmt::Debug, { - use protocol::{Command, Register}; - // Enable the ASIC if let Some(ref mut asic_enable) = peripherals.asic_enable { debug!("Enabling ASIC"); @@ -264,13 +262,17 @@ where tokio::time::sleep(std::time::Duration::from_millis(200)).await; // Send a register write command, converting the sink error to anyhow. - async fn send_reg(chip_commands: &mut W, broadcast: bool, register: Register) -> Result<()> + async fn send_reg( + chip_commands: &mut W, + broadcast: bool, + register: Register, + ) -> Result<()> where - W: Sink + Unpin, - W::Error: std::fmt::Debug, + W: Sink + Unpin, + E: std::fmt::Debug, { chip_commands - .send(Command::WriteRegister { + .send(RegisterCommand::WriteRegister { broadcast, chip_address: 0x00, register, @@ -315,13 +317,13 @@ where .await?; chip_commands - .send(Command::ChainInactive) + .send(RegisterCommand::ChainInactive) .await .map_err(|e| anyhow!("{e:?}")) .context("failed to send ChainInactive")?; chip_commands - .send(Command::SetChipAddress { chip_address: 0x00 }) + .send(RegisterCommand::SetChipAddress { chip_address: 0x00 }) .await .map_err(|e| anyhow!("{e:?}")) .context("failed to send SetChipAddress")?; @@ -602,7 +604,7 @@ fn calculate_pll_for_frequency(target_freq: f32) -> Option /// /// Chip is disabled on startup to establish known state. Chip is enabled and /// configured when scheduler assigns first work. -async fn bm13xx_thread_actor( +async fn bm13xx_thread_actor( mut cmd_rx: mpsc::Receiver, evt_tx: mpsc::Sender, mut removal_rx: watch::Receiver, @@ -612,8 +614,8 @@ async fn bm13xx_thread_actor( mut peripherals: BoardPeripherals, ) where R: Stream> + Unpin, - W: Sink + Unpin, - W::Error: std::fmt::Debug, + W: Sink + Sink + Unpin, + E: std::fmt::Debug, { // Disable ASIC on startup to establish known state if let Some(ref mut asic_enable) = peripherals.asic_enable @@ -693,7 +695,7 @@ async fn bm13xx_thread_actor( let old_task = current_task.replace(new_task.clone()); match task_to_job_full(&new_task, chip_job_id) { Ok(job_data) => { - if let Err(e) = chip_commands.send(protocol::Command::JobFull { job_data }).await { + if let Err(e) = chip_commands.send(JobCommand::JobFull { job_data }).await { error!(error = ?e, "Failed to send initial JobFull to chip"); let err = anyhow!("failed to send job to chip: {e:?}"); response_tx.send(Err(err)).ok(); @@ -746,7 +748,7 @@ async fn bm13xx_thread_actor( let old_task = current_task.replace(new_task.clone()); match task_to_job_full(&new_task, chip_job_id) { Ok(job_data) => { - if let Err(e) = chip_commands.send(protocol::Command::JobFull { job_data }).await { + if let Err(e) = chip_commands.send(JobCommand::JobFull { job_data }).await { error!(error = ?e, "Failed to send initial JobFull to chip"); let err = anyhow!("failed to send job to chip: {e:?}"); response_tx.send(Err(err)).ok(); @@ -906,7 +908,7 @@ async fn bm13xx_thread_actor( // Convert to chip format and send match task_to_job_full(task, chip_jobs.insert(task.clone())) { Ok(job_data) => { - if let Err(e) = chip_commands.send(protocol::Command::JobFull { job_data }).await { + if let Err(e) = chip_commands.send(JobCommand::JobFull { job_data }).await { error!(error = ?e, "Failed to send JobFull to chip"); } else { trace!(ntime = task.ntime, "Sent ntime-rolled job to chip"); diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 67ecf099..747e0176 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -23,7 +23,7 @@ use crate::{ api_client::types::{BoardTelemetry, Fan, PowerMeasurement, TemperatureSensor}, asic::{ ChipInfo, - bm13xx::{self, BM13xxProtocol, protocol::Command, thread::BM13xxThread}, + bm13xx::{self, BM13xxProtocol, protocol::RegisterCommand, thread::BM13xxThread}, hash_thread::{AsicEnable, BoardPeripherals, HashThread, ThreadRemovalSignal}, }, hw_trait::{ @@ -121,7 +121,7 @@ async fn create_from_usb(device: UsbDeviceInfo) -> Result { debug!("Sending version mask configuration (3 times)"); for i in 1..=3 { trace!("Version mask send {}/3", i); - let version_cmd = Command::WriteRegister { + let version_cmd = RegisterCommand::WriteRegister { broadcast: true, chip_address: 0x00, register: bm13xx::protocol::Register::VersionMask( From 5a1364dcda3239a26316ef56bc1c76d8f3ae605e Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Thu, 23 Apr 2026 13:36:13 -0500 Subject: [PATCH 13/38] refactor(bm13xx): unify register value type names Some register value types already named themselves after the register they fill (TicketMask, VersionMask, IoDriverStrength). Rename PllConfig, NonceRangeConfig, and BaudRate to follow the same convention. --- mujina-miner/src/asic/bm13xx/chip_config.rs | 160 ++++++++++---------- mujina-miner/src/asic/bm13xx/protocol.rs | 58 +++---- mujina-miner/src/asic/bm13xx/thread.rs | 8 +- 3 files changed, 113 insertions(+), 113 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/chip_config.rs b/mujina-miner/src/asic/bm13xx/chip_config.rs index f1b1fa5a..408d2548 100644 --- a/mujina-miner/src/asic/bm13xx/chip_config.rs +++ b/mujina-miner/src/asic/bm13xx/chip_config.rs @@ -6,7 +6,7 @@ //! each supported model, validated against serial captures of an //! S19 J Pro (BM1362) and an S21 Pro and Bitaxe Gamma (BM1370). -use super::protocol::{ChipModel, PllConfig}; +use super::protocol::{ChipModel, PllDivider}; use crate::types::Frequency; /// Per-chip-model configuration. @@ -39,7 +39,7 @@ impl ChipConfig { /// frequency to keep VCO within its optimal operating range. /// Returns `None` when `freq` lies outside the chip's frequency /// range or no divider configuration reaches it. - pub fn calculate_pll(&self, freq: Frequency) -> Option { + pub fn calculate_pll(&self, freq: Frequency) -> Option { if freq < self.min_freq || freq > self.max_freq { return None; } @@ -76,7 +76,7 @@ impl ChipConfig { min_error = error; best_vco = vco; let post_div = ((post_div1 - 1) << 4) | (post_div2 - 1); - best_config = Some(PllConfig::new(fb_div, ref_div, post_div)); + best_config = Some(PllDivider::new(fb_div, ref_div, post_div)); } } } @@ -260,83 +260,83 @@ mod tests { /// `write_register(pll)` command captured on the serial bus. /// 76 steps total. #[rustfmt::skip] - const S19J_PRO_RAMP: &[PllConfig] = &[ - PllConfig { flag: 0x40, fb_div: 0xa2, ref_div: 0x02, post_div: 0x55 }, - PllConfig { flag: 0x40, fb_div: 0xaf, ref_div: 0x02, post_div: 0x64 }, - PllConfig { flag: 0x40, fb_div: 0xa5, ref_div: 0x02, post_div: 0x54 }, - PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x63 }, - PllConfig { flag: 0x40, fb_div: 0xb6, ref_div: 0x02, post_div: 0x63 }, - PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x53 }, - PllConfig { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x53 }, - PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x62 }, - PllConfig { flag: 0x40, fb_div: 0xaa, ref_div: 0x02, post_div: 0x43 }, - PllConfig { flag: 0x40, fb_div: 0xa2, ref_div: 0x02, post_div: 0x52 }, - PllConfig { flag: 0x40, fb_div: 0xab, ref_div: 0x02, post_div: 0x52 }, - PllConfig { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x52 }, - PllConfig { flag: 0x40, fb_div: 0xbd, ref_div: 0x02, post_div: 0x52 }, - PllConfig { flag: 0x40, fb_div: 0xa5, ref_div: 0x02, post_div: 0x42 }, - PllConfig { flag: 0x40, fb_div: 0xa1, ref_div: 0x02, post_div: 0x61 }, - PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x61 }, - PllConfig { flag: 0x40, fb_div: 0xaf, ref_div: 0x02, post_div: 0x61 }, - PllConfig { flag: 0x40, fb_div: 0xb6, ref_div: 0x02, post_div: 0x61 }, - PllConfig { flag: 0x40, fb_div: 0xa2, ref_div: 0x02, post_div: 0x51 }, - PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x51 }, - PllConfig { flag: 0x40, fb_div: 0xae, ref_div: 0x02, post_div: 0x51 }, - PllConfig { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x51 }, - PllConfig { flag: 0x40, fb_div: 0xba, ref_div: 0x02, post_div: 0x51 }, - PllConfig { flag: 0x40, fb_div: 0xa0, ref_div: 0x02, post_div: 0x41 }, - PllConfig { flag: 0x40, fb_div: 0xa5, ref_div: 0x02, post_div: 0x41 }, - PllConfig { flag: 0x40, fb_div: 0xaa, ref_div: 0x02, post_div: 0x41 }, - PllConfig { flag: 0x40, fb_div: 0xaf, ref_div: 0x02, post_div: 0x41 }, - PllConfig { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x41 }, - PllConfig { flag: 0x40, fb_div: 0xb9, ref_div: 0x02, post_div: 0x41 }, - PllConfig { flag: 0x40, fb_div: 0xbe, ref_div: 0x02, post_div: 0x41 }, - PllConfig { flag: 0x50, fb_div: 0xc3, ref_div: 0x02, post_div: 0x41 }, - PllConfig { flag: 0x40, fb_div: 0xa0, ref_div: 0x02, post_div: 0x31 }, - PllConfig { flag: 0x40, fb_div: 0xa4, ref_div: 0x02, post_div: 0x31 }, - PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x31 }, - PllConfig { flag: 0x40, fb_div: 0xac, ref_div: 0x02, post_div: 0x31 }, - PllConfig { flag: 0x40, fb_div: 0xb0, ref_div: 0x02, post_div: 0x31 }, - PllConfig { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x31 }, - PllConfig { flag: 0x40, fb_div: 0xa1, ref_div: 0x02, post_div: 0x60 }, - PllConfig { flag: 0x40, fb_div: 0xbc, ref_div: 0x02, post_div: 0x31 }, - PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x60 }, - PllConfig { flag: 0x50, fb_div: 0xc4, ref_div: 0x02, post_div: 0x31 }, - PllConfig { flag: 0x40, fb_div: 0xaf, ref_div: 0x02, post_div: 0x60 }, - PllConfig { flag: 0x50, fb_div: 0xcc, ref_div: 0x02, post_div: 0x31 }, - PllConfig { flag: 0x40, fb_div: 0xb6, ref_div: 0x02, post_div: 0x60 }, - PllConfig { flag: 0x50, fb_div: 0xd4, ref_div: 0x02, post_div: 0x31 }, - PllConfig { flag: 0x40, fb_div: 0xa2, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x40, fb_div: 0xa5, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x40, fb_div: 0xab, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x40, fb_div: 0xae, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x40, fb_div: 0xb1, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x40, fb_div: 0xb7, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x40, fb_div: 0xba, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x40, fb_div: 0xbd, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x40, fb_div: 0xa0, ref_div: 0x02, post_div: 0x40 }, - PllConfig { flag: 0x50, fb_div: 0xc3, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x40, fb_div: 0xa5, ref_div: 0x02, post_div: 0x40 }, - PllConfig { flag: 0x50, fb_div: 0xc9, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x40, fb_div: 0xaa, ref_div: 0x02, post_div: 0x40 }, - PllConfig { flag: 0x50, fb_div: 0xcf, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x40, fb_div: 0xaf, ref_div: 0x02, post_div: 0x40 }, - PllConfig { flag: 0x50, fb_div: 0xd5, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x40 }, - PllConfig { flag: 0x50, fb_div: 0xdb, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x40, fb_div: 0xb9, ref_div: 0x02, post_div: 0x40 }, - PllConfig { flag: 0x50, fb_div: 0xe1, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x40, fb_div: 0xbe, ref_div: 0x02, post_div: 0x40 }, - PllConfig { flag: 0x50, fb_div: 0xe7, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x50, fb_div: 0xc3, ref_div: 0x02, post_div: 0x40 }, - PllConfig { flag: 0x50, fb_div: 0xed, ref_div: 0x02, post_div: 0x50 }, - PllConfig { flag: 0x40, fb_div: 0xa0, ref_div: 0x02, post_div: 0x30 }, - PllConfig { flag: 0x40, fb_div: 0xa2, ref_div: 0x02, post_div: 0x30 }, - PllConfig { flag: 0x40, fb_div: 0xa4, ref_div: 0x02, post_div: 0x30 }, - PllConfig { flag: 0x40, fb_div: 0xa6, ref_div: 0x02, post_div: 0x30 }, - PllConfig { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x30 }, + const S19J_PRO_RAMP: &[PllDivider] = &[ + PllDivider { flag: 0x40, fb_div: 0xa2, ref_div: 0x02, post_div: 0x55 }, + PllDivider { flag: 0x40, fb_div: 0xaf, ref_div: 0x02, post_div: 0x64 }, + PllDivider { flag: 0x40, fb_div: 0xa5, ref_div: 0x02, post_div: 0x54 }, + PllDivider { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x63 }, + PllDivider { flag: 0x40, fb_div: 0xb6, ref_div: 0x02, post_div: 0x63 }, + PllDivider { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x53 }, + PllDivider { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x53 }, + PllDivider { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x62 }, + PllDivider { flag: 0x40, fb_div: 0xaa, ref_div: 0x02, post_div: 0x43 }, + PllDivider { flag: 0x40, fb_div: 0xa2, ref_div: 0x02, post_div: 0x52 }, + PllDivider { flag: 0x40, fb_div: 0xab, ref_div: 0x02, post_div: 0x52 }, + PllDivider { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x52 }, + PllDivider { flag: 0x40, fb_div: 0xbd, ref_div: 0x02, post_div: 0x52 }, + PllDivider { flag: 0x40, fb_div: 0xa5, ref_div: 0x02, post_div: 0x42 }, + PllDivider { flag: 0x40, fb_div: 0xa1, ref_div: 0x02, post_div: 0x61 }, + PllDivider { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x61 }, + PllDivider { flag: 0x40, fb_div: 0xaf, ref_div: 0x02, post_div: 0x61 }, + PllDivider { flag: 0x40, fb_div: 0xb6, ref_div: 0x02, post_div: 0x61 }, + PllDivider { flag: 0x40, fb_div: 0xa2, ref_div: 0x02, post_div: 0x51 }, + PllDivider { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x51 }, + PllDivider { flag: 0x40, fb_div: 0xae, ref_div: 0x02, post_div: 0x51 }, + PllDivider { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x51 }, + PllDivider { flag: 0x40, fb_div: 0xba, ref_div: 0x02, post_div: 0x51 }, + PllDivider { flag: 0x40, fb_div: 0xa0, ref_div: 0x02, post_div: 0x41 }, + PllDivider { flag: 0x40, fb_div: 0xa5, ref_div: 0x02, post_div: 0x41 }, + PllDivider { flag: 0x40, fb_div: 0xaa, ref_div: 0x02, post_div: 0x41 }, + PllDivider { flag: 0x40, fb_div: 0xaf, ref_div: 0x02, post_div: 0x41 }, + PllDivider { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x41 }, + PllDivider { flag: 0x40, fb_div: 0xb9, ref_div: 0x02, post_div: 0x41 }, + PllDivider { flag: 0x40, fb_div: 0xbe, ref_div: 0x02, post_div: 0x41 }, + PllDivider { flag: 0x50, fb_div: 0xc3, ref_div: 0x02, post_div: 0x41 }, + PllDivider { flag: 0x40, fb_div: 0xa0, ref_div: 0x02, post_div: 0x31 }, + PllDivider { flag: 0x40, fb_div: 0xa4, ref_div: 0x02, post_div: 0x31 }, + PllDivider { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x31 }, + PllDivider { flag: 0x40, fb_div: 0xac, ref_div: 0x02, post_div: 0x31 }, + PllDivider { flag: 0x40, fb_div: 0xb0, ref_div: 0x02, post_div: 0x31 }, + PllDivider { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x31 }, + PllDivider { flag: 0x40, fb_div: 0xa1, ref_div: 0x02, post_div: 0x60 }, + PllDivider { flag: 0x40, fb_div: 0xbc, ref_div: 0x02, post_div: 0x31 }, + PllDivider { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x60 }, + PllDivider { flag: 0x50, fb_div: 0xc4, ref_div: 0x02, post_div: 0x31 }, + PllDivider { flag: 0x40, fb_div: 0xaf, ref_div: 0x02, post_div: 0x60 }, + PllDivider { flag: 0x50, fb_div: 0xcc, ref_div: 0x02, post_div: 0x31 }, + PllDivider { flag: 0x40, fb_div: 0xb6, ref_div: 0x02, post_div: 0x60 }, + PllDivider { flag: 0x50, fb_div: 0xd4, ref_div: 0x02, post_div: 0x31 }, + PllDivider { flag: 0x40, fb_div: 0xa2, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x40, fb_div: 0xa5, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x40, fb_div: 0xab, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x40, fb_div: 0xae, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x40, fb_div: 0xb1, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x40, fb_div: 0xb7, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x40, fb_div: 0xba, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x40, fb_div: 0xbd, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x40, fb_div: 0xa0, ref_div: 0x02, post_div: 0x40 }, + PllDivider { flag: 0x50, fb_div: 0xc3, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x40, fb_div: 0xa5, ref_div: 0x02, post_div: 0x40 }, + PllDivider { flag: 0x50, fb_div: 0xc9, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x40, fb_div: 0xaa, ref_div: 0x02, post_div: 0x40 }, + PllDivider { flag: 0x50, fb_div: 0xcf, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x40, fb_div: 0xaf, ref_div: 0x02, post_div: 0x40 }, + PllDivider { flag: 0x50, fb_div: 0xd5, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x40, fb_div: 0xb4, ref_div: 0x02, post_div: 0x40 }, + PllDivider { flag: 0x50, fb_div: 0xdb, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x40, fb_div: 0xb9, ref_div: 0x02, post_div: 0x40 }, + PllDivider { flag: 0x50, fb_div: 0xe1, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x40, fb_div: 0xbe, ref_div: 0x02, post_div: 0x40 }, + PllDivider { flag: 0x50, fb_div: 0xe7, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x50, fb_div: 0xc3, ref_div: 0x02, post_div: 0x40 }, + PllDivider { flag: 0x50, fb_div: 0xed, ref_div: 0x02, post_div: 0x50 }, + PllDivider { flag: 0x40, fb_div: 0xa0, ref_div: 0x02, post_div: 0x30 }, + PllDivider { flag: 0x40, fb_div: 0xa2, ref_div: 0x02, post_div: 0x30 }, + PllDivider { flag: 0x40, fb_div: 0xa4, ref_div: 0x02, post_div: 0x30 }, + PllDivider { flag: 0x40, fb_div: 0xa6, ref_div: 0x02, post_div: 0x30 }, + PllDivider { flag: 0x40, fb_div: 0xa8, ref_div: 0x02, post_div: 0x30 }, ]; /// Derives the target frequency of each captured PLL write in the diff --git a/mujina-miner/src/asic/bm13xx/protocol.rs b/mujina-miner/src/asic/bm13xx/protocol.rs index a0e96fcb..46654422 100644 --- a/mujina-miner/src/asic/bm13xx/protocol.rs +++ b/mujina-miner/src/asic/bm13xx/protocol.rs @@ -35,7 +35,7 @@ impl fmt::Display for HexBytes<'_> { /// PLL configuration for frequency control. #[derive(Debug, Clone, Copy, PartialEq)] -pub struct PllConfig { +pub struct PllDivider { /// VCO control flag (0x40 for low VCO, 0x50 for high VCO). pub flag: u8, /// Feedback divider. @@ -46,7 +46,7 @@ pub struct PllConfig { pub post_div: u8, } -impl PllConfig { +impl PllDivider { /// Create a PLL configuration, deriving the VCO flag from the /// resulting VCO frequency: `vco >= 2400 MHz` picks flag `0x50`, /// otherwise flag `0x40`. @@ -62,7 +62,7 @@ impl PllConfig { } } -impl From for PllConfig { +impl From for PllDivider { fn from(raw: u32) -> Self { Self { flag: (raw & 0xff) as u8, @@ -73,8 +73,8 @@ impl From for PllConfig { } } -impl From for [u8; 4] { - fn from(config: PllConfig) -> Self { +impl From for [u8; 4] { + fn from(config: PllDivider) -> Self { [config.flag, config.fb_div, config.ref_div, config.post_div] } } @@ -141,12 +141,12 @@ impl From for [u8; 2] { /// because the exact bit-level interpretation is still being reverse-engineered. /// The values below are empirically observed from production hardware. #[derive(Debug, Clone, Copy, PartialEq)] -pub struct NonceRangeConfig { +pub struct NonceRange { /// Raw bytes as sent over the wire bytes: [u8; 4], } -impl NonceRangeConfig { +impl NonceRange { // Nonce range values for different chain lengths (captured from hardware) const SINGLE_CHIP: [u8; 4] = [0xff, 0xff, 0xff, 0xff]; const SMALL_CHAIN: [u8; 4] = [0xff, 0xff, 0xff, 0x1f]; // 2-8 chips @@ -186,8 +186,8 @@ impl NonceRangeConfig { } } -impl From for [u8; 4] { - fn from(config: NonceRangeConfig) -> Self { +impl From for [u8; 4] { + fn from(config: NonceRange) -> Self { config.bytes } } @@ -312,7 +312,7 @@ fn decode_ticket_mask_bytes(bytes: &[u8; 4]) -> u32 { /// UART baud rate configuration #[derive(Debug, Clone, Copy, PartialEq)] -pub enum BaudRate { +pub enum UartBaud { /// 115200 baud Baud115200, /// 1 Mbaud @@ -323,17 +323,17 @@ pub enum BaudRate { Custom(u32), } -impl From for [u8; 4] { - fn from(baud: BaudRate) -> Self { +impl From for [u8; 4] { + fn from(baud: UartBaud) -> Self { let value = match baud { // From esp-miner BM1370/BM1366/BM1368 default baud config - BaudRate::Baud115200 => 0x00000271, + UartBaud::Baud115200 => 0x00000271, // From esp-miner BM1370_set_max_baud/BM1366_set_max_baud/BM1368_set_max_baud // All three chips use identical register value for 1Mbaud - BaudRate::Baud1M => 0x00023011, + UartBaud::Baud1M => 0x00023011, // From S21 Pro captures (BM1370 multi-chip chains) - BaudRate::Baud3M => 0x00003001, - BaudRate::Custom(val) => val, + UartBaud::Baud3M => 0x00003001, + UartBaud::Custom(val) => val, }; value.to_le_bytes() } @@ -457,13 +457,13 @@ pub enum Register { core_count: u8, // Core configuration byte address: u8, // Assigned chip address }, - PllDivider(PllConfig), - NonceRange(NonceRangeConfig), + PllDivider(PllDivider), + NonceRange(NonceRange), TicketMask(TicketMask), MiscControl { raw_value: u32, }, - UartBaud(BaudRate), + UartBaud(UartBaud), UartRelay { raw_value: u32, // Domain relay configuration (complex format) }, @@ -496,7 +496,7 @@ impl Register { address: bytes[3], }, RegisterAddress::PllDivider => Register::PllDivider(raw_value.into()), - RegisterAddress::NonceRange => Register::NonceRange(NonceRangeConfig { bytes: *bytes }), + RegisterAddress::NonceRange => Register::NonceRange(NonceRange { bytes: *bytes }), RegisterAddress::TicketMask => { // Decode wire bytes to TicketMask // Wire bytes are in encoded format; decode to extract zero_bits value @@ -508,10 +508,10 @@ impl Register { RegisterAddress::UartBaud => { // Decode known baud rates let baud = match raw_value { - 0x00000271 => BaudRate::Baud115200, - 0x00000130 => BaudRate::Baud1M, - 0x00003001 => BaudRate::Baud3M, - other => BaudRate::Custom(other), + 0x00000271 => UartBaud::Baud115200, + 0x00000130 => UartBaud::Baud1M, + 0x00003001 => UartBaud::Baud3M, + other => UartBaud::Custom(other), }; Register::UartBaud(baud) } @@ -1489,7 +1489,7 @@ mod command_tests { RegisterCommand::WriteRegister { broadcast: true, chip_address: 0x00, - register: Register::NonceRange(NonceRangeConfig::multi_chip(65)), + register: Register::NonceRange(NonceRange::multi_chip(65)), }, &[ 0x55, 0xaa, 0x51, 0x09, 0x00, 0x10, 0x00, 0x00, 0x1e, 0xb5, 0x0f, @@ -2521,7 +2521,7 @@ impl BM13xxProtocol { let mut commands = Vec::new(); // Calculate nonce range based on chain length - let nonce_config = NonceRangeConfig::multi_chip(chain_length); + let nonce_config = NonceRange::multi_chip(chain_length); // Write nonce range to all chips commands.push(self.broadcast_write(Register::NonceRange(nonce_config))); @@ -2539,7 +2539,7 @@ impl BM13xxProtocol { } /// Set UART baud rate on all chips - pub fn set_baudrate(&self, baudrate: BaudRate) -> RegisterCommand { + pub fn set_baudrate(&self, baudrate: UartBaud) -> RegisterCommand { RegisterCommand::WriteRegister { broadcast: true, chip_address: 0x00, @@ -2564,7 +2564,7 @@ impl BM13xxProtocol { return Err(ProtocolError::ReadOnlyRegister(register)); } RegisterAddress::PllDivider => Register::PllDivider(value.into()), - RegisterAddress::NonceRange => Register::NonceRange(NonceRangeConfig { + RegisterAddress::NonceRange => Register::NonceRange(NonceRange { bytes: value.to_le_bytes(), }), RegisterAddress::TicketMask => { @@ -2574,7 +2574,7 @@ impl BM13xxProtocol { Register::TicketMask(TicketMask { zero_bits }) } RegisterAddress::MiscControl => Register::MiscControl { raw_value: value }, - RegisterAddress::UartBaud => Register::UartBaud(BaudRate::Custom(value)), + RegisterAddress::UartBaud => Register::UartBaud(UartBaud::Custom(value)), RegisterAddress::UartRelay => Register::UartRelay { raw_value: value }, RegisterAddress::Core => Register::Core { raw_value: value }, RegisterAddress::AnalogMux => Register::AnalogMux { raw_value: value }, diff --git a/mujina-miner/src/asic/bm13xx/thread.rs b/mujina-miner/src/asic/bm13xx/thread.rs index c8dd89d3..5e9d430a 100644 --- a/mujina-miner/src/asic/bm13xx/thread.rs +++ b/mujina-miner/src/asic/bm13xx/thread.rs @@ -459,7 +459,7 @@ where send_reg( chip_commands, true, - Register::NonceRange(protocol::NonceRangeConfig::from_raw(0xB51E0000)), + Register::NonceRange(protocol::NonceRange::from_raw(0xB51E0000)), ) .await?; send_reg( @@ -479,7 +479,7 @@ fn generate_frequency_ramp_steps( start_mhz: f32, target_mhz: f32, step_mhz: f32, -) -> Vec { +) -> Vec { let mut configs = Vec::new(); let mut current = start_mhz; @@ -536,7 +536,7 @@ fn task_to_job_full(task: &HashTask, chip_job_id: u8) -> Result Option { +fn calculate_pll_for_frequency(target_freq: f32) -> Option { const CRYSTAL_FREQ: f32 = 25.0; const MAX_FREQ_ERROR: f32 = 1.0; @@ -586,7 +586,7 @@ fn calculate_pll_for_frequency(target_freq: f32) -> Option } let post_div = ((best_post_div1 - 1) << 4) | (best_post_div2 - 1); - Some(protocol::PllConfig::new( + Some(protocol::PllDivider::new( best_fb_div, best_ref_div, post_div, From a63f4b6c2806de5bb61678408d57a7aef7892815 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Fri, 24 Apr 2026 15:19:42 -0500 Subject: [PATCH 14/38] refactor(bm13xx): unify Register variant shapes Register had three variant shapes: struct variants with inline fields, tuple variants wrapping typed values, and variants carrying a bare { raw_value: u32 } field. That inconsistency forced every Register dispatch method to match arms of three different shapes and required a hand-rolled Debug impl. Wrap every Register variant in a single struct of the same name, and give every register value type the same encode and decode interface. Dispatch becomes uniform, and the hand-rolled Debug impl becomes a derive. --- mujina-miner/src/asic/bm13xx/protocol.rs | 502 ++++++++++------------- mujina-miner/src/asic/bm13xx/thread.rs | 87 +--- mujina-miner/src/board/bitaxe.rs | 10 +- 3 files changed, 250 insertions(+), 349 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/protocol.rs b/mujina-miner/src/asic/bm13xx/protocol.rs index 46654422..5a07ce08 100644 --- a/mujina-miner/src/asic/bm13xx/protocol.rs +++ b/mujina-miner/src/asic/bm13xx/protocol.rs @@ -60,22 +60,21 @@ impl PllDivider { post_div, } } -} -impl From for PllDivider { - fn from(raw: u32) -> Self { - Self { - flag: (raw & 0xff) as u8, - fb_div: ((raw >> 8) & 0xff) as u8, - ref_div: ((raw >> 16) & 0xff) as u8, - post_div: ((raw >> 24) & 0xff) as u8, - } + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_u8(self.flag); + dst.put_u8(self.fb_div); + dst.put_u8(self.ref_div); + dst.put_u8(self.post_div); } -} -impl From for [u8; 4] { - fn from(config: PllDivider) -> Self { - [config.flag, config.fb_div, config.ref_div, config.post_div] + pub fn decode(bytes: [u8; 4]) -> Self { + Self { + flag: bytes[0], + fb_div: bytes[1], + ref_div: bytes[2], + post_div: bytes[3], + } } } @@ -184,11 +183,13 @@ impl NonceRange { bytes: value.to_le_bytes(), } } -} -impl From for [u8; 4] { - fn from(config: NonceRange) -> Self { - config.bytes + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_slice(&self.bytes); + } + + pub fn decode(bytes: [u8; 4]) -> Self { + Self { bytes } } } @@ -280,11 +281,15 @@ impl TicketMask { bytes } -} -impl From for [u8; 4] { - fn from(mask: TicketMask) -> Self { - mask.to_wire_bytes() + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_slice(&self.to_wire_bytes()); + } + + pub fn decode(bytes: [u8; 4]) -> Self { + let mask_value = decode_ticket_mask_bytes(&bytes); + let zero_bits = mask_value.count_ones() as u8; + Self { zero_bits } } } @@ -323,9 +328,9 @@ pub enum UartBaud { Custom(u32), } -impl From for [u8; 4] { - fn from(baud: UartBaud) -> Self { - let value = match baud { +impl UartBaud { + pub fn encode(&self, dst: &mut BytesMut) { + let value = match self { // From esp-miner BM1370/BM1366/BM1368 default baud config UartBaud::Baud115200 => 0x00000271, // From esp-miner BM1370_set_max_baud/BM1366_set_max_baud/BM1368_set_max_baud @@ -333,9 +338,17 @@ impl From for [u8; 4] { UartBaud::Baud1M => 0x00023011, // From S21 Pro captures (BM1370 multi-chip chains) UartBaud::Baud3M => 0x00003001, - UartBaud::Custom(val) => val, + UartBaud::Custom(val) => *val, }; - value.to_le_bytes() + dst.put_u32_le(value); + } + pub fn decode(bytes: [u8; 4]) -> Self { + match u32::from_le_bytes(bytes) { + 0x00000271 => UartBaud::Baud115200, + 0x00000130 => UartBaud::Baud1M, + 0x00003001 => UartBaud::Baud3M, + other => UartBaud::Custom(other), + } } } @@ -362,25 +375,23 @@ impl IoDriverStrength { strengths: [0x0, 0x0, 0x1, 0xf, 0x1, 0x1, 0x1, 0x1], } } -} -impl From for [u8; 4] { - fn from(strength: IoDriverStrength) -> Self { - // Pack 8 4-bit values into 4 bytes (2 per byte) - // Each byte contains two strength values: [high_nibble|low_nibble] - [ - strength.strengths[0] | (strength.strengths[1] << 4), - strength.strengths[2] | (strength.strengths[3] << 4), - strength.strengths[4] | (strength.strengths[5] << 4), - strength.strengths[6] | (strength.strengths[7] << 4), - ] + pub fn encode(&self, dst: &mut BytesMut) { + // Pack 8 4-bit values into 4 bytes (2 per byte). + // Each byte contains two strength values: [high_nibble|low_nibble]. + dst.put_u8(self.strengths[0] | (self.strengths[1] << 4)); + dst.put_u8(self.strengths[2] | (self.strengths[3] << 4)); + dst.put_u8(self.strengths[4] | (self.strengths[5] << 4)); + dst.put_u8(self.strengths[6] | (self.strengths[7] << 4)); } -} -impl IoDriverStrength { - /// Get the raw bytes for testing - pub fn as_bytes(&self) -> [u8; 4] { - (*self).into() + pub fn decode(bytes: [u8; 4]) -> Self { + let raw = u32::from_le_bytes(bytes); + let mut strengths = [0u8; 8]; + for (i, strength) in strengths.iter_mut().enumerate() { + *strength = ((raw >> (i * 4)) & 0xf) as u8; + } + Self { strengths } } } @@ -406,6 +417,19 @@ impl VersionMask { control: Self::ENABLE_ROLLING, } } + + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_u16_le(self.control); + dst.put_u16_le(self.mask); + } + + pub fn decode(bytes: [u8; 4]) -> Self { + let raw = u32::from_le_bytes(bytes); + Self { + control: (raw & 0xffff) as u16, + mask: (raw >> 16) as u16, + } + } } impl fmt::Debug for VersionMask { @@ -422,15 +446,6 @@ impl fmt::Debug for VersionMask { } } -impl From for [u8; 4] { - fn from(mask: VersionMask) -> Self { - let mut bytes = [0u8; 4]; - bytes[0..2].copy_from_slice(&mask.control.to_le_bytes()); - bytes[2..4].copy_from_slice(&mask.mask.to_le_bytes()); - bytes - } -} - #[derive(FromRepr, Copy, Clone, Debug)] #[repr(u8)] pub enum RegisterAddress { @@ -450,207 +465,149 @@ pub enum RegisterAddress { MiscSettings = 0xB9, } -#[derive(Clone)] +/// Chip model + core count + assigned chain address. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChipId { + pub model: ChipModel, + pub core_count: u8, + pub address: u8, +} + +impl ChipId { + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_slice(&self.model.id_bytes()); + dst.put_u8(self.core_count); + dst.put_u8(self.address); + } + pub fn decode(bytes: [u8; 4]) -> Self { + Self { + model: ChipModel::from([bytes[0], bytes[1]]), + core_count: bytes[2], + address: bytes[3], + } + } +} + +// Placeholder newtypes for registers whose bit layout is not yet decomposed. +// Each wraps a raw u32 written little-endian to the wire. +macro_rules! raw_u32_register { + ($($(#[$meta:meta])* $name:ident),* $(,)?) => { + $( + $(#[$meta])* + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct $name(pub u32); + + impl $name { + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_u32_le(self.0); + } + pub fn decode(bytes: [u8; 4]) -> Self { + Self(u32::from_le_bytes(bytes)) + } + } + )* + }; +} + +raw_u32_register! { + MiscControl, + UartRelay, + AnalogMux, + Pll3Parameter, + InitControl, + MiscSettings, +} + +/// Transmitted big-endian, unlike the other raw-u32 registers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Core(pub u32); + +impl Core { + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_u32(self.0); + } + pub fn decode(bytes: [u8; 4]) -> Self { + Self(u32::from_be_bytes(bytes)) + } +} + +#[derive(Debug, Clone)] pub enum Register { - ChipId { - model: ChipModel, - core_count: u8, // Core configuration byte - address: u8, // Assigned chip address - }, + ChipId(ChipId), PllDivider(PllDivider), NonceRange(NonceRange), TicketMask(TicketMask), - MiscControl { - raw_value: u32, - }, + MiscControl(MiscControl), UartBaud(UartBaud), - UartRelay { - raw_value: u32, // Domain relay configuration (complex format) - }, - Core { - raw_value: u32, - }, - AnalogMux { - raw_value: u32, - }, + UartRelay(UartRelay), + Core(Core), + AnalogMux(AnalogMux), IoDriverStrength(IoDriverStrength), - Pll3Parameter { - raw_value: u32, - }, + Pll3Parameter(Pll3Parameter), VersionMask(VersionMask), - InitControl { - raw_value: u32, - }, - MiscSettings { - raw_value: u32, - }, + InitControl(InitControl), + MiscSettings(MiscSettings), } impl Register { - pub fn decode(address: RegisterAddress, bytes: &[u8; 4]) -> Register { - let raw_value = u32::from_le_bytes(*bytes); + pub fn decode(address: RegisterAddress, bytes: [u8; 4]) -> Register { match address { - RegisterAddress::ChipId => Register::ChipId { - model: ChipModel::from([bytes[0], bytes[1]]), - core_count: bytes[2], - address: bytes[3], - }, - RegisterAddress::PllDivider => Register::PllDivider(raw_value.into()), - RegisterAddress::NonceRange => Register::NonceRange(NonceRange { bytes: *bytes }), - RegisterAddress::TicketMask => { - // Decode wire bytes to TicketMask - // Wire bytes are in encoded format; decode to extract zero_bits value - let mask_value = decode_ticket_mask_bytes(bytes); - let zero_bits = mask_value.count_ones() as u8; - Register::TicketMask(TicketMask { zero_bits }) - } - RegisterAddress::MiscControl => Register::MiscControl { raw_value }, - RegisterAddress::UartBaud => { - // Decode known baud rates - let baud = match raw_value { - 0x00000271 => UartBaud::Baud115200, - 0x00000130 => UartBaud::Baud1M, - 0x00003001 => UartBaud::Baud3M, - other => UartBaud::Custom(other), - }; - Register::UartBaud(baud) - } - RegisterAddress::UartRelay => Register::UartRelay { raw_value }, - RegisterAddress::Core => Register::Core { raw_value }, - RegisterAddress::AnalogMux => Register::AnalogMux { raw_value }, + RegisterAddress::ChipId => Register::ChipId(ChipId::decode(bytes)), + RegisterAddress::PllDivider => Register::PllDivider(PllDivider::decode(bytes)), + RegisterAddress::NonceRange => Register::NonceRange(NonceRange::decode(bytes)), + RegisterAddress::TicketMask => Register::TicketMask(TicketMask::decode(bytes)), + RegisterAddress::MiscControl => Register::MiscControl(MiscControl::decode(bytes)), + RegisterAddress::UartBaud => Register::UartBaud(UartBaud::decode(bytes)), + RegisterAddress::UartRelay => Register::UartRelay(UartRelay::decode(bytes)), + RegisterAddress::Core => Register::Core(Core::decode(bytes)), + RegisterAddress::AnalogMux => Register::AnalogMux(AnalogMux::decode(bytes)), RegisterAddress::IoDriverStrength => { - // Parse driver strength from raw value - let mut strengths = [0u8; 8]; - for (i, strength) in strengths.iter_mut().enumerate() { - *strength = ((raw_value >> (i * 4)) & 0xf) as u8; - } - Register::IoDriverStrength(IoDriverStrength { strengths }) - } - RegisterAddress::Pll3Parameter => Register::Pll3Parameter { raw_value }, - RegisterAddress::VersionMask => { - let mask = (raw_value >> 16) as u16; - let control = (raw_value & 0xffff) as u16; - Register::VersionMask(VersionMask { mask, control }) + Register::IoDriverStrength(IoDriverStrength::decode(bytes)) } - RegisterAddress::InitControl => Register::InitControl { raw_value }, - RegisterAddress::MiscSettings => Register::MiscSettings { raw_value }, + RegisterAddress::Pll3Parameter => Register::Pll3Parameter(Pll3Parameter::decode(bytes)), + RegisterAddress::VersionMask => Register::VersionMask(VersionMask::decode(bytes)), + RegisterAddress::InitControl => Register::InitControl(InitControl::decode(bytes)), + RegisterAddress::MiscSettings => Register::MiscSettings(MiscSettings::decode(bytes)), } } /// Get the register address for this register fn address(&self) -> RegisterAddress { match self { - Register::ChipId { .. } => RegisterAddress::ChipId, + Register::ChipId(_) => RegisterAddress::ChipId, Register::PllDivider(_) => RegisterAddress::PllDivider, Register::NonceRange(_) => RegisterAddress::NonceRange, Register::TicketMask(_) => RegisterAddress::TicketMask, - Register::MiscControl { .. } => RegisterAddress::MiscControl, + Register::MiscControl(_) => RegisterAddress::MiscControl, Register::UartBaud(_) => RegisterAddress::UartBaud, - Register::UartRelay { .. } => RegisterAddress::UartRelay, - Register::Core { .. } => RegisterAddress::Core, - Register::AnalogMux { .. } => RegisterAddress::AnalogMux, + Register::UartRelay(_) => RegisterAddress::UartRelay, + Register::Core(_) => RegisterAddress::Core, + Register::AnalogMux(_) => RegisterAddress::AnalogMux, Register::IoDriverStrength(_) => RegisterAddress::IoDriverStrength, - Register::Pll3Parameter { .. } => RegisterAddress::Pll3Parameter, + Register::Pll3Parameter(_) => RegisterAddress::Pll3Parameter, Register::VersionMask(_) => RegisterAddress::VersionMask, - Register::InitControl { .. } => RegisterAddress::InitControl, - Register::MiscSettings { .. } => RegisterAddress::MiscSettings, + Register::InitControl(_) => RegisterAddress::InitControl, + Register::MiscSettings(_) => RegisterAddress::MiscSettings, } } /// Encode the register data (not the address) - fn encode_data(&self, dst: &mut BytesMut) { - match self { - Register::ChipId { - model, - core_count, - address, - } => { - dst.put_slice(&model.id_bytes()); - dst.put_u8(*core_count); - dst.put_u8(*address); - } - Register::PllDivider(config) => { - let bytes: [u8; 4] = (*config).into(); - dst.put_slice(&bytes); - } - Register::NonceRange(config) => { - let bytes: [u8; 4] = (*config).into(); - dst.put_slice(&bytes); - } - Register::TicketMask(mask) => { - let bytes: [u8; 4] = (*mask).into(); - dst.put_slice(&bytes); - } - Register::UartBaud(baud) => { - let bytes: [u8; 4] = (*baud).into(); - dst.put_slice(&bytes); - } - Register::Core { raw_value } => { - // Core register needs big-endian encoding - dst.put_u32(*raw_value); - } - Register::MiscControl { raw_value } - | Register::UartRelay { raw_value } - | Register::AnalogMux { raw_value } - | Register::Pll3Parameter { raw_value } - | Register::InitControl { raw_value } - | Register::MiscSettings { raw_value } => { - dst.put_u32_le(*raw_value); - } - Register::IoDriverStrength(strength) => { - let bytes: [u8; 4] = (*strength).into(); - dst.put_slice(&bytes); - } - Register::VersionMask(mask) => { - let bytes: [u8; 4] = (*mask).into(); - dst.put_slice(&bytes); - } - } - } -} - -impl std::fmt::Debug for Register { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn encode(&self, dst: &mut BytesMut) { match self { - Register::ChipId { - model, - core_count, - address, - } => f - .debug_struct("ChipId") - .field("model", model) - .field("core_count", core_count) - .field("address", address) - .finish(), - Register::PllDivider(config) => f.debug_tuple("PllDivider").field(config).finish(), - Register::NonceRange(config) => f.debug_tuple("NonceRange").field(config).finish(), - Register::TicketMask(mask) => f.debug_tuple("TicketMask").field(mask).finish(), - Register::UartBaud(baud) => f.debug_tuple("UartBaud").field(baud).finish(), - Register::IoDriverStrength(strength) => { - f.debug_tuple("IoDriverStrength").field(strength).finish() - } - Register::VersionMask(mask) => f.debug_tuple("VersionMask").field(mask).finish(), - Register::MiscControl { raw_value } - | Register::UartRelay { raw_value } - | Register::AnalogMux { raw_value } - | Register::Pll3Parameter { raw_value } - | Register::InitControl { raw_value } - | Register::Core { raw_value } - | Register::MiscSettings { raw_value } => { - let register_name = match self { - Register::MiscControl { .. } => "MiscControl", - Register::UartRelay { .. } => "UartRelay", - Register::AnalogMux { .. } => "AnalogMux", - Register::Pll3Parameter { .. } => "Pll3Parameter", - Register::InitControl { .. } => "InitControl", - Register::Core { .. } => "Core", - Register::MiscSettings { .. } => "MiscSettings", - _ => unreachable!(), - }; - f.debug_struct(register_name) - .field("raw_value", &format_args!("0x{:08x}", raw_value)) - .finish() - } + Register::ChipId(r) => r.encode(dst), + Register::PllDivider(r) => r.encode(dst), + Register::NonceRange(r) => r.encode(dst), + Register::TicketMask(r) => r.encode(dst), + Register::MiscControl(r) => r.encode(dst), + Register::UartBaud(r) => r.encode(dst), + Register::UartRelay(r) => r.encode(dst), + Register::Core(r) => r.encode(dst), + Register::AnalogMux(r) => r.encode(dst), + Register::IoDriverStrength(r) => r.encode(dst), + Register::Pll3Parameter(r) => r.encode(dst), + Register::VersionMask(r) => r.encode(dst), + Register::InitControl(r) => r.encode(dst), + Register::MiscSettings(r) => r.encode(dst), } } } @@ -879,7 +836,7 @@ impl RegisterCommand { dst.put_u8(TOTAL_LEN); dst.put_u8(*chip_address); dst.put_u8(register.address() as u8); - register.encode_data(dst); + register.encode(dst); } } } @@ -1015,7 +972,7 @@ impl Response { let register_address_repr = bytes.get_u8(); if let Some(register_address) = RegisterAddress::from_repr(register_address_repr) { - let register = Register::decode(register_address, &value); + let register = Register::decode(register_address, value); Ok(Response::ReadRegister { chip_address, register, @@ -1263,9 +1220,10 @@ mod init_tests { } = first_boundary { assert_eq!(*chip_address, 0x08); // 5th chip (index 4) * 2 - let strength_bytes: [u8; 4] = (*strength).into(); + let mut buf = BytesMut::new(); + strength.encode(&mut buf); // Expected bytes from hardware capture - assert_eq!(strength_bytes, [0x00, 0xf1, 0x11, 0x11]); + assert_eq!(&buf[..], &[0x00, 0xf1, 0x11, 0x11]); } } @@ -1282,8 +1240,9 @@ mod init_tests { .. } = &commands[0] { - let config_bytes: [u8; 4] = (*config).into(); - assert_eq!(config_bytes, [0xff, 0xff, 0xff, 0xff]); + let mut buf = BytesMut::new(); + config.encode(&mut buf); + assert_eq!(&buf[..], &[0xff, 0xff, 0xff, 0xff]); } // Test S21 Pro configuration (65 chips) @@ -1294,8 +1253,9 @@ mod init_tests { .. } = &commands[0] { - let config_bytes: [u8; 4] = (*config).into(); - assert_eq!(config_bytes, [0x00, 0x00, 0x1e, 0xb5]); + let mut buf = BytesMut::new(); + config.encode(&mut buf); + assert_eq!(&buf[..], &[0x00, 0x00, 0x1e, 0xb5]); } // Test small chain @@ -1305,8 +1265,9 @@ mod init_tests { .. } = &commands[0] { - let config_bytes: [u8; 4] = (*config).into(); - assert_eq!(config_bytes, [0xff, 0xff, 0xff, 0x1f]); + let mut buf = BytesMut::new(); + config.encode(&mut buf); + assert_eq!(&buf[..], &[0xff, 0xff, 0xff, 0x1f]); } } @@ -1333,8 +1294,9 @@ mod init_tests { .. }) = nonce_range_cmd { - let config_bytes: [u8; 4] = (*config).into(); - assert_eq!(config_bytes, [0x00, 0x00, 0x1e, 0xb5]); // S21 Pro value + let mut buf = BytesMut::new(); + config.encode(&mut buf); + assert_eq!(&buf[..], &[0x00, 0x00, 0x1e, 0xb5]); // S21 Pro value } } } @@ -1366,11 +1328,11 @@ mod command_tests { RegisterCommand::WriteRegister { broadcast: false, chip_address: 0x01, - register: Register::ChipId { + register: Register::ChipId(ChipId { model: ChipModel::BM1370, core_count: 0x00, address: 0x01, - }, + }), }, &[ 0x55, 0xaa, 0x41, 0x09, 0x01, 0x00, 0x13, 0x70, 0x00, 0x01, 0x0a, @@ -1402,9 +1364,7 @@ mod command_tests { RegisterCommand::WriteRegister { broadcast: true, chip_address: 0x00, - register: Register::InitControl { - raw_value: 0x00000700, - }, + register: Register::InitControl(InitControl(0x00000700)), }, &[ 0x55, 0xaa, 0x51, 0x09, 0x00, 0xa8, 0x00, 0x07, 0x00, 0x00, 0x03, @@ -1419,9 +1379,7 @@ mod command_tests { RegisterCommand::WriteRegister { broadcast: true, chip_address: 0x00, - register: Register::MiscControl { - raw_value: 0x00C100F0, - }, + register: Register::MiscControl(MiscControl(0x00C100F0)), }, &[ 0x55, 0xaa, 0x51, 0x09, 0x00, 0x18, 0xf0, 0x00, 0xc1, 0x00, 0x04, @@ -1455,9 +1413,8 @@ mod command_tests { RegisterCommand::WriteRegister { broadcast: true, chip_address: 0x00, - register: Register::Core { - raw_value: 0x80008B00, // Big-endian: produces bytes 80 00 8B 00 - }, + // Big-endian: produces bytes 80 00 8B 00 + register: Register::Core(Core(0x80008B00)), }, &[ 0x55, 0xaa, 0x51, 0x09, 0x00, 0x3c, 0x80, 0x00, 0x8b, 0x00, 0x12, @@ -1752,11 +1709,11 @@ mod response_tests { assert_eq!(chip_address, 0x00); - let Register::ChipId { + let Register::ChipId(ChipId { model, core_count, address, - } = register + }) = register else { panic!("Expected ChipId register, got {:?}", register); }; @@ -2311,11 +2268,12 @@ mod ticket_mask_tests { } #[test] - fn into_bytes_matches_to_wire_bytes() { + fn encode_matches_to_wire_bytes() { let diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); let mask = TicketMask::new(diff); - let bytes: [u8; 4] = mask.into(); - assert_eq!(bytes, [0x00, 0x00, 0x00, 0xFF]); + let mut buf = BytesMut::new(); + mask.encode(&mut buf); + assert_eq!(&buf[..], &[0x00, 0x00, 0x00, 0xFF]); } #[test] @@ -2414,14 +2372,12 @@ impl BM13xxProtocol { commands.push(self.broadcast_write(Register::VersionMask(VersionMask::full_rolling()))); // Step 2: Configure init control register - commands.push(self.broadcast_write(Register::InitControl { - raw_value: INIT_CONTROL_VALUE, - })); + commands.push(self.broadcast_write(Register::InitControl(InitControl(INIT_CONTROL_VALUE)))); // Step 3: Configure misc control - commands.push(self.broadcast_write(Register::MiscControl { - raw_value: MISC_CONTROL_MULTI_CHIP, - })); + commands.push( + self.broadcast_write(Register::MiscControl(MiscControl(MISC_CONTROL_MULTI_CHIP))), + ); // Step 4: Set chain inactive for address assignment commands.push(RegisterCommand::ChainInactive); @@ -2435,12 +2391,8 @@ impl BM13xxProtocol { } // Step 6: Configure core registers on all chips - commands.push(self.broadcast_write(Register::Core { - raw_value: CORE_REG_INIT_1, - })); - commands.push(self.broadcast_write(Register::Core { - raw_value: CORE_REG_INIT_2, - })); + commands.push(self.broadcast_write(Register::Core(Core(CORE_REG_INIT_1)))); + commands.push(self.broadcast_write(Register::Core(Core(CORE_REG_INIT_2)))); // Step 7: Set ticket mask (difficulty 256 = ~1 nonce/sec at 1 TH/s) let log2_diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); @@ -2492,9 +2444,7 @@ impl BM13xxProtocol { let relay_offset = (domain * chips_per_domain) as u32; commands.push(self.write_to( first_address, - Register::UartRelay { - raw_value: UART_RELAY_BASE | (relay_offset << 8), - }, + Register::UartRelay(UartRelay(UART_RELAY_BASE | (relay_offset << 8))), )); // Configure last chip in domain @@ -2502,9 +2452,7 @@ impl BM13xxProtocol { let last_address = (last_chip as u8) * ADDRESS_INCREMENT; commands.push(self.write_to( last_address, - Register::UartRelay { - raw_value: UART_RELAY_BASE | (relay_offset << 8), - }, + Register::UartRelay(UartRelay(UART_RELAY_BASE | (relay_offset << 8))), )); } } @@ -2563,7 +2511,9 @@ impl BM13xxProtocol { // Can't write chip ID register directly return Err(ProtocolError::ReadOnlyRegister(register)); } - RegisterAddress::PllDivider => Register::PllDivider(value.into()), + RegisterAddress::PllDivider => { + Register::PllDivider(PllDivider::decode(value.to_le_bytes())) + } RegisterAddress::NonceRange => Register::NonceRange(NonceRange { bytes: value.to_le_bytes(), }), @@ -2573,11 +2523,11 @@ impl BM13xxProtocol { let zero_bits = mask_value.count_ones() as u8; Register::TicketMask(TicketMask { zero_bits }) } - RegisterAddress::MiscControl => Register::MiscControl { raw_value: value }, + RegisterAddress::MiscControl => Register::MiscControl(MiscControl(value)), RegisterAddress::UartBaud => Register::UartBaud(UartBaud::Custom(value)), - RegisterAddress::UartRelay => Register::UartRelay { raw_value: value }, - RegisterAddress::Core => Register::Core { raw_value: value }, - RegisterAddress::AnalogMux => Register::AnalogMux { raw_value: value }, + RegisterAddress::UartRelay => Register::UartRelay(UartRelay(value)), + RegisterAddress::Core => Register::Core(Core(value)), + RegisterAddress::AnalogMux => Register::AnalogMux(AnalogMux(value)), RegisterAddress::IoDriverStrength => { let mut strengths = [0u8; 8]; for (i, strength) in strengths.iter_mut().enumerate() { @@ -2585,14 +2535,14 @@ impl BM13xxProtocol { } Register::IoDriverStrength(IoDriverStrength { strengths }) } - RegisterAddress::Pll3Parameter => Register::Pll3Parameter { raw_value: value }, + RegisterAddress::Pll3Parameter => Register::Pll3Parameter(Pll3Parameter(value)), RegisterAddress::VersionMask => { let mask = (value >> 16) as u16; let control = (value & 0xffff) as u16; Register::VersionMask(VersionMask { mask, control }) } - RegisterAddress::InitControl => Register::InitControl { raw_value: value }, - RegisterAddress::MiscSettings => Register::MiscSettings { raw_value: value }, + RegisterAddress::InitControl => Register::InitControl(InitControl(value)), + RegisterAddress::MiscSettings => Register::MiscSettings(MiscSettings(value)), }; Ok(RegisterCommand::WriteRegister { diff --git a/mujina-miner/src/asic/bm13xx/thread.rs b/mujina-miner/src/asic/bm13xx/thread.rs index 5e9d430a..6fbd63b7 100644 --- a/mujina-miner/src/asic/bm13xx/thread.rs +++ b/mujina-miner/src/asic/bm13xx/thread.rs @@ -17,7 +17,10 @@ use futures::{SinkExt, sink::Sink, stream::Stream}; use tokio::sync::{mpsc, oneshot, watch}; use tokio_stream::StreamExt; -use super::protocol::{self, JobCommand, Log2Difficulty, Register, RegisterCommand, TicketMask}; +use super::protocol::{ + self, AnalogMux, Core, InitControl, JobCommand, Log2Difficulty, MiscControl, MiscSettings, + Register, RegisterCommand, TicketMask, +}; use crate::{ asic::hash_thread::{ BoardPeripherals, HashTask, HashThread, HashThreadCapabilities, HashThreadEvent, @@ -302,17 +305,13 @@ where send_reg( chip_commands, true, - Register::InitControl { - raw_value: 0x00000700, - }, + Register::InitControl(InitControl(0x00000700)), ) .await?; send_reg( chip_commands, true, - Register::MiscControl { - raw_value: 0x00C100F0, - }, + Register::MiscControl(MiscControl(0x00C100F0)), ) .await?; @@ -331,22 +330,8 @@ where // Core configuration (broadcast) debug!("Sending broadcast core configuration"); - send_reg( - chip_commands, - true, - Register::Core { - raw_value: 0x8000_8B00, - }, - ) - .await?; - send_reg( - chip_commands, - true, - Register::Core { - raw_value: 0x8000_800C, - }, - ) - .await?; + send_reg(chip_commands, true, Register::Core(Core(0x8000_8B00))).await?; + send_reg(chip_commands, true, Register::Core(Core(0x8000_800C))).await?; // Ticket mask let ticket_mask = TicketMask::new(asic_difficulty); @@ -365,77 +350,39 @@ where send_reg( chip_commands, false, - Register::InitControl { - raw_value: 0xF0010700, - }, - ) - .await?; - send_reg( - chip_commands, - false, - Register::MiscControl { - raw_value: 0x00C100F0, - }, + Register::InitControl(InitControl(0xF0010700)), ) .await?; send_reg( chip_commands, false, - Register::Core { - raw_value: 0x8000_8B00, - }, - ) - .await?; - send_reg( - chip_commands, - false, - Register::Core { - raw_value: 0x8000_800C, - }, - ) - .await?; - send_reg( - chip_commands, - false, - Register::Core { - raw_value: 0x8000_82AA, - }, + Register::MiscControl(MiscControl(0x00C100F0)), ) .await?; + send_reg(chip_commands, false, Register::Core(Core(0x8000_8B00))).await?; + send_reg(chip_commands, false, Register::Core(Core(0x8000_800C))).await?; + send_reg(chip_commands, false, Register::Core(Core(0x8000_82AA))).await?; // Additional settings send_reg( chip_commands, true, - Register::MiscSettings { - raw_value: 0x80440000, - }, - ) - .await?; - send_reg( - chip_commands, - true, - Register::AnalogMux { - raw_value: 0x02000000, - }, + Register::MiscSettings(MiscSettings(0x80440000)), ) .await?; send_reg( chip_commands, true, - Register::MiscSettings { - raw_value: 0x80440000, - }, + Register::AnalogMux(AnalogMux(0x02000000)), ) .await?; send_reg( chip_commands, true, - Register::Core { - raw_value: 0x8000_8DEE, - }, + Register::MiscSettings(MiscSettings(0x80440000)), ) .await?; + send_reg(chip_commands, true, Register::Core(Core(0x8000_8DEE))).await?; // Frequency ramping (56.25 MHz -> 525 MHz) debug!("Ramping frequency from 56.25 MHz to 525 MHz"); diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 747e0176..5055d9a7 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -23,7 +23,11 @@ use crate::{ api_client::types::{BoardTelemetry, Fan, PowerMeasurement, TemperatureSensor}, asic::{ ChipInfo, - bm13xx::{self, BM13xxProtocol, protocol::RegisterCommand, thread::BM13xxThread}, + bm13xx::{ + self, BM13xxProtocol, Register, Response, + protocol::{ChipId, RegisterCommand}, + thread::BM13xxThread, + }, hash_thread::{AsicEnable, BoardPeripherals, HashThread, ThreadRemovalSignal}, }, hw_trait::{ @@ -569,9 +573,9 @@ async fn discover_chips( tokio::select! { response = reader.next() => { match response { - Some(Ok(bm13xx::Response::ReadRegister { + Some(Ok(Response::ReadRegister { chip_address: _, - register: bm13xx::Register::ChipId { model, core_count, address } + register: Register::ChipId(ChipId { model, core_count, address }), })) => { let chip_id = model.id_bytes(); debug!("Discovered chip {:?} ({:02x}{:02x}) at address {address}", From 8fc9d82b6f6d3a5fbdd0350890f051935e098615 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Fri, 24 Apr 2026 16:15:52 -0500 Subject: [PATCH 15/38] refactor(bm13xx): tighten Sink Error bound in hash thread The hash thread took any Sink whose error implemented Debug and then rendered sink errors by hand. Require the Error trait instead: ? then auto-converts into anyhow::Result, and the generic parameter still avoids tying callers to any concrete error type. --- mujina-miner/src/asic/bm13xx/thread.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/thread.rs b/mujina-miner/src/asic/bm13xx/thread.rs index 6fbd63b7..5a233986 100644 --- a/mujina-miner/src/asic/bm13xx/thread.rs +++ b/mujina-miner/src/asic/bm13xx/thread.rs @@ -138,7 +138,7 @@ impl BM13xxThread { where R: Stream> + Unpin + Send + 'static, W: Sink + Sink + Unpin + Send + 'static, - E: std::fmt::Debug + Send + 'static, + E: std::error::Error + Send + Sync + 'static, { let (cmd_tx, cmd_rx) = mpsc::channel(10); let (evt_tx, evt_rx) = mpsc::channel(100); @@ -251,7 +251,7 @@ async fn initialize_chip( ) -> Result<()> where W: Sink + Sink + Unpin, - E: std::fmt::Debug, + E: std::error::Error + Send + Sync + 'static, { // Enable the ASIC if let Some(ref mut asic_enable) = peripherals.asic_enable { @@ -264,7 +264,6 @@ where tokio::time::sleep(std::time::Duration::from_millis(200)).await; - // Send a register write command, converting the sink error to anyhow. async fn send_reg( chip_commands: &mut W, broadcast: bool, @@ -272,7 +271,7 @@ where ) -> Result<()> where W: Sink + Unpin, - E: std::fmt::Debug, + E: std::error::Error + Send + Sync + 'static, { chip_commands .send(RegisterCommand::WriteRegister { @@ -280,8 +279,8 @@ where chip_address: 0x00, register, }) - .await - .map_err(|e| anyhow!("{e:?}")) + .await?; + Ok(()) } // Send version mask configuration (3 times) @@ -318,13 +317,11 @@ where chip_commands .send(RegisterCommand::ChainInactive) .await - .map_err(|e| anyhow!("{e:?}")) .context("failed to send ChainInactive")?; chip_commands .send(RegisterCommand::SetChipAddress { chip_address: 0x00 }) .await - .map_err(|e| anyhow!("{e:?}")) .context("failed to send SetChipAddress")?; // Core configuration (broadcast) @@ -562,7 +559,7 @@ async fn bm13xx_thread_actor( ) where R: Stream> + Unpin, W: Sink + Sink + Unpin, - E: std::fmt::Debug, + E: std::error::Error + Send + Sync + 'static, { // Disable ASIC on startup to establish known state if let Some(ref mut asic_enable) = peripherals.asic_enable From eef21c53aa0458c66363b73aab21f75dcf806114 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Fri, 24 Apr 2026 16:25:45 -0500 Subject: [PATCH 16/38] refactor(bm13xx): introduce Destination for register commands Replace the broadcast/chip_address pair on ReadRegister and WriteRegister with a single Destination enum (Broadcast | Chip). One value is simpler than two correlated fields, and as a side benefit the illegal state (broadcast with a nonzero address) is no longer representable. Wire bytes are unchanged. SetChipAddress and ChainInactive are not affected: their addressing is fixed by the variant. Unroll the send_reg helper at the same time, since every call site is being rewritten anyway. --- mujina-miner/src/asic/bm13xx/protocol.rs | 95 +++++----- mujina-miner/src/asic/bm13xx/thread.rs | 212 +++++++++++++---------- mujina-miner/src/board/bitaxe.rs | 17 +- 3 files changed, 171 insertions(+), 153 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/protocol.rs b/mujina-miner/src/asic/bm13xx/protocol.rs index 5a07ce08..a13da91b 100644 --- a/mujina-miner/src/asic/bm13xx/protocol.rs +++ b/mujina-miner/src/asic/bm13xx/protocol.rs @@ -662,6 +662,30 @@ pub fn hash_from_wire_bytes(wire_bytes: &[u8; 32]) -> [u8; 32] { hash } +/// Target of a register read or write. +/// +/// Broadcast frames go to every chip on the chain; the address byte +/// is ignored on the wire. Chip-directed frames carry the assigned +/// address of a single chip. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Destination { + Broadcast, + Chip(u8), +} + +impl Destination { + fn is_broadcast(self) -> bool { + matches!(self, Self::Broadcast) + } + + fn address_byte(self) -> u8 { + match self { + Self::Broadcast => 0x00, + Self::Chip(addr) => addr, + } + } +} + /// TYPE=2 frames: register reads/writes and chain addressing. Use CRC5. #[derive(Debug)] pub enum RegisterCommand { @@ -671,14 +695,12 @@ pub enum RegisterCommand { ChainInactive, /// Read a register from chip(s) ReadRegister { - broadcast: bool, - chip_address: u8, + destination: Destination, register_address: RegisterAddress, }, /// Write a register to chip(s) WriteRegister { - broadcast: bool, - chip_address: u8, + destination: Destination, register: Register, }, } @@ -787,13 +809,12 @@ impl RegisterCommand { dst.put_u8(0x00); // Reserved byte } Self::ReadRegister { - broadcast, - chip_address, + destination, register_address, } => { dst.put_u8(build_flags( CommandFlagsType::Command, - *broadcast, + destination.is_broadcast(), CommandFlagsCmd::ReadRegister, )); @@ -806,17 +827,16 @@ impl RegisterCommand { FLAGS_LEN + LENGTH_FIELD_LEN + CHIP_ADDR_LEN + REG_ADDR_LEN + CRC_LEN; dst.put_u8(TOTAL_LEN); - dst.put_u8(*chip_address); + dst.put_u8(destination.address_byte()); dst.put_u8(*register_address as u8); } Self::WriteRegister { - broadcast, - chip_address, + destination, register, } => { dst.put_u8(build_flags( CommandFlagsType::Command, - *broadcast, + destination.is_broadcast(), CommandFlagsCmd::WriteRegisterOrJob, )); @@ -834,7 +854,7 @@ impl RegisterCommand { + CRC_LEN; dst.put_u8(TOTAL_LEN); - dst.put_u8(*chip_address); + dst.put_u8(destination.address_byte()); dst.put_u8(register.address() as u8); register.encode(dst); } @@ -1154,8 +1174,7 @@ mod init_tests { assert!(matches!( &commands[0], RegisterCommand::WriteRegister { - broadcast: true, - chip_address: 0x00, + destination: Destination::Broadcast, register: Register::VersionMask(_), } )); @@ -1214,9 +1233,8 @@ mod init_tests { // Check first domain boundary (chip 8 = address 0x08) let first_boundary = io_strength_commands[0]; if let RegisterCommand::WriteRegister { - chip_address, + destination: Destination::Chip(chip_address), register: Register::IoDriverStrength(strength), - .. } = first_boundary { assert_eq!(*chip_address, 0x08); // 5th chip (index 4) * 2 @@ -1236,8 +1254,7 @@ mod init_tests { assert_eq!(commands.len(), 1); if let RegisterCommand::WriteRegister { register: Register::NonceRange(config), - broadcast: true, - .. + destination: Destination::Broadcast, } = &commands[0] { let mut buf = BytesMut::new(); @@ -1314,8 +1331,7 @@ mod command_tests { fn read_register() { assert_frame_eq( RegisterCommand::ReadRegister { - broadcast: true, - chip_address: 0, + destination: Destination::Broadcast, register_address: RegisterAddress::ChipId, }, &[0x55, 0xaa, 0x52, 0x05, 0x00, 0x00, 0x0a], @@ -1326,8 +1342,7 @@ mod command_tests { fn write_register_chip_address() { assert_frame_eq( RegisterCommand::WriteRegister { - broadcast: false, - chip_address: 0x01, + destination: Destination::Chip(0x01), register: Register::ChipId(ChipId { model: ChipModel::BM1370, core_count: 0x00, @@ -1346,8 +1361,7 @@ mod command_tests { // From S21 Pro capture: TX: 55 AA 51 09 00 A4 90 00 FF FF 1C assert_frame_eq( RegisterCommand::WriteRegister { - broadcast: true, // 0x51 = broadcast - chip_address: 0x00, + destination: Destination::Broadcast, // 0x51 = broadcast register: Register::VersionMask(VersionMask::full_rolling()), }, &[ @@ -1362,8 +1376,7 @@ mod command_tests { // Value 0x00 07 00 00 in little-endian = 0x00000700 assert_frame_eq( RegisterCommand::WriteRegister { - broadcast: true, - chip_address: 0x00, + destination: Destination::Broadcast, register: Register::InitControl(InitControl(0x00000700)), }, &[ @@ -1377,8 +1390,7 @@ mod command_tests { // From Bitaxe capture: TX: 55 AA 51 09 00 18 F0 00 C1 00 04 assert_frame_eq( RegisterCommand::WriteRegister { - broadcast: true, - chip_address: 0x00, + destination: Destination::Broadcast, register: Register::MiscControl(MiscControl(0x00C100F0)), }, &[ @@ -1411,8 +1423,7 @@ mod command_tests { // Core register uses big-endian encoding assert_frame_eq( RegisterCommand::WriteRegister { - broadcast: true, - chip_address: 0x00, + destination: Destination::Broadcast, // Big-endian: produces bytes 80 00 8B 00 register: Register::Core(Core(0x80008B00)), }, @@ -1429,8 +1440,7 @@ mod command_tests { let log2_diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); assert_frame_eq( RegisterCommand::WriteRegister { - broadcast: true, - chip_address: 0x00, + destination: Destination::Broadcast, register: Register::TicketMask(TicketMask::new(log2_diff)), }, &[ @@ -1444,8 +1454,7 @@ mod command_tests { // From S21 Pro capture: TX: 55 AA 51 09 00 10 00 00 1E B5 0F assert_frame_eq( RegisterCommand::WriteRegister { - broadcast: true, - chip_address: 0x00, + destination: Destination::Broadcast, register: Register::NonceRange(NonceRange::multi_chip(65)), }, &[ @@ -2311,8 +2320,7 @@ impl BM13xxProtocol { /// Helper to create a broadcast write command fn broadcast_write(&self, register: Register) -> RegisterCommand { RegisterCommand::WriteRegister { - broadcast: true, - chip_address: 0x00, + destination: Destination::Broadcast, register, } } @@ -2321,8 +2329,7 @@ impl BM13xxProtocol { #[cfg_attr(not(test), allow(dead_code))] fn write_to(&self, chip_address: u8, register: Register) -> RegisterCommand { RegisterCommand::WriteRegister { - broadcast: false, - chip_address, + destination: Destination::Chip(chip_address), register, } } @@ -2480,8 +2487,7 @@ impl BM13xxProtocol { /// Create a command to read a register. pub fn read_register(&self, chip_address: u8, register: RegisterAddress) -> RegisterCommand { RegisterCommand::ReadRegister { - broadcast: false, - chip_address, + destination: Destination::Chip(chip_address), register_address: register, } } @@ -2489,8 +2495,7 @@ impl BM13xxProtocol { /// Set UART baud rate on all chips pub fn set_baudrate(&self, baudrate: UartBaud) -> RegisterCommand { RegisterCommand::WriteRegister { - broadcast: true, - chip_address: 0x00, + destination: Destination::Broadcast, register: Register::UartBaud(baudrate), } } @@ -2546,8 +2551,7 @@ impl BM13xxProtocol { }; Ok(RegisterCommand::WriteRegister { - broadcast: false, - chip_address, + destination: Destination::Chip(chip_address), register: register_value, }) } @@ -2555,8 +2559,7 @@ impl BM13xxProtocol { /// Create a broadcast command to discover all chips. pub fn discover_chips() -> RegisterCommand { RegisterCommand::ReadRegister { - broadcast: true, - chip_address: 0, + destination: Destination::Broadcast, register_address: RegisterAddress::ChipId, } } diff --git a/mujina-miner/src/asic/bm13xx/thread.rs b/mujina-miner/src/asic/bm13xx/thread.rs index 5a233986..a4caef5b 100644 --- a/mujina-miner/src/asic/bm13xx/thread.rs +++ b/mujina-miner/src/asic/bm13xx/thread.rs @@ -18,8 +18,8 @@ use tokio::sync::{mpsc, oneshot, watch}; use tokio_stream::StreamExt; use super::protocol::{ - self, AnalogMux, Core, InitControl, JobCommand, Log2Difficulty, MiscControl, MiscSettings, - Register, RegisterCommand, TicketMask, + self, AnalogMux, Core, Destination, InitControl, IoDriverStrength, JobCommand, Log2Difficulty, + MiscControl, MiscSettings, NonceRange, Register, RegisterCommand, TicketMask, VersionMask, }; use crate::{ asic::hash_thread::{ @@ -264,35 +264,16 @@ where tokio::time::sleep(std::time::Duration::from_millis(200)).await; - async fn send_reg( - chip_commands: &mut W, - broadcast: bool, - register: Register, - ) -> Result<()> - where - W: Sink + Unpin, - E: std::error::Error + Send + Sync + 'static, - { - chip_commands - .send(RegisterCommand::WriteRegister { - broadcast, - chip_address: 0x00, - register, - }) - .await?; - Ok(()) - } - // Send version mask configuration (3 times) debug!("Configuring version mask"); for _ in 1..=3 { - send_reg( - chip_commands, - true, - Register::VersionMask(protocol::VersionMask::full_rolling()), - ) - .await - .context("failed to send version mask")?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Broadcast, + register: Register::VersionMask(VersionMask::full_rolling()), + }) + .await + .context("failed to send version mask")?; tokio::time::sleep(std::time::Duration::from_millis(5)).await; } @@ -301,18 +282,18 @@ where // Pre-configuration registers debug!("Sending pre-configuration registers"); - send_reg( - chip_commands, - true, - Register::InitControl(InitControl(0x00000700)), - ) - .await?; - send_reg( - chip_commands, - true, - Register::MiscControl(MiscControl(0x00C100F0)), - ) - .await?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Broadcast, + register: Register::InitControl(InitControl(0x00000700)), + }) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Broadcast, + register: Register::MiscControl(MiscControl(0x00C100F0)), + }) + .await?; chip_commands .send(RegisterCommand::ChainInactive) @@ -327,66 +308,105 @@ where // Core configuration (broadcast) debug!("Sending broadcast core configuration"); - send_reg(chip_commands, true, Register::Core(Core(0x8000_8B00))).await?; - send_reg(chip_commands, true, Register::Core(Core(0x8000_800C))).await?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Broadcast, + register: Register::Core(Core(0x8000_8B00)), + }) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Broadcast, + register: Register::Core(Core(0x8000_800C)), + }) + .await?; // Ticket mask let ticket_mask = TicketMask::new(asic_difficulty); - send_reg(chip_commands, true, Register::TicketMask(ticket_mask)).await?; - send_reg( - chip_commands, - true, - Register::IoDriverStrength(protocol::IoDriverStrength::normal()), - ) - .await?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Broadcast, + register: Register::TicketMask(ticket_mask), + }) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Broadcast, + register: Register::IoDriverStrength(IoDriverStrength::normal()), + }) + .await?; // Chip-specific configuration debug!("Sending chip-specific configuration"); - send_reg( - chip_commands, - false, - Register::InitControl(InitControl(0xF0010700)), - ) - .await?; - send_reg( - chip_commands, - false, - Register::MiscControl(MiscControl(0x00C100F0)), - ) - .await?; - send_reg(chip_commands, false, Register::Core(Core(0x8000_8B00))).await?; - send_reg(chip_commands, false, Register::Core(Core(0x8000_800C))).await?; - send_reg(chip_commands, false, Register::Core(Core(0x8000_82AA))).await?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Chip(0x00), + register: Register::InitControl(InitControl(0xF0010700)), + }) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Chip(0x00), + register: Register::MiscControl(MiscControl(0x00C100F0)), + }) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Chip(0x00), + register: Register::Core(Core(0x8000_8B00)), + }) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Chip(0x00), + register: Register::Core(Core(0x8000_800C)), + }) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Chip(0x00), + register: Register::Core(Core(0x8000_82AA)), + }) + .await?; // Additional settings - send_reg( - chip_commands, - true, - Register::MiscSettings(MiscSettings(0x80440000)), - ) - .await?; - send_reg( - chip_commands, - true, - Register::AnalogMux(AnalogMux(0x02000000)), - ) - .await?; - send_reg( - chip_commands, - true, - Register::MiscSettings(MiscSettings(0x80440000)), - ) - .await?; - send_reg(chip_commands, true, Register::Core(Core(0x8000_8DEE))).await?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Broadcast, + register: Register::MiscSettings(MiscSettings(0x80440000)), + }) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Broadcast, + register: Register::AnalogMux(AnalogMux(0x02000000)), + }) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Broadcast, + register: Register::MiscSettings(MiscSettings(0x80440000)), + }) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Broadcast, + register: Register::Core(Core(0x8000_8DEE)), + }) + .await?; // Frequency ramping (56.25 MHz -> 525 MHz) debug!("Ramping frequency from 56.25 MHz to 525 MHz"); let frequency_steps = generate_frequency_ramp_steps(56.25, 525.0, 6.25); for (i, pll_config) in frequency_steps.iter().enumerate() { - send_reg(chip_commands, true, Register::PllDivider(*pll_config)) + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Broadcast, + register: Register::PllDivider(*pll_config), + }) .await .context("PLL ramp failed")?; @@ -400,18 +420,18 @@ where debug!("Frequency ramping complete"); // Final configuration - send_reg( - chip_commands, - true, - Register::NonceRange(protocol::NonceRange::from_raw(0xB51E0000)), - ) - .await?; - send_reg( - chip_commands, - true, - Register::VersionMask(protocol::VersionMask::full_rolling()), - ) - .await?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Broadcast, + register: Register::NonceRange(NonceRange::from_raw(0xB51E0000)), + }) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister { + destination: Destination::Broadcast, + register: Register::VersionMask(VersionMask::full_rolling()), + }) + .await?; tokio::time::sleep(std::time::Duration::from_millis(150)).await; diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 5055d9a7..9783e1e3 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -25,7 +25,7 @@ use crate::{ ChipInfo, bm13xx::{ self, BM13xxProtocol, Register, Response, - protocol::{ChipId, RegisterCommand}, + protocol::{ChipId, Destination, RegisterCommand, VersionMask}, thread::BM13xxThread, }, hash_thread::{AsicEnable, BoardPeripherals, HashThread, ThreadRemovalSignal}, @@ -123,17 +123,12 @@ async fn create_from_usb(device: UsbDeviceInfo) -> Result { // Version mask and chip discovery debug!("Sending version mask configuration (3 times)"); - for i in 1..=3 { - trace!("Version mask send {}/3", i); - let version_cmd = RegisterCommand::WriteRegister { - broadcast: true, - chip_address: 0x00, - register: bm13xx::protocol::Register::VersionMask( - bm13xx::protocol::VersionMask::full_rolling(), - ), - }; + for _ in 1..=3 { data_writer - .send(version_cmd) + .send(RegisterCommand::WriteRegister { + destination: Destination::Broadcast, + register: Register::VersionMask(VersionMask::full_rolling()), + }) .await .context("failed to send config command")?; time::sleep(Duration::from_millis(5)).await; From c0bb36086513c2ce9d1b71e02957e80e5a993de4 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Fri, 24 Apr 2026 18:48:14 -0500 Subject: [PATCH 17/38] refactor(bm13xx): wrap each command variant in its own struct Give each RegisterCommand and JobCommand variant a dedicated struct that carries its own encode method, collapsing the enum encode methods into one-line dispatchers. Wire bytes unchanged. --- mujina-miner/src/asic/bm13xx/protocol.rs | 517 +++++++++++------------ mujina-miner/src/asic/bm13xx/thread.rs | 93 ++-- mujina-miner/src/board/bitaxe.rs | 6 +- 3 files changed, 290 insertions(+), 326 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/protocol.rs b/mujina-miner/src/asic/bm13xx/protocol.rs index a13da91b..9f3c0181 100644 --- a/mujina-miner/src/asic/bm13xx/protocol.rs +++ b/mujina-miner/src/asic/bm13xx/protocol.rs @@ -686,34 +686,119 @@ impl Destination { } } +/// Assign an address to the first unaddressed chip via daisy-chain forwarding. +#[derive(Debug, Clone, Copy)] +pub struct SetChipAddress { + pub chip_address: u8, +} + +impl SetChipAddress { + fn encode(&self, dst: &mut BytesMut) { + dst.put_u8(build_flags( + CommandFlagsType::Command, + false, // never broadcast + CommandFlagsCmd::SetChipAddress, + )); + dst.put_u8(5); // flags + length + chip_addr + reg_addr + crc5 + dst.put_u8(self.chip_address); + dst.put_u8(0x00); // reserved + } +} + +/// Put all chips into addressing mode (enables daisy-chain forwarding). +#[derive(Debug, Clone, Copy)] +pub struct ChainInactive; + +impl ChainInactive { + fn encode(&self, dst: &mut BytesMut) { + dst.put_u8(build_flags( + CommandFlagsType::Command, + true, // always broadcast + CommandFlagsCmd::ChainInactive, + )); + dst.put_u8(5); // flags + length + reserved + reserved + crc5 + dst.put_u8(0x00); + dst.put_u8(0x00); + } +} + +/// Read a register from chip(s). +#[derive(Debug, Clone, Copy)] +pub struct ReadRegister { + pub destination: Destination, + pub register_address: RegisterAddress, +} + +impl ReadRegister { + fn encode(&self, dst: &mut BytesMut) { + dst.put_u8(build_flags( + CommandFlagsType::Command, + self.destination.is_broadcast(), + CommandFlagsCmd::ReadRegister, + )); + dst.put_u8(5); // flags + length + chip_addr + reg_addr + crc5 + dst.put_u8(self.destination.address_byte()); + dst.put_u8(self.register_address as u8); + } +} + +/// Write a register to chip(s). +#[derive(Debug, Clone)] +pub struct WriteRegister { + pub destination: Destination, + pub register: Register, +} + +impl WriteRegister { + fn encode(&self, dst: &mut BytesMut) { + dst.put_u8(build_flags( + CommandFlagsType::Command, + self.destination.is_broadcast(), + CommandFlagsCmd::WriteRegisterOrJob, + )); + dst.put_u8(9); // flags + length + chip_addr + reg_addr + 4 data + crc5 + dst.put_u8(self.destination.address_byte()); + dst.put_u8(self.register.address() as u8); + self.register.encode(dst); + } +} + /// TYPE=2 frames: register reads/writes and chain addressing. Use CRC5. #[derive(Debug)] pub enum RegisterCommand { - /// Assign an address to the first unaddressed chip via daisy-chain forwarding - SetChipAddress { chip_address: u8 }, - /// Put all chips into addressing mode (enables daisy-chain forwarding) - ChainInactive, - /// Read a register from chip(s) - ReadRegister { - destination: Destination, - register_address: RegisterAddress, - }, - /// Write a register to chip(s) - WriteRegister { - destination: Destination, - register: Register, - }, + SetChipAddress(SetChipAddress), + ChainInactive(ChainInactive), + ReadRegister(ReadRegister), + WriteRegister(WriteRegister), +} + +impl RegisterCommand { + fn encode(&self, dst: &mut BytesMut) { + match self { + Self::SetChipAddress(c) => c.encode(dst), + Self::ChainInactive(c) => c.encode(dst), + Self::ReadRegister(c) => c.encode(dst), + Self::WriteRegister(c) => c.encode(dst), + } + } } /// TYPE=1 frames: mining jobs. Use CRC16. #[derive(Debug)] pub enum JobCommand { - /// Send a job with full block header (BM1370/BM1362 style) - /// Chip calculates midstates internally - JobFull { job_data: JobFullFormat }, - /// Send a job with pre-calculated midstates (BM1397 style) - /// Host calculates midstates to save chip computation - JobMidstate { job_data: JobMidstateFormat }, + /// Full block header; chip calculates midstates internally (BM1370/BM1362 style). + JobFull(JobFullFormat), + /// Host pre-calculated midstates (BM1397 style). + JobMidstate(JobMidstateFormat), +} + +impl JobCommand { + fn encode(&self, dst: &mut BytesMut) { + match self { + Self::JobFull(j) => j.encode(dst), + Self::JobMidstate(j) => j.encode(dst), + } + } } /// Full format job structure @@ -741,6 +826,45 @@ pub struct JobFullFormat { pub version: bitcoin::block::Version, } +impl JobFullFormat { + fn encode(&self, dst: &mut BytesMut) { + dst.put_u8(build_flags( + CommandFlagsType::Job, + false, // jobs are never broadcast + CommandFlagsCmd::WriteRegisterOrJob, + )); + + // Captures from factory firmware use this value on both BM1362 + // (S19 J Pro) and BM1370 (S21 Pro). esp-miner firmware on + // Bitaxe sends 86 instead; the BM1370 appears to tolerate + // both. + // + // Hypothesis for why 54: a single midstate JobMidstate frame + // is exactly 54 bytes on the wire (flags + length + header + + // midstate0 + crc16). JobFull transmits 88 bytes but declares + // its length as if the frame were in midstate format, where + // prev_block_hash is folded into midstate0 rather than sent + // raw. + dst.put_u8(54); + + // job_id is a 4-bit value (0-15), encode into bits 6-3 of job_header + debug_assert!(self.job_id <= 15, "job_id must be 0-15"); + dst.put_u8(self.job_id << 3); + dst.put_u8(self.num_midstates); + dst.put_u32_le(self.starting_nonce); + dst.put_u32_le(self.nbits.to_consensus()); + dst.put_u32_le(self.ntime); + + let merkle_root_bytes = hash_to_wire_bytes(&self.merkle_root.to_byte_array()); + dst.put_slice(&merkle_root_bytes); + + let prev_hash_bytes = hash_to_wire_bytes(&self.prev_block_hash.to_byte_array()); + dst.put_slice(&prev_hash_bytes); + + dst.put_u32_le(self.version.to_consensus() as u32); + } +} + /// Midstate format job structure (BM1397?). /// Host pre-calculates SHA256 midstates to reduce chip workload. /// Supports up to 4 midstates for version rolling. @@ -758,6 +882,39 @@ pub struct JobMidstateFormat { pub midstate3: Option<[u8; 32]>, // Optional for version rolling } +impl JobMidstateFormat { + fn encode(&self, dst: &mut BytesMut) { + dst.put_u8(build_flags( + CommandFlagsType::Job, + false, // jobs are never broadcast + CommandFlagsCmd::WriteRegisterOrJob, + )); + + // Layout: flags + length + 18 header bytes + num_midstates * 32 + crc16 (2) + dst.put_u8(1 + 1 + 18 + self.num_midstates * 32 + 2); + + // job_id is a 4-bit value (0-15), encode into bits 6-3 of job_header + debug_assert!(self.job_id <= 15, "job_id must be 0-15"); + dst.put_u8(self.job_id << 3); + dst.put_u8(self.num_midstates); + dst.put_slice(&self.starting_nonce); + dst.put_slice(&self.nbits); + dst.put_slice(&self.ntime); + dst.put_slice(&self.merkle4); + dst.put_slice(&self.midstate0); + + if let Some(midstate) = &self.midstate1 { + dst.put_slice(midstate); + } + if let Some(midstate) = &self.midstate2 { + dst.put_slice(midstate); + } + if let Some(midstate) = &self.midstate3 { + dst.put_slice(midstate); + } + } +} + fn build_flags(typ: CommandFlagsType, broadcast: bool, cmd: CommandFlagsCmd) -> u8 { let mut flags = 0u8; let field = flags.view_bits_mut::(); @@ -767,189 +924,6 @@ fn build_flags(typ: CommandFlagsType, broadcast: bool, cmd: CommandFlagsCmd) -> flags } -impl RegisterCommand { - fn encode(&self, dst: &mut BytesMut) { - match self { - Self::SetChipAddress { chip_address } => { - dst.put_u8(build_flags( - CommandFlagsType::Command, - false, // Never broadcast - CommandFlagsCmd::SetChipAddress, - )); - - const FLAGS_LEN: u8 = 1; - const CHIP_ADDR_LEN: u8 = 1; - const REG_ADDR_LEN: u8 = 1; // Always 0x00 for set address - const LENGTH_FIELD_LEN: u8 = 1; - const CRC_LEN: u8 = 1; - const TOTAL_LEN: u8 = - FLAGS_LEN + LENGTH_FIELD_LEN + CHIP_ADDR_LEN + REG_ADDR_LEN + CRC_LEN; - - dst.put_u8(TOTAL_LEN); - dst.put_u8(*chip_address); - dst.put_u8(0x00); // Reserved byte (always 0x00) - } - Self::ChainInactive => { - dst.put_u8(build_flags( - CommandFlagsType::Command, - true, // Always broadcast - CommandFlagsCmd::ChainInactive, - )); - - // From capture: 55 AA 53 05 00 00 03 - // Length field (0x05) includes everything after preamble except itself - const FLAGS_LEN: u8 = 1; // 0x53 - const CHIP_ADDR_LEN: u8 = 1; // 0x00 - const REG_ADDR_LEN: u8 = 1; // 0x00 - const CRC_LEN: u8 = 1; // 0x03 - const TOTAL_LEN: u8 = FLAGS_LEN + CHIP_ADDR_LEN + REG_ADDR_LEN + CRC_LEN + 1; // +1 for length field - - dst.put_u8(TOTAL_LEN); - dst.put_u8(0x00); // Reserved byte - dst.put_u8(0x00); // Reserved byte - } - Self::ReadRegister { - destination, - register_address, - } => { - dst.put_u8(build_flags( - CommandFlagsType::Command, - destination.is_broadcast(), - CommandFlagsCmd::ReadRegister, - )); - - const FLAGS_LEN: u8 = 1; - const CHIP_ADDR_LEN: u8 = 1; - const REG_ADDR_LEN: u8 = 1; - const LENGTH_FIELD_LEN: u8 = 1; - const CRC_LEN: u8 = 1; - const TOTAL_LEN: u8 = - FLAGS_LEN + LENGTH_FIELD_LEN + CHIP_ADDR_LEN + REG_ADDR_LEN + CRC_LEN; - - dst.put_u8(TOTAL_LEN); - dst.put_u8(destination.address_byte()); - dst.put_u8(*register_address as u8); - } - Self::WriteRegister { - destination, - register, - } => { - dst.put_u8(build_flags( - CommandFlagsType::Command, - destination.is_broadcast(), - CommandFlagsCmd::WriteRegisterOrJob, - )); - - const FLAGS_LEN: u8 = 1; - const CHIP_ADDR_LEN: u8 = 1; - const REG_ADDR_LEN: u8 = 1; - const REG_DATA_LEN: u8 = 4; - const LENGTH_FIELD_LEN: u8 = 1; - const CRC_LEN: u8 = 1; - const TOTAL_LEN: u8 = FLAGS_LEN - + LENGTH_FIELD_LEN - + CHIP_ADDR_LEN - + REG_ADDR_LEN - + REG_DATA_LEN - + CRC_LEN; - - dst.put_u8(TOTAL_LEN); - dst.put_u8(destination.address_byte()); - dst.put_u8(register.address() as u8); - register.encode(dst); - } - } - } -} - -impl JobCommand { - fn encode(&self, dst: &mut BytesMut) { - match self { - Self::JobFull { job_data } => { - dst.put_u8(build_flags( - CommandFlagsType::Job, - false, // Jobs are never broadcast - CommandFlagsCmd::WriteRegisterOrJob, - )); - - // Captures from factory firmware use this value on both BM1362 - // (S19 J Pro) and BM1370 (S21 Pro). esp-miner firmware on - // Bitaxe sends 86 instead; the BM1370 appears to tolerate - // both. - // - // Hypothesis for why 54: a single midstate JobMidstate frame - // is exactly 54 bytes on the wire (flags + length + header + - // midstate0 + crc16). JobFull transmits 88 bytes but declares - // its length as if the frame were in midstate format, where - // prev_block_hash is folded into midstate0 rather than sent - // raw. - const JOB_LENGTH_BYTE: u8 = 54; - dst.put_u8(JOB_LENGTH_BYTE); - - // Write job data - // job_id is a 4-bit value (0-15), encode into bits 6-3 of job_header - debug_assert!(job_data.job_id <= 15, "job_id must be 0-15"); - dst.put_u8(job_data.job_id << 3); - dst.put_u8(job_data.num_midstates); - dst.put_u32_le(job_data.starting_nonce); - dst.put_u32_le(job_data.nbits.to_consensus()); - dst.put_u32_le(job_data.ntime); - - // Convert merkle_root from Bitcoin internal format to wire format - let merkle_root_bytes = hash_to_wire_bytes(&job_data.merkle_root.to_byte_array()); - dst.put_slice(&merkle_root_bytes); - - // Convert prev_block_hash from Bitcoin internal format to wire format - let prev_hash_bytes = hash_to_wire_bytes(&job_data.prev_block_hash.to_byte_array()); - dst.put_slice(&prev_hash_bytes); - - dst.put_u32_le(job_data.version.to_consensus() as u32); - } - Self::JobMidstate { job_data } => { - dst.put_u8(build_flags( - CommandFlagsType::Job, - false, // Jobs are never broadcast - CommandFlagsCmd::WriteRegisterOrJob, - )); - - // Calculate data length based on number of midstates - const BASE_LEN: u8 = 18; // job_id(1) + num_midstates(1) + nonce(4) + nbits(4) + ntime(4) + merkle4(4) - const MIDSTATE_LEN: u8 = 32; - let data_len = BASE_LEN + (job_data.num_midstates * MIDSTATE_LEN); - - const FLAGS_LEN: u8 = 1; - const LENGTH_FIELD_LEN: u8 = 1; - const CRC_LEN: u8 = 2; // Jobs use CRC16 - let total_len = FLAGS_LEN + LENGTH_FIELD_LEN + data_len + CRC_LEN; - - dst.put_u8(total_len); - - // Write job data - // job_id is a 4-bit value (0-15), encode into bits 6-3 of job_header - debug_assert!(job_data.job_id <= 15, "job_id must be 0-15"); - dst.put_u8(job_data.job_id << 3); - dst.put_u8(job_data.num_midstates); - dst.put_slice(&job_data.starting_nonce); - dst.put_slice(&job_data.nbits); - dst.put_slice(&job_data.ntime); - dst.put_slice(&job_data.merkle4); - dst.put_slice(&job_data.midstate0); - - // Write optional midstates - if let Some(midstate) = &job_data.midstate1 { - dst.put_slice(midstate); - } - if let Some(midstate) = &job_data.midstate2 { - dst.put_slice(midstate); - } - if let Some(midstate) = &job_data.midstate3 { - dst.put_slice(midstate); - } - } - } - } -} - #[derive(FromRepr)] #[repr(u8)] enum ResponseType { @@ -1173,16 +1147,16 @@ mod init_tests { // Verify the sequence starts with version rolling enable assert!(matches!( &commands[0], - RegisterCommand::WriteRegister { + RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::VersionMask(_), - } + }) )); // Verify chain inactive command let chain_inactive_pos = commands .iter() - .position(|c| matches!(c, RegisterCommand::ChainInactive)) + .position(|c| matches!(c, RegisterCommand::ChainInactive(ChainInactive))) .expect("ChainInactive command not found in initialization sequence"); assert!(chain_inactive_pos > 0); @@ -1190,7 +1164,7 @@ mod init_tests { let first_address_pos = chain_inactive_pos + 1; assert!(matches!( &commands[first_address_pos], - RegisterCommand::SetChipAddress { chip_address: 0x00 } + RegisterCommand::SetChipAddress(SetChipAddress { chip_address: 0x00 }) )); // Verify we have 65 address assignments @@ -1202,7 +1176,7 @@ mod init_tests { // Verify addresses increment by 2 for (i, cmd) in address_commands.iter().enumerate() { match cmd { - RegisterCommand::SetChipAddress { chip_address } => { + RegisterCommand::SetChipAddress(SetChipAddress { chip_address }) => { assert_eq!(*chip_address, (i * 2) as u8); } _ => panic!("Expected SetChipAddress command, got {:?}", cmd), @@ -1221,10 +1195,10 @@ mod init_tests { .filter(|c| { matches!( c, - RegisterCommand::WriteRegister { + RegisterCommand::WriteRegister(WriteRegister { register: Register::IoDriverStrength { .. }, .. - } + }) ) }) .collect(); @@ -1232,10 +1206,10 @@ mod init_tests { // Check first domain boundary (chip 8 = address 0x08) let first_boundary = io_strength_commands[0]; - if let RegisterCommand::WriteRegister { + if let RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Chip(chip_address), register: Register::IoDriverStrength(strength), - } = first_boundary + }) = first_boundary { assert_eq!(*chip_address, 0x08); // 5th chip (index 4) * 2 let mut buf = BytesMut::new(); @@ -1252,10 +1226,10 @@ mod init_tests { // Test single chip - full range let commands = protocol.configure_nonce_ranges(1); assert_eq!(commands.len(), 1); - if let RegisterCommand::WriteRegister { + if let RegisterCommand::WriteRegister(WriteRegister { register: Register::NonceRange(config), destination: Destination::Broadcast, - } = &commands[0] + }) = &commands[0] { let mut buf = BytesMut::new(); config.encode(&mut buf); @@ -1265,10 +1239,10 @@ mod init_tests { // Test S21 Pro configuration (65 chips) let commands = protocol.configure_nonce_ranges(65); assert_eq!(commands.len(), 1); - if let RegisterCommand::WriteRegister { + if let RegisterCommand::WriteRegister(WriteRegister { register: Register::NonceRange(config), .. - } = &commands[0] + }) = &commands[0] { let mut buf = BytesMut::new(); config.encode(&mut buf); @@ -1277,10 +1251,10 @@ mod init_tests { // Test small chain let commands = protocol.configure_nonce_ranges(8); - if let RegisterCommand::WriteRegister { + if let RegisterCommand::WriteRegister(WriteRegister { register: Register::NonceRange(config), .. - } = &commands[0] + }) = &commands[0] { let mut buf = BytesMut::new(); config.encode(&mut buf); @@ -1297,19 +1271,19 @@ mod init_tests { let nonce_range_cmd = commands.iter().find(|c| { matches!( c, - RegisterCommand::WriteRegister { + RegisterCommand::WriteRegister(WriteRegister { register: Register::NonceRange { .. }, .. - } + }) ) }); assert!(nonce_range_cmd.is_some()); - if let Some(RegisterCommand::WriteRegister { + if let Some(RegisterCommand::WriteRegister(WriteRegister { register: Register::NonceRange(config), .. - }) = nonce_range_cmd + })) = nonce_range_cmd { let mut buf = BytesMut::new(); config.encode(&mut buf); @@ -1330,10 +1304,10 @@ mod command_tests { #[test] fn read_register() { assert_frame_eq( - RegisterCommand::ReadRegister { + RegisterCommand::ReadRegister(ReadRegister { destination: Destination::Broadcast, register_address: RegisterAddress::ChipId, - }, + }), &[0x55, 0xaa, 0x52, 0x05, 0x00, 0x00, 0x0a], ); } @@ -1341,14 +1315,14 @@ mod command_tests { #[test] fn write_register_chip_address() { assert_frame_eq( - RegisterCommand::WriteRegister { + RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Chip(0x01), register: Register::ChipId(ChipId { model: ChipModel::BM1370, core_count: 0x00, address: 0x01, }), - }, + }), &[ 0x55, 0xaa, 0x41, 0x09, 0x01, 0x00, 0x13, 0x70, 0x00, 0x01, 0x0a, ], @@ -1360,10 +1334,10 @@ mod command_tests { fn write_version_mask_from_capture() { // From S21 Pro capture: TX: 55 AA 51 09 00 A4 90 00 FF FF 1C assert_frame_eq( - RegisterCommand::WriteRegister { + RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, // 0x51 = broadcast register: Register::VersionMask(VersionMask::full_rolling()), - }, + }), &[ 0x55, 0xaa, 0x51, 0x09, 0x00, 0xa4, 0x90, 0x00, 0xff, 0xff, 0x1c, ], @@ -1375,10 +1349,10 @@ mod command_tests { // From Bitaxe capture: TX: 55 AA 51 09 00 A8 00 07 00 00 03 // Value 0x00 07 00 00 in little-endian = 0x00000700 assert_frame_eq( - RegisterCommand::WriteRegister { + RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::InitControl(InitControl(0x00000700)), - }, + }), &[ 0x55, 0xaa, 0x51, 0x09, 0x00, 0xa8, 0x00, 0x07, 0x00, 0x00, 0x03, ], @@ -1389,10 +1363,10 @@ mod command_tests { fn write_misc_control_from_capture() { // From Bitaxe capture: TX: 55 AA 51 09 00 18 F0 00 C1 00 04 assert_frame_eq( - RegisterCommand::WriteRegister { + RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::MiscControl(MiscControl(0x00C100F0)), - }, + }), &[ 0x55, 0xaa, 0x51, 0x09, 0x00, 0x18, 0xf0, 0x00, 0xc1, 0x00, 0x04, ], @@ -1403,7 +1377,7 @@ mod command_tests { fn chain_inactive_from_capture() { // From S21 Pro capture: TX: 55 AA 53 05 00 00 03 assert_frame_eq( - RegisterCommand::ChainInactive, + RegisterCommand::ChainInactive(ChainInactive), &[0x55, 0xaa, 0x53, 0x05, 0x00, 0x00, 0x03], ); } @@ -1412,7 +1386,7 @@ mod command_tests { fn set_chip_address_from_capture() { // From S21 Pro capture: TX: 55 AA 40 05 04 00 03 (assign address 0x04) assert_frame_eq( - RegisterCommand::SetChipAddress { chip_address: 0x04 }, + RegisterCommand::SetChipAddress(SetChipAddress { chip_address: 0x04 }), &[0x55, 0xaa, 0x40, 0x05, 0x04, 0x00, 0x03], ); } @@ -1422,11 +1396,11 @@ mod command_tests { // From Bitaxe capture: TX: 55 AA 51 09 00 3C 80 00 8B 00 12 // Core register uses big-endian encoding assert_frame_eq( - RegisterCommand::WriteRegister { + RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, // Big-endian: produces bytes 80 00 8B 00 register: Register::Core(Core(0x80008B00)), - }, + }), &[ 0x55, 0xaa, 0x51, 0x09, 0x00, 0x3c, 0x80, 0x00, 0x8b, 0x00, 0x12, ], @@ -1439,10 +1413,10 @@ mod command_tests { // Difficulty 256 = 8 zero_bits let log2_diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); assert_frame_eq( - RegisterCommand::WriteRegister { + RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::TicketMask(TicketMask::new(log2_diff)), - }, + }), &[ 0x55, 0xaa, 0x51, 0x09, 0x00, 0x14, 0x00, 0x00, 0x00, 0xff, 0x08, ], @@ -1453,10 +1427,10 @@ mod command_tests { fn write_nonce_range_from_capture() { // From S21 Pro capture: TX: 55 AA 51 09 00 10 00 00 1E B5 0F assert_frame_eq( - RegisterCommand::WriteRegister { + RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::NonceRange(NonceRange::multi_chip(65)), - }, + }), &[ 0x55, 0xaa, 0x51, 0x09, 0x00, 0x10, 0x00, 0x00, 0x1e, 0xb5, 0x0f, ], @@ -1506,12 +1480,7 @@ mod command_tests { let mut codec = FrameCodec; let mut frame = BytesMut::new(); codec - .encode( - JobCommand::JobFull { - job_data: job.clone(), - }, - &mut frame, - ) + .encode(JobCommand::JobFull(job.clone()), &mut frame) .expect("Failed to encode job command"); // Verify packet structure @@ -1584,16 +1553,12 @@ mod command_tests { let mut codec = FrameCodec; let mut frame = BytesMut::new(); codec - .encode( - JobCommand::JobFull { - job_data: job.clone(), - }, - &mut frame, - ) + .encode(JobCommand::JobFull(job.clone()), &mut frame) .expect("Failed to encode job command"); - // Our body bytes match esp-miner's wire capture. Byte 3 (length byte) - // and bytes 86..88 (CRC16) intentionally differ; see JOB_LENGTH_BYTE. + // Our body bytes match esp-miner's wire capture. Byte 3 (length + // byte) and bytes 86..88 (CRC16) intentionally differ; see the + // length-byte comment in JobFullFormat::encode. assert_eq!(&frame[4..86], &esp_miner_job::wire_tx::FRAME[4..86]); } @@ -1604,7 +1569,7 @@ mod command_tests { let mut codec = FrameCodec; let mut frame = BytesMut::new(); codec - .encode(JobCommand::JobFull { job_data: job }, &mut frame) + .encode(JobCommand::JobFull(job), &mut frame) .expect("Failed to encode job command"); // Byte-for-byte match, length byte and CRC16 included: the @@ -2139,16 +2104,12 @@ mod response_tests { let mut codec = FrameCodec; let mut tx_frame = BytesMut::new(); codec - .encode( - JobCommand::JobFull { - job_data: job.clone(), - }, - &mut tx_frame, - ) + .encode(JobCommand::JobFull(job.clone()), &mut tx_frame) .expect("Should encode JobFull command"); - // Our body bytes match esp-miner's wire capture. Byte 3 (length byte) - // and bytes 86..88 (CRC16) intentionally differ; see JOB_LENGTH_BYTE. + // Our body bytes match esp-miner's wire capture. Byte 3 (length + // byte) and bytes 86..88 (CRC16) intentionally differ; see the + // length-byte comment in JobFullFormat::encode. assert_eq!(&tx_frame[4..86], &esp_miner_job::wire_tx::FRAME[4..86]); let rx_response = @@ -2319,19 +2280,19 @@ impl BM13xxProtocol { /// Helper to create a broadcast write command fn broadcast_write(&self, register: Register) -> RegisterCommand { - RegisterCommand::WriteRegister { + RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register, - } + }) } /// Helper to create a targeted write command #[cfg_attr(not(test), allow(dead_code))] fn write_to(&self, chip_address: u8, register: Register) -> RegisterCommand { - RegisterCommand::WriteRegister { + RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Chip(chip_address), register, - } + }) } /// Returns the initialization sequence for a single chip (e.g., Bitaxe). @@ -2387,14 +2348,14 @@ impl BM13xxProtocol { ); // Step 4: Set chain inactive for address assignment - commands.push(RegisterCommand::ChainInactive); + commands.push(RegisterCommand::ChainInactive(ChainInactive)); // Step 5: Assign addresses (increment by 2) for i in 0..chain_length { let address = (i as u8) * ADDRESS_INCREMENT; - commands.push(RegisterCommand::SetChipAddress { + commands.push(RegisterCommand::SetChipAddress(SetChipAddress { chip_address: address, - }); + })); } // Step 6: Configure core registers on all chips @@ -2486,18 +2447,18 @@ impl BM13xxProtocol { /// Create a command to read a register. pub fn read_register(&self, chip_address: u8, register: RegisterAddress) -> RegisterCommand { - RegisterCommand::ReadRegister { + RegisterCommand::ReadRegister(ReadRegister { destination: Destination::Chip(chip_address), register_address: register, - } + }) } /// Set UART baud rate on all chips pub fn set_baudrate(&self, baudrate: UartBaud) -> RegisterCommand { - RegisterCommand::WriteRegister { + RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::UartBaud(baudrate), - } + }) } /// Create a command to write a register. @@ -2550,17 +2511,17 @@ impl BM13xxProtocol { RegisterAddress::MiscSettings => Register::MiscSettings(MiscSettings(value)), }; - Ok(RegisterCommand::WriteRegister { + Ok(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Chip(chip_address), register: register_value, - }) + })) } /// Create a broadcast command to discover all chips. pub fn discover_chips() -> RegisterCommand { - RegisterCommand::ReadRegister { + RegisterCommand::ReadRegister(ReadRegister { destination: Destination::Broadcast, register_address: RegisterAddress::ChipId, - } + }) } } diff --git a/mujina-miner/src/asic/bm13xx/thread.rs b/mujina-miner/src/asic/bm13xx/thread.rs index a4caef5b..a94a7e25 100644 --- a/mujina-miner/src/asic/bm13xx/thread.rs +++ b/mujina-miner/src/asic/bm13xx/thread.rs @@ -18,8 +18,9 @@ use tokio::sync::{mpsc, oneshot, watch}; use tokio_stream::StreamExt; use super::protocol::{ - self, AnalogMux, Core, Destination, InitControl, IoDriverStrength, JobCommand, Log2Difficulty, - MiscControl, MiscSettings, NonceRange, Register, RegisterCommand, TicketMask, VersionMask, + self, AnalogMux, ChainInactive, Core, Destination, InitControl, IoDriverStrength, JobCommand, + Log2Difficulty, MiscControl, MiscSettings, NonceRange, Register, RegisterCommand, + SetChipAddress, TicketMask, VersionMask, WriteRegister, }; use crate::{ asic::hash_thread::{ @@ -268,10 +269,10 @@ where debug!("Configuring version mask"); for _ in 1..=3 { chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::VersionMask(VersionMask::full_rolling()), - }) + })) .await .context("failed to send version mask")?; tokio::time::sleep(std::time::Duration::from_millis(5)).await; @@ -283,25 +284,27 @@ where debug!("Sending pre-configuration registers"); chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::InitControl(InitControl(0x00000700)), - }) + })) .await?; chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::MiscControl(MiscControl(0x00C100F0)), - }) + })) .await?; chip_commands - .send(RegisterCommand::ChainInactive) + .send(RegisterCommand::ChainInactive(ChainInactive)) .await .context("failed to send ChainInactive")?; chip_commands - .send(RegisterCommand::SetChipAddress { chip_address: 0x00 }) + .send(RegisterCommand::SetChipAddress(SetChipAddress { + chip_address: 0x00, + })) .await .context("failed to send SetChipAddress")?; @@ -309,92 +312,92 @@ where debug!("Sending broadcast core configuration"); chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::Core(Core(0x8000_8B00)), - }) + })) .await?; chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::Core(Core(0x8000_800C)), - }) + })) .await?; // Ticket mask let ticket_mask = TicketMask::new(asic_difficulty); chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::TicketMask(ticket_mask), - }) + })) .await?; chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::IoDriverStrength(IoDriverStrength::normal()), - }) + })) .await?; // Chip-specific configuration debug!("Sending chip-specific configuration"); chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Chip(0x00), register: Register::InitControl(InitControl(0xF0010700)), - }) + })) .await?; chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Chip(0x00), register: Register::MiscControl(MiscControl(0x00C100F0)), - }) + })) .await?; chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Chip(0x00), register: Register::Core(Core(0x8000_8B00)), - }) + })) .await?; chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Chip(0x00), register: Register::Core(Core(0x8000_800C)), - }) + })) .await?; chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Chip(0x00), register: Register::Core(Core(0x8000_82AA)), - }) + })) .await?; // Additional settings chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::MiscSettings(MiscSettings(0x80440000)), - }) + })) .await?; chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::AnalogMux(AnalogMux(0x02000000)), - }) + })) .await?; chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::MiscSettings(MiscSettings(0x80440000)), - }) + })) .await?; chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::Core(Core(0x8000_8DEE)), - }) + })) .await?; // Frequency ramping (56.25 MHz -> 525 MHz) @@ -403,10 +406,10 @@ where for (i, pll_config) in frequency_steps.iter().enumerate() { chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::PllDivider(*pll_config), - }) + })) .await .context("PLL ramp failed")?; @@ -421,16 +424,16 @@ where // Final configuration chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::NonceRange(NonceRange::from_raw(0xB51E0000)), - }) + })) .await?; chip_commands - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::VersionMask(VersionMask::full_rolling()), - }) + })) .await?; tokio::time::sleep(std::time::Duration::from_millis(150)).await; @@ -659,7 +662,7 @@ async fn bm13xx_thread_actor( let old_task = current_task.replace(new_task.clone()); match task_to_job_full(&new_task, chip_job_id) { Ok(job_data) => { - if let Err(e) = chip_commands.send(JobCommand::JobFull { job_data }).await { + if let Err(e) = chip_commands.send(JobCommand::JobFull(job_data)).await { error!(error = ?e, "Failed to send initial JobFull to chip"); let err = anyhow!("failed to send job to chip: {e:?}"); response_tx.send(Err(err)).ok(); @@ -712,7 +715,7 @@ async fn bm13xx_thread_actor( let old_task = current_task.replace(new_task.clone()); match task_to_job_full(&new_task, chip_job_id) { Ok(job_data) => { - if let Err(e) = chip_commands.send(JobCommand::JobFull { job_data }).await { + if let Err(e) = chip_commands.send(JobCommand::JobFull(job_data)).await { error!(error = ?e, "Failed to send initial JobFull to chip"); let err = anyhow!("failed to send job to chip: {e:?}"); response_tx.send(Err(err)).ok(); @@ -872,7 +875,7 @@ async fn bm13xx_thread_actor( // Convert to chip format and send match task_to_job_full(task, chip_jobs.insert(task.clone())) { Ok(job_data) => { - if let Err(e) = chip_commands.send(JobCommand::JobFull { job_data }).await { + if let Err(e) = chip_commands.send(JobCommand::JobFull(job_data)).await { error!(error = ?e, "Failed to send JobFull to chip"); } else { trace!(ntime = task.ntime, "Sent ntime-rolled job to chip"); diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 9783e1e3..7501d3b9 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -25,7 +25,7 @@ use crate::{ ChipInfo, bm13xx::{ self, BM13xxProtocol, Register, Response, - protocol::{ChipId, Destination, RegisterCommand, VersionMask}, + protocol::{ChipId, Destination, RegisterCommand, VersionMask, WriteRegister}, thread::BM13xxThread, }, hash_thread::{AsicEnable, BoardPeripherals, HashThread, ThreadRemovalSignal}, @@ -125,10 +125,10 @@ async fn create_from_usb(device: UsbDeviceInfo) -> Result { debug!("Sending version mask configuration (3 times)"); for _ in 1..=3 { data_writer - .send(RegisterCommand::WriteRegister { + .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, register: Register::VersionMask(VersionMask::full_rolling()), - }) + })) .await .context("failed to send config command")?; time::sleep(Duration::from_millis(5)).await; From 2aa516b20e740f487433cc51475195318f239d4b Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Fri, 24 Apr 2026 20:03:29 -0500 Subject: [PATCH 18/38] refactor(bm13xx): use a struct for command flags Replace the positional build_flags helper with a CommandFlags struct that matches other frame-element encoders. --- mujina-miner/src/asic/bm13xx/protocol.rs | 98 ++++++++++++++---------- 1 file changed, 57 insertions(+), 41 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/protocol.rs b/mujina-miner/src/asic/bm13xx/protocol.rs index 9f3c0181..afcb6cd0 100644 --- a/mujina-miner/src/asic/bm13xx/protocol.rs +++ b/mujina-miner/src/asic/bm13xx/protocol.rs @@ -612,14 +612,33 @@ impl Register { } } +struct CommandFlags { + kind: Kind, + broadcast: bool, + cmd: Cmd, +} + +impl CommandFlags { + fn encode(&self, dst: &mut BytesMut) { + let mut byte = 0u8; + let field = byte.view_bits_mut::(); + field[5..7].store(self.kind as u8); + field[4..5].store(self.broadcast as u8); + field[0..4].store(self.cmd as u8); + dst.put_u8(byte); + } +} + +#[derive(Clone, Copy)] #[repr(u8)] -enum CommandFlagsType { +enum Kind { Job = 1, Command = 2, } +#[derive(Clone, Copy)] #[repr(u8)] -enum CommandFlagsCmd { +enum Cmd { SetChipAddress = 0, WriteRegisterOrJob = 1, ReadRegister = 2, @@ -694,11 +713,12 @@ pub struct SetChipAddress { impl SetChipAddress { fn encode(&self, dst: &mut BytesMut) { - dst.put_u8(build_flags( - CommandFlagsType::Command, - false, // never broadcast - CommandFlagsCmd::SetChipAddress, - )); + CommandFlags { + kind: Kind::Command, + broadcast: false, + cmd: Cmd::SetChipAddress, + } + .encode(dst); dst.put_u8(5); // flags + length + chip_addr + reg_addr + crc5 dst.put_u8(self.chip_address); dst.put_u8(0x00); // reserved @@ -711,11 +731,12 @@ pub struct ChainInactive; impl ChainInactive { fn encode(&self, dst: &mut BytesMut) { - dst.put_u8(build_flags( - CommandFlagsType::Command, - true, // always broadcast - CommandFlagsCmd::ChainInactive, - )); + CommandFlags { + kind: Kind::Command, + broadcast: true, + cmd: Cmd::ChainInactive, + } + .encode(dst); dst.put_u8(5); // flags + length + reserved + reserved + crc5 dst.put_u8(0x00); dst.put_u8(0x00); @@ -731,11 +752,12 @@ pub struct ReadRegister { impl ReadRegister { fn encode(&self, dst: &mut BytesMut) { - dst.put_u8(build_flags( - CommandFlagsType::Command, - self.destination.is_broadcast(), - CommandFlagsCmd::ReadRegister, - )); + CommandFlags { + kind: Kind::Command, + broadcast: self.destination.is_broadcast(), + cmd: Cmd::ReadRegister, + } + .encode(dst); dst.put_u8(5); // flags + length + chip_addr + reg_addr + crc5 dst.put_u8(self.destination.address_byte()); dst.put_u8(self.register_address as u8); @@ -751,11 +773,12 @@ pub struct WriteRegister { impl WriteRegister { fn encode(&self, dst: &mut BytesMut) { - dst.put_u8(build_flags( - CommandFlagsType::Command, - self.destination.is_broadcast(), - CommandFlagsCmd::WriteRegisterOrJob, - )); + CommandFlags { + kind: Kind::Command, + broadcast: self.destination.is_broadcast(), + cmd: Cmd::WriteRegisterOrJob, + } + .encode(dst); dst.put_u8(9); // flags + length + chip_addr + reg_addr + 4 data + crc5 dst.put_u8(self.destination.address_byte()); dst.put_u8(self.register.address() as u8); @@ -828,11 +851,12 @@ pub struct JobFullFormat { impl JobFullFormat { fn encode(&self, dst: &mut BytesMut) { - dst.put_u8(build_flags( - CommandFlagsType::Job, - false, // jobs are never broadcast - CommandFlagsCmd::WriteRegisterOrJob, - )); + CommandFlags { + kind: Kind::Job, + broadcast: false, + cmd: Cmd::WriteRegisterOrJob, + } + .encode(dst); // Captures from factory firmware use this value on both BM1362 // (S19 J Pro) and BM1370 (S21 Pro). esp-miner firmware on @@ -884,11 +908,12 @@ pub struct JobMidstateFormat { impl JobMidstateFormat { fn encode(&self, dst: &mut BytesMut) { - dst.put_u8(build_flags( - CommandFlagsType::Job, - false, // jobs are never broadcast - CommandFlagsCmd::WriteRegisterOrJob, - )); + CommandFlags { + kind: Kind::Job, + broadcast: false, + cmd: Cmd::WriteRegisterOrJob, + } + .encode(dst); // Layout: flags + length + 18 header bytes + num_midstates * 32 + crc16 (2) dst.put_u8(1 + 1 + 18 + self.num_midstates * 32 + 2); @@ -915,15 +940,6 @@ impl JobMidstateFormat { } } -fn build_flags(typ: CommandFlagsType, broadcast: bool, cmd: CommandFlagsCmd) -> u8 { - let mut flags = 0u8; - let field = flags.view_bits_mut::(); - field[5..7].store(typ as u8); - field[4..5].store(broadcast as u8); - field[0..4].store(cmd as u8); - flags -} - #[derive(FromRepr)] #[repr(u8)] enum ResponseType { From c8d9b47cc95695020d7c7dd0317f4f5c7dbb1d1c Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Sat, 25 Apr 2026 08:53:18 -0500 Subject: [PATCH 19/38] refactor(bm13xx): split protocol into submodules The protocol module held register definitions and payloads, command framing, response decoding, and the serial codec in one file. Move each into its own sibling module. --- mujina-miner/src/asic/bm13xx/chip_config.rs | 2 +- mujina-miner/src/asic/bm13xx/codec.rs | 158 ++ mujina-miner/src/asic/bm13xx/command.rs | 689 ++++++ mujina-miner/src/asic/bm13xx/error.rs | 2 +- mujina-miner/src/asic/bm13xx/mod.rs | 8 +- mujina-miner/src/asic/bm13xx/protocol.rs | 2475 ++----------------- mujina-miner/src/asic/bm13xx/register.rs | 695 ++++++ mujina-miner/src/asic/bm13xx/response.rs | 624 +++++ mujina-miner/src/asic/bm13xx/thread.rs | 34 +- mujina-miner/src/board/bitaxe.rs | 3 +- 10 files changed, 2375 insertions(+), 2315 deletions(-) create mode 100644 mujina-miner/src/asic/bm13xx/codec.rs create mode 100644 mujina-miner/src/asic/bm13xx/command.rs create mode 100644 mujina-miner/src/asic/bm13xx/register.rs create mode 100644 mujina-miner/src/asic/bm13xx/response.rs diff --git a/mujina-miner/src/asic/bm13xx/chip_config.rs b/mujina-miner/src/asic/bm13xx/chip_config.rs index 408d2548..2931bd29 100644 --- a/mujina-miner/src/asic/bm13xx/chip_config.rs +++ b/mujina-miner/src/asic/bm13xx/chip_config.rs @@ -6,7 +6,7 @@ //! each supported model, validated against serial captures of an //! S19 J Pro (BM1362) and an S21 Pro and Bitaxe Gamma (BM1370). -use super::protocol::{ChipModel, PllDivider}; +use super::register::{ChipModel, PllDivider}; use crate::types::Frequency; /// Per-chip-model configuration. diff --git a/mujina-miner/src/asic/bm13xx/codec.rs b/mujina-miner/src/asic/bm13xx/codec.rs new file mode 100644 index 00000000..ef090b10 --- /dev/null +++ b/mujina-miner/src/asic/bm13xx/codec.rs @@ -0,0 +1,158 @@ +//! Wire framing for BM13xx serial communication. +//! +//! [`FrameCodec`] converts between typed commands or responses for +//! BM13xx chips and the bytes on the serial bus, handling the +//! preamble and CRC. It implements [`tokio_util::codec`] traits so a +//! single framed serial port handles both directions. + +use bytes::{Buf, BufMut, BytesMut}; +use std::{fmt, io}; +use tokio_util::codec::{Decoder, Encoder}; + +use super::command::{JobCommand, RegisterCommand}; +use super::response::Response; +use crate::asic::bm13xx::crc::{crc5, crc5_is_valid, crc16}; +use crate::tracing::prelude::*; + +#[derive(Default)] +pub struct FrameCodec; + +impl Encoder for FrameCodec { + type Error = io::Error; + + fn encode(&mut self, command: RegisterCommand, dst: &mut BytesMut) -> Result<(), Self::Error> { + const PREAMBLE: [u8; 2] = [0x55, 0xaa]; + dst.put_slice(&PREAMBLE); + + let start_pos = dst.len(); + command.encode(dst); + let crc = crc5(&dst[start_pos..]); + dst.put_u8(crc); + + trace!( + cmd = ?command, + bytes = dst.len(), + frame = %HexBytes(dst.as_ref()), + "TX BM13xx" + ); + + Ok(()) + } +} + +impl Encoder for FrameCodec { + type Error = io::Error; + + fn encode(&mut self, command: JobCommand, dst: &mut BytesMut) -> Result<(), Self::Error> { + const PREAMBLE: [u8; 2] = [0x55, 0xaa]; + dst.put_slice(&PREAMBLE); + + let start_pos = dst.len(); + command.encode(dst); + let crc = crc16(&dst[start_pos..]); + dst.put_slice(&crc.to_be_bytes()); + + trace!( + cmd = ?command, + bytes = dst.len(), + frame = %HexBytes(dst.as_ref()), + "TX BM13xx" + ); + + Ok(()) + } +} + +impl Decoder for FrameCodec { + type Item = Response; + type Error = io::Error; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + // Return Ok(Item) with a valid frame, or Ok(None) if to be called again, potentially with + // more data. Returning an Error causes the stream to be terminated, so don't do that. + // + // There are three cases: + // + // 1. More data needed + // 2. Invalid frame + // 3. Valid frame + // + // In the case of an invalid frame, consume the first byte and request another call by + // returning Ok(None). In the case of a valid frame, consume that frame's worth of bytes. + + const PREAMBLE: [u8; 2] = [0xaa, 0x55]; + // All BM13xx responses are 11 bytes (2 preamble + 9 data) + const FRAME_LEN: usize = PREAMBLE.len() + 9; + const CALL_AGAIN: Result, io::Error> = Ok(None); + + if src.len() < FRAME_LEN { + return CALL_AGAIN; + } + + // Check preamble without consuming the buffer + if src[0] != PREAMBLE[0] { + src.advance(1); + return CALL_AGAIN; + } + + if src[1] != PREAMBLE[1] { + src.advance(1); + return CALL_AGAIN; + } + + // Validate CRC5 over the entire frame (excluding preamble) + // CRC5 is computed over the 9 data bytes after the preamble + if !crc5_is_valid(&src[2..FRAME_LEN]) { + trace!( + "Frame sync lost: CRC5 failed for potential frame at position 0. Searching for next frame..." + ); + src.advance(1); + return CALL_AGAIN; + } + + // We have a valid frame with correct CRC + // Save the frame bytes before consuming + let frame_bytes = src[..FRAME_LEN].to_vec(); + + // Create a buffer for decoding + let mut decode_buf = BytesMut::from(&src[..FRAME_LEN]); + decode_buf.advance(2); // Skip preamble for Response::decode + + match Response::decode(&mut decode_buf) { + Ok(response) => { + // Only advance if decode was successful + src.advance(FRAME_LEN); + + // Log the received frame for debugging + trace!( + resp = ?response, + bytes = FRAME_LEN, + frame = %HexBytes(&frame_bytes), + "RX BM13xx" + ); + Ok(Some(response)) + } + Err(err) => { + warn!("Failed to decode response: {}", err); + // Advance by 1 to try to find next valid frame + src.advance(1); + CALL_AGAIN + } + } + } +} + +/// Wrapper for formatting byte slices as space-separated hex. +struct HexBytes<'a>(&'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(()) + } +} diff --git a/mujina-miner/src/asic/bm13xx/command.rs b/mujina-miner/src/asic/bm13xx/command.rs new file mode 100644 index 00000000..159a78a4 --- /dev/null +++ b/mujina-miner/src/asic/bm13xx/command.rs @@ -0,0 +1,689 @@ +//! Frames the host sends to BM13xx chips. +//! +//! [`RegisterCommand`] handles register access and chain addressing +//! on a BM13xx chain. [`JobCommand`] carries mining work. The two +//! travel with different framing on the wire, so they're separate +//! types rather than one combined enum. + +use bitcoin::hashes::Hash; +use bitvec::prelude::*; +use bytes::{BufMut, BytesMut}; + +use super::register::{Register, RegisterAddress}; + +/// TYPE=2 frames: register reads/writes and chain addressing. Use CRC5. +#[derive(Debug)] +pub enum RegisterCommand { + SetChipAddress(SetChipAddress), + ChainInactive(ChainInactive), + ReadRegister(ReadRegister), + WriteRegister(WriteRegister), +} + +impl RegisterCommand { + pub(super) fn encode(&self, dst: &mut BytesMut) { + match self { + Self::SetChipAddress(c) => c.encode(dst), + Self::ChainInactive(c) => c.encode(dst), + Self::ReadRegister(c) => c.encode(dst), + Self::WriteRegister(c) => c.encode(dst), + } + } +} + +/// TYPE=1 frames: mining jobs. Use CRC16. +#[derive(Debug)] +pub enum JobCommand { + /// Full block header; chip calculates midstates internally (BM1370/BM1362 style). + JobFull(JobFullFormat), + /// Host pre-calculated midstates (BM1397 style). + JobMidstate(JobMidstateFormat), +} + +impl JobCommand { + pub(super) fn encode(&self, dst: &mut BytesMut) { + match self { + Self::JobFull(j) => j.encode(dst), + Self::JobMidstate(j) => j.encode(dst), + } + } +} + +/// Assign an address to the first unaddressed chip via daisy-chain forwarding. +#[derive(Debug, Clone, Copy)] +pub struct SetChipAddress { + pub chip_address: u8, +} + +impl SetChipAddress { + fn encode(&self, dst: &mut BytesMut) { + CommandFlags { + kind: Kind::Command, + broadcast: false, + cmd: Cmd::SetChipAddress, + } + .encode(dst); + dst.put_u8(5); // flags + length + chip_addr + reg_addr + crc5 + dst.put_u8(self.chip_address); + dst.put_u8(0x00); // reserved + } +} + +/// Put all chips into addressing mode (enables daisy-chain forwarding). +#[derive(Debug, Clone, Copy)] +pub struct ChainInactive; + +impl ChainInactive { + fn encode(&self, dst: &mut BytesMut) { + CommandFlags { + kind: Kind::Command, + broadcast: true, + cmd: Cmd::ChainInactive, + } + .encode(dst); + dst.put_u8(5); // flags + length + reserved + reserved + crc5 + dst.put_u8(0x00); + dst.put_u8(0x00); + } +} + +/// Read a register from chip(s). +#[derive(Debug, Clone, Copy)] +pub struct ReadRegister { + pub destination: Destination, + pub register_address: RegisterAddress, +} + +impl ReadRegister { + fn encode(&self, dst: &mut BytesMut) { + CommandFlags { + kind: Kind::Command, + broadcast: self.destination.is_broadcast(), + cmd: Cmd::ReadRegister, + } + .encode(dst); + dst.put_u8(5); // flags + length + chip_addr + reg_addr + crc5 + dst.put_u8(self.destination.address_byte()); + dst.put_u8(self.register_address as u8); + } +} + +/// Write a register to chip(s). +#[derive(Debug, Clone)] +pub struct WriteRegister { + pub destination: Destination, + pub register: Register, +} + +impl WriteRegister { + fn encode(&self, dst: &mut BytesMut) { + CommandFlags { + kind: Kind::Command, + broadcast: self.destination.is_broadcast(), + cmd: Cmd::WriteRegisterOrJob, + } + .encode(dst); + dst.put_u8(9); // flags + length + chip_addr + reg_addr + 4 data + crc5 + dst.put_u8(self.destination.address_byte()); + dst.put_u8(self.register.address() as u8); + self.register.encode(dst); + } +} + +/// Full format job structure +/// +/// The chip calculates midstates internally from the full block header. +/// This structure uses Bitcoin types internally; conversion to/from wire format +/// happens during encoding/decoding. +#[derive(Debug, Clone)] +pub struct JobFullFormat { + /// 4-bit job identifier (0-15), encoded into bits 6-3 of job_header on wire + pub job_id: u8, + /// Number of midstates (typically 0x01 for BM1370) + pub num_midstates: u8, + /// Starting nonce value (typically 0x00000000) + pub starting_nonce: u32, + /// Encoded difficulty target + pub nbits: bitcoin::CompactTarget, + /// Block timestamp (Unix time) + pub ntime: u32, + /// Transaction merkle tree root + pub merkle_root: bitcoin::hash_types::TxMerkleNode, + /// Previous block hash + pub prev_block_hash: bitcoin::BlockHash, + /// Block version (base version, chip may roll additional bits) + pub version: bitcoin::block::Version, +} + +impl JobFullFormat { + fn encode(&self, dst: &mut BytesMut) { + CommandFlags { + kind: Kind::Job, + broadcast: false, + cmd: Cmd::WriteRegisterOrJob, + } + .encode(dst); + + // Captures from factory firmware use this value on both BM1362 + // (S19 J Pro) and BM1370 (S21 Pro). esp-miner firmware on + // Bitaxe sends 86 instead; the BM1370 appears to tolerate + // both. + // + // Hypothesis for why 54: a single midstate JobMidstate frame + // is exactly 54 bytes on the wire (flags + length + header + + // midstate0 + crc16). JobFull transmits 88 bytes but declares + // its length as if the frame were in midstate format, where + // prev_block_hash is folded into midstate0 rather than sent + // raw. + dst.put_u8(54); + + // job_id is a 4-bit value (0-15), encode into bits 6-3 of job_header + debug_assert!(self.job_id <= 15, "job_id must be 0-15"); + dst.put_u8(self.job_id << 3); + dst.put_u8(self.num_midstates); + dst.put_u32_le(self.starting_nonce); + dst.put_u32_le(self.nbits.to_consensus()); + dst.put_u32_le(self.ntime); + + let merkle_root_bytes = hash_to_wire_bytes(&self.merkle_root.to_byte_array()); + dst.put_slice(&merkle_root_bytes); + + let prev_hash_bytes = hash_to_wire_bytes(&self.prev_block_hash.to_byte_array()); + dst.put_slice(&prev_hash_bytes); + + dst.put_u32_le(self.version.to_consensus() as u32); + } +} + +/// Midstate format job structure (BM1397?). +/// Host pre-calculates SHA256 midstates to reduce chip workload. +/// Supports up to 4 midstates for version rolling. +#[derive(Debug, Clone)] +pub struct JobMidstateFormat { + pub job_id: u8, + pub num_midstates: u8, // 1 or 4 typically + pub starting_nonce: [u8; 4], + pub nbits: [u8; 4], // Difficulty target + pub ntime: [u8; 4], // Timestamp + pub merkle4: [u8; 4], // Last 4 bytes of merkle root + pub midstate0: [u8; 32], // Primary midstate + pub midstate1: Option<[u8; 32]>, // Optional for version rolling + pub midstate2: Option<[u8; 32]>, // Optional for version rolling + pub midstate3: Option<[u8; 32]>, // Optional for version rolling +} + +impl JobMidstateFormat { + fn encode(&self, dst: &mut BytesMut) { + CommandFlags { + kind: Kind::Job, + broadcast: false, + cmd: Cmd::WriteRegisterOrJob, + } + .encode(dst); + + // Layout: flags + length + 18 header bytes + num_midstates * 32 + crc16 (2) + dst.put_u8(1 + 1 + 18 + self.num_midstates * 32 + 2); + + // job_id is a 4-bit value (0-15), encode into bits 6-3 of job_header + debug_assert!(self.job_id <= 15, "job_id must be 0-15"); + dst.put_u8(self.job_id << 3); + dst.put_u8(self.num_midstates); + dst.put_slice(&self.starting_nonce); + dst.put_slice(&self.nbits); + dst.put_slice(&self.ntime); + dst.put_slice(&self.merkle4); + dst.put_slice(&self.midstate0); + + if let Some(midstate) = &self.midstate1 { + dst.put_slice(midstate); + } + if let Some(midstate) = &self.midstate2 { + dst.put_slice(midstate); + } + if let Some(midstate) = &self.midstate3 { + dst.put_slice(midstate); + } + } +} + +/// Target of a register read or write. +/// +/// Broadcast frames go to every chip on the chain; the address byte +/// is ignored on the wire. Chip-directed frames carry the assigned +/// address of a single chip. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Destination { + Broadcast, + Chip(u8), +} + +impl Destination { + fn is_broadcast(self) -> bool { + matches!(self, Self::Broadcast) + } + + fn address_byte(self) -> u8 { + match self { + Self::Broadcast => 0x00, + Self::Chip(addr) => addr, + } + } +} + +/// Convert Bitcoin internal hash format to BM13xx wire format. +/// +/// Bitcoin uses little-endian 32-byte hashes internally. The BM13xx wire +/// protocol expects these hashes with 4-byte words reversed: +/// - Split the 32 bytes into 8 4-byte words +/// - Reverse word order (word 0 with 7, 1 with 6, 2 with 5, 3 with 4) +/// +/// Example: +/// Internal: [w0_byte0, w0_byte1, w0_byte2, w0_byte3, w1_..., w7_byte3] +/// Wire: [w7_byte0, w7_byte1, w7_byte2, w7_byte3, w6_..., w0_byte3] +pub fn hash_to_wire_bytes(hash: &[u8; 32]) -> [u8; 32] { + let mut wire_bytes = [0u8; 32]; + for i in 0..8 { + let src_word = &hash[i * 4..(i + 1) * 4]; + let dst_word = &mut wire_bytes[(7 - i) * 4..(8 - i) * 4]; + dst_word.copy_from_slice(src_word); + } + wire_bytes +} + +/// Convert BM13xx wire format to Bitcoin internal hash format. +/// +/// Inverse of `hash_to_wire_bytes`. Takes wire bytes and reverses the 4-byte +/// word order to produce Bitcoin's internal little-endian format. +pub fn hash_from_wire_bytes(wire_bytes: &[u8; 32]) -> [u8; 32] { + let mut hash = [0u8; 32]; + for i in 0..8 { + let src_word = &wire_bytes[i * 4..(i + 1) * 4]; + let dst_word = &mut hash[(7 - i) * 4..(8 - i) * 4]; + dst_word.copy_from_slice(src_word); + } + hash +} + +/// Flag byte at the start of every TX frame. +struct CommandFlags { + kind: Kind, + broadcast: bool, + cmd: Cmd, +} + +impl CommandFlags { + fn encode(&self, dst: &mut BytesMut) { + let mut byte = 0u8; + let field = byte.view_bits_mut::(); + field[5..7].store(self.kind as u8); + field[4..5].store(self.broadcast as u8); + field[0..4].store(self.cmd as u8); + dst.put_u8(byte); + } +} + +#[derive(Clone, Copy)] +#[repr(u8)] +enum Kind { + Job = 1, + Command = 2, +} + +#[derive(Clone, Copy)] +#[repr(u8)] +enum Cmd { + SetChipAddress = 0, + WriteRegisterOrJob = 1, + ReadRegister = 2, + ChainInactive = 3, +} + +#[cfg(test)] +mod tests { + use std::io; + + use bitcoin::block::Version; + use bitcoin::hash_types::TxMerkleNode; + use bitcoin::hashes::Hash; + use bitcoin::{BlockHash, CompactTarget}; + use bytes::BytesMut; + use tokio_util::codec::Encoder; + + use super::super::codec::FrameCodec; + use super::super::register::{ + ChipId, ChipModel, Core, InitControl, Log2Difficulty, MiscControl, NonceRange, Register, + RegisterAddress, TicketMask, VersionMask, + }; + use super::*; + use crate::asic::bm13xx::crc::crc16; + use crate::asic::bm13xx::test_data::{esp_miner_job, s19jpro_job}; + use crate::types::Difficulty; + + #[test] + fn read_register() { + assert_frame_eq( + RegisterCommand::ReadRegister(ReadRegister { + destination: Destination::Broadcast, + register_address: RegisterAddress::ChipId, + }), + &[0x55, 0xaa, 0x52, 0x05, 0x00, 0x00, 0x0a], + ); + } + + #[test] + fn write_register_chip_address() { + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Chip(0x01), + register: Register::ChipId(ChipId { + model: ChipModel::BM1370, + core_count: 0x00, + address: 0x01, + }), + }), + &[ + 0x55, 0xaa, 0x41, 0x09, 0x01, 0x00, 0x13, 0x70, 0x00, 0x01, 0x0a, + ], + ); + } + + #[test] + fn write_version_mask_from_capture() { + // From S21 Pro capture: TX: 55 AA 51 09 00 A4 90 00 FF FF 1C + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, // 0x51 = broadcast + register: Register::VersionMask(VersionMask::full_rolling()), + }), + &[ + 0x55, 0xaa, 0x51, 0x09, 0x00, 0xa4, 0x90, 0x00, 0xff, 0xff, 0x1c, + ], + ); + } + + #[test] + fn write_init_control_from_capture() { + // From Bitaxe capture: TX: 55 AA 51 09 00 A8 00 07 00 00 03 + // Value 0x00 07 00 00 in little-endian = 0x00000700 + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::InitControl(InitControl(0x00000700)), + }), + &[ + 0x55, 0xaa, 0x51, 0x09, 0x00, 0xa8, 0x00, 0x07, 0x00, 0x00, 0x03, + ], + ); + } + + #[test] + fn write_misc_control_from_capture() { + // From Bitaxe capture: TX: 55 AA 51 09 00 18 F0 00 C1 00 04 + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::MiscControl(MiscControl(0x00C100F0)), + }), + &[ + 0x55, 0xaa, 0x51, 0x09, 0x00, 0x18, 0xf0, 0x00, 0xc1, 0x00, 0x04, + ], + ); + } + + #[test] + fn chain_inactive_from_capture() { + // From S21 Pro capture: TX: 55 AA 53 05 00 00 03 + assert_frame_eq( + RegisterCommand::ChainInactive(ChainInactive), + &[0x55, 0xaa, 0x53, 0x05, 0x00, 0x00, 0x03], + ); + } + + #[test] + fn set_chip_address_from_capture() { + // From S21 Pro capture: TX: 55 AA 40 05 04 00 03 (assign address 0x04) + assert_frame_eq( + RegisterCommand::SetChipAddress(SetChipAddress { chip_address: 0x04 }), + &[0x55, 0xaa, 0x40, 0x05, 0x04, 0x00, 0x03], + ); + } + + #[test] + fn write_core_register_sequence() { + // From Bitaxe capture: TX: 55 AA 51 09 00 3C 80 00 8B 00 12 + // Core register uses big-endian encoding + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + // Big-endian: produces bytes 80 00 8B 00 + register: Register::Core(Core(0x80008B00)), + }), + &[ + 0x55, 0xaa, 0x51, 0x09, 0x00, 0x3c, 0x80, 0x00, 0x8b, 0x00, 0x12, + ], + ); + } + + #[test] + fn write_ticket_mask_from_capture() { + // From S21 Pro capture: TX: 55 AA 51 09 00 14 00 00 00 FF 08 + // Difficulty 256 = 8 zero_bits + let log2_diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::TicketMask(TicketMask::new(log2_diff)), + }), + &[ + 0x55, 0xaa, 0x51, 0x09, 0x00, 0x14, 0x00, 0x00, 0x00, 0xff, 0x08, + ], + ); + } + + #[test] + fn write_nonce_range_from_capture() { + // From S21 Pro capture: TX: 55 AA 51 09 00 10 00 00 1E B5 0F + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::NonceRange(NonceRange::multi_chip(65)), + }), + &[ + 0x55, 0xaa, 0x51, 0x09, 0x00, 0x10, 0x00, 0x00, 0x1e, 0xb5, 0x0f, + ], + ); + } + + #[test] + fn job_full_format_encoding() { + use bitcoin::CompactTarget; + + // Test BM1370 job packet encoding with patterns that verify word-swapping + // Use sequential bytes so we can verify word reversal + // Internal format: [w0, w1, w2, w3, w4, w5, w6, w7] (each word is 4 bytes) + // Wire format: [w7, w6, w5, w4, w3, w2, w1, w0] + let merkle_internal = [ + 0x00, 0x01, 0x02, 0x03, // word 0 + 0x04, 0x05, 0x06, 0x07, // word 1 + 0x08, 0x09, 0x0a, 0x0b, // word 2 + 0x0c, 0x0d, 0x0e, 0x0f, // word 3 + 0x10, 0x11, 0x12, 0x13, // word 4 + 0x14, 0x15, 0x16, 0x17, // word 5 + 0x18, 0x19, 0x1a, 0x1b, // word 6 + 0x1c, 0x1d, 0x1e, 0x1f, // word 7 + ]; + let prev_hash_internal = [ + 0x20, 0x21, 0x22, 0x23, // word 0 + 0x24, 0x25, 0x26, 0x27, // word 1 + 0x28, 0x29, 0x2a, 0x2b, // word 2 + 0x2c, 0x2d, 0x2e, 0x2f, // word 3 + 0x30, 0x31, 0x32, 0x33, // word 4 + 0x34, 0x35, 0x36, 0x37, // word 5 + 0x38, 0x39, 0x3a, 0x3b, // word 6 + 0x3c, 0x3d, 0x3e, 0x3f, // word 7 + ]; + + let job = JobFullFormat { + job_id: 0x00, + num_midstates: 0x01, + starting_nonce: 0x00000000, + nbits: CompactTarget::from_consensus(0x6ad60e17), + ntime: 0x208c7366, + merkle_root: bitcoin::hash_types::TxMerkleNode::from_byte_array(merkle_internal), + prev_block_hash: bitcoin::BlockHash::from_byte_array(prev_hash_internal), + version: bitcoin::block::Version::from_consensus(0x20000000), + }; + + let mut codec = FrameCodec; + let mut frame = BytesMut::new(); + codec + .encode(JobCommand::JobFull(job.clone()), &mut frame) + .expect("Failed to encode job command"); + + // Verify packet structure + assert_eq!(&frame[0..2], &[0x55, 0xaa]); // Preamble + assert_eq!(frame[2], 0x21); // TYPE_JOB | GROUP_SINGLE | CMD_WRITE + assert_eq!(frame[3], 54); // Length byte per factory captures (not a byte count) + assert_eq!(frame[4], job.job_id); + assert_eq!(frame[5], job.num_midstates); + assert_eq!(&frame[6..10], &job.starting_nonce.to_le_bytes()); + assert_eq!(&frame[10..14], &job.nbits.to_consensus().to_le_bytes()); + assert_eq!(&frame[14..18], &job.ntime.to_le_bytes()); + + // Verify merkle_root word-swapping: wire should have word 7 first, then 6, etc. + let expected_merkle_wire = [ + 0x1c, 0x1d, 0x1e, 0x1f, // word 7 (was last) + 0x18, 0x19, 0x1a, 0x1b, // word 6 + 0x14, 0x15, 0x16, 0x17, // word 5 + 0x10, 0x11, 0x12, 0x13, // word 4 + 0x0c, 0x0d, 0x0e, 0x0f, // word 3 + 0x08, 0x09, 0x0a, 0x0b, // word 2 + 0x04, 0x05, 0x06, 0x07, // word 1 + 0x00, 0x01, 0x02, 0x03, // word 0 (was first) + ]; + assert_eq!(&frame[18..50], &expected_merkle_wire); + + // Verify prev_block_hash word-swapping + let expected_prev_hash_wire = [ + 0x3c, 0x3d, 0x3e, 0x3f, // word 7 (was last) + 0x38, 0x39, 0x3a, 0x3b, // word 6 + 0x34, 0x35, 0x36, 0x37, // word 5 + 0x30, 0x31, 0x32, 0x33, // word 4 + 0x2c, 0x2d, 0x2e, 0x2f, // word 3 + 0x28, 0x29, 0x2a, 0x2b, // word 2 + 0x24, 0x25, 0x26, 0x27, // word 1 + 0x20, 0x21, 0x22, 0x23, // word 0 (was first) + ]; + assert_eq!(&frame[50..82], &expected_prev_hash_wire); + + assert_eq!(&frame[82..86], &job.version.to_consensus().to_le_bytes()); + + // Verify CRC16 (big-endian) + assert_eq!(frame.len(), 88); + let crc_bytes = &frame[86..88]; + let calculated_crc = crc16(&frame[2..86]); + let frame_crc = u16::from_be_bytes([crc_bytes[0], crc_bytes[1]]); + assert_eq!(calculated_crc, frame_crc); + } + + #[test] + fn job_full_matches_esp_miner_capture() { + // Build JobFullFormat from high-level Bitcoin types + // Verify encoding produces exact wire bytes from hardware capture + let job = JobFullFormat { + job_id: *esp_miner_job::wire_tx::JOB_ID, + num_midstates: esp_miner_job::wire_tx::NUM_MIDSTATES_BYTE[0], + starting_nonce: u32::from_le_bytes( + (*esp_miner_job::wire_tx::STARTING_NONCE_BYTES) + .try_into() + .unwrap(), + ), + nbits: *esp_miner_job::wire_tx::NBITS, + ntime: *esp_miner_job::wire_tx::NTIME, + merkle_root: *esp_miner_job::wire_tx::MERKLE_ROOT, + prev_block_hash: *esp_miner_job::wire_tx::PREV_BLOCKHASH, + version: *esp_miner_job::wire_tx::VERSION, + }; + + let mut codec = FrameCodec; + let mut frame = BytesMut::new(); + codec + .encode(JobCommand::JobFull(job.clone()), &mut frame) + .expect("Failed to encode job command"); + + // Our body bytes match esp-miner's wire capture. Byte 3 (length + // byte) and bytes 86..88 (CRC16) intentionally differ; see the + // length-byte comment in JobFullFormat::encode. + assert_eq!(&frame[4..86], &esp_miner_job::wire_tx::FRAME[4..86]); + } + + #[test] + fn job_full_matches_s19jpro_factory_capture() { + let job = job_full_from_wire(&s19jpro_job::wire_tx::FRAME); + + let mut codec = FrameCodec; + let mut frame = BytesMut::new(); + codec + .encode(JobCommand::JobFull(job), &mut frame) + .expect("Failed to encode job command"); + + // Byte-for-byte match, length byte and CRC16 included: the + // encoder reproduces factory firmware exactly. + assert_eq!(&frame[..], &s19jpro_job::wire_tx::FRAME[..]); + } + + /// Builds a JobFullFormat from a captured JobFull wire frame. + fn job_full_from_wire(frame: &[u8; 88]) -> JobFullFormat { + // The wire sends each 32-byte hash as eight 4-byte words, most + // significant word first; internal order reverses the words. + fn hash_from_wire(wire: &[u8]) -> [u8; 32] { + let mut internal = [0u8; 32]; + for i in 0..8 { + internal[(7 - i) * 4..(8 - i) * 4].copy_from_slice(&wire[i * 4..(i + 1) * 4]); + } + internal + } + + JobFullFormat { + job_id: frame[4] >> 3, + num_midstates: frame[5], + starting_nonce: u32::from_le_bytes(frame[6..10].try_into().unwrap()), + nbits: CompactTarget::from_consensus(u32::from_le_bytes( + frame[10..14].try_into().unwrap(), + )), + ntime: u32::from_le_bytes(frame[14..18].try_into().unwrap()), + merkle_root: TxMerkleNode::from_byte_array(hash_from_wire(&frame[18..50])), + prev_block_hash: BlockHash::from_byte_array(hash_from_wire(&frame[50..82])), + version: Version::from_consensus( + u32::from_le_bytes(frame[82..86].try_into().unwrap()) as i32 + ), + } + } + + fn assert_frame_eq(cmd: C, expect: &[u8]) + where + FrameCodec: Encoder, + { + let mut codec = FrameCodec; + let mut frame = BytesMut::new(); + codec + .encode(cmd, &mut frame) + .expect("Failed to encode command for test"); + + assert_eq!( + &frame[..], + expect, + "\nFrame mismatch!\nExpected: {}\nActual: {}", + as_hex(expect), + as_hex(&frame[..]) + ); + } + + fn as_hex(bytes: &[u8]) -> String { + bytes + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join(" ") + } +} diff --git a/mujina-miner/src/asic/bm13xx/error.rs b/mujina-miner/src/asic/bm13xx/error.rs index dd80e6cd..e012f7fb 100644 --- a/mujina-miner/src/asic/bm13xx/error.rs +++ b/mujina-miner/src/asic/bm13xx/error.rs @@ -11,7 +11,7 @@ pub enum ProtocolError { InvalidResponseType(u8), #[error("Cannot write to read-only register: {0:?}")] - ReadOnlyRegister(super::protocol::RegisterAddress), + ReadOnlyRegister(super::register::RegisterAddress), #[error("Invalid frame format")] InvalidFrame, diff --git a/mujina-miner/src/asic/bm13xx/mod.rs b/mujina-miner/src/asic/bm13xx/mod.rs index 54c1456c..7dd87d13 100644 --- a/mujina-miner/src/asic/bm13xx/mod.rs +++ b/mujina-miner/src/asic/bm13xx/mod.rs @@ -4,16 +4,22 @@ //! communicating with BM13xx series mining chips (BM1366, BM1370, etc). pub mod chip_config; +pub mod codec; +pub mod command; pub mod crc; pub mod error; pub mod protocol; +pub mod register; +pub mod response; pub mod thread; #[cfg(test)] pub mod test_data; // Re-export commonly used types -pub use protocol::{FrameCodec, Register, Response}; +pub use codec::FrameCodec; +pub use register::Register; +pub use response::Response; // Re-export the protocol handler pub use protocol::BM13xxProtocol; diff --git a/mujina-miner/src/asic/bm13xx/protocol.rs b/mujina-miner/src/asic/bm13xx/protocol.rs index afcb6cd0..88886f7e 100644 --- a/mujina-miner/src/asic/bm13xx/protocol.rs +++ b/mujina-miner/src/asic/bm13xx/protocol.rs @@ -1,2280 +1,21 @@ -//! BM13xx protocol implementation for chip communication. +//! Vestigial command-builder for BM13xx chips. //! -//! This module handles the encoding and decoding of commands and responses -//! for BM13xx family chips (BM1366, BM1370, etc). +//! [`BM13xxProtocol`] predates the typed `Register` payloads and +//! returns `Vec` for callers to execute. Only +//! `discover_chips` retains a live caller (Bitaxe). The multi-chip +//! EmberOne work replaces this type with a typed driver. -use bitcoin::hashes::Hash; -use bitcoin::pow::Work; -use bitvec::prelude::*; -use bytes::{Buf, BufMut, BytesMut}; -use std::{fmt, io}; -use strum::FromRepr; -use tokio_util::codec::{Decoder, Encoder}; - -use super::chip_config::ChipConfig; -use super::crc::{crc5, crc5_is_valid, crc16}; -use super::error::ProtocolError; -use crate::job_source::GeneralPurposeBits; -use crate::tracing::prelude::*; -use crate::types::{Difficulty, Frequency}; - -/// Wrapper for formatting byte slices as space-separated hex. -struct HexBytes<'a>(&'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(()) - } -} - -/// PLL configuration for frequency control. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct PllDivider { - /// VCO control flag (0x40 for low VCO, 0x50 for high VCO). - pub flag: u8, - /// Feedback divider. - pub fb_div: u8, - /// Reference divider (typically 1 or 2). - pub ref_div: u8, - /// Post divider, encoded as `((post_div1-1) << 4) | (post_div2-1)`. - pub post_div: u8, -} - -impl PllDivider { - /// Create a PLL configuration, deriving the VCO flag from the - /// resulting VCO frequency: `vco >= 2400 MHz` picks flag `0x50`, - /// otherwise flag `0x40`. - pub fn new(fb_div: u8, ref_div: u8, post_div: u8) -> Self { - let vco_mhz = fb_div as f32 * 25.0 / ref_div as f32; - let flag = if vco_mhz >= 2400.0 { 0x50 } else { 0x40 }; - Self { - flag, - fb_div, - ref_div, - post_div, - } - } - - pub fn encode(&self, dst: &mut BytesMut) { - dst.put_u8(self.flag); - dst.put_u8(self.fb_div); - dst.put_u8(self.ref_div); - dst.put_u8(self.post_div); - } - - pub fn decode(bytes: [u8; 4]) -> Self { - Self { - flag: bytes[0], - fb_div: bytes[1], - ref_div: bytes[2], - post_div: bytes[3], - } - } -} - -/// Known chip models in the BM13xx family. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChipModel { - /// BM1362 - Used in Antminer S19 J Pro (126 chips) - /// Core count unknown - BM1362, - /// BM1366 - Newer generation chip - BM1366, - /// BM1370 - Used in Bitaxe Gamma and Antminer S21 Pro - /// ~2,040 hash engines organized as 128 domains of ~16 engines each - BM1370, - /// BM1397 - Previous generation chip - BM1397, - /// Unknown chip model with raw ID bytes. - Unknown([u8; 2]), -} - -impl ChipModel { - /// Get the raw chip ID bytes - pub fn id_bytes(&self) -> [u8; 2] { - match self { - Self::BM1362 => [0x13, 0x62], - Self::BM1366 => [0x13, 0x66], - Self::BM1370 => [0x13, 0x70], - Self::BM1397 => [0x13, 0x97], - Self::Unknown(bytes) => *bytes, - } - } - - /// Returns the expected hash engine count for this model, if known. - pub fn core_count(&self) -> Option { - match self { - Self::BM1370 => Some(2048), // 128 x 16; esp-miner uses 2040 - _ => None, - } - } -} - -impl From<[u8; 2]> for ChipModel { - fn from(bytes: [u8; 2]) -> Self { - match bytes { - [0x13, 0x62] => Self::BM1362, - [0x13, 0x66] => Self::BM1366, - [0x13, 0x70] => Self::BM1370, - [0x13, 0x97] => Self::BM1397, - _ => Self::Unknown(bytes), - } - } -} - -impl From for [u8; 2] { - fn from(model: ChipModel) -> Self { - model.id_bytes() - } -} - -/// Nonce range configuration for work distribution. -/// -/// NOTE: We store this as a byte array rather than interpreting it as a u32 -/// because the exact bit-level interpretation is still being reverse-engineered. -/// The values below are empirically observed from production hardware. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct NonceRange { - /// Raw bytes as sent over the wire - bytes: [u8; 4], -} - -impl NonceRange { - // Nonce range values for different chain lengths (captured from hardware) - const SINGLE_CHIP: [u8; 4] = [0xff, 0xff, 0xff, 0xff]; - const SMALL_CHAIN: [u8; 4] = [0xff, 0xff, 0xff, 0x1f]; // 2-8 chips - const MEDIUM_CHAIN: [u8; 4] = [0xff, 0xff, 0xff, 0x0f]; // 9-16 chips - const LARGE_CHAIN: [u8; 4] = [0xff, 0xff, 0xff, 0x07]; // 17-32 chips - const XLARGE_CHAIN: [u8; 4] = [0xff, 0xff, 0xff, 0x03]; // 33-64 chips - const S21_PRO: [u8; 4] = [0x00, 0x00, 0x1e, 0xb5]; // 65-128 chips (empirical) - const DEFAULT_LARGE: [u8; 4] = [0xff, 0xff, 0xff, 0x01]; // >128 chips - - /// Create config for single chip (full range) - pub fn single_chip() -> Self { - Self { - bytes: Self::SINGLE_CHIP, - } - } - - /// Create config for multi-chip chain - pub fn multi_chip(chain_length: usize) -> Self { - let bytes = match chain_length { - 1 => Self::SINGLE_CHIP, - 2..=8 => Self::SMALL_CHAIN, - 9..=16 => Self::MEDIUM_CHAIN, - 17..=32 => Self::LARGE_CHAIN, - 33..=64 => Self::XLARGE_CHAIN, - 65..=128 => Self::S21_PRO, - _ => Self::DEFAULT_LARGE, - }; - Self { bytes } - } - - /// Create config from raw 32-bit value (little-endian) - /// Used for exact configuration from protocol captures - pub fn from_raw(value: u32) -> Self { - Self { - bytes: value.to_le_bytes(), - } - } - - pub fn encode(&self, dst: &mut BytesMut) { - dst.put_slice(&self.bytes); - } - - pub fn decode(bytes: [u8; 4]) -> Self { - Self { bytes } - } -} - -/// ASIC difficulty as a power-of-2 exponent. -/// -/// BM13xx chips filter nonces using bitmask comparison (`hash & -/// mask == 0`) rather than numerical target comparison (`hash < -/// target`). Each bit in the mask independently halves the pass -/// rate, so only power-of-2 difficulty steps are representable. -/// This type stores the log2 of the difficulty: a value of 8 -/// means difficulty 2^8 = 256. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Log2Difficulty { - exponent: u8, -} - -impl Log2Difficulty { - /// Floor an arbitrary difficulty to the nearest power-of-2 - /// ASIC difficulty. - /// - /// The conversion is lossy: non-power-of-2 difficulties are - /// rounded down. This ensures the actual nonce rate is at least - /// as high as the rate implied by the input difficulty. - pub fn from_difficulty(difficulty: Difficulty) -> Self { - let d = difficulty.as_f64(); - let exponent = if d >= 1.0 { d.log2().floor() as u8 } else { 0 }; - Self { exponent } - } - - /// The log2 of the difficulty (e.g., 8 for difficulty 256). - pub const fn exponent(&self) -> u8 { - self.exponent - } - - /// Expected work per nonce at this difficulty. - /// - /// A nonce that passes the ASIC's difficulty filter represents - /// this many hashes of work on average. - pub fn to_work(&self) -> Work { - Difficulty::from(1_u64 << self.exponent) - .to_target() - .to_work() - } -} - -impl fmt::Display for Log2Difficulty { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "2^{}", self.exponent) - } -} - -/// Ticket mask controlling ASIC nonce reporting -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct TicketMask { - // Number of additional zero bits required in the bit-reversed hash, - // beyond the base 32 bits. The chip always requires bits 0-31 of the - // bit-reversed hash to be zero. This parameter adds bits 32..(32+zero_bits) - // that must also be zero. - zero_bits: u8, -} - -impl TicketMask { - /// Create ticket mask from an ASIC difficulty. - /// - /// The [`Log2Difficulty`] exponent maps directly to the number - /// of extra zero bits the chip requires beyond its hardwired - /// difficulty-1 gate. - pub const fn new(difficulty: Log2Difficulty) -> Self { - Self { - zero_bits: difficulty.exponent(), - } - } - - /// Encode ticket mask to wire format bytes - pub fn to_wire_bytes(&self) -> [u8; 4] { - if self.zero_bits == 0 { - return [0, 0, 0, 0]; - } - - // Create mask value: 2^zero_bits - 1 - let mask_value = (1u32 << self.zero_bits) - 1; - - // Encode to wire format with bit-reversal and byte-reversal - let mut bytes = [0u8; 4]; - for i in 0..4 { - let byte = ((mask_value >> (8 * i)) & 0xFF) as u8; - bytes[3 - i] = reverse_bits(byte); - } - - bytes - } - - pub fn encode(&self, dst: &mut BytesMut) { - dst.put_slice(&self.to_wire_bytes()); - } - - pub fn decode(bytes: [u8; 4]) -> Self { - let mask_value = decode_ticket_mask_bytes(&bytes); - let zero_bits = mask_value.count_ones() as u8; - Self { zero_bits } - } -} - -/// Helper function to reverse bits in a byte -fn reverse_bits(byte: u8) -> u8 { - let mut result = 0u8; - let mut b = byte; - for _ in 0..8 { - result = (result << 1) | (b & 1); - b >>= 1; - } - result -} - -/// Helper function to decode ticket mask bytes back to mask value -fn decode_ticket_mask_bytes(bytes: &[u8; 4]) -> u32 { - // Reverse the encoding process: undo byte reversal and bit reversal - let mut mask_value = 0u32; - for i in 0..4 { - let byte = reverse_bits(bytes[3 - i]); - mask_value |= (byte as u32) << (8 * i); - } - mask_value -} - -/// UART baud rate configuration -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum UartBaud { - /// 115200 baud - Baud115200, - /// 1 Mbaud - Baud1M, - /// 3 Mbaud (common for multi-chip) - Baud3M, - /// Custom baud rate with raw register value - Custom(u32), -} - -impl UartBaud { - pub fn encode(&self, dst: &mut BytesMut) { - let value = match self { - // From esp-miner BM1370/BM1366/BM1368 default baud config - UartBaud::Baud115200 => 0x00000271, - // From esp-miner BM1370_set_max_baud/BM1366_set_max_baud/BM1368_set_max_baud - // All three chips use identical register value for 1Mbaud - UartBaud::Baud1M => 0x00023011, - // From S21 Pro captures (BM1370 multi-chip chains) - UartBaud::Baud3M => 0x00003001, - UartBaud::Custom(val) => *val, - }; - dst.put_u32_le(value); - } - pub fn decode(bytes: [u8; 4]) -> Self { - match u32::from_le_bytes(bytes) { - 0x00000271 => UartBaud::Baud115200, - 0x00000130 => UartBaud::Baud1M, - 0x00003001 => UartBaud::Baud3M, - other => UartBaud::Custom(other), - } - } -} - -/// IO driver strength configuration -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct IoDriverStrength { - /// Drive strength for each signal group (4 bits each) - strengths: [u8; 8], -} - -impl IoDriverStrength { - /// Normal strength for chips in middle of chain - pub fn normal() -> Self { - // 0x11110100 = 0001 0001 0001 0001 0000 0001 0000 0000 - Self { - strengths: [0x0, 0x0, 0x1, 0x0, 0x1, 0x1, 0x1, 0x1], - } - } - - /// Strong drive for domain boundary chips - pub fn domain_boundary() -> Self { - // 0x1111f100 = 0001 0001 0001 0001 1111 0001 0000 0000 - Self { - strengths: [0x0, 0x0, 0x1, 0xf, 0x1, 0x1, 0x1, 0x1], - } - } - - pub fn encode(&self, dst: &mut BytesMut) { - // Pack 8 4-bit values into 4 bytes (2 per byte). - // Each byte contains two strength values: [high_nibble|low_nibble]. - dst.put_u8(self.strengths[0] | (self.strengths[1] << 4)); - dst.put_u8(self.strengths[2] | (self.strengths[3] << 4)); - dst.put_u8(self.strengths[4] | (self.strengths[5] << 4)); - dst.put_u8(self.strengths[6] | (self.strengths[7] << 4)); - } - - pub fn decode(bytes: [u8; 4]) -> Self { - let raw = u32::from_le_bytes(bytes); - let mut strengths = [0u8; 8]; - for (i, strength) in strengths.iter_mut().enumerate() { - *strength = ((raw >> (i * 4)) & 0xf) as u8; - } - Self { strengths } - } -} - -/// Version mask for version rolling -#[derive(Clone, Copy, PartialEq)] -pub struct VersionMask { - /// Which bits can be rolled - mask: u16, - /// Enable flag and other control bits - control: u16, -} - -impl VersionMask { - /// Full 16-bit mask for version rolling - const FULL_MASK: u16 = 0xffff; - /// Fixed control pattern used by all implementations to enable version rolling - const ENABLE_ROLLING: u16 = 0x0090; - - /// Create version mask with all lower 16 bits enabled - pub fn full_rolling() -> Self { - Self { - mask: Self::FULL_MASK, - control: Self::ENABLE_ROLLING, - } - } - - pub fn encode(&self, dst: &mut BytesMut) { - dst.put_u16_le(self.control); - dst.put_u16_le(self.mask); - } - - pub fn decode(bytes: [u8; 4]) -> Self { - let raw = u32::from_le_bytes(bytes); - Self { - control: (raw & 0xffff) as u16, - mask: (raw >> 16) as u16, - } - } -} - -impl fmt::Debug for VersionMask { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let control_str = if self.control == Self::ENABLE_ROLLING { - "ENABLE_ROLLING".to_string() - } else { - format!("{:#06x}", self.control) - }; - f.debug_struct("VersionMask") - .field("mask", &format_args!("{:#06x}", self.mask)) - .field("control", &control_str) - .finish() - } -} - -#[derive(FromRepr, Copy, Clone, Debug)] -#[repr(u8)] -pub enum RegisterAddress { - ChipId = 0x00, - PllDivider = 0x08, - NonceRange = 0x10, - TicketMask = 0x14, - MiscControl = 0x18, - UartBaud = 0x28, - UartRelay = 0x2C, - Core = 0x3C, - AnalogMux = 0x54, - IoDriverStrength = 0x58, - Pll3Parameter = 0x68, - VersionMask = 0xA4, - InitControl = 0xA8, - MiscSettings = 0xB9, -} - -/// Chip model + core count + assigned chain address. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ChipId { - pub model: ChipModel, - pub core_count: u8, - pub address: u8, -} - -impl ChipId { - pub fn encode(&self, dst: &mut BytesMut) { - dst.put_slice(&self.model.id_bytes()); - dst.put_u8(self.core_count); - dst.put_u8(self.address); - } - pub fn decode(bytes: [u8; 4]) -> Self { - Self { - model: ChipModel::from([bytes[0], bytes[1]]), - core_count: bytes[2], - address: bytes[3], - } - } -} - -// Placeholder newtypes for registers whose bit layout is not yet decomposed. -// Each wraps a raw u32 written little-endian to the wire. -macro_rules! raw_u32_register { - ($($(#[$meta:meta])* $name:ident),* $(,)?) => { - $( - $(#[$meta])* - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub struct $name(pub u32); - - impl $name { - pub fn encode(&self, dst: &mut BytesMut) { - dst.put_u32_le(self.0); - } - pub fn decode(bytes: [u8; 4]) -> Self { - Self(u32::from_le_bytes(bytes)) - } - } - )* - }; -} - -raw_u32_register! { - MiscControl, - UartRelay, - AnalogMux, - Pll3Parameter, - InitControl, - MiscSettings, -} - -/// Transmitted big-endian, unlike the other raw-u32 registers. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Core(pub u32); - -impl Core { - pub fn encode(&self, dst: &mut BytesMut) { - dst.put_u32(self.0); - } - pub fn decode(bytes: [u8; 4]) -> Self { - Self(u32::from_be_bytes(bytes)) - } -} - -#[derive(Debug, Clone)] -pub enum Register { - ChipId(ChipId), - PllDivider(PllDivider), - NonceRange(NonceRange), - TicketMask(TicketMask), - MiscControl(MiscControl), - UartBaud(UartBaud), - UartRelay(UartRelay), - Core(Core), - AnalogMux(AnalogMux), - IoDriverStrength(IoDriverStrength), - Pll3Parameter(Pll3Parameter), - VersionMask(VersionMask), - InitControl(InitControl), - MiscSettings(MiscSettings), -} - -impl Register { - pub fn decode(address: RegisterAddress, bytes: [u8; 4]) -> Register { - match address { - RegisterAddress::ChipId => Register::ChipId(ChipId::decode(bytes)), - RegisterAddress::PllDivider => Register::PllDivider(PllDivider::decode(bytes)), - RegisterAddress::NonceRange => Register::NonceRange(NonceRange::decode(bytes)), - RegisterAddress::TicketMask => Register::TicketMask(TicketMask::decode(bytes)), - RegisterAddress::MiscControl => Register::MiscControl(MiscControl::decode(bytes)), - RegisterAddress::UartBaud => Register::UartBaud(UartBaud::decode(bytes)), - RegisterAddress::UartRelay => Register::UartRelay(UartRelay::decode(bytes)), - RegisterAddress::Core => Register::Core(Core::decode(bytes)), - RegisterAddress::AnalogMux => Register::AnalogMux(AnalogMux::decode(bytes)), - RegisterAddress::IoDriverStrength => { - Register::IoDriverStrength(IoDriverStrength::decode(bytes)) - } - RegisterAddress::Pll3Parameter => Register::Pll3Parameter(Pll3Parameter::decode(bytes)), - RegisterAddress::VersionMask => Register::VersionMask(VersionMask::decode(bytes)), - RegisterAddress::InitControl => Register::InitControl(InitControl::decode(bytes)), - RegisterAddress::MiscSettings => Register::MiscSettings(MiscSettings::decode(bytes)), - } - } - - /// Get the register address for this register - fn address(&self) -> RegisterAddress { - match self { - Register::ChipId(_) => RegisterAddress::ChipId, - Register::PllDivider(_) => RegisterAddress::PllDivider, - Register::NonceRange(_) => RegisterAddress::NonceRange, - Register::TicketMask(_) => RegisterAddress::TicketMask, - Register::MiscControl(_) => RegisterAddress::MiscControl, - Register::UartBaud(_) => RegisterAddress::UartBaud, - Register::UartRelay(_) => RegisterAddress::UartRelay, - Register::Core(_) => RegisterAddress::Core, - Register::AnalogMux(_) => RegisterAddress::AnalogMux, - Register::IoDriverStrength(_) => RegisterAddress::IoDriverStrength, - Register::Pll3Parameter(_) => RegisterAddress::Pll3Parameter, - Register::VersionMask(_) => RegisterAddress::VersionMask, - Register::InitControl(_) => RegisterAddress::InitControl, - Register::MiscSettings(_) => RegisterAddress::MiscSettings, - } - } - - /// Encode the register data (not the address) - fn encode(&self, dst: &mut BytesMut) { - match self { - Register::ChipId(r) => r.encode(dst), - Register::PllDivider(r) => r.encode(dst), - Register::NonceRange(r) => r.encode(dst), - Register::TicketMask(r) => r.encode(dst), - Register::MiscControl(r) => r.encode(dst), - Register::UartBaud(r) => r.encode(dst), - Register::UartRelay(r) => r.encode(dst), - Register::Core(r) => r.encode(dst), - Register::AnalogMux(r) => r.encode(dst), - Register::IoDriverStrength(r) => r.encode(dst), - Register::Pll3Parameter(r) => r.encode(dst), - Register::VersionMask(r) => r.encode(dst), - Register::InitControl(r) => r.encode(dst), - Register::MiscSettings(r) => r.encode(dst), - } - } -} - -struct CommandFlags { - kind: Kind, - broadcast: bool, - cmd: Cmd, -} - -impl CommandFlags { - fn encode(&self, dst: &mut BytesMut) { - let mut byte = 0u8; - let field = byte.view_bits_mut::(); - field[5..7].store(self.kind as u8); - field[4..5].store(self.broadcast as u8); - field[0..4].store(self.cmd as u8); - dst.put_u8(byte); - } -} - -#[derive(Clone, Copy)] -#[repr(u8)] -enum Kind { - Job = 1, - Command = 2, -} - -#[derive(Clone, Copy)] -#[repr(u8)] -enum Cmd { - SetChipAddress = 0, - WriteRegisterOrJob = 1, - ReadRegister = 2, - ChainInactive = 3, -} - -/// Convert Bitcoin internal hash format to BM13xx wire format. -/// -/// Bitcoin uses little-endian 32-byte hashes internally. The BM13xx wire -/// protocol expects these hashes with 4-byte words reversed: -/// - Split the 32 bytes into 8 4-byte words -/// - Reverse word order (word 0 with 7, 1 with 6, 2 with 5, 3 with 4) -/// -/// Example: -/// Internal: [w0_byte0, w0_byte1, w0_byte2, w0_byte3, w1_..., w7_byte3] -/// Wire: [w7_byte0, w7_byte1, w7_byte2, w7_byte3, w6_..., w0_byte3] -pub fn hash_to_wire_bytes(hash: &[u8; 32]) -> [u8; 32] { - let mut wire_bytes = [0u8; 32]; - // Reverse the order of 4-byte words - for i in 0..8 { - let src_word = &hash[i * 4..(i + 1) * 4]; - let dst_word = &mut wire_bytes[(7 - i) * 4..(8 - i) * 4]; - dst_word.copy_from_slice(src_word); - } - wire_bytes -} - -/// Convert BM13xx wire format to Bitcoin internal hash format. -/// -/// Inverse of `hash_to_wire_bytes`. Takes wire bytes and reverses the 4-byte -/// word order to produce Bitcoin's internal little-endian format. -pub fn hash_from_wire_bytes(wire_bytes: &[u8; 32]) -> [u8; 32] { - let mut hash = [0u8; 32]; - // Reverse the order of 4-byte words - for i in 0..8 { - let src_word = &wire_bytes[i * 4..(i + 1) * 4]; - let dst_word = &mut hash[(7 - i) * 4..(8 - i) * 4]; - dst_word.copy_from_slice(src_word); - } - hash -} - -/// Target of a register read or write. -/// -/// Broadcast frames go to every chip on the chain; the address byte -/// is ignored on the wire. Chip-directed frames carry the assigned -/// address of a single chip. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Destination { - Broadcast, - Chip(u8), -} - -impl Destination { - fn is_broadcast(self) -> bool { - matches!(self, Self::Broadcast) - } - - fn address_byte(self) -> u8 { - match self { - Self::Broadcast => 0x00, - Self::Chip(addr) => addr, - } - } -} - -/// Assign an address to the first unaddressed chip via daisy-chain forwarding. -#[derive(Debug, Clone, Copy)] -pub struct SetChipAddress { - pub chip_address: u8, -} - -impl SetChipAddress { - fn encode(&self, dst: &mut BytesMut) { - CommandFlags { - kind: Kind::Command, - broadcast: false, - cmd: Cmd::SetChipAddress, - } - .encode(dst); - dst.put_u8(5); // flags + length + chip_addr + reg_addr + crc5 - dst.put_u8(self.chip_address); - dst.put_u8(0x00); // reserved - } -} - -/// Put all chips into addressing mode (enables daisy-chain forwarding). -#[derive(Debug, Clone, Copy)] -pub struct ChainInactive; - -impl ChainInactive { - fn encode(&self, dst: &mut BytesMut) { - CommandFlags { - kind: Kind::Command, - broadcast: true, - cmd: Cmd::ChainInactive, - } - .encode(dst); - dst.put_u8(5); // flags + length + reserved + reserved + crc5 - dst.put_u8(0x00); - dst.put_u8(0x00); - } -} - -/// Read a register from chip(s). -#[derive(Debug, Clone, Copy)] -pub struct ReadRegister { - pub destination: Destination, - pub register_address: RegisterAddress, -} - -impl ReadRegister { - fn encode(&self, dst: &mut BytesMut) { - CommandFlags { - kind: Kind::Command, - broadcast: self.destination.is_broadcast(), - cmd: Cmd::ReadRegister, - } - .encode(dst); - dst.put_u8(5); // flags + length + chip_addr + reg_addr + crc5 - dst.put_u8(self.destination.address_byte()); - dst.put_u8(self.register_address as u8); - } -} - -/// Write a register to chip(s). -#[derive(Debug, Clone)] -pub struct WriteRegister { - pub destination: Destination, - pub register: Register, -} - -impl WriteRegister { - fn encode(&self, dst: &mut BytesMut) { - CommandFlags { - kind: Kind::Command, - broadcast: self.destination.is_broadcast(), - cmd: Cmd::WriteRegisterOrJob, - } - .encode(dst); - dst.put_u8(9); // flags + length + chip_addr + reg_addr + 4 data + crc5 - dst.put_u8(self.destination.address_byte()); - dst.put_u8(self.register.address() as u8); - self.register.encode(dst); - } -} - -/// TYPE=2 frames: register reads/writes and chain addressing. Use CRC5. -#[derive(Debug)] -pub enum RegisterCommand { - SetChipAddress(SetChipAddress), - ChainInactive(ChainInactive), - ReadRegister(ReadRegister), - WriteRegister(WriteRegister), -} - -impl RegisterCommand { - fn encode(&self, dst: &mut BytesMut) { - match self { - Self::SetChipAddress(c) => c.encode(dst), - Self::ChainInactive(c) => c.encode(dst), - Self::ReadRegister(c) => c.encode(dst), - Self::WriteRegister(c) => c.encode(dst), - } - } -} - -/// TYPE=1 frames: mining jobs. Use CRC16. -#[derive(Debug)] -pub enum JobCommand { - /// Full block header; chip calculates midstates internally (BM1370/BM1362 style). - JobFull(JobFullFormat), - /// Host pre-calculated midstates (BM1397 style). - JobMidstate(JobMidstateFormat), -} - -impl JobCommand { - fn encode(&self, dst: &mut BytesMut) { - match self { - Self::JobFull(j) => j.encode(dst), - Self::JobMidstate(j) => j.encode(dst), - } - } -} - -/// Full format job structure -/// -/// The chip calculates midstates internally from the full block header. -/// This structure uses Bitcoin types internally; conversion to/from wire format -/// happens during encoding/decoding. -#[derive(Debug, Clone)] -pub struct JobFullFormat { - /// 4-bit job identifier (0-15), encoded into bits 6-3 of job_header on wire - pub job_id: u8, - /// Number of midstates (typically 0x01 for BM1370) - pub num_midstates: u8, - /// Starting nonce value (typically 0x00000000) - pub starting_nonce: u32, - /// Encoded difficulty target - pub nbits: bitcoin::CompactTarget, - /// Block timestamp (Unix time) - pub ntime: u32, - /// Transaction merkle tree root - pub merkle_root: bitcoin::hash_types::TxMerkleNode, - /// Previous block hash - pub prev_block_hash: bitcoin::BlockHash, - /// Block version (base version, chip may roll additional bits) - pub version: bitcoin::block::Version, -} - -impl JobFullFormat { - fn encode(&self, dst: &mut BytesMut) { - CommandFlags { - kind: Kind::Job, - broadcast: false, - cmd: Cmd::WriteRegisterOrJob, - } - .encode(dst); - - // Captures from factory firmware use this value on both BM1362 - // (S19 J Pro) and BM1370 (S21 Pro). esp-miner firmware on - // Bitaxe sends 86 instead; the BM1370 appears to tolerate - // both. - // - // Hypothesis for why 54: a single midstate JobMidstate frame - // is exactly 54 bytes on the wire (flags + length + header + - // midstate0 + crc16). JobFull transmits 88 bytes but declares - // its length as if the frame were in midstate format, where - // prev_block_hash is folded into midstate0 rather than sent - // raw. - dst.put_u8(54); - - // job_id is a 4-bit value (0-15), encode into bits 6-3 of job_header - debug_assert!(self.job_id <= 15, "job_id must be 0-15"); - dst.put_u8(self.job_id << 3); - dst.put_u8(self.num_midstates); - dst.put_u32_le(self.starting_nonce); - dst.put_u32_le(self.nbits.to_consensus()); - dst.put_u32_le(self.ntime); - - let merkle_root_bytes = hash_to_wire_bytes(&self.merkle_root.to_byte_array()); - dst.put_slice(&merkle_root_bytes); - - let prev_hash_bytes = hash_to_wire_bytes(&self.prev_block_hash.to_byte_array()); - dst.put_slice(&prev_hash_bytes); - - dst.put_u32_le(self.version.to_consensus() as u32); - } -} - -/// Midstate format job structure (BM1397?). -/// Host pre-calculates SHA256 midstates to reduce chip workload. -/// Supports up to 4 midstates for version rolling. -#[derive(Debug, Clone)] -pub struct JobMidstateFormat { - pub job_id: u8, - pub num_midstates: u8, // 1 or 4 typically - pub starting_nonce: [u8; 4], - pub nbits: [u8; 4], // Difficulty target - pub ntime: [u8; 4], // Timestamp - pub merkle4: [u8; 4], // Last 4 bytes of merkle root - pub midstate0: [u8; 32], // Primary midstate - pub midstate1: Option<[u8; 32]>, // Optional for version rolling - pub midstate2: Option<[u8; 32]>, // Optional for version rolling - pub midstate3: Option<[u8; 32]>, // Optional for version rolling -} - -impl JobMidstateFormat { - fn encode(&self, dst: &mut BytesMut) { - CommandFlags { - kind: Kind::Job, - broadcast: false, - cmd: Cmd::WriteRegisterOrJob, - } - .encode(dst); - - // Layout: flags + length + 18 header bytes + num_midstates * 32 + crc16 (2) - dst.put_u8(1 + 1 + 18 + self.num_midstates * 32 + 2); - - // job_id is a 4-bit value (0-15), encode into bits 6-3 of job_header - debug_assert!(self.job_id <= 15, "job_id must be 0-15"); - dst.put_u8(self.job_id << 3); - dst.put_u8(self.num_midstates); - dst.put_slice(&self.starting_nonce); - dst.put_slice(&self.nbits); - dst.put_slice(&self.ntime); - dst.put_slice(&self.merkle4); - dst.put_slice(&self.midstate0); - - if let Some(midstate) = &self.midstate1 { - dst.put_slice(midstate); - } - if let Some(midstate) = &self.midstate2 { - dst.put_slice(midstate); - } - if let Some(midstate) = &self.midstate3 { - dst.put_slice(midstate); - } - } -} - -#[derive(FromRepr)] -#[repr(u8)] -enum ResponseType { - ReadRegister = 0, - Nonce = 4, -} - -#[derive(Debug)] -#[cfg_attr(not(test), allow(dead_code))] -pub enum Response { - ReadRegister { - chip_address: u8, - register: Register, - }, - Nonce { - nonce: u32, - job_id: u8, - midstate_num: u8, - version: GeneralPurposeBits, - subcore_id: u8, - }, -} - -impl Response { - fn decode(bytes: &mut BytesMut) -> Result { - let type_and_crc = bytes[bytes.len() - 1].view_bits::(); - let type_repr = type_and_crc[5..].load::(); - - match ResponseType::from_repr(type_repr) { - Some(ResponseType::ReadRegister) => { - let value_bytes = bytes.split_to(4); - let value: [u8; 4] = - value_bytes[..] - .try_into() - .map_err(|_| ProtocolError::BufferTooSmall { - need: 4, - have: value_bytes.len(), - })?; - let chip_address = bytes.get_u8(); - let register_address_repr = bytes.get_u8(); - - if let Some(register_address) = RegisterAddress::from_repr(register_address_repr) { - let register = Register::decode(register_address, value); - Ok(Response::ReadRegister { - chip_address, - register, - }) - } else { - Err(ProtocolError::InvalidRegisterAddress(register_address_repr)) - } - } - Some(ResponseType::Nonce) => { - // BM1370 nonce response format (11 bytes total, including preamble): - // Already consumed: preamble (2 bytes) - // Remaining: nonce(4) + midstate_num(1) + result_header(1) + version(2) + crc(1) - let nonce = bytes.get_u32_le(); - let midstate_num = bytes.get_u8(); - let result_header = bytes.get_u8(); - - // Version rolling field: 2 bytes, big-endian - // Occupies bits 13-28 of block version when shifted left 13 - let version_bytes = [bytes.get_u8(), bytes.get_u8()]; - let version = GeneralPurposeBits::from(version_bytes); - // CRC already consumed - - // Extract job_id and subcore_id from result_header - // job_id is a 4-bit field (0-15) at bits 7-4 of result_header - let job_id = (result_header >> 4) & 0x0f; - let subcore_id = result_header & 0x0f; - - Ok(Response::Nonce { - nonce, - job_id, - midstate_num, - version, - subcore_id, - }) - } - None => Err(ProtocolError::InvalidResponseType(type_repr)), - } - } -} - -#[derive(Default)] -pub struct FrameCodec; - -impl Encoder for FrameCodec { - type Error = io::Error; - - fn encode(&mut self, command: RegisterCommand, dst: &mut BytesMut) -> Result<(), Self::Error> { - const PREAMBLE: [u8; 2] = [0x55, 0xaa]; - dst.put_slice(&PREAMBLE); - - let start_pos = dst.len(); - command.encode(dst); - let crc = crc5(&dst[start_pos..]); - dst.put_u8(crc); - - trace!( - cmd = ?command, - bytes = dst.len(), - frame = %HexBytes(dst.as_ref()), - "TX BM13xx" - ); - - Ok(()) - } -} - -impl Encoder for FrameCodec { - type Error = io::Error; - - fn encode(&mut self, command: JobCommand, dst: &mut BytesMut) -> Result<(), Self::Error> { - const PREAMBLE: [u8; 2] = [0x55, 0xaa]; - dst.put_slice(&PREAMBLE); - - let start_pos = dst.len(); - command.encode(dst); - let crc = crc16(&dst[start_pos..]); - dst.put_slice(&crc.to_be_bytes()); - - trace!( - cmd = ?command, - bytes = dst.len(), - frame = %HexBytes(dst.as_ref()), - "TX BM13xx" - ); - - Ok(()) - } -} - -impl Decoder for FrameCodec { - type Item = Response; - type Error = io::Error; - - fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { - // Return Ok(Item) with a valid frame, or Ok(None) if to be called again, potentially with - // more data. Returning an Error causes the stream to be terminated, so don't do that. - // - // There are three cases: - // - // 1. More data needed - // 2. Invalid frame - // 3. Valid frame - // - // In the case of an invalid frame, consume the first byte and request another call by - // returning Ok(None). In the case of a valid frame, consume that frame's worth of bytes. - - const PREAMBLE: [u8; 2] = [0xaa, 0x55]; - // All BM13xx responses are 11 bytes (2 preamble + 9 data) - const FRAME_LEN: usize = PREAMBLE.len() + 9; - const CALL_AGAIN: Result, io::Error> = Ok(None); - - if src.len() < FRAME_LEN { - return CALL_AGAIN; - } - - // Check preamble without consuming the buffer - if src[0] != PREAMBLE[0] { - src.advance(1); - return CALL_AGAIN; - } - - if src[1] != PREAMBLE[1] { - src.advance(1); - return CALL_AGAIN; - } - - // Validate CRC5 over the entire frame (excluding preamble) - // CRC5 is computed over the 9 data bytes after the preamble - if !crc5_is_valid(&src[2..FRAME_LEN]) { - trace!( - "Frame sync lost: CRC5 failed for potential frame at position 0. Searching for next frame..." - ); - src.advance(1); - return CALL_AGAIN; - } - - // We have a valid frame with correct CRC - // Save the frame bytes before consuming - let frame_bytes = src[..FRAME_LEN].to_vec(); - - // Create a buffer for decoding - let mut decode_buf = BytesMut::from(&src[..FRAME_LEN]); - decode_buf.advance(2); // Skip preamble for Response::decode - - match Response::decode(&mut decode_buf) { - Ok(response) => { - // Only advance if decode was successful - src.advance(FRAME_LEN); - - // Log the received frame for debugging - trace!( - resp = ?response, - bytes = FRAME_LEN, - frame = %HexBytes(&frame_bytes), - "RX BM13xx" - ); - Ok(Some(response)) - } - Err(err) => { - warn!("Failed to decode response: {}", err); - // Advance by 1 to try to find next valid frame - src.advance(1); - CALL_AGAIN - } - } - } -} - -#[cfg(test)] -mod init_tests { - use super::*; - - #[test] - fn multi_chip_init_sequence() { - let protocol = BM13xxProtocol::new(); - let commands = protocol.multi_chip_init(65); // S21 Pro has 65 chips - - // Verify the sequence starts with version rolling enable - assert!(matches!( - &commands[0], - RegisterCommand::WriteRegister(WriteRegister { - destination: Destination::Broadcast, - register: Register::VersionMask(_), - }) - )); - - // Verify chain inactive command - let chain_inactive_pos = commands - .iter() - .position(|c| matches!(c, RegisterCommand::ChainInactive(ChainInactive))) - .expect("ChainInactive command not found in initialization sequence"); - assert!(chain_inactive_pos > 0); - - // Verify chip addressing starts after chain inactive - let first_address_pos = chain_inactive_pos + 1; - assert!(matches!( - &commands[first_address_pos], - RegisterCommand::SetChipAddress(SetChipAddress { chip_address: 0x00 }) - )); - - // Verify we have 65 address assignments - let address_commands: Vec<_> = commands[first_address_pos..first_address_pos + 65] - .iter() - .collect(); - assert_eq!(address_commands.len(), 65); - - // Verify addresses increment by 2 - for (i, cmd) in address_commands.iter().enumerate() { - match cmd { - RegisterCommand::SetChipAddress(SetChipAddress { chip_address }) => { - assert_eq!(*chip_address, (i * 2) as u8); - } - _ => panic!("Expected SetChipAddress command, got {:?}", cmd), - } - } - } - - #[test] - fn domain_configuration() { - let protocol = BM13xxProtocol::new(); - let commands = protocol.configure_domains(65, 5); // 65 chips, 5 per domain - - // Should have 13 domains - let io_strength_commands: Vec<_> = commands - .iter() - .filter(|c| { - matches!( - c, - RegisterCommand::WriteRegister(WriteRegister { - register: Register::IoDriverStrength { .. }, - .. - }) - ) - }) - .collect(); - assert_eq!(io_strength_commands.len(), 13); - - // Check first domain boundary (chip 8 = address 0x08) - let first_boundary = io_strength_commands[0]; - if let RegisterCommand::WriteRegister(WriteRegister { - destination: Destination::Chip(chip_address), - register: Register::IoDriverStrength(strength), - }) = first_boundary - { - assert_eq!(*chip_address, 0x08); // 5th chip (index 4) * 2 - let mut buf = BytesMut::new(); - strength.encode(&mut buf); - // Expected bytes from hardware capture - assert_eq!(&buf[..], &[0x00, 0xf1, 0x11, 0x11]); - } - } - - #[test] - fn nonce_range_configuration() { - let protocol = BM13xxProtocol::new(); - - // Test single chip - full range - let commands = protocol.configure_nonce_ranges(1); - assert_eq!(commands.len(), 1); - if let RegisterCommand::WriteRegister(WriteRegister { - register: Register::NonceRange(config), - destination: Destination::Broadcast, - }) = &commands[0] - { - let mut buf = BytesMut::new(); - config.encode(&mut buf); - assert_eq!(&buf[..], &[0xff, 0xff, 0xff, 0xff]); - } - - // Test S21 Pro configuration (65 chips) - let commands = protocol.configure_nonce_ranges(65); - assert_eq!(commands.len(), 1); - if let RegisterCommand::WriteRegister(WriteRegister { - register: Register::NonceRange(config), - .. - }) = &commands[0] - { - let mut buf = BytesMut::new(); - config.encode(&mut buf); - assert_eq!(&buf[..], &[0x00, 0x00, 0x1e, 0xb5]); - } - - // Test small chain - let commands = protocol.configure_nonce_ranges(8); - if let RegisterCommand::WriteRegister(WriteRegister { - register: Register::NonceRange(config), - .. - }) = &commands[0] - { - let mut buf = BytesMut::new(); - config.encode(&mut buf); - assert_eq!(&buf[..], &[0xff, 0xff, 0xff, 0x1f]); - } - } - - #[test] - fn multi_chip_init_includes_nonce_range() { - let protocol = BM13xxProtocol::new(); - let commands = protocol.multi_chip_init(65); - - // Find the nonce range configuration - let nonce_range_cmd = commands.iter().find(|c| { - matches!( - c, - RegisterCommand::WriteRegister(WriteRegister { - register: Register::NonceRange { .. }, - .. - }) - ) - }); - - assert!(nonce_range_cmd.is_some()); - - if let Some(RegisterCommand::WriteRegister(WriteRegister { - register: Register::NonceRange(config), - .. - })) = nonce_range_cmd - { - let mut buf = BytesMut::new(); - config.encode(&mut buf); - assert_eq!(&buf[..], &[0x00, 0x00, 0x1e, 0xb5]); // S21 Pro value - } - } -} - -#[cfg(test)] -mod command_tests { - use super::*; - use crate::asic::bm13xx::test_data::s19jpro_job; - use bitcoin::block::Version; - use bitcoin::hash_types::TxMerkleNode; - use bitcoin::hashes::Hash; - use bitcoin::{BlockHash, CompactTarget}; - - #[test] - fn read_register() { - assert_frame_eq( - RegisterCommand::ReadRegister(ReadRegister { - destination: Destination::Broadcast, - register_address: RegisterAddress::ChipId, - }), - &[0x55, 0xaa, 0x52, 0x05, 0x00, 0x00, 0x0a], - ); - } - - #[test] - fn write_register_chip_address() { - assert_frame_eq( - RegisterCommand::WriteRegister(WriteRegister { - destination: Destination::Chip(0x01), - register: Register::ChipId(ChipId { - model: ChipModel::BM1370, - core_count: 0x00, - address: 0x01, - }), - }), - &[ - 0x55, 0xaa, 0x41, 0x09, 0x01, 0x00, 0x13, 0x70, 0x00, 0x01, 0x0a, - ], - ); - } - - // Tests from actual captures - #[test] - fn write_version_mask_from_capture() { - // From S21 Pro capture: TX: 55 AA 51 09 00 A4 90 00 FF FF 1C - assert_frame_eq( - RegisterCommand::WriteRegister(WriteRegister { - destination: Destination::Broadcast, // 0x51 = broadcast - register: Register::VersionMask(VersionMask::full_rolling()), - }), - &[ - 0x55, 0xaa, 0x51, 0x09, 0x00, 0xa4, 0x90, 0x00, 0xff, 0xff, 0x1c, - ], - ); - } - - #[test] - fn write_init_control_from_capture() { - // From Bitaxe capture: TX: 55 AA 51 09 00 A8 00 07 00 00 03 - // Value 0x00 07 00 00 in little-endian = 0x00000700 - assert_frame_eq( - RegisterCommand::WriteRegister(WriteRegister { - destination: Destination::Broadcast, - register: Register::InitControl(InitControl(0x00000700)), - }), - &[ - 0x55, 0xaa, 0x51, 0x09, 0x00, 0xa8, 0x00, 0x07, 0x00, 0x00, 0x03, - ], - ); - } - - #[test] - fn write_misc_control_from_capture() { - // From Bitaxe capture: TX: 55 AA 51 09 00 18 F0 00 C1 00 04 - assert_frame_eq( - RegisterCommand::WriteRegister(WriteRegister { - destination: Destination::Broadcast, - register: Register::MiscControl(MiscControl(0x00C100F0)), - }), - &[ - 0x55, 0xaa, 0x51, 0x09, 0x00, 0x18, 0xf0, 0x00, 0xc1, 0x00, 0x04, - ], - ); - } - - #[test] - fn chain_inactive_from_capture() { - // From S21 Pro capture: TX: 55 AA 53 05 00 00 03 - assert_frame_eq( - RegisterCommand::ChainInactive(ChainInactive), - &[0x55, 0xaa, 0x53, 0x05, 0x00, 0x00, 0x03], - ); - } - - #[test] - fn set_chip_address_from_capture() { - // From S21 Pro capture: TX: 55 AA 40 05 04 00 03 (assign address 0x04) - assert_frame_eq( - RegisterCommand::SetChipAddress(SetChipAddress { chip_address: 0x04 }), - &[0x55, 0xaa, 0x40, 0x05, 0x04, 0x00, 0x03], - ); - } - - #[test] - fn write_core_register_sequence() { - // From Bitaxe capture: TX: 55 AA 51 09 00 3C 80 00 8B 00 12 - // Core register uses big-endian encoding - assert_frame_eq( - RegisterCommand::WriteRegister(WriteRegister { - destination: Destination::Broadcast, - // Big-endian: produces bytes 80 00 8B 00 - register: Register::Core(Core(0x80008B00)), - }), - &[ - 0x55, 0xaa, 0x51, 0x09, 0x00, 0x3c, 0x80, 0x00, 0x8b, 0x00, 0x12, - ], - ); - } - - #[test] - fn write_ticket_mask_from_capture() { - // From S21 Pro capture: TX: 55 AA 51 09 00 14 00 00 00 FF 08 - // Difficulty 256 = 8 zero_bits - let log2_diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); - assert_frame_eq( - RegisterCommand::WriteRegister(WriteRegister { - destination: Destination::Broadcast, - register: Register::TicketMask(TicketMask::new(log2_diff)), - }), - &[ - 0x55, 0xaa, 0x51, 0x09, 0x00, 0x14, 0x00, 0x00, 0x00, 0xff, 0x08, - ], - ); - } - - #[test] - fn write_nonce_range_from_capture() { - // From S21 Pro capture: TX: 55 AA 51 09 00 10 00 00 1E B5 0F - assert_frame_eq( - RegisterCommand::WriteRegister(WriteRegister { - destination: Destination::Broadcast, - register: Register::NonceRange(NonceRange::multi_chip(65)), - }), - &[ - 0x55, 0xaa, 0x51, 0x09, 0x00, 0x10, 0x00, 0x00, 0x1e, 0xb5, 0x0f, - ], - ); - } - - #[test] - fn job_full_format_encoding() { - use bitcoin::CompactTarget; - - // Test BM1370 job packet encoding with patterns that verify word-swapping - // Use sequential bytes so we can verify word reversal - // Internal format: [w0, w1, w2, w3, w4, w5, w6, w7] (each word is 4 bytes) - // Wire format: [w7, w6, w5, w4, w3, w2, w1, w0] - let merkle_internal = [ - 0x00, 0x01, 0x02, 0x03, // word 0 - 0x04, 0x05, 0x06, 0x07, // word 1 - 0x08, 0x09, 0x0a, 0x0b, // word 2 - 0x0c, 0x0d, 0x0e, 0x0f, // word 3 - 0x10, 0x11, 0x12, 0x13, // word 4 - 0x14, 0x15, 0x16, 0x17, // word 5 - 0x18, 0x19, 0x1a, 0x1b, // word 6 - 0x1c, 0x1d, 0x1e, 0x1f, // word 7 - ]; - let prev_hash_internal = [ - 0x20, 0x21, 0x22, 0x23, // word 0 - 0x24, 0x25, 0x26, 0x27, // word 1 - 0x28, 0x29, 0x2a, 0x2b, // word 2 - 0x2c, 0x2d, 0x2e, 0x2f, // word 3 - 0x30, 0x31, 0x32, 0x33, // word 4 - 0x34, 0x35, 0x36, 0x37, // word 5 - 0x38, 0x39, 0x3a, 0x3b, // word 6 - 0x3c, 0x3d, 0x3e, 0x3f, // word 7 - ]; - - let job = JobFullFormat { - job_id: 0x00, - num_midstates: 0x01, - starting_nonce: 0x00000000, - nbits: CompactTarget::from_consensus(0x6ad60e17), - ntime: 0x208c7366, - merkle_root: bitcoin::hash_types::TxMerkleNode::from_byte_array(merkle_internal), - prev_block_hash: bitcoin::BlockHash::from_byte_array(prev_hash_internal), - version: bitcoin::block::Version::from_consensus(0x20000000), - }; - - let mut codec = FrameCodec; - let mut frame = BytesMut::new(); - codec - .encode(JobCommand::JobFull(job.clone()), &mut frame) - .expect("Failed to encode job command"); - - // Verify packet structure - assert_eq!(&frame[0..2], &[0x55, 0xaa]); // Preamble - assert_eq!(frame[2], 0x21); // TYPE_JOB | GROUP_SINGLE | CMD_WRITE - assert_eq!(frame[3], 54); // Length byte per factory captures (not a byte count) - assert_eq!(frame[4], job.job_id); - assert_eq!(frame[5], job.num_midstates); - assert_eq!(&frame[6..10], &job.starting_nonce.to_le_bytes()); - assert_eq!(&frame[10..14], &job.nbits.to_consensus().to_le_bytes()); - assert_eq!(&frame[14..18], &job.ntime.to_le_bytes()); - - // Verify merkle_root word-swapping: wire should have word 7 first, then 6, etc. - let expected_merkle_wire = [ - 0x1c, 0x1d, 0x1e, 0x1f, // word 7 (was last) - 0x18, 0x19, 0x1a, 0x1b, // word 6 - 0x14, 0x15, 0x16, 0x17, // word 5 - 0x10, 0x11, 0x12, 0x13, // word 4 - 0x0c, 0x0d, 0x0e, 0x0f, // word 3 - 0x08, 0x09, 0x0a, 0x0b, // word 2 - 0x04, 0x05, 0x06, 0x07, // word 1 - 0x00, 0x01, 0x02, 0x03, // word 0 (was first) - ]; - assert_eq!(&frame[18..50], &expected_merkle_wire); - - // Verify prev_block_hash word-swapping - let expected_prev_hash_wire = [ - 0x3c, 0x3d, 0x3e, 0x3f, // word 7 (was last) - 0x38, 0x39, 0x3a, 0x3b, // word 6 - 0x34, 0x35, 0x36, 0x37, // word 5 - 0x30, 0x31, 0x32, 0x33, // word 4 - 0x2c, 0x2d, 0x2e, 0x2f, // word 3 - 0x28, 0x29, 0x2a, 0x2b, // word 2 - 0x24, 0x25, 0x26, 0x27, // word 1 - 0x20, 0x21, 0x22, 0x23, // word 0 (was first) - ]; - assert_eq!(&frame[50..82], &expected_prev_hash_wire); - - assert_eq!(&frame[82..86], &job.version.to_consensus().to_le_bytes()); - - // Verify CRC16 (big-endian) - assert_eq!(frame.len(), 88); - let crc_bytes = &frame[86..88]; - let calculated_crc = crc16(&frame[2..86]); - let frame_crc = u16::from_be_bytes([crc_bytes[0], crc_bytes[1]]); - assert_eq!(calculated_crc, frame_crc); - } - - #[test] - fn job_full_matches_esp_miner_capture() { - use crate::asic::bm13xx::test_data::esp_miner_job; - - // Build JobFullFormat from high-level Bitcoin types - // Verify encoding produces exact wire bytes from hardware capture - let job = JobFullFormat { - job_id: *esp_miner_job::wire_tx::JOB_ID, - num_midstates: esp_miner_job::wire_tx::NUM_MIDSTATES_BYTE[0], - starting_nonce: u32::from_le_bytes( - (*esp_miner_job::wire_tx::STARTING_NONCE_BYTES) - .try_into() - .unwrap(), - ), - nbits: *esp_miner_job::wire_tx::NBITS, - ntime: *esp_miner_job::wire_tx::NTIME, - merkle_root: *esp_miner_job::wire_tx::MERKLE_ROOT, - prev_block_hash: *esp_miner_job::wire_tx::PREV_BLOCKHASH, - version: *esp_miner_job::wire_tx::VERSION, - }; - - let mut codec = FrameCodec; - let mut frame = BytesMut::new(); - codec - .encode(JobCommand::JobFull(job.clone()), &mut frame) - .expect("Failed to encode job command"); - - // Our body bytes match esp-miner's wire capture. Byte 3 (length - // byte) and bytes 86..88 (CRC16) intentionally differ; see the - // length-byte comment in JobFullFormat::encode. - assert_eq!(&frame[4..86], &esp_miner_job::wire_tx::FRAME[4..86]); - } - - #[test] - fn job_full_matches_s19jpro_factory_capture() { - let job = job_full_from_wire(&s19jpro_job::wire_tx::FRAME); - - let mut codec = FrameCodec; - let mut frame = BytesMut::new(); - codec - .encode(JobCommand::JobFull(job), &mut frame) - .expect("Failed to encode job command"); - - // Byte-for-byte match, length byte and CRC16 included: the - // encoder reproduces factory firmware exactly. - assert_eq!(&frame[..], &s19jpro_job::wire_tx::FRAME[..]); - } - - /// Builds a JobFullFormat from a captured JobFull wire frame. - fn job_full_from_wire(frame: &[u8; 88]) -> JobFullFormat { - // The wire sends each 32-byte hash as eight 4-byte words, most - // significant word first; internal order reverses the words. - fn hash_from_wire(wire: &[u8]) -> [u8; 32] { - let mut internal = [0u8; 32]; - for i in 0..8 { - internal[(7 - i) * 4..(8 - i) * 4].copy_from_slice(&wire[i * 4..(i + 1) * 4]); - } - internal - } - - JobFullFormat { - job_id: frame[4] >> 3, - num_midstates: frame[5], - starting_nonce: u32::from_le_bytes(frame[6..10].try_into().unwrap()), - nbits: CompactTarget::from_consensus(u32::from_le_bytes( - frame[10..14].try_into().unwrap(), - )), - ntime: u32::from_le_bytes(frame[14..18].try_into().unwrap()), - merkle_root: TxMerkleNode::from_byte_array(hash_from_wire(&frame[18..50])), - prev_block_hash: BlockHash::from_byte_array(hash_from_wire(&frame[50..82])), - version: Version::from_consensus( - u32::from_le_bytes(frame[82..86].try_into().unwrap()) as i32 - ), - } - } - - fn assert_frame_eq(cmd: C, expect: &[u8]) - where - FrameCodec: Encoder, - { - let mut codec = FrameCodec; - let mut frame = BytesMut::new(); - codec - .encode(cmd, &mut frame) - .expect("Failed to encode command for test"); - - assert_eq!( - &frame[..], - expect, - "\nFrame mismatch!\nExpected: {}\nActual: {}", - as_hex(expect), - as_hex(&frame[..]) - ); - } - - fn as_hex(bytes: &[u8]) -> String { - bytes - .iter() - .map(|b| format!("{:02x}", b)) - .collect::>() - .join(" ") - } -} - -#[cfg(test)] -mod response_tests { - use super::*; - use bytes::BufMut; - - #[test] - fn verify_crc_calculation() { - // Test that our known good frame has valid CRC - let frame = &[0x13, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10]; // without preamble - assert!( - crc5_is_valid(frame), - "Known good frame should have valid CRC" - ); - } - - #[test] - fn decoder_with_exact_frame_size() { - let mut codec = FrameCodec; - - // Exactly 11 bytes - a complete frame - let mut buf = BytesMut::new(); - buf.put_slice(&[ - 0xaa, 0x55, 0x13, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, - ]); - - let result = codec.decode(&mut buf).unwrap(); - assert!( - result.is_some(), - "Should decode frame when buffer has exactly 11 bytes" - ); - } - - #[test] - fn read_register() { - // 11-byte register read response from captures - let wire = &[ - 0xaa, 0x55, 0x13, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, - ]; - let response = decode_frame(wire).expect("decode_frame should return Some for valid frame"); - - let Response::ReadRegister { - chip_address, - register, - } = response - else { - panic!("Expected ReadRegister response, got {:?}", response); - }; - - assert_eq!(chip_address, 0x00); - - let Register::ChipId(ChipId { - model, - core_count, - address, - }) = register - else { - panic!("Expected ChipId register, got {:?}", register); - }; - - assert_eq!(model, ChipModel::BM1370); - assert_eq!(core_count, 0x00); - assert_eq!(address, 0x00); - } - - fn decode_frame(frame: &[u8]) -> Option { - let mut buf = BytesMut::from(frame); - let mut codec = FrameCodec; - codec.decode(&mut buf).expect("Failed to decode frame") - } - - #[test] - fn decode_nonce_response_from_capture() { - // From Bitaxe capture: RX: AA 55 18 00 A6 40 02 99 22 F9 91 - let wire = &[ - 0xaa, 0x55, 0x18, 0x00, 0xa6, 0x40, 0x02, 0x99, 0x22, 0xf9, 0x91, - ]; - let response = decode_frame(wire).expect("decode_frame should return Some for valid frame"); - - let Response::Nonce { - nonce, - job_id, - midstate_num, - version, - subcore_id, - } = response - else { - panic!("Expected nonce response"); - }; - - // From protocol doc: nonce 0x40A60018 -> Main core 32, nonce value 0x00A60018 - assert_eq!(nonce, 0x40a60018); - assert_eq!(midstate_num, 0x02); - - // Result header: 0x99 -> bits[7:4]=9 (job_id), bits[3:0]=9 (subcore_id) - assert_eq!(job_id, 9); - assert_eq!(subcore_id, 9); - - // Version - assert_eq!(version, GeneralPurposeBits::new([0x22, 0xF9])); - - // Verify main core extraction - let main_core = (nonce >> 25) & 0x7f; - assert_eq!(main_core, 32); - } - - #[test] - fn decode_multiple_nonce_responses() { - // Additional nonce responses from S21 Pro capture - let test_cases = vec![ - // RX: AA 55 07 35 CD CF 02 5E 00 2E 96 - // result_header=0x5e: bits[7:4]=5, bits[3:0]=14 - // version bytes [0x00, 0x2E] big-endian = 0x002E - ( - &[ - 0xaa, 0x55, 0x07, 0x35, 0xcd, 0xcf, 0x02, 0x5e, 0x00, 0x2e, 0x96, - ], - 0xcfcd3507, - 0x02, - 5, - 14, - GeneralPurposeBits::new([0x00, 0x2E]), - ), - // RX: AA 55 46 03 32 E7 00 C3 2C 83 99 - // result_header=0xc3: bits[7:4]=12, bits[3:0]=3 - // version bytes [0x2C, 0x83] big-endian = 0x2C83 - ( - &[ - 0xaa, 0x55, 0x46, 0x03, 0x32, 0xe7, 0x00, 0xc3, 0x2c, 0x83, 0x99, - ], - 0xe7320346, - 0x00, - 12, - 3, - GeneralPurposeBits::new([0x2C, 0x83]), - ), - ]; - - for (wire, exp_nonce, exp_midstate, exp_job_id, exp_subcore, exp_version) in test_cases { - let response = - decode_frame(wire).expect("decode_frame should return Some for valid frame"); - - let Response::Nonce { - nonce, - job_id, - midstate_num, - version, - subcore_id, - } = response - else { - panic!("Expected nonce response"); - }; - - assert_eq!(nonce, exp_nonce); - assert_eq!(midstate_num, exp_midstate); - assert_eq!(job_id, exp_job_id); - assert_eq!(subcore_id, exp_subcore); - assert_eq!(version, exp_version); - } - } - - #[test] - fn decoder_handles_partial_frames() { - let mut codec = FrameCodec; - - // Test with incomplete frame (less than 11 bytes) - let mut buf = BytesMut::new(); - buf.put_slice(&[0xaa, 0x55, 0x13, 0x70, 0x00]); // Only 5 bytes - - let result = codec.decode(&mut buf).unwrap(); - assert!(result.is_none(), "Should return None for incomplete frame"); - assert_eq!(buf.len(), 5, "Buffer should not be consumed"); - - // Add more bytes to complete the frame - buf.put_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x10]); // Complete to 11 bytes - - let result = codec.decode(&mut buf).unwrap(); - assert!(result.is_some(), "Should decode complete frame"); - assert_eq!(buf.len(), 0, "Buffer should be fully consumed"); - } - - #[test] - fn decoder_handles_corrupted_crc() { - let mut codec = FrameCodec; - - // Valid frame with corrupted CRC (last byte) - let mut buf = BytesMut::new(); - buf.put_slice(&[ - 0xaa, 0x55, 0x13, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, - ]); // Bad CRC - - let result = codec.decode(&mut buf).unwrap(); - assert!(result.is_none(), "Should reject frame with bad CRC"); - assert_eq!( - buf.len(), - 10, - "Should consume 1 byte when searching for valid frame" - ); - } - - #[test] - fn decoder_finds_frame_after_garbage() { - let mut codec = FrameCodec; - - // Garbage bytes followed by valid frame - let mut buf = BytesMut::new(); - buf.put_slice(&[0xFF, 0xEE, 0xDD]); // Garbage - buf.put_slice(&[ - 0xaa, 0x55, 0x13, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, - ]); // Valid frame - - // First calls should skip garbage - assert!(codec.decode(&mut buf).unwrap().is_none()); - assert!(codec.decode(&mut buf).unwrap().is_none()); - assert!(codec.decode(&mut buf).unwrap().is_none()); - - // Should find valid frame - let result = codec.decode(&mut buf).unwrap(); - assert!(result.is_some(), "Should find valid frame after garbage"); - assert_eq!(buf.len(), 0, "All data should be consumed"); - } - - #[test] - fn decoder_handles_false_start() { - let mut codec = FrameCodec; - - // Frame that starts with 0xAA but not followed by 0x55 - let mut buf = BytesMut::new(); - buf.put_slice(&[0xaa, 0x00]); // False start - buf.put_slice(&[ - 0xaa, 0x55, 0x13, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, - ]); // Valid frame - - // Total buffer: [AA, 00, AA, 55, 13, 70, 00, 00, 00, 00, 00, 00, 10] = 13 bytes - assert_eq!(buf.len(), 13, "Initial buffer should have 13 bytes"); - - // First decode: sees AA at pos 0, but 00 at pos 1, so should skip 1 byte - let first = codec.decode(&mut buf).unwrap(); - assert!(first.is_none(), "First decode should return None"); - assert_eq!(buf.len(), 12, "Should have consumed 1 byte"); - - // Buffer now: [00, AA, 55, 13, 70, 00, 00, 00, 00, 00, 00, 10] = 12 bytes - // Second decode: sees 00 at pos 0, should skip 1 byte - let second = codec.decode(&mut buf).unwrap(); - assert!(second.is_none(), "Second decode should return None"); - assert_eq!(buf.len(), 11, "Should have consumed another byte"); - - // Buffer now: [AA, 55, 13, 70, 00, 00, 00, 00, 00, 00, 10] = 11 bytes = valid frame - // Third decode should succeed - let result = codec.decode(&mut buf); - match result { - Ok(Some(Response::ReadRegister { .. })) => {} // Success - Ok(Some(other)) => panic!("Expected ReadRegister, got {:?}", other), - Ok(None) => panic!( - "Expected Some, got None. Buffer len: {}, contents: {:02x?}", - buf.len(), - &buf[..] - ), - Err(e) => panic!("Decode error: {}", e), - } - } - - #[test] - fn decoder_handles_back_to_back_frames() { - let mut codec = FrameCodec; - - // Two valid frames back-to-back - let mut buf = BytesMut::new(); - // First frame: register read - buf.put_slice(&[ - 0xaa, 0x55, 0x13, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, - ]); - // Second frame: nonce response - buf.put_slice(&[ - 0xaa, 0x55, 0x18, 0x00, 0xa6, 0x40, 0x02, 0x99, 0x22, 0xf9, 0x91, - ]); - - // Decode first frame - let result1 = codec.decode(&mut buf).unwrap(); - assert!(matches!(result1, Some(Response::ReadRegister { .. }))); - assert_eq!(buf.len(), 11, "Should have second frame remaining"); - - // Decode second frame - let result2 = codec.decode(&mut buf).unwrap(); - assert!(matches!(result2, Some(Response::Nonce { .. }))); - assert_eq!(buf.len(), 0, "Buffer should be empty"); - } - - #[test] - fn decoder_handles_real_s21_pro_frames() { - let mut codec = FrameCodec; - - // Real frames from S21 Pro capture - let frames = vec![ - [ - 0xaa, 0x55, 0x07, 0x35, 0xcd, 0xcf, 0x02, 0x5e, 0x00, 0x2e, 0x96, - ], - [ - 0xaa, 0x55, 0x7b, 0x8d, 0x81, 0x60, 0x02, 0x55, 0x00, 0x85, 0x81, - ], - [ - 0xaa, 0x55, 0x32, 0x2a, 0x84, 0x5a, 0x02, 0x52, 0x01, 0xb2, 0x8c, - ], - ]; - - for frame in frames { - let mut buf = BytesMut::new(); - buf.put_slice(&frame); - - let result = codec.decode(&mut buf).unwrap(); - assert!(result.is_some(), "Should decode real S21 Pro frame"); - assert!( - matches!(result, Some(Response::Nonce { .. })), - "Should be nonce response" - ); - } - } - - #[test] - fn decoder_handles_stream_with_lost_bytes() { - let mut codec = FrameCodec; - - // Simulate a stream where some bytes in the middle are lost - let mut buf = BytesMut::new(); - // Start of first frame - buf.put_slice(&[0xaa, 0x55, 0x13, 0x70, 0x00]); // 5 bytes - // Lost bytes... skip to middle of nowhere - buf.put_slice(&[0x99, 0x22, 0xf9]); // Random bytes - // Valid complete frame - buf.put_slice(&[ - 0xaa, 0x55, 0x18, 0x00, 0xa6, 0x40, 0x02, 0x99, 0x22, 0xf9, 0x91, - ]); - - // Decoder should skip the incomplete/corrupted data and find the valid frame - let mut found_valid = false; - for _ in 0..20 { - // Try up to 20 times - if let Some(response) = codec.decode(&mut buf).unwrap() { - assert!(matches!(response, Response::Nonce { .. })); - found_valid = true; - break; - } - } - assert!(found_valid, "Should eventually find the valid frame"); - } - - #[test] - fn decoder_handles_mid_frame_start() { - let mut codec = FrameCodec; - - // Start reading in the middle of a frame - let mut buf = BytesMut::new(); - // Last 5 bytes of some frame - buf.put_slice(&[0x02, 0x99, 0x22, 0xf9, 0x91]); - // Valid complete frame - buf.put_slice(&[ - 0xaa, 0x55, 0x50, 0x03, 0x41, 0xd6, 0x00, 0x81, 0x18, 0x01, 0x9b, - ]); - - // Total: 5 + 11 = 16 bytes - // Should skip the partial frame bytes one by one until finding the valid frame - for i in 0..5 { - let result = codec.decode(&mut buf).unwrap(); - assert!(result.is_none(), "Decode {} should return None", i + 1); - assert_eq!( - buf.len(), - 16 - i - 1, - "Should have consumed {} bytes", - i + 1 - ); - } - - // Now we should have the valid frame - let result = codec.decode(&mut buf).unwrap(); - assert!( - result.is_some(), - "Should find valid frame after partial data" - ); - assert!( - matches!(result, Some(Response::Nonce { .. })), - "Should be nonce response" - ); - } - - #[test] - fn decoder_validates_real_register_responses() { - // Test all register read responses are handled correctly - let mut codec = FrameCodec; - - // Standard chip detection response - let mut buf = BytesMut::new(); - buf.put_slice(&[ - 0xaa, 0x55, 0x13, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, - ]); - - let response = codec.decode(&mut buf).unwrap().unwrap(); - match response { - Response::ReadRegister { - chip_address, - register, - } => { - assert_eq!(chip_address, 0x00); - assert!(matches!(register, Register::ChipId { .. })); - } - _ => panic!("Expected ReadRegister response"), - } - } - - #[test] - fn decode_nonce_response_from_esp_miner_capture() { - use crate::asic::bm13xx::test_data::esp_miner_job; - - // Decode nonce response from hardware capture and verify against test data - let response = - decode_frame(&esp_miner_job::wire_rx::FRAME).expect("Should decode valid frame"); - - let Response::Nonce { - nonce, - job_id, - midstate_num, - version, - subcore_id, - } = response - else { - panic!("Expected nonce response"); - }; - - // Verify all fields match test data - assert_eq!(nonce, *esp_miner_job::wire_rx::NONCE); - assert_eq!(midstate_num, *esp_miner_job::wire_rx::MIDSTATE_NUM); - assert_eq!(job_id, *esp_miner_job::wire_rx::JOB_ID); - assert_eq!(subcore_id, *esp_miner_job::wire_rx::SUBCORE_ID); - // VERSION_ROLLING_FIELD is u16, convert to big-endian bytes - let expected_bytes = esp_miner_job::wire_rx::VERSION_ROLLING_FIELD.to_be_bytes(); - assert_eq!(version, GeneralPurposeBits::new(expected_bytes)); - - // Verify version rolling field shifted left 13 matches submit VERSION - let bits_as_u16 = u16::from_be_bytes(*version.as_bytes()); - let version_shifted = (bits_as_u16 as u32) << 13; - assert_eq!( - version_shifted, - *esp_miner_job::submit::VERSION, - "Version rolling field << 13 should match mining.submit version" - ); - } - - #[test] - fn test_full_mining_round_trip() { - use crate::asic::bm13xx::test_data::esp_miner_job; - use crate::types::Difficulty; - use bitcoin::block::Header as BlockHeader; - - // Build JobFullFormat, encode to wire, decode nonce response, - // apply version rolling, compute hash, and verify difficulty. - let job = JobFullFormat { - job_id: *esp_miner_job::wire_tx::JOB_ID, - num_midstates: esp_miner_job::wire_tx::NUM_MIDSTATES_BYTE[0], - starting_nonce: u32::from_le_bytes( - (*esp_miner_job::wire_tx::STARTING_NONCE_BYTES) - .try_into() - .unwrap(), - ), - nbits: *esp_miner_job::notify::NBITS, - ntime: *esp_miner_job::notify::NTIME, - merkle_root: *esp_miner_job::notify::MERKLE_ROOT, - prev_block_hash: *esp_miner_job::notify::PREV_BLOCKHASH, - version: *esp_miner_job::notify::VERSION, - }; - - let mut codec = FrameCodec; - let mut tx_frame = BytesMut::new(); - codec - .encode(JobCommand::JobFull(job.clone()), &mut tx_frame) - .expect("Should encode JobFull command"); - - // Our body bytes match esp-miner's wire capture. Byte 3 (length - // byte) and bytes 86..88 (CRC16) intentionally differ; see the - // length-byte comment in JobFullFormat::encode. - assert_eq!(&tx_frame[4..86], &esp_miner_job::wire_tx::FRAME[4..86]); - - let rx_response = - decode_frame(&esp_miner_job::wire_rx::FRAME).expect("Should decode RX frame"); - - let Response::Nonce { - nonce, - job_id: rx_job_id, - version: version_rolling, - .. - } = rx_response - else { - panic!("Expected Nonce response"); - }; - - assert_eq!(rx_job_id, job.job_id, "Job ID should round-trip"); - - let full_version = version_rolling.apply_to_version(job.version); - let header = BlockHeader { - version: full_version, - prev_blockhash: job.prev_block_hash, - merkle_root: job.merkle_root, - time: job.ntime, - bits: job.nbits, - nonce, - }; - - let hash = header.block_hash(); - let difficulty = Difficulty::from_hash(&hash); - - // Allow +/-1 tolerance for integer division rounding - let expected = Difficulty::from(esp_miner_job::EXPECTED_HASH_DIFFICULTY as u64); - assert!( - difficulty >= Difficulty::from(expected.as_u64() - 1) - && difficulty <= Difficulty::from(expected.as_u64() + 1), - "Hash difficulty should match esp-miner result" - ); - assert!( - difficulty >= Difficulty::from(esp_miner_job::POOL_SHARE_DIFFICULTY_INT), - "Hash should meet pool difficulty" - ); - } -} - -#[cfg(test)] -mod log2_difficulty_tests { - use super::*; - use crate::types::Difficulty; - - #[test] - fn power_of_two_difficulty_exact() { - let diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); - assert_eq!(diff.exponent(), 8); - } - - #[test] - fn non_power_of_two_floors() { - // 300 is between 2^8=256 and 2^9=512, should floor to 8 - let diff = Log2Difficulty::from_difficulty(Difficulty::from(300_u64)); - assert_eq!(diff.exponent(), 8); - } - - #[test] - fn difficulty_one() { - let diff = Log2Difficulty::from_difficulty(Difficulty::from(1_u64)); - assert_eq!(diff.exponent(), 0); - } - - #[test] - fn large_difficulty() { - let diff = Log2Difficulty::from_difficulty(Difficulty::from(65536_u64)); - assert_eq!(diff.exponent(), 16); - } - - #[test] - fn display() { - let diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); - assert_eq!(format!("{diff}"), "2^8"); - } - - #[test] - fn to_work_matches_target_to_work() { - // Log2Difficulty's to_work should agree with computing work - // from the equivalent target directly. - let diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); - let expected = Difficulty::from(256_u64).to_target().to_work(); - assert_eq!(diff.to_work(), expected); - } -} - -#[cfg(test)] -mod ticket_mask_tests { - use super::*; - use crate::types::Difficulty; - - #[test] - fn wire_encoding_difficulty_256() { - // 8 zero_bits -> mask 0xFF -> [00, 00, 00, FF] - let diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); - let bytes = TicketMask::new(diff).to_wire_bytes(); - assert_eq!(bytes, [0x00, 0x00, 0x00, 0xFF]); - } - - #[test] - fn wire_encoding_difficulty_1024() { - // 10 zero_bits -> mask 0x3FF -> [00, 00, C0, FF] - let diff = Log2Difficulty::from_difficulty(Difficulty::from(1024_u64)); - let bytes = TicketMask::new(diff).to_wire_bytes(); - assert_eq!(bytes, [0x00, 0x00, 0xC0, 0xFF]); - } - - #[test] - fn wire_encoding_difficulty_65536() { - // 16 zero_bits -> mask 0xFFFF -> [00, 00, FF, FF] - let diff = Log2Difficulty::from_difficulty(Difficulty::from(65536_u64)); - let bytes = TicketMask::new(diff).to_wire_bytes(); - assert_eq!(bytes, [0x00, 0x00, 0xFF, 0xFF]); - } - - #[test] - fn wire_encoding_difficulty_1() { - // 0 zero_bits -> [00, 00, 00, 00] - let diff = Log2Difficulty::from_difficulty(Difficulty::from(1_u64)); - let bytes = TicketMask::new(diff).to_wire_bytes(); - assert_eq!(bytes, [0x00, 0x00, 0x00, 0x00]); - } - - #[test] - fn encode_matches_to_wire_bytes() { - let diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); - let mask = TicketMask::new(diff); - let mut buf = BytesMut::new(); - mask.encode(&mut buf); - assert_eq!(&buf[..], &[0x00, 0x00, 0x00, 0xFF]); - } - - #[test] - fn reverse_bits_examples() { - assert_eq!(reverse_bits(0x00), 0x00); - assert_eq!(reverse_bits(0xFF), 0xFF); - assert_eq!(reverse_bits(0x01), 0x80); - assert_eq!(reverse_bits(0x80), 0x01); - assert_eq!(reverse_bits(0x03), 0xC0); - assert_eq!(reverse_bits(0x0F), 0xF0); - } -} - -// Bytes go out on the wire least-significant byte first. -// Multi-byte fields are sent most-significant byte first, i.e., big-endian. +use super::chip_config::ChipConfig; +use super::command::{ + ChainInactive, Destination, ReadRegister, RegisterCommand, SetChipAddress, WriteRegister, +}; +use super::error::ProtocolError; +use super::register::{ + AnalogMux, Core, InitControl, IoDriverStrength, Log2Difficulty, MiscControl, MiscSettings, + NonceRange, Pll3Parameter, PllDivider, Register, RegisterAddress, TicketMask, UartBaud, + UartRelay, VersionMask, +}; +use crate::types::{Difficulty, Frequency}; /// Protocol handler for BM13xx family chips. /// @@ -2294,7 +35,6 @@ impl BM13xxProtocol { Self {} } - /// Helper to create a broadcast write command fn broadcast_write(&self, register: Register) -> RegisterCommand { RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, @@ -2302,7 +42,6 @@ impl BM13xxProtocol { }) } - /// Helper to create a targeted write command #[cfg_attr(not(test), allow(dead_code))] fn write_to(&self, chip_address: u8, register: Register) -> RegisterCommand { RegisterCommand::WriteRegister(WriteRegister { @@ -2486,8 +225,6 @@ impl BM13xxProtocol { register: RegisterAddress, value: u32, ) -> Result { - // TODO: Properly encode register based on type - // For now, just handle RegA8 as an example let register_value = match register { RegisterAddress::ChipId => { // Can't write chip ID register directly @@ -2496,14 +233,11 @@ impl BM13xxProtocol { RegisterAddress::PllDivider => { Register::PllDivider(PllDivider::decode(value.to_le_bytes())) } - RegisterAddress::NonceRange => Register::NonceRange(NonceRange { - bytes: value.to_le_bytes(), - }), + RegisterAddress::NonceRange => { + Register::NonceRange(NonceRange::decode(value.to_le_bytes())) + } RegisterAddress::TicketMask => { - let bytes = value.to_le_bytes(); - let mask_value = decode_ticket_mask_bytes(&bytes); - let zero_bits = mask_value.count_ones() as u8; - Register::TicketMask(TicketMask { zero_bits }) + Register::TicketMask(TicketMask::decode(value.to_le_bytes())) } RegisterAddress::MiscControl => Register::MiscControl(MiscControl(value)), RegisterAddress::UartBaud => Register::UartBaud(UartBaud::Custom(value)), @@ -2511,17 +245,11 @@ impl BM13xxProtocol { RegisterAddress::Core => Register::Core(Core(value)), RegisterAddress::AnalogMux => Register::AnalogMux(AnalogMux(value)), RegisterAddress::IoDriverStrength => { - let mut strengths = [0u8; 8]; - for (i, strength) in strengths.iter_mut().enumerate() { - *strength = ((value >> (i * 4)) & 0xf) as u8; - } - Register::IoDriverStrength(IoDriverStrength { strengths }) + Register::IoDriverStrength(IoDriverStrength::decode(value.to_le_bytes())) } RegisterAddress::Pll3Parameter => Register::Pll3Parameter(Pll3Parameter(value)), RegisterAddress::VersionMask => { - let mask = (value >> 16) as u16; - let control = (value & 0xffff) as u16; - Register::VersionMask(VersionMask { mask, control }) + Register::VersionMask(VersionMask::decode(value.to_le_bytes())) } RegisterAddress::InitControl => Register::InitControl(InitControl(value)), RegisterAddress::MiscSettings => Register::MiscSettings(MiscSettings(value)), @@ -2541,3 +269,162 @@ impl BM13xxProtocol { }) } } + +#[cfg(test)] +mod init_tests { + use bytes::BytesMut; + + use super::*; + + #[test] + fn multi_chip_init_sequence() { + let protocol = BM13xxProtocol::new(); + let commands = protocol.multi_chip_init(65); // S21 Pro has 65 chips + + // Verify the sequence starts with version rolling enable + assert!(matches!( + &commands[0], + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::VersionMask(_), + }) + )); + + // Verify chain inactive command + let chain_inactive_pos = commands + .iter() + .position(|c| matches!(c, RegisterCommand::ChainInactive(ChainInactive))) + .expect("ChainInactive command not found in initialization sequence"); + assert!(chain_inactive_pos > 0); + + // Verify chip addressing starts after chain inactive + let first_address_pos = chain_inactive_pos + 1; + assert!(matches!( + &commands[first_address_pos], + RegisterCommand::SetChipAddress(SetChipAddress { chip_address: 0x00 }) + )); + + // Verify we have 65 address assignments + let address_commands: Vec<_> = commands[first_address_pos..first_address_pos + 65] + .iter() + .collect(); + assert_eq!(address_commands.len(), 65); + + // Verify addresses increment by 2 + for (i, cmd) in address_commands.iter().enumerate() { + match cmd { + RegisterCommand::SetChipAddress(SetChipAddress { chip_address }) => { + assert_eq!(*chip_address, (i * 2) as u8); + } + _ => panic!("Expected SetChipAddress command, got {:?}", cmd), + } + } + } + + #[test] + fn domain_configuration() { + let protocol = BM13xxProtocol::new(); + let commands = protocol.configure_domains(65, 5); // 65 chips, 5 per domain + + // Should have 13 domains + let io_strength_commands: Vec<_> = commands + .iter() + .filter(|c| { + matches!( + c, + RegisterCommand::WriteRegister(WriteRegister { + register: Register::IoDriverStrength { .. }, + .. + }) + ) + }) + .collect(); + assert_eq!(io_strength_commands.len(), 13); + + // Check first domain boundary (chip 8 = address 0x08) + let first_boundary = io_strength_commands[0]; + if let RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Chip(chip_address), + register: Register::IoDriverStrength(strength), + }) = first_boundary + { + assert_eq!(*chip_address, 0x08); // 5th chip (index 4) * 2 + let mut buf = BytesMut::new(); + strength.encode(&mut buf); + // Expected bytes from hardware capture + assert_eq!(&buf[..], &[0x00, 0xf1, 0x11, 0x11]); + } + } + + #[test] + fn nonce_range_configuration() { + let protocol = BM13xxProtocol::new(); + + // Test single chip - full range + let commands = protocol.configure_nonce_ranges(1); + assert_eq!(commands.len(), 1); + if let RegisterCommand::WriteRegister(WriteRegister { + register: Register::NonceRange(config), + destination: Destination::Broadcast, + }) = &commands[0] + { + let mut buf = BytesMut::new(); + config.encode(&mut buf); + assert_eq!(&buf[..], &[0xff, 0xff, 0xff, 0xff]); + } + + // Test S21 Pro configuration (65 chips) + let commands = protocol.configure_nonce_ranges(65); + assert_eq!(commands.len(), 1); + if let RegisterCommand::WriteRegister(WriteRegister { + register: Register::NonceRange(config), + .. + }) = &commands[0] + { + let mut buf = BytesMut::new(); + config.encode(&mut buf); + assert_eq!(&buf[..], &[0x00, 0x00, 0x1e, 0xb5]); + } + + // Test small chain + let commands = protocol.configure_nonce_ranges(8); + if let RegisterCommand::WriteRegister(WriteRegister { + register: Register::NonceRange(config), + .. + }) = &commands[0] + { + let mut buf = BytesMut::new(); + config.encode(&mut buf); + assert_eq!(&buf[..], &[0xff, 0xff, 0xff, 0x1f]); + } + } + + #[test] + fn multi_chip_init_includes_nonce_range() { + let protocol = BM13xxProtocol::new(); + let commands = protocol.multi_chip_init(65); + + // Find the nonce range configuration + let nonce_range_cmd = commands.iter().find(|c| { + matches!( + c, + RegisterCommand::WriteRegister(WriteRegister { + register: Register::NonceRange { .. }, + .. + }) + ) + }); + + assert!(nonce_range_cmd.is_some()); + + if let Some(RegisterCommand::WriteRegister(WriteRegister { + register: Register::NonceRange(config), + .. + })) = nonce_range_cmd + { + let mut buf = BytesMut::new(); + config.encode(&mut buf); + assert_eq!(&buf[..], &[0x00, 0x00, 0x1e, 0xb5]); // S21 Pro value + } + } +} diff --git a/mujina-miner/src/asic/bm13xx/register.rs b/mujina-miner/src/asic/bm13xx/register.rs new file mode 100644 index 00000000..a9797a20 --- /dev/null +++ b/mujina-miner/src/asic/bm13xx/register.rs @@ -0,0 +1,695 @@ +//! BM13xx chip registers as typed values. +//! +//! [`RegisterAddress`] names each register on a BM13xx chip. +//! [`Register`] carries the same set with a typed payload per +//! variant, so values coming off the wire are typed rather than raw +//! bit fields. + +use bitcoin::pow::Work; +use bytes::{BufMut, BytesMut}; +use std::fmt; +use strum::FromRepr; + +use crate::types::Difficulty; + +/// Register addresses on the wire. +#[derive(FromRepr, Copy, Clone, Debug)] +#[repr(u8)] +pub enum RegisterAddress { + ChipId = 0x00, + PllDivider = 0x08, + NonceRange = 0x10, + TicketMask = 0x14, + MiscControl = 0x18, + UartBaud = 0x28, + UartRelay = 0x2C, + Core = 0x3C, + AnalogMux = 0x54, + IoDriverStrength = 0x58, + Pll3Parameter = 0x68, + VersionMask = 0xA4, + InitControl = 0xA8, + MiscSettings = 0xB9, +} + +/// A register with its typed payload. +#[derive(Debug, Clone)] +pub enum Register { + ChipId(ChipId), + PllDivider(PllDivider), + NonceRange(NonceRange), + TicketMask(TicketMask), + MiscControl(MiscControl), + UartBaud(UartBaud), + UartRelay(UartRelay), + Core(Core), + AnalogMux(AnalogMux), + IoDriverStrength(IoDriverStrength), + Pll3Parameter(Pll3Parameter), + VersionMask(VersionMask), + InitControl(InitControl), + MiscSettings(MiscSettings), +} + +impl Register { + pub fn decode(address: RegisterAddress, bytes: [u8; 4]) -> Register { + match address { + RegisterAddress::ChipId => Register::ChipId(ChipId::decode(bytes)), + RegisterAddress::PllDivider => Register::PllDivider(PllDivider::decode(bytes)), + RegisterAddress::NonceRange => Register::NonceRange(NonceRange::decode(bytes)), + RegisterAddress::TicketMask => Register::TicketMask(TicketMask::decode(bytes)), + RegisterAddress::MiscControl => Register::MiscControl(MiscControl::decode(bytes)), + RegisterAddress::UartBaud => Register::UartBaud(UartBaud::decode(bytes)), + RegisterAddress::UartRelay => Register::UartRelay(UartRelay::decode(bytes)), + RegisterAddress::Core => Register::Core(Core::decode(bytes)), + RegisterAddress::AnalogMux => Register::AnalogMux(AnalogMux::decode(bytes)), + RegisterAddress::IoDriverStrength => { + Register::IoDriverStrength(IoDriverStrength::decode(bytes)) + } + RegisterAddress::Pll3Parameter => Register::Pll3Parameter(Pll3Parameter::decode(bytes)), + RegisterAddress::VersionMask => Register::VersionMask(VersionMask::decode(bytes)), + RegisterAddress::InitControl => Register::InitControl(InitControl::decode(bytes)), + RegisterAddress::MiscSettings => Register::MiscSettings(MiscSettings::decode(bytes)), + } + } + + pub(super) fn address(&self) -> RegisterAddress { + match self { + Register::ChipId(_) => RegisterAddress::ChipId, + Register::PllDivider(_) => RegisterAddress::PllDivider, + Register::NonceRange(_) => RegisterAddress::NonceRange, + Register::TicketMask(_) => RegisterAddress::TicketMask, + Register::MiscControl(_) => RegisterAddress::MiscControl, + Register::UartBaud(_) => RegisterAddress::UartBaud, + Register::UartRelay(_) => RegisterAddress::UartRelay, + Register::Core(_) => RegisterAddress::Core, + Register::AnalogMux(_) => RegisterAddress::AnalogMux, + Register::IoDriverStrength(_) => RegisterAddress::IoDriverStrength, + Register::Pll3Parameter(_) => RegisterAddress::Pll3Parameter, + Register::VersionMask(_) => RegisterAddress::VersionMask, + Register::InitControl(_) => RegisterAddress::InitControl, + Register::MiscSettings(_) => RegisterAddress::MiscSettings, + } + } + + pub(super) fn encode(&self, dst: &mut BytesMut) { + match self { + Register::ChipId(r) => r.encode(dst), + Register::PllDivider(r) => r.encode(dst), + Register::NonceRange(r) => r.encode(dst), + Register::TicketMask(r) => r.encode(dst), + Register::MiscControl(r) => r.encode(dst), + Register::UartBaud(r) => r.encode(dst), + Register::UartRelay(r) => r.encode(dst), + Register::Core(r) => r.encode(dst), + Register::AnalogMux(r) => r.encode(dst), + Register::IoDriverStrength(r) => r.encode(dst), + Register::Pll3Parameter(r) => r.encode(dst), + Register::VersionMask(r) => r.encode(dst), + Register::InitControl(r) => r.encode(dst), + Register::MiscSettings(r) => r.encode(dst), + } + } +} + +/// Chip model + core count + assigned chain address. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChipId { + pub model: ChipModel, + pub core_count: u8, + pub address: u8, +} + +impl ChipId { + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_slice(&self.model.id_bytes()); + dst.put_u8(self.core_count); + dst.put_u8(self.address); + } + pub fn decode(bytes: [u8; 4]) -> Self { + Self { + model: ChipModel::from([bytes[0], bytes[1]]), + core_count: bytes[2], + address: bytes[3], + } + } +} + +/// Known chip models in the BM13xx family. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChipModel { + /// BM1362 - Used in Antminer S19 J Pro (126 chips) + /// Core count unknown + BM1362, + /// BM1366 - Newer generation chip + BM1366, + /// BM1370 - Used in Bitaxe Gamma and Antminer S21 Pro + /// ~2,040 hash engines organized as 128 domains of ~16 engines each + BM1370, + /// BM1397 - Previous generation chip + BM1397, + /// Unknown chip model with raw ID bytes. + Unknown([u8; 2]), +} + +impl ChipModel { + /// Returns the raw chip ID bytes. + pub fn id_bytes(&self) -> [u8; 2] { + match self { + Self::BM1362 => [0x13, 0x62], + Self::BM1366 => [0x13, 0x66], + Self::BM1370 => [0x13, 0x70], + Self::BM1397 => [0x13, 0x97], + Self::Unknown(bytes) => *bytes, + } + } + + /// Returns the expected hash engine count for this model, if known. + pub fn core_count(&self) -> Option { + match self { + Self::BM1370 => Some(2048), // 128 x 16; esp-miner uses 2040 + _ => None, + } + } +} + +impl From<[u8; 2]> for ChipModel { + fn from(bytes: [u8; 2]) -> Self { + match bytes { + [0x13, 0x62] => Self::BM1362, + [0x13, 0x66] => Self::BM1366, + [0x13, 0x70] => Self::BM1370, + [0x13, 0x97] => Self::BM1397, + _ => Self::Unknown(bytes), + } + } +} + +impl From for [u8; 2] { + fn from(model: ChipModel) -> Self { + model.id_bytes() + } +} + +/// PLL configuration for frequency control. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PllDivider { + /// VCO control flag (0x40 for low VCO, 0x50 for high VCO). + pub flag: u8, + /// Feedback divider. + pub fb_div: u8, + /// Reference divider (typically 1 or 2). + pub ref_div: u8, + /// Post divider, encoded as `((post_div1-1) << 4) | (post_div2-1)`. + pub post_div: u8, +} + +impl PllDivider { + /// Create a PLL configuration, deriving the VCO flag from the + /// resulting VCO frequency: `vco >= 2400 MHz` picks flag `0x50`, + /// otherwise flag `0x40`. + pub fn new(fb_div: u8, ref_div: u8, post_div: u8) -> Self { + let vco_mhz = fb_div as f32 * 25.0 / ref_div as f32; + let flag = if vco_mhz >= 2400.0 { 0x50 } else { 0x40 }; + Self { + flag, + fb_div, + ref_div, + post_div, + } + } + + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_u8(self.flag); + dst.put_u8(self.fb_div); + dst.put_u8(self.ref_div); + dst.put_u8(self.post_div); + } + + pub fn decode(bytes: [u8; 4]) -> Self { + Self { + flag: bytes[0], + fb_div: bytes[1], + ref_div: bytes[2], + post_div: bytes[3], + } + } +} + +/// Nonce range configuration for work distribution. +/// +/// NOTE: We store this as a byte array rather than interpreting it as a u32 +/// because the exact bit-level interpretation is still being reverse-engineered. +/// The values below are empirically observed from production hardware. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct NonceRange { + /// Raw bytes as sent over the wire + bytes: [u8; 4], +} + +impl NonceRange { + // Nonce range values for different chain lengths (captured from hardware) + const SINGLE_CHIP: [u8; 4] = [0xff, 0xff, 0xff, 0xff]; + const SMALL_CHAIN: [u8; 4] = [0xff, 0xff, 0xff, 0x1f]; // 2-8 chips + const MEDIUM_CHAIN: [u8; 4] = [0xff, 0xff, 0xff, 0x0f]; // 9-16 chips + const LARGE_CHAIN: [u8; 4] = [0xff, 0xff, 0xff, 0x07]; // 17-32 chips + const XLARGE_CHAIN: [u8; 4] = [0xff, 0xff, 0xff, 0x03]; // 33-64 chips + const S21_PRO: [u8; 4] = [0x00, 0x00, 0x1e, 0xb5]; // 65-128 chips (empirical) + const DEFAULT_LARGE: [u8; 4] = [0xff, 0xff, 0xff, 0x01]; // >128 chips + + /// Create config for single chip (full range) + pub fn single_chip() -> Self { + Self { + bytes: Self::SINGLE_CHIP, + } + } + + /// Create config for multi-chip chain + pub fn multi_chip(chain_length: usize) -> Self { + let bytes = match chain_length { + 1 => Self::SINGLE_CHIP, + 2..=8 => Self::SMALL_CHAIN, + 9..=16 => Self::MEDIUM_CHAIN, + 17..=32 => Self::LARGE_CHAIN, + 33..=64 => Self::XLARGE_CHAIN, + 65..=128 => Self::S21_PRO, + _ => Self::DEFAULT_LARGE, + }; + Self { bytes } + } + + /// Create config from raw 32-bit value (little-endian) + /// Used for exact configuration from protocol captures + pub fn from_raw(value: u32) -> Self { + Self { + bytes: value.to_le_bytes(), + } + } + + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_slice(&self.bytes); + } + + pub fn decode(bytes: [u8; 4]) -> Self { + Self { bytes } + } +} + +/// Ticket mask controlling ASIC nonce reporting +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TicketMask { + // Number of additional zero bits required in the bit-reversed hash, + // beyond the base 32 bits. The chip always requires bits 0-31 of the + // bit-reversed hash to be zero. This parameter adds bits 32..(32+zero_bits) + // that must also be zero. + zero_bits: u8, +} + +impl TicketMask { + /// Create ticket mask from an ASIC difficulty. + /// + /// The [`Log2Difficulty`] exponent maps directly to the number + /// of extra zero bits the chip requires beyond its hardwired + /// difficulty-1 gate. + pub const fn new(difficulty: Log2Difficulty) -> Self { + Self { + zero_bits: difficulty.exponent(), + } + } + + /// Encode ticket mask to wire format bytes + pub fn to_wire_bytes(&self) -> [u8; 4] { + if self.zero_bits == 0 { + return [0, 0, 0, 0]; + } + + // Create mask value: 2^zero_bits - 1 + let mask_value = (1u32 << self.zero_bits) - 1; + + // Encode to wire format with bit-reversal and byte-reversal + let mut bytes = [0u8; 4]; + for i in 0..4 { + let byte = ((mask_value >> (8 * i)) & 0xFF) as u8; + bytes[3 - i] = reverse_bits(byte); + } + + bytes + } + + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_slice(&self.to_wire_bytes()); + } + + pub fn decode(bytes: [u8; 4]) -> Self { + let mask_value = decode_ticket_mask_bytes(&bytes); + let zero_bits = mask_value.count_ones() as u8; + Self { zero_bits } + } +} + +/// ASIC difficulty as a power-of-2 exponent. +/// +/// BM13xx chips filter nonces using bitmask comparison (`hash & +/// mask == 0`) rather than numerical target comparison (`hash < +/// target`). Each bit in the mask independently halves the pass +/// rate, so only power-of-2 difficulty steps are representable. +/// This type stores the log2 of the difficulty: a value of 8 +/// means difficulty 2^8 = 256. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Log2Difficulty { + exponent: u8, +} + +impl Log2Difficulty { + /// Floor an arbitrary difficulty to the nearest power-of-2 + /// ASIC difficulty. + /// + /// The conversion is lossy: non-power-of-2 difficulties are + /// rounded down. This ensures the actual nonce rate is at least + /// as high as the rate implied by the input difficulty. + pub fn from_difficulty(difficulty: Difficulty) -> Self { + let d = difficulty.as_f64(); + let exponent = if d >= 1.0 { d.log2().floor() as u8 } else { 0 }; + Self { exponent } + } + + /// The log2 of the difficulty (e.g., 8 for difficulty 256). + pub const fn exponent(&self) -> u8 { + self.exponent + } + + /// Expected work per nonce at this difficulty. + /// + /// A nonce that passes the ASIC's difficulty filter represents + /// this many hashes of work on average. + pub fn to_work(&self) -> Work { + Difficulty::from(1_u64 << self.exponent) + .to_target() + .to_work() + } +} + +impl fmt::Display for Log2Difficulty { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "2^{}", self.exponent) + } +} + +/// UART baud rate configuration +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum UartBaud { + /// 115200 baud + Baud115200, + /// 1 Mbaud + Baud1M, + /// 3 Mbaud (common for multi-chip) + Baud3M, + /// Custom baud rate with raw register value + Custom(u32), +} + +impl UartBaud { + pub fn encode(&self, dst: &mut BytesMut) { + let value = match self { + // From esp-miner BM1370/BM1366/BM1368 default baud config + UartBaud::Baud115200 => 0x00000271, + // From esp-miner BM1370_set_max_baud/BM1366_set_max_baud/BM1368_set_max_baud + // All three chips use identical register value for 1Mbaud + UartBaud::Baud1M => 0x00023011, + // From S21 Pro captures (BM1370 multi-chip chains) + UartBaud::Baud3M => 0x00003001, + UartBaud::Custom(val) => *val, + }; + dst.put_u32_le(value); + } + pub fn decode(bytes: [u8; 4]) -> Self { + match u32::from_le_bytes(bytes) { + 0x00000271 => UartBaud::Baud115200, + 0x00000130 => UartBaud::Baud1M, + 0x00003001 => UartBaud::Baud3M, + other => UartBaud::Custom(other), + } + } +} + +/// Transmitted big-endian, unlike the other raw-u32 registers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Core(pub u32); + +impl Core { + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_u32(self.0); + } + pub fn decode(bytes: [u8; 4]) -> Self { + Self(u32::from_be_bytes(bytes)) + } +} + +/// IO driver strength configuration +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct IoDriverStrength { + /// Drive strength for each signal group (4 bits each) + strengths: [u8; 8], +} + +impl IoDriverStrength { + /// Normal strength for chips in middle of chain + pub fn normal() -> Self { + // 0x11110100 = 0001 0001 0001 0001 0000 0001 0000 0000 + Self { + strengths: [0x0, 0x0, 0x1, 0x0, 0x1, 0x1, 0x1, 0x1], + } + } + + /// Strong drive for domain boundary chips + pub fn domain_boundary() -> Self { + // 0x1111f100 = 0001 0001 0001 0001 1111 0001 0000 0000 + Self { + strengths: [0x0, 0x0, 0x1, 0xf, 0x1, 0x1, 0x1, 0x1], + } + } + + pub fn encode(&self, dst: &mut BytesMut) { + // Pack 8 4-bit values into 4 bytes (2 per byte). + // Each byte contains two strength values: [high_nibble|low_nibble]. + dst.put_u8(self.strengths[0] | (self.strengths[1] << 4)); + dst.put_u8(self.strengths[2] | (self.strengths[3] << 4)); + dst.put_u8(self.strengths[4] | (self.strengths[5] << 4)); + dst.put_u8(self.strengths[6] | (self.strengths[7] << 4)); + } + + pub fn decode(bytes: [u8; 4]) -> Self { + let raw = u32::from_le_bytes(bytes); + let mut strengths = [0u8; 8]; + for (i, strength) in strengths.iter_mut().enumerate() { + *strength = ((raw >> (i * 4)) & 0xf) as u8; + } + Self { strengths } + } +} + +/// Version mask for version rolling +#[derive(Clone, Copy, PartialEq)] +pub struct VersionMask { + /// Which bits can be rolled + mask: u16, + /// Enable flag and other control bits + control: u16, +} + +impl VersionMask { + /// Full 16-bit mask for version rolling + const FULL_MASK: u16 = 0xffff; + /// Fixed control pattern used by all implementations to enable version rolling + const ENABLE_ROLLING: u16 = 0x0090; + + /// Create version mask with all lower 16 bits enabled + pub fn full_rolling() -> Self { + Self { + mask: Self::FULL_MASK, + control: Self::ENABLE_ROLLING, + } + } + + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_u16_le(self.control); + dst.put_u16_le(self.mask); + } + + pub fn decode(bytes: [u8; 4]) -> Self { + let raw = u32::from_le_bytes(bytes); + Self { + control: (raw & 0xffff) as u16, + mask: (raw >> 16) as u16, + } + } +} + +impl fmt::Debug for VersionMask { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let control_str = if self.control == Self::ENABLE_ROLLING { + "ENABLE_ROLLING".to_string() + } else { + format!("{:#06x}", self.control) + }; + f.debug_struct("VersionMask") + .field("mask", &format_args!("{:#06x}", self.mask)) + .field("control", &control_str) + .finish() + } +} + +// Placeholder newtypes for registers whose bit layout is not yet +// decomposed. Each wraps a raw u32 written little-endian to the wire. +macro_rules! raw_u32_register { + ($($(#[$meta:meta])* $name:ident),* $(,)?) => { + $( + $(#[$meta])* + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct $name(pub u32); + + impl $name { + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_u32_le(self.0); + } + pub fn decode(bytes: [u8; 4]) -> Self { + Self(u32::from_le_bytes(bytes)) + } + } + )* + }; +} + +raw_u32_register! { + MiscControl, + UartRelay, + AnalogMux, + Pll3Parameter, + InitControl, + MiscSettings, +} + +/// Reverse bits within a single byte (bit 0 swaps with bit 7, etc.). +fn reverse_bits(byte: u8) -> u8 { + let mut result = 0u8; + let mut b = byte; + for _ in 0..8 { + result = (result << 1) | (b & 1); + b >>= 1; + } + result +} + +/// Inverse of [`TicketMask::to_wire_bytes`]: undo byte and bit reversal +/// to recover the underlying mask value. +fn decode_ticket_mask_bytes(bytes: &[u8; 4]) -> u32 { + let mut mask_value = 0u32; + for i in 0..4 { + let byte = reverse_bits(bytes[3 - i]); + mask_value |= (byte as u32) << (8 * i); + } + mask_value +} + +#[cfg(test)] +mod log2_difficulty_tests { + use super::*; + use crate::types::Difficulty; + + #[test] + fn power_of_two_difficulty_exact() { + let diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); + assert_eq!(diff.exponent(), 8); + } + + #[test] + fn non_power_of_two_floors() { + // 300 is between 2^8=256 and 2^9=512, should floor to 8 + let diff = Log2Difficulty::from_difficulty(Difficulty::from(300_u64)); + assert_eq!(diff.exponent(), 8); + } + + #[test] + fn difficulty_one() { + let diff = Log2Difficulty::from_difficulty(Difficulty::from(1_u64)); + assert_eq!(diff.exponent(), 0); + } + + #[test] + fn large_difficulty() { + let diff = Log2Difficulty::from_difficulty(Difficulty::from(65536_u64)); + assert_eq!(diff.exponent(), 16); + } + + #[test] + fn display() { + let diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); + assert_eq!(format!("{diff}"), "2^8"); + } + + #[test] + fn to_work_matches_target_to_work() { + // Log2Difficulty's to_work should agree with computing work + // from the equivalent target directly. + let diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); + let expected = Difficulty::from(256_u64).to_target().to_work(); + assert_eq!(diff.to_work(), expected); + } +} + +#[cfg(test)] +mod ticket_mask_tests { + use super::*; + use crate::types::Difficulty; + + #[test] + fn wire_encoding_difficulty_256() { + // 8 zero_bits -> mask 0xFF -> [00, 00, 00, FF] + let diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); + let bytes = TicketMask::new(diff).to_wire_bytes(); + assert_eq!(bytes, [0x00, 0x00, 0x00, 0xFF]); + } + + #[test] + fn wire_encoding_difficulty_1024() { + // 10 zero_bits -> mask 0x3FF -> [00, 00, C0, FF] + let diff = Log2Difficulty::from_difficulty(Difficulty::from(1024_u64)); + let bytes = TicketMask::new(diff).to_wire_bytes(); + assert_eq!(bytes, [0x00, 0x00, 0xC0, 0xFF]); + } + + #[test] + fn wire_encoding_difficulty_65536() { + // 16 zero_bits -> mask 0xFFFF -> [00, 00, FF, FF] + let diff = Log2Difficulty::from_difficulty(Difficulty::from(65536_u64)); + let bytes = TicketMask::new(diff).to_wire_bytes(); + assert_eq!(bytes, [0x00, 0x00, 0xFF, 0xFF]); + } + + #[test] + fn wire_encoding_difficulty_1() { + // 0 zero_bits -> [00, 00, 00, 00] + let diff = Log2Difficulty::from_difficulty(Difficulty::from(1_u64)); + let bytes = TicketMask::new(diff).to_wire_bytes(); + assert_eq!(bytes, [0x00, 0x00, 0x00, 0x00]); + } + + #[test] + fn encode_matches_to_wire_bytes() { + let diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); + let mask = TicketMask::new(diff); + let mut buf = BytesMut::new(); + mask.encode(&mut buf); + assert_eq!(&buf[..], &[0x00, 0x00, 0x00, 0xFF]); + } + + #[test] + fn reverse_bits_examples() { + assert_eq!(reverse_bits(0x00), 0x00); + assert_eq!(reverse_bits(0xFF), 0xFF); + assert_eq!(reverse_bits(0x01), 0x80); + assert_eq!(reverse_bits(0x80), 0x01); + assert_eq!(reverse_bits(0x03), 0xC0); + assert_eq!(reverse_bits(0x0F), 0xF0); + } +} diff --git a/mujina-miner/src/asic/bm13xx/response.rs b/mujina-miner/src/asic/bm13xx/response.rs new file mode 100644 index 00000000..3ce3d950 --- /dev/null +++ b/mujina-miner/src/asic/bm13xx/response.rs @@ -0,0 +1,624 @@ +//! Frames BM13xx chips send back to the host. +//! +//! BM13xx chips send back two kinds of frames: replies to register +//! reads, and nonce reports when a chip finds passing work. + +use bitvec::prelude::*; +use bytes::{Buf, BytesMut}; +use strum::FromRepr; + +use super::register::{Register, RegisterAddress}; +use crate::asic::bm13xx::error::ProtocolError; +use crate::job_source::GeneralPurposeBits; + +#[derive(Debug)] +#[cfg_attr(not(test), allow(dead_code))] +pub enum Response { + ReadRegister { + chip_address: u8, + register: Register, + }, + Nonce { + nonce: u32, + job_id: u8, + midstate_num: u8, + version: GeneralPurposeBits, + subcore_id: u8, + }, +} + +impl Response { + pub(super) fn decode(bytes: &mut BytesMut) -> Result { + let type_and_crc = bytes[bytes.len() - 1].view_bits::(); + let type_repr = type_and_crc[5..].load::(); + + match ResponseType::from_repr(type_repr) { + Some(ResponseType::ReadRegister) => { + let value_bytes = bytes.split_to(4); + let value: [u8; 4] = + value_bytes[..] + .try_into() + .map_err(|_| ProtocolError::BufferTooSmall { + need: 4, + have: value_bytes.len(), + })?; + let chip_address = bytes.get_u8(); + let register_address_repr = bytes.get_u8(); + + if let Some(register_address) = RegisterAddress::from_repr(register_address_repr) { + let register = Register::decode(register_address, value); + Ok(Response::ReadRegister { + chip_address, + register, + }) + } else { + Err(ProtocolError::InvalidRegisterAddress(register_address_repr)) + } + } + Some(ResponseType::Nonce) => { + // BM1370 nonce response format (11 bytes total, including preamble): + // Already consumed: preamble (2 bytes) + // Remaining: nonce(4) + midstate_num(1) + result_header(1) + version(2) + crc(1) + let nonce = bytes.get_u32_le(); + let midstate_num = bytes.get_u8(); + let result_header = bytes.get_u8(); + + // Version rolling field: 2 bytes, big-endian + // Occupies bits 13-28 of block version when shifted left 13 + let version_bytes = [bytes.get_u8(), bytes.get_u8()]; + let version = GeneralPurposeBits::from(version_bytes); + // CRC already consumed + + // Extract job_id and subcore_id from result_header + // job_id is a 4-bit field (0-15) at bits 7-4 of result_header + let job_id = (result_header >> 4) & 0x0f; + let subcore_id = result_header & 0x0f; + + Ok(Response::Nonce { + nonce, + job_id, + midstate_num, + version, + subcore_id, + }) + } + None => Err(ProtocolError::InvalidResponseType(type_repr)), + } + } +} + +#[derive(FromRepr)] +#[repr(u8)] +enum ResponseType { + ReadRegister = 0, + Nonce = 4, +} + +#[cfg(test)] +mod tests { + use bytes::{BufMut, BytesMut}; + use tokio_util::codec::Decoder; + + use super::super::codec::FrameCodec; + use super::super::register::{ChipId, ChipModel, Register}; + use super::*; + use crate::asic::bm13xx::crc::crc5_is_valid; + + #[test] + fn verify_crc_calculation() { + // Test that our known good frame has valid CRC + let frame = &[0x13, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10]; // without preamble + assert!( + crc5_is_valid(frame), + "Known good frame should have valid CRC" + ); + } + + #[test] + fn decoder_with_exact_frame_size() { + let mut codec = FrameCodec; + + // Exactly 11 bytes - a complete frame + let mut buf = BytesMut::new(); + buf.put_slice(&[ + 0xaa, 0x55, 0x13, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, + ]); + + let result = codec.decode(&mut buf).unwrap(); + assert!( + result.is_some(), + "Should decode frame when buffer has exactly 11 bytes" + ); + } + + #[test] + fn read_register() { + // 11-byte register read response from captures + let wire = &[ + 0xaa, 0x55, 0x13, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, + ]; + let response = decode_frame(wire).expect("decode_frame should return Some for valid frame"); + + let Response::ReadRegister { + chip_address, + register, + } = response + else { + panic!("Expected ReadRegister response, got {:?}", response); + }; + + assert_eq!(chip_address, 0x00); + + let Register::ChipId(ChipId { + model, + core_count, + address, + }) = register + else { + panic!("Expected ChipId register, got {:?}", register); + }; + + assert_eq!(model, ChipModel::BM1370); + assert_eq!(core_count, 0x00); + assert_eq!(address, 0x00); + } + + fn decode_frame(frame: &[u8]) -> Option { + let mut buf = BytesMut::from(frame); + let mut codec = FrameCodec; + codec.decode(&mut buf).expect("Failed to decode frame") + } + + #[test] + fn decode_nonce_response_from_capture() { + // From Bitaxe capture: RX: AA 55 18 00 A6 40 02 99 22 F9 91 + let wire = &[ + 0xaa, 0x55, 0x18, 0x00, 0xa6, 0x40, 0x02, 0x99, 0x22, 0xf9, 0x91, + ]; + let response = decode_frame(wire).expect("decode_frame should return Some for valid frame"); + + let Response::Nonce { + nonce, + job_id, + midstate_num, + version, + subcore_id, + } = response + else { + panic!("Expected nonce response"); + }; + + // From protocol doc: nonce 0x40A60018 -> Main core 32, nonce value 0x00A60018 + assert_eq!(nonce, 0x40a60018); + assert_eq!(midstate_num, 0x02); + + // Result header: 0x99 -> bits[7:4]=9 (job_id), bits[3:0]=9 (subcore_id) + assert_eq!(job_id, 9); + assert_eq!(subcore_id, 9); + + // Version + assert_eq!(version, GeneralPurposeBits::new([0x22, 0xF9])); + + // Verify main core extraction + let main_core = (nonce >> 25) & 0x7f; + assert_eq!(main_core, 32); + } + + #[test] + fn decode_multiple_nonce_responses() { + // Additional nonce responses from S21 Pro capture + let test_cases = vec![ + // RX: AA 55 07 35 CD CF 02 5E 00 2E 96 + // result_header=0x5e: bits[7:4]=5, bits[3:0]=14 + // version bytes [0x00, 0x2E] big-endian = 0x002E + ( + &[ + 0xaa, 0x55, 0x07, 0x35, 0xcd, 0xcf, 0x02, 0x5e, 0x00, 0x2e, 0x96, + ], + 0xcfcd3507, + 0x02, + 5, + 14, + GeneralPurposeBits::new([0x00, 0x2E]), + ), + // RX: AA 55 46 03 32 E7 00 C3 2C 83 99 + // result_header=0xc3: bits[7:4]=12, bits[3:0]=3 + // version bytes [0x2C, 0x83] big-endian = 0x2C83 + ( + &[ + 0xaa, 0x55, 0x46, 0x03, 0x32, 0xe7, 0x00, 0xc3, 0x2c, 0x83, 0x99, + ], + 0xe7320346, + 0x00, + 12, + 3, + GeneralPurposeBits::new([0x2C, 0x83]), + ), + ]; + + for (wire, exp_nonce, exp_midstate, exp_job_id, exp_subcore, exp_version) in test_cases { + let response = + decode_frame(wire).expect("decode_frame should return Some for valid frame"); + + let Response::Nonce { + nonce, + job_id, + midstate_num, + version, + subcore_id, + } = response + else { + panic!("Expected nonce response"); + }; + + assert_eq!(nonce, exp_nonce); + assert_eq!(midstate_num, exp_midstate); + assert_eq!(job_id, exp_job_id); + assert_eq!(subcore_id, exp_subcore); + assert_eq!(version, exp_version); + } + } + + #[test] + fn decoder_handles_partial_frames() { + let mut codec = FrameCodec; + + // Test with incomplete frame (less than 11 bytes) + let mut buf = BytesMut::new(); + buf.put_slice(&[0xaa, 0x55, 0x13, 0x70, 0x00]); // Only 5 bytes + + let result = codec.decode(&mut buf).unwrap(); + assert!(result.is_none(), "Should return None for incomplete frame"); + assert_eq!(buf.len(), 5, "Buffer should not be consumed"); + + // Add more bytes to complete the frame + buf.put_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x10]); // Complete to 11 bytes + + let result = codec.decode(&mut buf).unwrap(); + assert!(result.is_some(), "Should decode complete frame"); + assert_eq!(buf.len(), 0, "Buffer should be fully consumed"); + } + + #[test] + fn decoder_handles_corrupted_crc() { + let mut codec = FrameCodec; + + // Valid frame with corrupted CRC (last byte) + let mut buf = BytesMut::new(); + buf.put_slice(&[ + 0xaa, 0x55, 0x13, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, + ]); // Bad CRC + + let result = codec.decode(&mut buf).unwrap(); + assert!(result.is_none(), "Should reject frame with bad CRC"); + assert_eq!( + buf.len(), + 10, + "Should consume 1 byte when searching for valid frame" + ); + } + + #[test] + fn decoder_finds_frame_after_garbage() { + let mut codec = FrameCodec; + + // Garbage bytes followed by valid frame + let mut buf = BytesMut::new(); + buf.put_slice(&[0xFF, 0xEE, 0xDD]); // Garbage + buf.put_slice(&[ + 0xaa, 0x55, 0x13, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, + ]); // Valid frame + + // First calls should skip garbage + assert!(codec.decode(&mut buf).unwrap().is_none()); + assert!(codec.decode(&mut buf).unwrap().is_none()); + assert!(codec.decode(&mut buf).unwrap().is_none()); + + // Should find valid frame + let result = codec.decode(&mut buf).unwrap(); + assert!(result.is_some(), "Should find valid frame after garbage"); + assert_eq!(buf.len(), 0, "All data should be consumed"); + } + + #[test] + fn decoder_handles_false_start() { + let mut codec = FrameCodec; + + // Frame that starts with 0xAA but not followed by 0x55 + let mut buf = BytesMut::new(); + buf.put_slice(&[0xaa, 0x00]); // False start + buf.put_slice(&[ + 0xaa, 0x55, 0x13, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, + ]); // Valid frame + + // Total buffer: [AA, 00, AA, 55, 13, 70, 00, 00, 00, 00, 00, 00, 10] = 13 bytes + assert_eq!(buf.len(), 13, "Initial buffer should have 13 bytes"); + + // First decode: sees AA at pos 0, but 00 at pos 1, so should skip 1 byte + let first = codec.decode(&mut buf).unwrap(); + assert!(first.is_none(), "First decode should return None"); + assert_eq!(buf.len(), 12, "Should have consumed 1 byte"); + + // Buffer now: [00, AA, 55, 13, 70, 00, 00, 00, 00, 00, 00, 10] = 12 bytes + // Second decode: sees 00 at pos 0, should skip 1 byte + let second = codec.decode(&mut buf).unwrap(); + assert!(second.is_none(), "Second decode should return None"); + assert_eq!(buf.len(), 11, "Should have consumed another byte"); + + // Buffer now: [AA, 55, 13, 70, 00, 00, 00, 00, 00, 00, 10] = 11 bytes = valid frame + // Third decode should succeed + let result = codec.decode(&mut buf); + match result { + Ok(Some(Response::ReadRegister { .. })) => {} // Success + Ok(Some(other)) => panic!("Expected ReadRegister, got {:?}", other), + Ok(None) => panic!( + "Expected Some, got None. Buffer len: {}, contents: {:02x?}", + buf.len(), + &buf[..] + ), + Err(e) => panic!("Decode error: {}", e), + } + } + + #[test] + fn decoder_handles_back_to_back_frames() { + let mut codec = FrameCodec; + + // Two valid frames back-to-back + let mut buf = BytesMut::new(); + // First frame: register read + buf.put_slice(&[ + 0xaa, 0x55, 0x13, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, + ]); + // Second frame: nonce response + buf.put_slice(&[ + 0xaa, 0x55, 0x18, 0x00, 0xa6, 0x40, 0x02, 0x99, 0x22, 0xf9, 0x91, + ]); + + // Decode first frame + let result1 = codec.decode(&mut buf).unwrap(); + assert!(matches!(result1, Some(Response::ReadRegister { .. }))); + assert_eq!(buf.len(), 11, "Should have second frame remaining"); + + // Decode second frame + let result2 = codec.decode(&mut buf).unwrap(); + assert!(matches!(result2, Some(Response::Nonce { .. }))); + assert_eq!(buf.len(), 0, "Buffer should be empty"); + } + + #[test] + fn decoder_handles_real_s21_pro_frames() { + let mut codec = FrameCodec; + + // Real frames from S21 Pro capture + let frames = vec![ + [ + 0xaa, 0x55, 0x07, 0x35, 0xcd, 0xcf, 0x02, 0x5e, 0x00, 0x2e, 0x96, + ], + [ + 0xaa, 0x55, 0x7b, 0x8d, 0x81, 0x60, 0x02, 0x55, 0x00, 0x85, 0x81, + ], + [ + 0xaa, 0x55, 0x32, 0x2a, 0x84, 0x5a, 0x02, 0x52, 0x01, 0xb2, 0x8c, + ], + ]; + + for frame in frames { + let mut buf = BytesMut::new(); + buf.put_slice(&frame); + + let result = codec.decode(&mut buf).unwrap(); + assert!(result.is_some(), "Should decode real S21 Pro frame"); + assert!( + matches!(result, Some(Response::Nonce { .. })), + "Should be nonce response" + ); + } + } + + #[test] + fn decoder_handles_stream_with_lost_bytes() { + let mut codec = FrameCodec; + + // Simulate a stream where some bytes in the middle are lost + let mut buf = BytesMut::new(); + // Start of first frame + buf.put_slice(&[0xaa, 0x55, 0x13, 0x70, 0x00]); // 5 bytes + // Lost bytes... skip to middle of nowhere + buf.put_slice(&[0x99, 0x22, 0xf9]); // Random bytes + // Valid complete frame + buf.put_slice(&[ + 0xaa, 0x55, 0x18, 0x00, 0xa6, 0x40, 0x02, 0x99, 0x22, 0xf9, 0x91, + ]); + + // Decoder should skip the incomplete/corrupted data and find the valid frame + let mut found_valid = false; + for _ in 0..20 { + // Try up to 20 times + if let Some(response) = codec.decode(&mut buf).unwrap() { + assert!(matches!(response, Response::Nonce { .. })); + found_valid = true; + break; + } + } + assert!(found_valid, "Should eventually find the valid frame"); + } + + #[test] + fn decoder_handles_mid_frame_start() { + let mut codec = FrameCodec; + + // Start reading in the middle of a frame + let mut buf = BytesMut::new(); + // Last 5 bytes of some frame + buf.put_slice(&[0x02, 0x99, 0x22, 0xf9, 0x91]); + // Valid complete frame + buf.put_slice(&[ + 0xaa, 0x55, 0x50, 0x03, 0x41, 0xd6, 0x00, 0x81, 0x18, 0x01, 0x9b, + ]); + + // Total: 5 + 11 = 16 bytes + // Should skip the partial frame bytes one by one until finding the valid frame + for i in 0..5 { + let result = codec.decode(&mut buf).unwrap(); + assert!(result.is_none(), "Decode {} should return None", i + 1); + assert_eq!( + buf.len(), + 16 - i - 1, + "Should have consumed {} bytes", + i + 1 + ); + } + + // Now we should have the valid frame + let result = codec.decode(&mut buf).unwrap(); + assert!( + result.is_some(), + "Should find valid frame after partial data" + ); + assert!( + matches!(result, Some(Response::Nonce { .. })), + "Should be nonce response" + ); + } + + #[test] + fn decoder_validates_real_register_responses() { + // Test all register read responses are handled correctly + let mut codec = FrameCodec; + + // Standard chip detection response + let mut buf = BytesMut::new(); + buf.put_slice(&[ + 0xaa, 0x55, 0x13, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, + ]); + + let response = codec.decode(&mut buf).unwrap().unwrap(); + match response { + Response::ReadRegister { + chip_address, + register, + } => { + assert_eq!(chip_address, 0x00); + assert!(matches!(register, Register::ChipId { .. })); + } + _ => panic!("Expected ReadRegister response"), + } + } + + #[test] + fn decode_nonce_response_from_esp_miner_capture() { + use crate::asic::bm13xx::test_data::esp_miner_job; + + // Decode nonce response from hardware capture and verify against test data + let response = + decode_frame(&esp_miner_job::wire_rx::FRAME).expect("Should decode valid frame"); + + let Response::Nonce { + nonce, + job_id, + midstate_num, + version, + subcore_id, + } = response + else { + panic!("Expected nonce response"); + }; + + // Verify all fields match test data + assert_eq!(nonce, *esp_miner_job::wire_rx::NONCE); + assert_eq!(midstate_num, *esp_miner_job::wire_rx::MIDSTATE_NUM); + assert_eq!(job_id, *esp_miner_job::wire_rx::JOB_ID); + assert_eq!(subcore_id, *esp_miner_job::wire_rx::SUBCORE_ID); + // VERSION_ROLLING_FIELD is u16, convert to big-endian bytes + let expected_bytes = esp_miner_job::wire_rx::VERSION_ROLLING_FIELD.to_be_bytes(); + assert_eq!(version, GeneralPurposeBits::new(expected_bytes)); + + // Verify version rolling field shifted left 13 matches submit VERSION + let bits_as_u16 = u16::from_be_bytes(*version.as_bytes()); + let version_shifted = (bits_as_u16 as u32) << 13; + assert_eq!( + version_shifted, + *esp_miner_job::submit::VERSION, + "Version rolling field << 13 should match mining.submit version" + ); + } + + #[test] + fn test_full_mining_round_trip() { + use bitcoin::block::Header as BlockHeader; + use tokio_util::codec::Encoder; + + use super::super::command::{JobCommand, JobFullFormat}; + use crate::asic::bm13xx::test_data::esp_miner_job; + use crate::types::Difficulty; + + // Build JobFullFormat, encode to wire, decode nonce response, + // apply version rolling, compute hash, and verify difficulty. + let job = JobFullFormat { + job_id: *esp_miner_job::wire_tx::JOB_ID, + num_midstates: esp_miner_job::wire_tx::NUM_MIDSTATES_BYTE[0], + starting_nonce: u32::from_le_bytes( + (*esp_miner_job::wire_tx::STARTING_NONCE_BYTES) + .try_into() + .unwrap(), + ), + nbits: *esp_miner_job::notify::NBITS, + ntime: *esp_miner_job::notify::NTIME, + merkle_root: *esp_miner_job::notify::MERKLE_ROOT, + prev_block_hash: *esp_miner_job::notify::PREV_BLOCKHASH, + version: *esp_miner_job::notify::VERSION, + }; + + let mut codec = FrameCodec; + let mut tx_frame = BytesMut::new(); + codec + .encode(JobCommand::JobFull(job.clone()), &mut tx_frame) + .expect("Should encode JobFull command"); + + // Our body bytes match esp-miner's wire capture. Byte 3 (length + // byte) and bytes 86..88 (CRC16) intentionally differ; see the + // length-byte comment in JobFullFormat::encode. + assert_eq!(&tx_frame[4..86], &esp_miner_job::wire_tx::FRAME[4..86]); + + let rx_response = + decode_frame(&esp_miner_job::wire_rx::FRAME).expect("Should decode RX frame"); + + let Response::Nonce { + nonce, + job_id: rx_job_id, + version: version_rolling, + .. + } = rx_response + else { + panic!("Expected Nonce response"); + }; + + assert_eq!(rx_job_id, job.job_id, "Job ID should round-trip"); + + let full_version = version_rolling.apply_to_version(job.version); + let header = BlockHeader { + version: full_version, + prev_blockhash: job.prev_block_hash, + merkle_root: job.merkle_root, + time: job.ntime, + bits: job.nbits, + nonce, + }; + + let hash = header.block_hash(); + let difficulty = Difficulty::from_hash(&hash); + + // Allow +/-1 tolerance for integer division rounding + let expected = Difficulty::from(esp_miner_job::EXPECTED_HASH_DIFFICULTY as u64); + assert!( + difficulty >= Difficulty::from(expected.as_u64() - 1) + && difficulty <= Difficulty::from(expected.as_u64() + 1), + "Hash difficulty should match esp-miner result" + ); + assert!( + difficulty >= Difficulty::from(esp_miner_job::POOL_SHARE_DIFFICULTY_INT), + "Hash should meet pool difficulty" + ); + } +} diff --git a/mujina-miner/src/asic/bm13xx/thread.rs b/mujina-miner/src/asic/bm13xx/thread.rs index a94a7e25..a175bd7e 100644 --- a/mujina-miner/src/asic/bm13xx/thread.rs +++ b/mujina-miner/src/asic/bm13xx/thread.rs @@ -17,11 +17,15 @@ use futures::{SinkExt, sink::Sink, stream::Stream}; use tokio::sync::{mpsc, oneshot, watch}; use tokio_stream::StreamExt; -use super::protocol::{ - self, AnalogMux, ChainInactive, Core, Destination, InitControl, IoDriverStrength, JobCommand, - Log2Difficulty, MiscControl, MiscSettings, NonceRange, Register, RegisterCommand, - SetChipAddress, TicketMask, VersionMask, WriteRegister, +use super::command::{ + ChainInactive, Destination, JobCommand, JobFullFormat, RegisterCommand, SetChipAddress, + WriteRegister, }; +use super::register::{ + AnalogMux, Core, InitControl, IoDriverStrength, Log2Difficulty, MiscControl, MiscSettings, + NonceRange, PllDivider, Register, TicketMask, VersionMask, +}; +use super::response::Response; use crate::{ asic::hash_thread::{ BoardPeripherals, HashTask, HashThread, HashThreadCapabilities, HashThreadEvent, @@ -137,7 +141,7 @@ impl BM13xxThread { removal_rx: watch::Receiver, ) -> Self where - R: Stream> + Unpin + Send + 'static, + R: Stream> + Unpin + Send + 'static, W: Sink + Sink + Unpin + Send + 'static, E: std::error::Error + Send + Sync + 'static, { @@ -446,7 +450,7 @@ fn generate_frequency_ramp_steps( start_mhz: f32, target_mhz: f32, step_mhz: f32, -) -> Vec { +) -> Vec { let mut configs = Vec::new(); let mut current = start_mhz; @@ -468,7 +472,7 @@ fn generate_frequency_ramp_steps( /// Extracts or computes the merkle root, then builds a JobFullFormat with all /// block header fields. For computed merkle roots, requires EN2. For fixed merkle /// roots (Stratum v2 header-only), uses the template's fixed value directly. -fn task_to_job_full(task: &HashTask, chip_job_id: u8) -> Result { +fn task_to_job_full(task: &HashTask, chip_job_id: u8) -> Result { use crate::job_source::MerkleRootKind; let template = task.template.as_ref(); @@ -490,7 +494,7 @@ fn task_to_job_full(task: &HashTask, chip_job_id: u8) -> Result *merkle_root, }; - Ok(protocol::JobFullFormat { + Ok(JobFullFormat { job_id: chip_job_id, num_midstates: 1, starting_nonce: 0, @@ -503,7 +507,7 @@ fn task_to_job_full(task: &HashTask, chip_job_id: u8) -> Result Option { +fn calculate_pll_for_frequency(target_freq: f32) -> Option { const CRYSTAL_FREQ: f32 = 25.0; const MAX_FREQ_ERROR: f32 = 1.0; @@ -553,11 +557,7 @@ fn calculate_pll_for_frequency(target_freq: f32) -> Option } let post_div = ((best_post_div1 - 1) << 4) | (best_post_div2 - 1); - Some(protocol::PllDivider::new( - best_fb_div, - best_ref_div, - post_div, - )) + Some(PllDivider::new(best_fb_div, best_ref_div, post_div)) } /// Internal actor task for BM13xxThread. @@ -580,7 +580,7 @@ async fn bm13xx_thread_actor( mut chip_commands: W, mut peripherals: BoardPeripherals, ) where - R: Stream> + Unpin, + R: Stream> + Unpin, W: Sink + Sink + Unpin, E: std::error::Error + Send + Sync + 'static, { @@ -765,7 +765,7 @@ async fn bm13xx_thread_actor( match result { Ok(response) => { match response { - protocol::Response::Nonce { nonce, job_id, version, midstate_num, subcore_id } => { + Response::Nonce { nonce, job_id, version, midstate_num, subcore_id } => { // Look up the task for this job_id if let Some(task) = chip_jobs.get(job_id) { let template = task.template.as_ref(); @@ -852,7 +852,7 @@ async fn bm13xx_thread_actor( let _ = (midstate_num, subcore_id); // Unused for now } - protocol::Response::ReadRegister { chip_address, register } => { + Response::ReadRegister { chip_address, register } => { trace!(chip_address = %format!("0x{:02x}", chip_address), register = ?register, "Register read response"); } } diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 7501d3b9..6066e797 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -25,7 +25,8 @@ use crate::{ ChipInfo, bm13xx::{ self, BM13xxProtocol, Register, Response, - protocol::{ChipId, Destination, RegisterCommand, VersionMask, WriteRegister}, + command::{Destination, RegisterCommand, WriteRegister}, + register::{ChipId, VersionMask}, thread::BM13xxThread, }, hash_thread::{AsicEnable, BoardPeripherals, HashThread, ThreadRemovalSignal}, From c53937b88da17dc416396a88262d0d45eb418e75 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Sat, 2 May 2026 22:53:55 -0500 Subject: [PATCH 20/38] refactor(bm13xx): delete BM13xxProtocol Only discover_chips retained a live caller. Inline its body at the Bitaxe discovery site and drop the rest as dead code. --- mujina-miner/src/asic/bm13xx/mod.rs | 4 - mujina-miner/src/asic/bm13xx/protocol.rs | 430 ---------------------- mujina-miner/src/asic/bm13xx/test_data.rs | 8 +- mujina-miner/src/board/bitaxe.rs | 11 +- 4 files changed, 11 insertions(+), 442 deletions(-) delete mode 100644 mujina-miner/src/asic/bm13xx/protocol.rs diff --git a/mujina-miner/src/asic/bm13xx/mod.rs b/mujina-miner/src/asic/bm13xx/mod.rs index 7dd87d13..2c3f3808 100644 --- a/mujina-miner/src/asic/bm13xx/mod.rs +++ b/mujina-miner/src/asic/bm13xx/mod.rs @@ -8,7 +8,6 @@ pub mod codec; pub mod command; pub mod crc; pub mod error; -pub mod protocol; pub mod register; pub mod response; pub mod thread; @@ -20,6 +19,3 @@ pub mod test_data; pub use codec::FrameCodec; pub use register::Register; pub use response::Response; - -// Re-export the protocol handler -pub use protocol::BM13xxProtocol; diff --git a/mujina-miner/src/asic/bm13xx/protocol.rs b/mujina-miner/src/asic/bm13xx/protocol.rs deleted file mode 100644 index 88886f7e..00000000 --- a/mujina-miner/src/asic/bm13xx/protocol.rs +++ /dev/null @@ -1,430 +0,0 @@ -//! Vestigial command-builder for BM13xx chips. -//! -//! [`BM13xxProtocol`] predates the typed `Register` payloads and -//! returns `Vec` for callers to execute. Only -//! `discover_chips` retains a live caller (Bitaxe). The multi-chip -//! EmberOne work replaces this type with a typed driver. - -use super::chip_config::ChipConfig; -use super::command::{ - ChainInactive, Destination, ReadRegister, RegisterCommand, SetChipAddress, WriteRegister, -}; -use super::error::ProtocolError; -use super::register::{ - AnalogMux, Core, InitControl, IoDriverStrength, Log2Difficulty, MiscControl, MiscSettings, - NonceRange, Pll3Parameter, PllDivider, Register, RegisterAddress, TicketMask, UartBaud, - UartRelay, VersionMask, -}; -use crate::types::{Difficulty, Frequency}; - -/// Protocol handler for BM13xx family chips. -/// -/// Encodes high-level operations into chip-specific commands and -/// decodes chip responses into meaningful results. -pub struct BM13xxProtocol {} - -impl Default for BM13xxProtocol { - fn default() -> Self { - Self::new() - } -} - -impl BM13xxProtocol { - /// Create a new protocol instance. - pub fn new() -> Self { - Self {} - } - - fn broadcast_write(&self, register: Register) -> RegisterCommand { - RegisterCommand::WriteRegister(WriteRegister { - destination: Destination::Broadcast, - register, - }) - } - - #[cfg_attr(not(test), allow(dead_code))] - fn write_to(&self, chip_address: u8, register: Register) -> RegisterCommand { - RegisterCommand::WriteRegister(WriteRegister { - destination: Destination::Chip(chip_address), - register, - }) - } - - /// Returns the initialization sequence for a single chip (e.g., Bitaxe). - /// - /// The commands configure the chip for mining: - /// 1. Enable version rolling - /// 2. Set PLL parameters for desired frequency using the chip's - /// family-specific PLL parameters - /// - /// Returns `None` when `frequency` is unreachable for this chip - /// model. - pub fn single_chip_init( - &self, - chip_config: &ChipConfig, - frequency: Frequency, - ) -> Option> { - let pll_config = chip_config.calculate_pll(frequency)?; - Some(vec![ - self.broadcast_write(Register::VersionMask(VersionMask::full_rolling())), - self.broadcast_write(Register::PllDivider(pll_config)), - ]) - } - - /// Initialize a multi-chip chain (e.g., S21 Pro, S19 J Pro). - /// - /// This follows the initialization sequence from production miners: - /// 1. Enable version rolling on all chips - /// 2. Configure initial settings - /// 3. Set chain inactive and assign addresses - /// 4. Configure domain boundaries - /// 5. Ramp up frequency gradually - #[cfg_attr(not(test), allow(dead_code))] - pub fn multi_chip_init(&self, chain_length: usize) -> Vec { - // Multi-chip initialization register values - const INIT_CONTROL_VALUE: u32 = 0x00000700; - const MISC_CONTROL_MULTI_CHIP: u32 = 0x0000c1f0; - const CORE_REG_INIT_1: u32 = 0x00008b80; - const CORE_REG_INIT_2: u32 = 0x0c800080; - const ADDRESS_INCREMENT: u8 = 2; - - // Pre-allocate for efficiency (rough estimate of commands) - let mut commands = Vec::with_capacity(10 + chain_length); - - // Step 1: Enable version rolling on all chips (broadcast) - commands.push(self.broadcast_write(Register::VersionMask(VersionMask::full_rolling()))); - - // Step 2: Configure init control register - commands.push(self.broadcast_write(Register::InitControl(InitControl(INIT_CONTROL_VALUE)))); - - // Step 3: Configure misc control - commands.push( - self.broadcast_write(Register::MiscControl(MiscControl(MISC_CONTROL_MULTI_CHIP))), - ); - - // Step 4: Set chain inactive for address assignment - commands.push(RegisterCommand::ChainInactive(ChainInactive)); - - // Step 5: Assign addresses (increment by 2) - for i in 0..chain_length { - let address = (i as u8) * ADDRESS_INCREMENT; - commands.push(RegisterCommand::SetChipAddress(SetChipAddress { - chip_address: address, - })); - } - - // Step 6: Configure core registers on all chips - commands.push(self.broadcast_write(Register::Core(Core(CORE_REG_INIT_1)))); - commands.push(self.broadcast_write(Register::Core(Core(CORE_REG_INIT_2)))); - - // Step 7: Set ticket mask (difficulty 256 = ~1 nonce/sec at 1 TH/s) - let log2_diff = Log2Difficulty::from_difficulty(Difficulty::from(256_u64)); - commands.push(self.broadcast_write(Register::TicketMask(TicketMask::new(log2_diff)))); - - // Step 8: Configure IO driver strength on all chips - commands.push(self.broadcast_write(Register::IoDriverStrength(IoDriverStrength::normal()))); - - // Step 9: Configure nonce range partitioning - commands.extend(self.configure_nonce_ranges(chain_length)); - - commands - } - - /// Configure domain boundaries for a multi-chip chain. - /// - /// Domains are groups of chips that share signal integrity settings. - /// This configures IO driver strength and UART relay for domain boundaries. - #[cfg_attr(not(test), allow(dead_code))] - pub fn configure_domains( - &self, - chain_length: usize, - chips_per_domain: usize, - ) -> Vec { - const UART_RELAY_BASE: u32 = 0x03000000; - const ADDRESS_INCREMENT: u8 = 2; - - let mut commands = Vec::new(); - let num_domains = chain_length.div_ceil(chips_per_domain); - - // Configure IO driver strength at domain boundaries - for domain in 0..num_domains { - let last_chip_in_domain = ((domain + 1) * chips_per_domain - 1).min(chain_length - 1); - let chip_address = (last_chip_in_domain as u8) * ADDRESS_INCREMENT; - - commands.push(self.write_to( - chip_address, - Register::IoDriverStrength(IoDriverStrength::domain_boundary()), - )); - } - - // Configure UART relay for each domain - for domain in 0..num_domains { - let first_chip = domain * chips_per_domain; - let last_chip = ((domain + 1) * chips_per_domain - 1).min(chain_length - 1); - - // Configure first chip in domain - let first_address = (first_chip as u8) * ADDRESS_INCREMENT; - let relay_offset = (domain * chips_per_domain) as u32; - commands.push(self.write_to( - first_address, - Register::UartRelay(UartRelay(UART_RELAY_BASE | (relay_offset << 8))), - )); - - // Configure last chip in domain - if first_chip != last_chip { - let last_address = (last_chip as u8) * ADDRESS_INCREMENT; - commands.push(self.write_to( - last_address, - Register::UartRelay(UartRelay(UART_RELAY_BASE | (relay_offset << 8))), - )); - } - } - - commands - } - - /// Configure nonce range partitioning for multi-chip operation. - /// - /// This distributes the 32-bit nonce space across all chips in the chain - /// to avoid duplicate work. Each chip searches a unique portion of the nonce space. - #[cfg_attr(not(test), allow(dead_code))] - pub fn configure_nonce_ranges(&self, chain_length: usize) -> Vec { - let mut commands = Vec::new(); - - // Calculate nonce range based on chain length - let nonce_config = NonceRange::multi_chip(chain_length); - - // Write nonce range to all chips - commands.push(self.broadcast_write(Register::NonceRange(nonce_config))); - - commands - } - - /// Create a command to read a register. - pub fn read_register(&self, chip_address: u8, register: RegisterAddress) -> RegisterCommand { - RegisterCommand::ReadRegister(ReadRegister { - destination: Destination::Chip(chip_address), - register_address: register, - }) - } - - /// Set UART baud rate on all chips - pub fn set_baudrate(&self, baudrate: UartBaud) -> RegisterCommand { - RegisterCommand::WriteRegister(WriteRegister { - destination: Destination::Broadcast, - register: Register::UartBaud(baudrate), - }) - } - - /// Create a command to write a register. - /// - /// Note: This is a placeholder - actual register encoding depends on the register type - pub fn write_register( - &self, - chip_address: u8, - register: RegisterAddress, - value: u32, - ) -> Result { - let register_value = match register { - RegisterAddress::ChipId => { - // Can't write chip ID register directly - return Err(ProtocolError::ReadOnlyRegister(register)); - } - RegisterAddress::PllDivider => { - Register::PllDivider(PllDivider::decode(value.to_le_bytes())) - } - RegisterAddress::NonceRange => { - Register::NonceRange(NonceRange::decode(value.to_le_bytes())) - } - RegisterAddress::TicketMask => { - Register::TicketMask(TicketMask::decode(value.to_le_bytes())) - } - RegisterAddress::MiscControl => Register::MiscControl(MiscControl(value)), - RegisterAddress::UartBaud => Register::UartBaud(UartBaud::Custom(value)), - RegisterAddress::UartRelay => Register::UartRelay(UartRelay(value)), - RegisterAddress::Core => Register::Core(Core(value)), - RegisterAddress::AnalogMux => Register::AnalogMux(AnalogMux(value)), - RegisterAddress::IoDriverStrength => { - Register::IoDriverStrength(IoDriverStrength::decode(value.to_le_bytes())) - } - RegisterAddress::Pll3Parameter => Register::Pll3Parameter(Pll3Parameter(value)), - RegisterAddress::VersionMask => { - Register::VersionMask(VersionMask::decode(value.to_le_bytes())) - } - RegisterAddress::InitControl => Register::InitControl(InitControl(value)), - RegisterAddress::MiscSettings => Register::MiscSettings(MiscSettings(value)), - }; - - Ok(RegisterCommand::WriteRegister(WriteRegister { - destination: Destination::Chip(chip_address), - register: register_value, - })) - } - - /// Create a broadcast command to discover all chips. - pub fn discover_chips() -> RegisterCommand { - RegisterCommand::ReadRegister(ReadRegister { - destination: Destination::Broadcast, - register_address: RegisterAddress::ChipId, - }) - } -} - -#[cfg(test)] -mod init_tests { - use bytes::BytesMut; - - use super::*; - - #[test] - fn multi_chip_init_sequence() { - let protocol = BM13xxProtocol::new(); - let commands = protocol.multi_chip_init(65); // S21 Pro has 65 chips - - // Verify the sequence starts with version rolling enable - assert!(matches!( - &commands[0], - RegisterCommand::WriteRegister(WriteRegister { - destination: Destination::Broadcast, - register: Register::VersionMask(_), - }) - )); - - // Verify chain inactive command - let chain_inactive_pos = commands - .iter() - .position(|c| matches!(c, RegisterCommand::ChainInactive(ChainInactive))) - .expect("ChainInactive command not found in initialization sequence"); - assert!(chain_inactive_pos > 0); - - // Verify chip addressing starts after chain inactive - let first_address_pos = chain_inactive_pos + 1; - assert!(matches!( - &commands[first_address_pos], - RegisterCommand::SetChipAddress(SetChipAddress { chip_address: 0x00 }) - )); - - // Verify we have 65 address assignments - let address_commands: Vec<_> = commands[first_address_pos..first_address_pos + 65] - .iter() - .collect(); - assert_eq!(address_commands.len(), 65); - - // Verify addresses increment by 2 - for (i, cmd) in address_commands.iter().enumerate() { - match cmd { - RegisterCommand::SetChipAddress(SetChipAddress { chip_address }) => { - assert_eq!(*chip_address, (i * 2) as u8); - } - _ => panic!("Expected SetChipAddress command, got {:?}", cmd), - } - } - } - - #[test] - fn domain_configuration() { - let protocol = BM13xxProtocol::new(); - let commands = protocol.configure_domains(65, 5); // 65 chips, 5 per domain - - // Should have 13 domains - let io_strength_commands: Vec<_> = commands - .iter() - .filter(|c| { - matches!( - c, - RegisterCommand::WriteRegister(WriteRegister { - register: Register::IoDriverStrength { .. }, - .. - }) - ) - }) - .collect(); - assert_eq!(io_strength_commands.len(), 13); - - // Check first domain boundary (chip 8 = address 0x08) - let first_boundary = io_strength_commands[0]; - if let RegisterCommand::WriteRegister(WriteRegister { - destination: Destination::Chip(chip_address), - register: Register::IoDriverStrength(strength), - }) = first_boundary - { - assert_eq!(*chip_address, 0x08); // 5th chip (index 4) * 2 - let mut buf = BytesMut::new(); - strength.encode(&mut buf); - // Expected bytes from hardware capture - assert_eq!(&buf[..], &[0x00, 0xf1, 0x11, 0x11]); - } - } - - #[test] - fn nonce_range_configuration() { - let protocol = BM13xxProtocol::new(); - - // Test single chip - full range - let commands = protocol.configure_nonce_ranges(1); - assert_eq!(commands.len(), 1); - if let RegisterCommand::WriteRegister(WriteRegister { - register: Register::NonceRange(config), - destination: Destination::Broadcast, - }) = &commands[0] - { - let mut buf = BytesMut::new(); - config.encode(&mut buf); - assert_eq!(&buf[..], &[0xff, 0xff, 0xff, 0xff]); - } - - // Test S21 Pro configuration (65 chips) - let commands = protocol.configure_nonce_ranges(65); - assert_eq!(commands.len(), 1); - if let RegisterCommand::WriteRegister(WriteRegister { - register: Register::NonceRange(config), - .. - }) = &commands[0] - { - let mut buf = BytesMut::new(); - config.encode(&mut buf); - assert_eq!(&buf[..], &[0x00, 0x00, 0x1e, 0xb5]); - } - - // Test small chain - let commands = protocol.configure_nonce_ranges(8); - if let RegisterCommand::WriteRegister(WriteRegister { - register: Register::NonceRange(config), - .. - }) = &commands[0] - { - let mut buf = BytesMut::new(); - config.encode(&mut buf); - assert_eq!(&buf[..], &[0xff, 0xff, 0xff, 0x1f]); - } - } - - #[test] - fn multi_chip_init_includes_nonce_range() { - let protocol = BM13xxProtocol::new(); - let commands = protocol.multi_chip_init(65); - - // Find the nonce range configuration - let nonce_range_cmd = commands.iter().find(|c| { - matches!( - c, - RegisterCommand::WriteRegister(WriteRegister { - register: Register::NonceRange { .. }, - .. - }) - ) - }); - - assert!(nonce_range_cmd.is_some()); - - if let Some(RegisterCommand::WriteRegister(WriteRegister { - register: Register::NonceRange(config), - .. - })) = nonce_range_cmd - { - let mut buf = BytesMut::new(); - config.encode(&mut buf); - assert_eq!(&buf[..], &[0x00, 0x00, 0x1e, 0xb5]); // S21 Pro value - } - } -} diff --git a/mujina-miner/src/asic/bm13xx/test_data.rs b/mujina-miner/src/asic/bm13xx/test_data.rs index e648bb16..e787e2a3 100644 --- a/mujina-miner/src/asic/bm13xx/test_data.rs +++ b/mujina-miner/src/asic/bm13xx/test_data.rs @@ -15,10 +15,10 @@ //! consistency of the test data itself (e.g., that Stratum constants match //! wire frame values, that computed merkle roots match captured values). //! -//! **Parser tests live in their respective modules:** -//! - Stratum parsing tests → `stratum_v1::messages::tests` -//! - Job conversion tests → `job_source::stratum_v1::tests` -//! - Wire protocol tests → `asic::bm13xx::protocol::tests` +//! **Parser tests live in the module that owns the type under test:** +//! - Stratum parsing tests in `stratum_v1::messages::tests` +//! - Job conversion tests in `job_source::stratum_v1::tests` +//! - Wire protocol tests alongside the wire types in `asic::bm13xx` //! //! This separation ensures test_data remains a reference dataset that other //! modules can depend on without circular dependencies. diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 6066e797..7e0e0415 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -24,9 +24,9 @@ use crate::{ asic::{ ChipInfo, bm13xx::{ - self, BM13xxProtocol, Register, Response, - command::{Destination, RegisterCommand, WriteRegister}, - register::{ChipId, VersionMask}, + self, Register, Response, + command::{Destination, ReadRegister, RegisterCommand, WriteRegister}, + register::{ChipId, RegisterAddress, VersionMask}, thread::BM13xxThread, }, hash_thread::{AsicEnable, BoardPeripherals, HashThread, ThreadRemovalSignal}, @@ -554,7 +554,10 @@ async fn discover_chips( reader: &mut FramedRead, bm13xx::FrameCodec>, writer: &mut FramedWrite, ) -> Result> { - let discover_cmd = BM13xxProtocol::discover_chips(); + let discover_cmd = RegisterCommand::ReadRegister(ReadRegister { + destination: Destination::Broadcast, + register_address: RegisterAddress::ChipId, + }); writer .send(discover_cmd) From a854ea17c66ea976d5a446ade9cd2687436a7221 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Thu, 2 Jul 2026 19:20:13 -0500 Subject: [PATCH 21/38] fix(bm13xx): correct domain-boundary drive strength The IO driver strength register holds a 4-bit drive strength for each of the chip's output pins: command, busy, reset, and clock toward the next chip, and response toward the host. Factory firmware runs every pin at strength 1 and raises the clock output to maximum on the last chip of each voltage domain, the chip that drives the clock across the domain gap. Our value for domain-boundary chips came from reading capture bytes in the wrong order, so it strengthened an unnamed high field instead of the clock output: factory firmware sends 00 01 F1 11 where we sent 00 F1 11 11. Name the drive strength fields so the register layout is recorded in the type, correct the boundary value, and check both values byte for byte against frames from the factory captures. Record the field layout in the protocol notes as well. --- mujina-miner/src/asic/bm13xx/PROTOCOL.md | 23 ++++++-- mujina-miner/src/asic/bm13xx/command.rs | 32 ++++++++++- mujina-miner/src/asic/bm13xx/register.rs | 68 ++++++++++++++++-------- 3 files changed, 96 insertions(+), 27 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/PROTOCOL.md b/mujina-miner/src/asic/bm13xx/PROTOCOL.md index 3ba6f344..c2ce9f12 100644 --- a/mujina-miner/src/asic/bm13xx/PROTOCOL.md +++ b/mujina-miner/src/asic/bm13xx/PROTOCOL.md @@ -517,10 +517,25 @@ Controls analog multiplexer, possibly for temperature sensing: - Purpose not fully documented by manufacturer #### 0x58 - IO_DRIVER_STRENGTH -Controls IO signal driver strength (4 bytes): -- Normal chips: 0x00011111 -- Domain-end chips: 0x0001F111 (stronger drive for signal integrity) -- Configured differently for last chip in each domain +Sets the drive strength of each chip output pin. Each output has a +4-bit field: + +| Bits | Field | Output | +|-------|----------|-------------------------------| +| 0-3 | CO_DS | Command output (to next chip) | +| 4-7 | BO_DS | Busy output | +| 8-11 | NRSTO_DS | Reset output | +| 12-15 | CLKO_DS | Clock output | +| 16-19 | RO_DS | Response output (to host) | +| 20-27 | (varies) | Relay enables, RF strength | + +Note: this register's value travels big-endian on the wire, unlike +most register data. Value 0x0001F111 appears as bytes `00 01 F1 11`. + +Values observed in factory captures: +- All chips at init: 0x00011111 (every output at strength 1) +- Last chip of each domain: 0x0001F111 (clock output raised to + maximum; the boundary chip drives the clock across the domain gap) #### 0x68 - PLL3_PARAMETER PLL3 configuration for multi-chip chains: diff --git a/mujina-miner/src/asic/bm13xx/command.rs b/mujina-miner/src/asic/bm13xx/command.rs index 159a78a4..08a63f8e 100644 --- a/mujina-miner/src/asic/bm13xx/command.rs +++ b/mujina-miner/src/asic/bm13xx/command.rs @@ -351,8 +351,8 @@ mod tests { use super::super::codec::FrameCodec; use super::super::register::{ - ChipId, ChipModel, Core, InitControl, Log2Difficulty, MiscControl, NonceRange, Register, - RegisterAddress, TicketMask, VersionMask, + ChipId, ChipModel, Core, InitControl, IoDriverStrength, Log2Difficulty, MiscControl, + NonceRange, Register, RegisterAddress, TicketMask, VersionMask, }; use super::*; use crate::asic::bm13xx::crc::crc16; @@ -494,6 +494,34 @@ mod tests { ); } + #[test] + fn write_io_driver_strength_from_capture() { + // From S19 J Pro and S21 Pro captures: TX: 55 AA 51 09 00 58 00 01 11 11 0D + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::IoDriverStrength(IoDriverStrength::normal()), + }), + &[ + 0x55, 0xaa, 0x51, 0x09, 0x00, 0x58, 0x00, 0x01, 0x11, 0x11, 0x0d, + ], + ); + } + + #[test] + fn write_domain_boundary_io_driver_strength_from_capture() { + // From S21 Pro capture: TX: 55 AA 41 09 08 58 00 01 F1 11 1F + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Chip(0x08), + register: Register::IoDriverStrength(IoDriverStrength::domain_boundary()), + }), + &[ + 0x55, 0xaa, 0x41, 0x09, 0x08, 0x58, 0x00, 0x01, 0xf1, 0x11, 0x1f, + ], + ); + } + #[test] fn job_full_format_encoding() { use bitcoin::CompactTarget; diff --git a/mujina-miner/src/asic/bm13xx/register.rs b/mujina-miner/src/asic/bm13xx/register.rs index a9797a20..212df231 100644 --- a/mujina-miner/src/asic/bm13xx/register.rs +++ b/mujina-miner/src/asic/bm13xx/register.rs @@ -445,46 +445,72 @@ impl Core { } } -/// IO driver strength configuration -#[derive(Debug, Clone, Copy, PartialEq)] +/// Drive strength of each chip output pin. +/// +/// Each output has a 4-bit drive strength. Factory firmware runs +/// every output at strength 1 and raises the clock output on the +/// last chip of each voltage domain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct IoDriverStrength { - /// Drive strength for each signal group (4 bits each) - strengths: [u8; 8], + /// Drive strength of the command output (CO), toward the next chip. + command_out: u8, + /// Drive strength of the busy output (BO), toward the next chip. + busy_out: u8, + /// Drive strength of the reset output (NRSTO), toward the next chip. + reset_out: u8, + /// Drive strength of the clock output (CLKO), toward the next chip. + clock_out: u8, + /// Drive strength of the response output (RO), toward the host. + response_out: u8, + /// Relay enables and RF drive strength; zero in all captured traffic. + high_bits: u16, } impl IoDriverStrength { - /// Normal strength for chips in middle of chain + /// Returns the baseline strength: every output at 1. pub fn normal() -> Self { - // 0x11110100 = 0001 0001 0001 0001 0000 0001 0000 0000 Self { - strengths: [0x0, 0x0, 0x1, 0x0, 0x1, 0x1, 0x1, 0x1], + command_out: 0x1, + busy_out: 0x1, + reset_out: 0x1, + clock_out: 0x1, + response_out: 0x1, + high_bits: 0, } } - /// Strong drive for domain boundary chips + /// Returns the strength for the last chip of a voltage domain: + /// clock output at maximum, the rest at the baseline. The boundary + /// chip drives the clock across the gap to the next domain. pub fn domain_boundary() -> Self { - // 0x1111f100 = 0001 0001 0001 0001 1111 0001 0000 0000 Self { - strengths: [0x0, 0x0, 0x1, 0xf, 0x1, 0x1, 0x1, 0x1], + clock_out: 0xf, + ..Self::normal() } } pub fn encode(&self, dst: &mut BytesMut) { - // Pack 8 4-bit values into 4 bytes (2 per byte). - // Each byte contains two strength values: [high_nibble|low_nibble]. - dst.put_u8(self.strengths[0] | (self.strengths[1] << 4)); - dst.put_u8(self.strengths[2] | (self.strengths[3] << 4)); - dst.put_u8(self.strengths[4] | (self.strengths[5] << 4)); - dst.put_u8(self.strengths[6] | (self.strengths[7] << 4)); + // Unlike most registers, captures show this register's value + // big-endian on the wire: 0x0001F111 is sent as 00 01 F1 11. + let value = (self.high_bits as u32) << 20 + | (self.response_out as u32) << 16 + | (self.clock_out as u32) << 12 + | (self.reset_out as u32) << 8 + | (self.busy_out as u32) << 4 + | self.command_out as u32; + dst.put_u32(value); } pub fn decode(bytes: [u8; 4]) -> Self { - let raw = u32::from_le_bytes(bytes); - let mut strengths = [0u8; 8]; - for (i, strength) in strengths.iter_mut().enumerate() { - *strength = ((raw >> (i * 4)) & 0xf) as u8; + let value = u32::from_be_bytes(bytes); + Self { + command_out: (value & 0xf) as u8, + busy_out: (value >> 4 & 0xf) as u8, + reset_out: (value >> 8 & 0xf) as u8, + clock_out: (value >> 12 & 0xf) as u8, + response_out: (value >> 16 & 0xf) as u8, + high_bits: (value >> 20) as u16, } - Self { strengths } } } From e5b0e35d31113d0223fa409fea1d739d11874821 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Sat, 2 May 2026 23:50:43 -0500 Subject: [PATCH 22/38] test(bm13xx): round-trip each register value type Add round-trip tests to verify each register payload's encode and decode fns invert. UartBaud::Baud1M is broken: encode and decode use different constants. Mark its test #[should_panic] and fix later. --- mujina-miner/src/asic/bm13xx/register.rs | 173 +++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/mujina-miner/src/asic/bm13xx/register.rs b/mujina-miner/src/asic/bm13xx/register.rs index 212df231..9380efce 100644 --- a/mujina-miner/src/asic/bm13xx/register.rs +++ b/mujina-miner/src/asic/bm13xx/register.rs @@ -718,4 +718,177 @@ mod ticket_mask_tests { assert_eq!(reverse_bits(0x03), 0xC0); assert_eq!(reverse_bits(0x0F), 0xF0); } + + fn round_trip(difficulty: Difficulty) { + let mask = TicketMask::new(Log2Difficulty::from_difficulty(difficulty)); + let mut buf = BytesMut::new(); + mask.encode(&mut buf); + let bytes: [u8; 4] = buf[..].try_into().unwrap(); + assert_eq!(TicketMask::decode(bytes), mask); + } + + #[test] + fn round_trip_difficulty_1() { + round_trip(Difficulty::from(1_u64)); + } + + #[test] + fn round_trip_difficulty_256() { + round_trip(Difficulty::from(256_u64)); + } +} + +#[cfg(test)] +mod chip_id_tests { + use super::*; + + fn round_trip(original: ChipId) { + let mut buf = BytesMut::new(); + original.encode(&mut buf); + let bytes: [u8; 4] = buf[..].try_into().unwrap(); + assert_eq!(ChipId::decode(bytes), original); + } + + #[test] + fn known_model() { + round_trip(ChipId { + model: ChipModel::BM1362, + core_count: 80, + address: 0x42, + }); + } + + #[test] + fn unknown_model() { + round_trip(ChipId { + model: ChipModel::Unknown([0x12, 0x34]), + core_count: 0, + address: 0x00, + }); + } +} + +#[cfg(test)] +mod pll_divider_tests { + use super::*; + + fn round_trip(original: PllDivider) { + let mut buf = BytesMut::new(); + original.encode(&mut buf); + let bytes: [u8; 4] = buf[..].try_into().unwrap(); + assert_eq!(PllDivider::decode(bytes), original); + } + + #[test] + fn from_new() { + round_trip(PllDivider::new(100, 1, 0x00)); + } + + #[test] + fn from_literal_fields() { + round_trip(PllDivider { + flag: 0x40, + fb_div: 0x68, + ref_div: 0x01, + post_div: 0x33, + }); + } +} + +#[cfg(test)] +mod nonce_range_tests { + use super::*; + + fn round_trip(original: NonceRange) { + let mut buf = BytesMut::new(); + original.encode(&mut buf); + let bytes: [u8; 4] = buf[..].try_into().unwrap(); + assert_eq!(NonceRange::decode(bytes), original); + } + + #[test] + fn single_chip() { + round_trip(NonceRange::single_chip()); + } + + #[test] + fn multi_chip() { + round_trip(NonceRange::multi_chip(16)); + } + + #[test] + fn from_raw() { + round_trip(NonceRange::from_raw(0xdeadbeef)); + } +} + +#[cfg(test)] +mod uart_baud_tests { + use super::*; + + fn round_trip(original: UartBaud) { + let mut buf = BytesMut::new(); + original.encode(&mut buf); + let bytes: [u8; 4] = buf[..].try_into().unwrap(); + assert_eq!(UartBaud::decode(bytes), original); + } + + #[test] + fn baud_115200() { + round_trip(UartBaud::Baud115200); + } + + #[test] + #[should_panic] + fn baud_1m() { + // Currently fails: encode emits 0x00023011 but decode + // matches 0x00000130 for Baud1M, so the round-trip + // collapses to Custom. Drop #[should_panic] once the + // constants are reconciled. + round_trip(UartBaud::Baud1M); + } + + #[test] + fn baud_3m() { + round_trip(UartBaud::Baud3M); + } + + #[test] + fn custom_value() { + round_trip(UartBaud::Custom(0xdeadbeef)); + } +} + +#[cfg(test)] +mod io_driver_strength_tests { + use super::*; + + fn round_trip(original: IoDriverStrength) { + let mut buf = BytesMut::new(); + original.encode(&mut buf); + let bytes: [u8; 4] = buf[..].try_into().unwrap(); + assert_eq!(IoDriverStrength::decode(bytes), original); + } + + #[test] + fn normal() { + round_trip(IoDriverStrength::normal()); + } +} + +#[cfg(test)] +mod version_mask_tests { + use super::*; + + fn round_trip(original: VersionMask) { + let mut buf = BytesMut::new(); + original.encode(&mut buf); + let bytes: [u8; 4] = buf[..].try_into().unwrap(); + assert_eq!(VersionMask::decode(bytes), original); + } + + #[test] + fn full_rolling() { + round_trip(VersionMask::full_rolling()); + } } From 1627a637b931a3ed7b12e4fb9b6722eb1cdf3f45 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Sun, 3 May 2026 00:16:51 -0500 Subject: [PATCH 23/38] refactor(bm13xx): collapse duplicated sink bounds The hash thread takes its chip-bound sink as a generic with two parallel `Sink` bounds, one per command family. The pair repeats verbatim at every signature that drives the chain and runs past 100 characters, burying the parameter that matters behind where-clause boilerplate. Introduce `ChipCommandSink` as a single-name supertrait of the two `Sink` impls, with a blanket impl that auto-applies to any type already carrying both bounds. Call sites collapse to one name. --- mujina-miner/src/asic/bm13xx/command.rs | 12 ++++++++++++ mujina-miner/src/asic/bm13xx/thread.rs | 12 ++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/command.rs b/mujina-miner/src/asic/bm13xx/command.rs index 08a63f8e..737381ec 100644 --- a/mujina-miner/src/asic/bm13xx/command.rs +++ b/mujina-miner/src/asic/bm13xx/command.rs @@ -8,9 +8,21 @@ use bitcoin::hashes::Hash; use bitvec::prelude::*; use bytes::{BufMut, BytesMut}; +use futures::sink::Sink; use super::register::{Register, RegisterAddress}; +/// Sink that accepts both BM13xx command families. +pub trait ChipCommandSink: + Sink + Sink +{ +} + +impl ChipCommandSink for T where + T: Sink + Sink +{ +} + /// TYPE=2 frames: register reads/writes and chain addressing. Use CRC5. #[derive(Debug)] pub enum RegisterCommand { diff --git a/mujina-miner/src/asic/bm13xx/thread.rs b/mujina-miner/src/asic/bm13xx/thread.rs index a175bd7e..69f10b43 100644 --- a/mujina-miner/src/asic/bm13xx/thread.rs +++ b/mujina-miner/src/asic/bm13xx/thread.rs @@ -13,13 +13,13 @@ use std::sync::{Arc, RwLock}; use anyhow::{Context as _, Result, anyhow}; use async_trait::async_trait; use bitcoin::block::Header as BlockHeader; -use futures::{SinkExt, sink::Sink, stream::Stream}; +use futures::{SinkExt, stream::Stream}; use tokio::sync::{mpsc, oneshot, watch}; use tokio_stream::StreamExt; use super::command::{ - ChainInactive, Destination, JobCommand, JobFullFormat, RegisterCommand, SetChipAddress, - WriteRegister, + ChainInactive, ChipCommandSink, Destination, JobCommand, JobFullFormat, RegisterCommand, + SetChipAddress, WriteRegister, }; use super::register::{ AnalogMux, Core, InitControl, IoDriverStrength, Log2Difficulty, MiscControl, MiscSettings, @@ -142,7 +142,7 @@ impl BM13xxThread { ) -> Self where R: Stream> + Unpin + Send + 'static, - W: Sink + Sink + Unpin + Send + 'static, + W: ChipCommandSink + Unpin + Send + 'static, E: std::error::Error + Send + Sync + 'static, { let (cmd_tx, cmd_rx) = mpsc::channel(10); @@ -255,7 +255,7 @@ async fn initialize_chip( asic_difficulty: Log2Difficulty, ) -> Result<()> where - W: Sink + Sink + Unpin, + W: ChipCommandSink + Unpin, E: std::error::Error + Send + Sync + 'static, { // Enable the ASIC @@ -581,7 +581,7 @@ async fn bm13xx_thread_actor( mut peripherals: BoardPeripherals, ) where R: Stream> + Unpin, - W: Sink + Sink + Unpin, + W: ChipCommandSink + Unpin, E: std::error::Error + Send + Sync + 'static, { // Disable ASIC on startup to establish known state From a282c1637e803c54750e7329ce665d69f0368923 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Sun, 3 May 2026 00:59:59 -0500 Subject: [PATCH 24/38] refactor(bm13xx): name PLL VCO flag values Replace magic numbers in the PLL divider constructor with named constants for the VCO threshold and flag bytes, and share one crystal frequency constant with the PLL search loop. Move the flag test next to the constructor. The old test recomputed the expected flag with the same constants the constructor uses, so it could only fail in lockstep. The new one asserts hand-picked flags for VCOs below, at, and above the threshold. --- mujina-miner/src/asic/bm13xx/chip_config.rs | 20 +--------- mujina-miner/src/asic/bm13xx/register.rs | 42 ++++++++++++++++++--- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/chip_config.rs b/mujina-miner/src/asic/bm13xx/chip_config.rs index 2931bd29..fee9b2d2 100644 --- a/mujina-miner/src/asic/bm13xx/chip_config.rs +++ b/mujina-miner/src/asic/bm13xx/chip_config.rs @@ -138,7 +138,7 @@ pub struct PllParams { } /// Crystal oscillator frequency for BM13xx chips (25 MHz). -const CRYSTAL_MHZ: f32 = 25.0; +pub(super) const CRYSTAL_MHZ: f32 = 25.0; #[cfg(test)] mod tests { @@ -219,24 +219,6 @@ mod tests { assert_eq!(bm1370().calculate_pll(Frequency::from_mhz(40.0)), None); } - #[test] - fn pll_vco_flag_set_correctly() { - // Flag 0x50 for vco >= 2400 MHz, otherwise 0x40. Both families - // use the same rule; VCO bounds differ but the threshold does not. - for config in [bm1362(), bm1370()] { - for freq_mhz in [100.0, 200.0, 300.0, 400.0, 500.0, 525.0] { - let pll = config.calculate_pll(Frequency::from_mhz(freq_mhz)).unwrap(); - let vco = pll.fb_div as f32 * CRYSTAL_MHZ / pll.ref_div as f32; - let expected = if vco >= 2400.0 { 0x50 } else { 0x40 }; - assert_eq!( - pll.flag, expected, - "{:?} {} MHz: VCO={:.1} should use flag=0x{:02X}, got 0x{:02X}", - config.model, freq_mhz, vco, expected, pll.flag - ); - } - } - } - #[test] fn bm1362_and_bm1370_pll_identical_in_shared_vco_range() { // Within the VCO range both models accept (BM1362 [1600, 3200] diff --git a/mujina-miner/src/asic/bm13xx/register.rs b/mujina-miner/src/asic/bm13xx/register.rs index 9380efce..7ae80b25 100644 --- a/mujina-miner/src/asic/bm13xx/register.rs +++ b/mujina-miner/src/asic/bm13xx/register.rs @@ -10,6 +10,7 @@ use bytes::{BufMut, BytesMut}; use std::fmt; use strum::FromRepr; +use super::chip_config::CRYSTAL_MHZ; use crate::types::Difficulty; /// Register addresses on the wire. @@ -205,12 +206,18 @@ pub struct PllDivider { } impl PllDivider { - /// Create a PLL configuration, deriving the VCO flag from the - /// resulting VCO frequency: `vco >= 2400 MHz` picks flag `0x50`, - /// otherwise flag `0x40`. + /// Builds a [`PllDivider`] with `flag` derived from the dividers. pub fn new(fb_div: u8, ref_div: u8, post_div: u8) -> Self { - let vco_mhz = fb_div as f32 * 25.0 / ref_div as f32; - let flag = if vco_mhz >= 2400.0 { 0x50 } else { 0x40 }; + const VCO_FLAG_THRESHOLD_MHZ: f32 = 2400.0; + const PLL_FLAG_HIGH_VCO: u8 = 0x50; + const PLL_FLAG_LOW_VCO: u8 = 0x40; + + let vco_mhz = fb_div as f32 * CRYSTAL_MHZ / ref_div as f32; + let flag = if vco_mhz >= VCO_FLAG_THRESHOLD_MHZ { + PLL_FLAG_HIGH_VCO + } else { + PLL_FLAG_LOW_VCO + }; Self { flag, fb_div, @@ -793,6 +800,31 @@ mod pll_divider_tests { post_div: 0x33, }); } + + #[test] + fn new_picks_flag_from_resulting_vco() { + // VCO = fb_div * crystal / ref_div. Pick targets across the + // boundary, back-derive fb_div, and assert the flag matches + // the bracket. The threshold rule is `>=`, so a target that + // hits the boundary exactly picks the high flag. + const REF_DIV: u8 = 2; + let fb_div_for = |vco_mhz: f32| (vco_mhz * REF_DIV as f32 / CRYSTAL_MHZ) as u8; + + let cases = [ + (2000.0, 0x40u8), // below + (2400.0, 0x50), // at threshold (>= picks high) + (2800.0, 0x50), // above + ]; + for (target_vco, expected_flag) in cases { + let fb_div = fb_div_for(target_vco); + assert_eq!( + PllDivider::new(fb_div, REF_DIV, 0).flag, + expected_flag, + "target VCO {} MHz", + target_vco, + ); + } + } } #[cfg(test)] From 319305111cc0e8cbb88b86b416999e22255d22b5 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Sun, 3 May 2026 09:10:04 -0500 Subject: [PATCH 25/38] feat(bm13xx): add chain and topology domain model A BM13xx hash board organizes chips along two orthogonal axes: serial chain order and voltage-domain grouping. TopologySpec captures the static wiring (which domain each chain position belongs to). Chain is the live model that owns the Chip and Domain objects, holds enumeration addresses, and carries per-chip runtime stats. Factory methods on TopologySpec cover the common layouts: one domain per chip (EmberOne), a single domain for all chips (Bitaxe), equal-sized cycling domains (S21 Pro), and an explicit mapping with contiguous-domain validation for unusual wiring. Address assignment uses the interval-2 convention seen in firmware captures across the family. Tests pin the assignment against hardware constants from S21 Pro, S19 J Pro, and Bitaxe captures, and reject chip counts that overflow the 8-bit address space. --- mujina-miner/src/asic/bm13xx/chain.rs | 332 +++++++++++++++++++++++ mujina-miner/src/asic/bm13xx/mod.rs | 2 + mujina-miner/src/asic/bm13xx/topology.rs | 219 +++++++++++++++ 3 files changed, 553 insertions(+) create mode 100644 mujina-miner/src/asic/bm13xx/chain.rs create mode 100644 mujina-miner/src/asic/bm13xx/topology.rs diff --git a/mujina-miner/src/asic/bm13xx/chain.rs b/mujina-miner/src/asic/bm13xx/chain.rs new file mode 100644 index 00000000..0529d2ef --- /dev/null +++ b/mujina-miner/src/asic/bm13xx/chain.rs @@ -0,0 +1,332 @@ +//! Live runtime model of the chip serial chain, built from a TopologySpec. +//! +//! TopologySpec is the static blueprint; Chain is what the miner actively +//! tracks during a session. It owns the Chip and Domain instances, holds +//! the serial addresses assigned during enumeration, and carries per-chip +//! runtime stats that mutate as work flows. +//! +//! Chain order and domain grouping are orthogonal. Chain order is how +//! chips are wired for serial communication; domains group chips that +//! share voltage rails. A domain's chips may not be contiguous in chain +//! order. + +use std::time::Instant; + +use crate::types::HashRate; + +use super::topology::TopologySpec; + +/// The complete chain model. +/// +/// Owns all chips and domains. Structure reflects physical wiring; state +/// reflects current operation. +#[derive(Debug, Clone)] +pub struct Chain { + chips: Vec, + domains: Vec, +} + +impl Chain { + /// Builds the chain structure from a topology specification. + /// + /// Creates chips and domains based on the topology. Addresses are not + /// yet assigned; call `assign_addresses()` after construction. + pub fn from_topology(spec: &TopologySpec) -> Self { + let chip_count = spec.expected_chip_count(); + + let chips: Vec = (0..chip_count) + .map(|i| Chip { + address: 0, + domain: spec.domain_for(ChipId(i)), + stats: ChipStats::default(), + }) + .collect(); + + let domain_count = spec.domain_count(); + let mut domains: Vec = (0..domain_count) + .map(|i| Domain { + id: DomainId(i), + chips: Vec::new(), + }) + .collect(); + + for (i, chip) in chips.iter().enumerate() { + domains[chip.domain.index()].chips.push(ChipId(i)); + } + + Self { chips, domains } + } + + /// Assigns chip addresses with the standard interval of 2. + /// + /// Interval 2 is the convention observed in firmware captures: + /// - S21 Pro (65 chips): addresses 0x00, 0x02, ... 0x80 + /// - S19 J Pro (126 chips): addresses 0x00, 0x02, ... 0xFA + /// + /// Returns error if chip count exceeds address space (max 128 chips). + pub fn assign_addresses(&mut self) -> Result<(), AddressOverflowError> { + self.assign_addresses_with_interval(2) + } + + /// Assigns addresses with an explicit interval for unusual boards. + pub fn assign_addresses_with_interval( + &mut self, + interval: u8, + ) -> Result<(), AddressOverflowError> { + if self.chips.is_empty() { + return Ok(()); + } + + let last_address = (self.chips.len() - 1) * interval as usize; + if last_address > 0xFF { + return Err(AddressOverflowError { + chip_count: self.chips.len(), + interval, + }); + } + + for (i, chip) in self.chips.iter_mut().enumerate() { + chip.address = (i * interval as usize) as u8; + } + + Ok(()) + } + + pub fn chip(&self, id: ChipId) -> &Chip { + &self.chips[id.index()] + } + + pub fn chip_mut(&mut self, id: ChipId) -> &mut Chip { + &mut self.chips[id.index()] + } + + pub fn domain(&self, id: DomainId) -> &Domain { + &self.domains[id.index()] + } + + /// Iterates over all chips in chain order. + pub fn chips(&self) -> impl Iterator { + self.chips.iter().enumerate().map(|(i, c)| (ChipId(i), c)) + } + + /// Iterates over all domains. + pub fn domains(&self) -> impl Iterator { + self.domains.iter() + } + + /// Iterates over domains from far end toward host (for domain + /// configuration). + pub fn domains_far_to_near(&self) -> impl Iterator { + self.domains.iter().rev() + } + + /// Returns the first chip of the domain in chain order. + pub fn domain_first(&self, id: DomainId) -> ChipId { + *self.domain(id).chips.first().expect("domain has no chips") + } + + /// Returns the last chip of the domain in chain order. + pub fn domain_last(&self, id: DomainId) -> ChipId { + *self.domain(id).chips.last().expect("domain has no chips") + } + + pub fn chip_count(&self) -> usize { + self.chips.len() + } + + pub fn domain_count(&self) -> usize { + self.domains.len() + } +} + +/// Individual BM13xx ASIC chip. +#[derive(Debug, Clone)] +pub struct Chip { + /// Serial address (assigned during enumeration) + pub address: u8, + /// Which voltage domain this chip belongs to + pub domain: DomainId, + /// Runtime statistics + pub stats: ChipStats, +} + +/// A voltage domain: a group of chips sharing power rails. +#[derive(Debug, Clone)] +pub struct Domain { + pub id: DomainId, + /// Chips in this domain, ordered by chain position + pub chips: Vec, +} + +/// Runtime statistics for a single chip. +#[derive(Debug, Clone, Default)] +pub struct ChipStats { + pub shares_found: u64, + pub last_share_time: Option, + pub estimated_hashrate: HashRate, + pub healthy: bool, +} + +/// Type-safe index for chips in the chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ChipId(pub usize); + +impl ChipId { + pub fn index(self) -> usize { + self.0 + } +} + +/// Type-safe index for voltage domains. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct DomainId(pub usize); + +impl DomainId { + pub fn index(self) -> usize { + self.0 + } +} + +/// Error when chip addresses would overflow the 8-bit address space. +#[derive(Debug, Clone)] +pub struct AddressOverflowError { + pub chip_count: usize, + pub interval: u8, +} + +impl std::fmt::Display for AddressOverflowError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "address overflow: {} chips with interval {} exceeds 0xFF", + self.chip_count, self.interval + ) + } +} + +impl std::error::Error for AddressOverflowError {} + +#[cfg(test)] +mod tests { + use super::*; + + /// Hardware constants from protocol captures. + mod fixtures { + /// Antminer S21 Pro: 65 BM1370 chips in 13 voltage domains. + pub mod s21_pro { + pub const CHIP_COUNT: usize = 65; + pub const DOMAIN_COUNT: usize = 13; + pub const CHIPS_PER_DOMAIN: usize = 5; + pub const FIRST_CHIP_ADDRESS: u8 = 0x00; + pub const LAST_CHIP_ADDRESS: u8 = 0x80; // (65-1) * 2 + /// Last chip address in each domain (chips 4, 9, 14, ... 64). + pub const DOMAIN_END_ADDRESSES: [u8; 13] = [ + 0x08, 0x12, 0x1C, 0x26, 0x30, 0x3A, 0x44, 0x4E, 0x58, 0x62, 0x6C, 0x76, 0x80, + ]; + } + + /// Antminer S19 J Pro: 126 BM1362 chips, single domain. + pub mod s19_jpro { + pub const CHIP_COUNT: usize = 126; + pub const FIRST_CHIP_ADDRESS: u8 = 0x00; + pub const LAST_CHIP_ADDRESS: u8 = 0xFA; // (126-1) * 2 + } + + /// Bitaxe Gamma: single BM1370 chip. + pub mod bitaxe { + pub const CHIP_COUNT: usize = 1; + pub const CHIP_ADDRESS: u8 = 0x00; + } + } + + #[test] + fn s21_pro_addresses() { + use fixtures::s21_pro::*; + + let topology = TopologySpec::uniform_domains(DOMAIN_COUNT, CHIPS_PER_DOMAIN, true); + let mut chain = Chain::from_topology(&topology); + chain.assign_addresses().unwrap(); + + assert_eq!(chain.chip_count(), CHIP_COUNT); + assert_eq!(chain.chip(ChipId(0)).address, FIRST_CHIP_ADDRESS); + assert_eq!( + chain.chip(ChipId(CHIP_COUNT - 1)).address, + LAST_CHIP_ADDRESS + ); + } + + #[test] + fn s21_pro_domain_ends() { + use fixtures::s21_pro::*; + + let topology = TopologySpec::uniform_domains(DOMAIN_COUNT, CHIPS_PER_DOMAIN, true); + let mut chain = Chain::from_topology(&topology); + chain.assign_addresses().unwrap(); + + let actual: Vec = (0..DOMAIN_COUNT) + .map(|d| chain.chip(chain.domain_last(DomainId(d))).address) + .collect(); + + assert_eq!(actual.as_slice(), DOMAIN_END_ADDRESSES); + } + + #[test] + fn s19_jpro_addresses() { + use fixtures::s19_jpro::*; + + let topology = TopologySpec::single_domain(CHIP_COUNT); + let mut chain = Chain::from_topology(&topology); + chain.assign_addresses().unwrap(); + + assert_eq!(chain.chip(ChipId(0)).address, FIRST_CHIP_ADDRESS); + assert_eq!( + chain.chip(ChipId(CHIP_COUNT - 1)).address, + LAST_CHIP_ADDRESS + ); + } + + #[test] + fn bitaxe_single_chip() { + use fixtures::bitaxe::*; + + let topology = TopologySpec::single_domain(CHIP_COUNT); + let mut chain = Chain::from_topology(&topology); + chain.assign_addresses().unwrap(); + + assert_eq!(chain.chip_count(), 1); + assert_eq!(chain.chip(ChipId(0)).address, CHIP_ADDRESS); + } + + #[test] + fn address_overflow_detected() { + // 129 chips * interval 2 = last address 256, which overflows u8 + let topology = TopologySpec::single_domain(129); + let mut chain = Chain::from_topology(&topology); + + let result = chain.assign_addresses(); + assert!(result.is_err()); + } + + #[test] + fn domain_chips_in_chain_order() { + // With contiguous domains, chips 0-4 are domain 0, 5-9 are domain 1, etc. + let topology = TopologySpec::uniform_domains(3, 5, true); + let chain = Chain::from_topology(&topology); + + let domain0_chips: Vec = chain + .domain(DomainId(0)) + .chips + .iter() + .map(|c| c.index()) + .collect(); + assert_eq!(domain0_chips, vec![0, 1, 2, 3, 4]); + + let domain1_chips: Vec = chain + .domain(DomainId(1)) + .chips + .iter() + .map(|c| c.index()) + .collect(); + assert_eq!(domain1_chips, vec![5, 6, 7, 8, 9]); + } +} diff --git a/mujina-miner/src/asic/bm13xx/mod.rs b/mujina-miner/src/asic/bm13xx/mod.rs index 2c3f3808..4107fb4f 100644 --- a/mujina-miner/src/asic/bm13xx/mod.rs +++ b/mujina-miner/src/asic/bm13xx/mod.rs @@ -3,6 +3,7 @@ //! This module provides protocol implementation and utilities for //! communicating with BM13xx series mining chips (BM1366, BM1370, etc). +pub mod chain; pub mod chip_config; pub mod codec; pub mod command; @@ -11,6 +12,7 @@ pub mod error; pub mod register; pub mod response; pub mod thread; +pub mod topology; #[cfg(test)] pub mod test_data; diff --git a/mujina-miner/src/asic/bm13xx/topology.rs b/mujina-miner/src/asic/bm13xx/topology.rs new file mode 100644 index 00000000..a8019f80 --- /dev/null +++ b/mujina-miner/src/asic/bm13xx/topology.rs @@ -0,0 +1,219 @@ +//! Static specification of chip wiring on a BM13xx hash board. +//! +//! For each chain position, identifies the voltage domain the chip +//! belongs to and whether the board needs domain boundary configuration. +//! TopologySpec is the input to `chain::Chain`, which is the live runtime +//! model populated and updated during a mining session. + +use std::collections::HashSet; + +use super::chain::{ChipId, DomainId}; + +/// Describes the physical wiring of chips on a board. +/// +/// For each chain position, specifies which voltage domain the chip belongs +/// to. Also indicates whether the board needs domain boundary configuration +/// (IO driver strength, UART relay). +#[derive(Debug, Clone)] +pub struct TopologySpec { + /// For each chain position, which domain does that chip belong to? + chip_domains: Vec, + /// Whether this board needs domain boundary configuration + needs_domain_config: bool, +} + +impl TopologySpec { + /// Uniform domains with equal chip counts (S21 Pro style). + /// + /// Chain visits all chips in domain 0, then domain 1, etc. + /// Each domain has the same number of chips. + /// + /// Example: `uniform_domains(13, 5, true)` creates 65 chips in 13 domains of 5. + pub fn uniform_domains( + domain_count: usize, + chips_per_domain: usize, + needs_domain_config: bool, + ) -> Self { + let chip_domains = (0..domain_count * chips_per_domain) + .map(|i| DomainId(i / chips_per_domain)) + .collect(); + Self { + chip_domains, + needs_domain_config, + } + } + + /// Individual domains, one per chip (EmberOne style). + /// + /// Each chip is its own voltage domain. Useful for boards where each + /// chip has independent power regulation. + pub fn individual_domains(chip_count: usize, needs_domain_config: bool) -> Self { + let chip_domains = (0..chip_count).map(DomainId).collect(); + Self { + chip_domains, + needs_domain_config, + } + } + + /// Single domain for all chips (Bitaxe, simple boards). + /// + /// All chips share one voltage domain. No domain boundary configuration + /// is needed. + pub fn single_domain(chip_count: usize) -> Self { + Self { + chip_domains: vec![DomainId(0); chip_count], + needs_domain_config: false, + } + } + + /// Explicit mapping for complex or non-standard routing. + /// + /// Domain IDs must be contiguous starting from 0 (e.g., `[0, 0, 1, 1, 2, 2]`). + /// Returns error if domains are sparse (e.g., `[0, 2, 3]` skipping 1). + pub fn custom( + chip_domains: Vec, + needs_domain_config: bool, + ) -> Result { + if chip_domains.is_empty() { + return Ok(Self { + chip_domains: Vec::new(), + needs_domain_config, + }); + } + + let max_domain = *chip_domains.iter().max().unwrap(); + let unique_domains: HashSet = chip_domains.iter().copied().collect(); + + let expected: HashSet = (0..=max_domain).collect(); + if unique_domains != expected { + return Err(NonContiguousDomainsError { + found: unique_domains.into_iter().collect(), + }); + } + + Ok(Self { + chip_domains: chip_domains.into_iter().map(DomainId).collect(), + needs_domain_config, + }) + } + + /// Number of chips in the topology. + pub fn expected_chip_count(&self) -> usize { + self.chip_domains.len() + } + + /// Number of domains in the topology. + pub fn domain_count(&self) -> usize { + self.chip_domains + .iter() + .map(|d| d.index()) + .max() + .map(|m| m + 1) + .unwrap_or(0) + } + + /// Which domain does the chip at this chain position belong to? + pub fn domain_for(&self, id: ChipId) -> DomainId { + self.chip_domains[id.index()] + } + + /// Whether this board needs domain boundary configuration. + pub fn needs_domain_config(&self) -> bool { + self.needs_domain_config + } +} + +/// Error when domain IDs are not contiguous. +#[derive(Debug, Clone)] +pub struct NonContiguousDomainsError { + pub found: Vec, +} + +impl std::fmt::Display for NonContiguousDomainsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "domain IDs must be contiguous from 0, found: {:?}", + self.found + ) + } +} + +impl std::error::Error for NonContiguousDomainsError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uniform_domains_creates_correct_chip_count() { + let spec = TopologySpec::uniform_domains(13, 5, true); + assert_eq!(spec.expected_chip_count(), 65); + assert_eq!(spec.domain_count(), 13); + } + + #[test] + fn uniform_domains_assigns_domains_correctly() { + let spec = TopologySpec::uniform_domains(3, 2, true); + // 6 chips: [D0, D0, D1, D1, D2, D2] + assert_eq!(spec.domain_for(ChipId(0)), DomainId(0)); + assert_eq!(spec.domain_for(ChipId(1)), DomainId(0)); + assert_eq!(spec.domain_for(ChipId(2)), DomainId(1)); + assert_eq!(spec.domain_for(ChipId(3)), DomainId(1)); + assert_eq!(spec.domain_for(ChipId(4)), DomainId(2)); + assert_eq!(spec.domain_for(ChipId(5)), DomainId(2)); + } + + #[test] + fn individual_domains_creates_separate_domains() { + let spec = TopologySpec::individual_domains(12, false); + assert_eq!(spec.expected_chip_count(), 12); + assert_eq!(spec.domain_count(), 12); + + for i in 0..12 { + assert_eq!(spec.domain_for(ChipId(i)), DomainId(i)); + } + } + + #[test] + fn single_domain_puts_all_in_domain_zero() { + let spec = TopologySpec::single_domain(5); + assert_eq!(spec.expected_chip_count(), 5); + assert_eq!(spec.domain_count(), 1); + + for i in 0..5 { + assert_eq!(spec.domain_for(ChipId(i)), DomainId(0)); + } + } + + #[test] + fn custom_accepts_valid_domains() { + let spec = TopologySpec::custom(vec![0, 0, 1, 1, 2, 2], true).unwrap(); + assert_eq!(spec.expected_chip_count(), 6); + assert_eq!(spec.domain_count(), 3); + assert!(spec.needs_domain_config()); + } + + #[test] + fn custom_rejects_sparse_domains() { + // Skips domain 1 + let result = TopologySpec::custom(vec![0, 0, 2, 2], true); + assert!(result.is_err()); + } + + #[test] + fn custom_accepts_empty() { + let spec = TopologySpec::custom(vec![], false).unwrap(); + assert_eq!(spec.expected_chip_count(), 0); + assert_eq!(spec.domain_count(), 0); + } + + #[test] + fn needs_domain_config_propagates() { + let with = TopologySpec::uniform_domains(2, 3, true); + assert!(with.needs_domain_config()); + + let without = TopologySpec::single_domain(5); + assert!(!without.needs_domain_config()); + } +} From ef858395a8f594fac6f79f9ad868dcc2581fc90f Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Fri, 3 Jul 2026 17:23:14 -0500 Subject: [PATCH 26/38] refactor(bm13xx): type 0xA8 as SoftResetControl Register 0xA8 drives soft resets inside the chip. Every write in the captures is either the register's hardware reset default or the default plus the bits that assert a reset on that model of chip. Until now the register was a raw placeholder whose name and values were copied from a reference implementation without understanding. Replace the placeholder, which encoded its value little-endian, with a typed register that encodes big-endian, so values in the code read like the values in the captures and references. Add two constructors, both taking the chip model, to cover the operations we have evidence for: the reset default, broadcast during bring-up, and the core reset, written to each chip just before its cores are configured. Test both operations on both models against the exact bytes from the captures. Stop short of decomposing the register into bit fields: the models disagree on the layout, and most bits have no reliable name in any reference. Record what is known of the bit maps on the type and in the protocol document. --- mujina-miner/src/asic/bm13xx/PROTOCOL.md | 41 +++++++--- mujina-miner/src/asic/bm13xx/command.rs | 55 ++++++++++++-- mujina-miner/src/asic/bm13xx/register.rs | 97 ++++++++++++++++++++++-- mujina-miner/src/asic/bm13xx/thread.rs | 8 +- 4 files changed, 175 insertions(+), 26 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/PROTOCOL.md b/mujina-miner/src/asic/bm13xx/PROTOCOL.md index c2ce9f12..b78954f5 100644 --- a/mujina-miner/src/asic/bm13xx/PROTOCOL.md +++ b/mujina-miner/src/asic/bm13xx/PROTOCOL.md @@ -389,7 +389,7 @@ Key registers used across BM13xx chips: | 0x58 | IO_DRIVER_STRENGTH | IO driver strength configuration | | 0x68 | PLL3_PARAMETER | PLL3 configuration (multi-chip chains) | | 0xA4 | VERSION_MASK | Version rolling mask configuration | -| 0xA8 | INIT_CONTROL | Initialization control register | +| 0xA8 | SOFT_RESET_CONTROL | Chip-internal soft resets | | 0xB9 | MISC_SETTINGS | Miscellaneous settings (BM1370 only, value 0x00004480) | ### Register Details @@ -550,16 +550,35 @@ Controls version rolling for AsicBoost optimization (32-bit register): - Initial: `0xFFFF0090` (full rolling enabled) - Stratum: `0x3FFF0090` (from version_mask=0x1FFFE000) -#### 0xA8 - INIT_CONTROL -Initialization control register requiring specific magic values (purpose undocumented): -- **Initial broadcast** (all chips): - - BM1362: `0x00000000` - - BM1366/68/70: `0x00070000` -- **Per-chip configuration**: - - BM1362: `0x02000000` - - BM1366/68/70: `0xF0010700` -- Written twice: first broadcast to all chips, then individually to each chip -- Values appear fixed across all implementations, suggesting required magic values +#### 0xA8 - SOFT_RESET_CONTROL +Drives chip-internal soft resets. The register first appears in the +BM1362 generation (BM1397 has no 0xA8) and its bit layout varies by +model. "Core" here means the whole hashing array as a +reset domain, in contrast to the always-on control logic that speaks +UART and distributes work; nothing in this register addresses +individual cores. + +Bit layout: +- **BM1362**: bit 0 CORE_SRST, bit 1 CORE_SRST_FAST, bit 2 TVER_RST, + bit 3 TOPCTRL_RST, bit 4 CHIP_RST. Resets to `0x00000000`. +- **BM1366/68/70**: bits 0-3 runtime core soft reset; bits 4-8 set + once per chip at bring-up and kept set while hashing; bits 16-18 + set from power-on and preserved by every write. Resets to + `0x00070000`. + +Every observed write is either the model's reset default or the +default plus reset-assert bits: +- **Broadcast during bring-up**, normalizing chip state before + enumeration: the reset default. BM1362 `0x00000000`, + BM1366/68/70 `0x00070000`. +- **Per chip, immediately before core configuration**, asserting the + core reset: BM1362 `0x00000002` (CORE_SRST_FAST), + BM1366/68/70 `0x000701F0`. + +Each 0xA8 write is followed by a MISC_CONTROL (0x18) write; the two +registers cooperate during reset sequencing (MISC_CONTROL bits 16-19 +move with the reset state). The register is write-only in practice: +no capture reads it back. #### 0xB9 - MISC_SETTINGS (BM1370 only) Undocumented miscellaneous settings register: diff --git a/mujina-miner/src/asic/bm13xx/command.rs b/mujina-miner/src/asic/bm13xx/command.rs index 737381ec..47b936f0 100644 --- a/mujina-miner/src/asic/bm13xx/command.rs +++ b/mujina-miner/src/asic/bm13xx/command.rs @@ -363,8 +363,8 @@ mod tests { use super::super::codec::FrameCodec; use super::super::register::{ - ChipId, ChipModel, Core, InitControl, IoDriverStrength, Log2Difficulty, MiscControl, - NonceRange, Register, RegisterAddress, TicketMask, VersionMask, + ChipId, ChipModel, Core, IoDriverStrength, Log2Difficulty, MiscControl, NonceRange, + Register, RegisterAddress, SoftResetControl, TicketMask, VersionMask, }; use super::*; use crate::asic::bm13xx::crc::crc16; @@ -414,13 +414,12 @@ mod tests { } #[test] - fn write_init_control_from_capture() { + fn write_soft_reset_defaults_from_capture() { // From Bitaxe capture: TX: 55 AA 51 09 00 A8 00 07 00 00 03 - // Value 0x00 07 00 00 in little-endian = 0x00000700 assert_frame_eq( RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, - register: Register::InitControl(InitControl(0x00000700)), + register: Register::SoftResetControl(SoftResetControl::defaults(ChipModel::BM1370)), }), &[ 0x55, 0xaa, 0x51, 0x09, 0x00, 0xa8, 0x00, 0x07, 0x00, 0x00, 0x03, @@ -428,6 +427,52 @@ mod tests { ); } + #[test] + fn write_soft_reset_core_reset_from_capture() { + // From Bitaxe capture: TX: 55 AA 41 09 00 A8 00 07 01 F0 15 + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Chip(0x00), + register: Register::SoftResetControl(SoftResetControl::core_reset( + ChipModel::BM1370, + )), + }), + &[ + 0x55, 0xaa, 0x41, 0x09, 0x00, 0xa8, 0x00, 0x07, 0x01, 0xf0, 0x15, + ], + ); + } + + #[test] + fn write_soft_reset_defaults_bm1362_from_capture() { + // From S19j Pro capture: TX: 55 AA 51 09 00 A8 00 00 00 00 01 + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::SoftResetControl(SoftResetControl::defaults(ChipModel::BM1362)), + }), + &[ + 0x55, 0xaa, 0x51, 0x09, 0x00, 0xa8, 0x00, 0x00, 0x00, 0x00, 0x01, + ], + ); + } + + #[test] + fn write_soft_reset_core_reset_bm1362_from_capture() { + // From S19j Pro capture: TX: 55 AA 41 09 00 A8 00 00 00 02 03 + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Chip(0x00), + register: Register::SoftResetControl(SoftResetControl::core_reset( + ChipModel::BM1362, + )), + }), + &[ + 0x55, 0xaa, 0x41, 0x09, 0x00, 0xa8, 0x00, 0x00, 0x00, 0x02, 0x03, + ], + ); + } + #[test] fn write_misc_control_from_capture() { // From Bitaxe capture: TX: 55 AA 51 09 00 18 F0 00 C1 00 04 diff --git a/mujina-miner/src/asic/bm13xx/register.rs b/mujina-miner/src/asic/bm13xx/register.rs index 7ae80b25..cba78ffe 100644 --- a/mujina-miner/src/asic/bm13xx/register.rs +++ b/mujina-miner/src/asic/bm13xx/register.rs @@ -29,7 +29,7 @@ pub enum RegisterAddress { IoDriverStrength = 0x58, Pll3Parameter = 0x68, VersionMask = 0xA4, - InitControl = 0xA8, + SoftResetControl = 0xA8, MiscSettings = 0xB9, } @@ -48,7 +48,7 @@ pub enum Register { IoDriverStrength(IoDriverStrength), Pll3Parameter(Pll3Parameter), VersionMask(VersionMask), - InitControl(InitControl), + SoftResetControl(SoftResetControl), MiscSettings(MiscSettings), } @@ -69,7 +69,9 @@ impl Register { } RegisterAddress::Pll3Parameter => Register::Pll3Parameter(Pll3Parameter::decode(bytes)), RegisterAddress::VersionMask => Register::VersionMask(VersionMask::decode(bytes)), - RegisterAddress::InitControl => Register::InitControl(InitControl::decode(bytes)), + RegisterAddress::SoftResetControl => { + Register::SoftResetControl(SoftResetControl::decode(bytes)) + } RegisterAddress::MiscSettings => Register::MiscSettings(MiscSettings::decode(bytes)), } } @@ -88,7 +90,7 @@ impl Register { Register::IoDriverStrength(_) => RegisterAddress::IoDriverStrength, Register::Pll3Parameter(_) => RegisterAddress::Pll3Parameter, Register::VersionMask(_) => RegisterAddress::VersionMask, - Register::InitControl(_) => RegisterAddress::InitControl, + Register::SoftResetControl(_) => RegisterAddress::SoftResetControl, Register::MiscSettings(_) => RegisterAddress::MiscSettings, } } @@ -107,7 +109,7 @@ impl Register { Register::IoDriverStrength(r) => r.encode(dst), Register::Pll3Parameter(r) => r.encode(dst), Register::VersionMask(r) => r.encode(dst), - Register::InitControl(r) => r.encode(dst), + Register::SoftResetControl(r) => r.encode(dst), Register::MiscSettings(r) => r.encode(dst), } } @@ -572,6 +574,66 @@ impl fmt::Debug for VersionMask { } } +/// Soft reset control (0xA8). +/// +/// Drives chip-internal soft resets. The register first appears in +/// the BM1362 generation (BM1397 has no 0xA8) and its bit layout +/// varies by model. +/// +/// BM1362: +/// - bit 0: CORE_SRST +/// - bit 1: CORE_SRST_FAST +/// - bit 2: TVER_RST +/// - bit 3: TOPCTRL_RST +/// - bit 4: CHIP_RST +/// - resets to 0x0000_0000 +/// +/// BM1366 and later: +/// - bits 0-3: runtime core-domain soft reset +/// - bits 4-8: set once per chip at bring-up, kept set while hashing +/// - bits 16-18: set from power-on, preserved by every write +/// - resets to 0x0007_0000 +/// +/// "Core" here means the whole hashing array as a reset domain, in +/// contrast to the always-on control logic; nothing in this register +/// addresses individual cores. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct SoftResetControl(pub u32); + +impl SoftResetControl { + /// Returns the hardware reset value, broadcast during bring-up + /// to normalize chip state before enumeration. + pub fn defaults(model: ChipModel) -> Self { + match model { + ChipModel::BM1362 => Self(0x0000_0000), + _ => Self(0x0007_0000), + } + } + + /// Returns the value asserting the core-domain reset, written + /// per chip immediately before core configuration. + pub fn core_reset(model: ChipModel) -> Self { + match model { + ChipModel::BM1362 => Self(0x0000_0002), + _ => Self(0x0007_01F0), + } + } + + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_u32(self.0); + } + + pub fn decode(bytes: [u8; 4]) -> Self { + Self(u32::from_be_bytes(bytes)) + } +} + +impl fmt::Debug for SoftResetControl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SoftResetControl({:#010x})", self.0) + } +} + // Placeholder newtypes for registers whose bit layout is not yet // decomposed. Each wraps a raw u32 written little-endian to the wire. macro_rules! raw_u32_register { @@ -598,7 +660,6 @@ raw_u32_register! { UartRelay, AnalogMux, Pll3Parameter, - InitControl, MiscSettings, } @@ -924,3 +985,27 @@ mod version_mask_tests { round_trip(VersionMask::full_rolling()); } } + +#[cfg(test)] +mod soft_reset_control_tests { + use super::*; + + fn round_trip(original: SoftResetControl) { + let mut buf = BytesMut::new(); + original.encode(&mut buf); + let bytes: [u8; 4] = buf[..].try_into().unwrap(); + assert_eq!(SoftResetControl::decode(bytes), original); + } + + #[test] + fn defaults() { + round_trip(SoftResetControl::defaults(ChipModel::BM1362)); + round_trip(SoftResetControl::defaults(ChipModel::BM1370)); + } + + #[test] + fn core_reset() { + round_trip(SoftResetControl::core_reset(ChipModel::BM1362)); + round_trip(SoftResetControl::core_reset(ChipModel::BM1370)); + } +} diff --git a/mujina-miner/src/asic/bm13xx/thread.rs b/mujina-miner/src/asic/bm13xx/thread.rs index 69f10b43..f0aea7f8 100644 --- a/mujina-miner/src/asic/bm13xx/thread.rs +++ b/mujina-miner/src/asic/bm13xx/thread.rs @@ -22,8 +22,8 @@ use super::command::{ SetChipAddress, WriteRegister, }; use super::register::{ - AnalogMux, Core, InitControl, IoDriverStrength, Log2Difficulty, MiscControl, MiscSettings, - NonceRange, PllDivider, Register, TicketMask, VersionMask, + AnalogMux, ChipModel, Core, IoDriverStrength, Log2Difficulty, MiscControl, MiscSettings, + NonceRange, PllDivider, Register, SoftResetControl, TicketMask, VersionMask, }; use super::response::Response; use crate::{ @@ -290,7 +290,7 @@ where chip_commands .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, - register: Register::InitControl(InitControl(0x00000700)), + register: Register::SoftResetControl(SoftResetControl::defaults(ChipModel::BM1370)), })) .await?; chip_commands @@ -350,7 +350,7 @@ where chip_commands .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Chip(0x00), - register: Register::InitControl(InitControl(0xF0010700)), + register: Register::SoftResetControl(SoftResetControl::core_reset(ChipModel::BM1370)), })) .await?; chip_commands From 0a149911d2cebbfee8e3f292270a0664fd9efb46 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Fri, 3 Jul 2026 17:49:28 -0500 Subject: [PATCH 27/38] refactor(bm13xx): type 0xA4 as MidstateConfig Register 0xA4 configures how the chip generates midstates and rolls version bits. Our old type modeled it as a 16-bit mask plus a magic 16-bit enable constant, byte-swapped relative to the values in the captures and references. Read big-endian, the magic constant is just two flags and a small field: automatic midstate generation, a version-fix flag, and a 2-bit code for how many midstates the chip generates per job. Rename the type to match its wider job and decompose the value into those fields. Keep the constructor for the one configuration every capture uses, so the bytes on the wire do not change. Store the generation code raw rather than as a midstate count, because the mapping from code to count differs by chip model; the doc comment records the mapping. Thin the protocol document's section on this register down to the narrative, following the rule that bit layouts live on the typed register. --- mujina-miner/src/asic/bm13xx/PROTOCOL.md | 28 +++--- mujina-miner/src/asic/bm13xx/command.rs | 8 +- mujina-miner/src/asic/bm13xx/register.rs | 104 ++++++++++++++--------- mujina-miner/src/asic/bm13xx/thread.rs | 8 +- mujina-miner/src/board/bitaxe.rs | 4 +- 5 files changed, 90 insertions(+), 62 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/PROTOCOL.md b/mujina-miner/src/asic/bm13xx/PROTOCOL.md index b78954f5..b62fa262 100644 --- a/mujina-miner/src/asic/bm13xx/PROTOCOL.md +++ b/mujina-miner/src/asic/bm13xx/PROTOCOL.md @@ -219,7 +219,7 @@ merkle_root[32] | prev_block_hash[32] | version[4] | - The chip extracts bits 6-3 as the job identifier - **num_midstates** (1 byte): Number of midstates (always 0x01 for BM1370) - ESP-miner hardcodes this to 0x01 regardless of version rolling - - Version rolling is actually controlled by register 0xA4 (VERSION_MASK) + - Version rolling is actually controlled by register 0xA4 (MIDSTATE_CONFIG) - This field may be vestigial for chips using full format - **starting_nonce** (4 bytes): Starting nonce value (always 0x00000000) - **nbits** (4 bytes): Encoded difficulty target (little-endian) @@ -264,7 +264,7 @@ midstates for version rolling. In this format: Since BM1362/BM1370 calculate midstates internally, mujina-miner uses the full format exclusively. Version rolling is controlled by register 0xA4 -(VERSION_MASK), not by the `num_midstates` field. +(MIDSTATE_CONFIG), not by the `num_midstates` field. ## Response Types @@ -388,7 +388,7 @@ Key registers used across BM13xx chips: | 0x54 | ANALOG_MUX | Analog mux control (rumored to control temp diode) | | 0x58 | IO_DRIVER_STRENGTH | IO driver strength configuration | | 0x68 | PLL3_PARAMETER | PLL3 configuration (multi-chip chains) | -| 0xA4 | VERSION_MASK | Version rolling mask configuration | +| 0xA4 | MIDSTATE_CONFIG | Midstate generation and version rolling | | 0xA8 | SOFT_RESET_CONTROL | Chip-internal soft resets | | 0xB9 | MISC_SETTINGS | Miscellaneous settings (BM1370 only, value 0x00004480) | @@ -542,13 +542,15 @@ PLL3 configuration for multi-chip chains: - Value: 0x5AA55AA5 (appears to be a magic pattern) - Only used in multi-chip configurations -#### 0xA4 - VERSION_MASK -Controls version rolling for AsicBoost optimization (32-bit register): -- **Bits 0-15 (control)**: Always `0x0090` - fixed enable pattern in all implementations -- **Bits 16-31 (mask)**: Which version bits can be rolled (from Stratum's version_mask >> 13) -- Common values: - - Initial: `0xFFFF0090` (full rolling enabled) - - Stratum: `0x3FFF0090` (from version_mask=0x1FFFE000) +#### 0xA4 - MIDSTATE_CONFIG +Configures version rolling for AsicBoost: a 16-bit mask of rollable +version bits, a midstate generation code, and a flag for automatic +midstate generation. The bit layout and the model-specific meaning +of the generation code live on the typed register in the code. +Every capture writes `0x9000FFFF` (full mask, generation code 1, +automatic generation on). A pool's version-rolling mask maps to the +register's mask field shifted right by 13 bits, so Stratum's +`0x1FFFE000` becomes a register mask of `0xFFFF`. #### 0xA8 - SOFT_RESET_CONTROL Drives chip-internal soft resets. The register first appears in the @@ -592,7 +594,7 @@ Undocumented miscellaneous settings register: ### Single-Chip Initialization (e.g., Bitaxe) 1. **Chip Detection** - - Write 0xFFFF0090 to register 0xA4 (enable and set version mask) + - Write 0x9000FFFF to register 0xA4 (enable and set version mask) - Read register 0x00 to get chip_id - Verify chip type @@ -615,7 +617,7 @@ Undocumented miscellaneous settings register: ### Multi-Chip Initialization (e.g., S21 Pro, S19 J Pro) 1. **Chain Reset and Discovery** - - Write 0xFFFF0090 to register 0xA4 three times + - Write 0x9000FFFF to register 0xA4 three times - Broadcast read register 0x00 (command 0x52) - Count responding chips @@ -841,7 +843,7 @@ nonce range by modifying the block version field. - Continues until all allowed version values are exhausted 2. **Version Rolling Control**: - - Version rolling is enabled via register 0xA4 (VERSION_MASK) + - Version rolling is enabled via register 0xA4 (MIDSTATE_CONFIG) - The chip internally modifies version bits as allowed by the mask - For BM1370, ESP-miner always sets `num_midstates = 1` - AsicBoost optimization happens internally in the chip diff --git a/mujina-miner/src/asic/bm13xx/command.rs b/mujina-miner/src/asic/bm13xx/command.rs index 47b936f0..02faf8fc 100644 --- a/mujina-miner/src/asic/bm13xx/command.rs +++ b/mujina-miner/src/asic/bm13xx/command.rs @@ -363,8 +363,8 @@ mod tests { use super::super::codec::FrameCodec; use super::super::register::{ - ChipId, ChipModel, Core, IoDriverStrength, Log2Difficulty, MiscControl, NonceRange, - Register, RegisterAddress, SoftResetControl, TicketMask, VersionMask, + ChipId, ChipModel, Core, IoDriverStrength, Log2Difficulty, MidstateConfig, MiscControl, + NonceRange, Register, RegisterAddress, SoftResetControl, TicketMask, }; use super::*; use crate::asic::bm13xx::crc::crc16; @@ -400,12 +400,12 @@ mod tests { } #[test] - fn write_version_mask_from_capture() { + fn write_midstate_config_from_capture() { // From S21 Pro capture: TX: 55 AA 51 09 00 A4 90 00 FF FF 1C assert_frame_eq( RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, // 0x51 = broadcast - register: Register::VersionMask(VersionMask::full_rolling()), + register: Register::MidstateConfig(MidstateConfig::full_rolling()), }), &[ 0x55, 0xaa, 0x51, 0x09, 0x00, 0xa4, 0x90, 0x00, 0xff, 0xff, 0x1c, diff --git a/mujina-miner/src/asic/bm13xx/register.rs b/mujina-miner/src/asic/bm13xx/register.rs index cba78ffe..fc90a945 100644 --- a/mujina-miner/src/asic/bm13xx/register.rs +++ b/mujina-miner/src/asic/bm13xx/register.rs @@ -28,7 +28,7 @@ pub enum RegisterAddress { AnalogMux = 0x54, IoDriverStrength = 0x58, Pll3Parameter = 0x68, - VersionMask = 0xA4, + MidstateConfig = 0xA4, SoftResetControl = 0xA8, MiscSettings = 0xB9, } @@ -47,7 +47,7 @@ pub enum Register { AnalogMux(AnalogMux), IoDriverStrength(IoDriverStrength), Pll3Parameter(Pll3Parameter), - VersionMask(VersionMask), + MidstateConfig(MidstateConfig), SoftResetControl(SoftResetControl), MiscSettings(MiscSettings), } @@ -68,7 +68,9 @@ impl Register { Register::IoDriverStrength(IoDriverStrength::decode(bytes)) } RegisterAddress::Pll3Parameter => Register::Pll3Parameter(Pll3Parameter::decode(bytes)), - RegisterAddress::VersionMask => Register::VersionMask(VersionMask::decode(bytes)), + RegisterAddress::MidstateConfig => { + Register::MidstateConfig(MidstateConfig::decode(bytes)) + } RegisterAddress::SoftResetControl => { Register::SoftResetControl(SoftResetControl::decode(bytes)) } @@ -89,7 +91,7 @@ impl Register { Register::AnalogMux(_) => RegisterAddress::AnalogMux, Register::IoDriverStrength(_) => RegisterAddress::IoDriverStrength, Register::Pll3Parameter(_) => RegisterAddress::Pll3Parameter, - Register::VersionMask(_) => RegisterAddress::VersionMask, + Register::MidstateConfig(_) => RegisterAddress::MidstateConfig, Register::SoftResetControl(_) => RegisterAddress::SoftResetControl, Register::MiscSettings(_) => RegisterAddress::MiscSettings, } @@ -108,7 +110,7 @@ impl Register { Register::AnalogMux(r) => r.encode(dst), Register::IoDriverStrength(r) => r.encode(dst), Register::Pll3Parameter(r) => r.encode(dst), - Register::VersionMask(r) => r.encode(dst), + Register::MidstateConfig(r) => r.encode(dst), Register::SoftResetControl(r) => r.encode(dst), Register::MiscSettings(r) => r.encode(dst), } @@ -523,53 +525,67 @@ impl IoDriverStrength { } } -/// Version mask for version rolling -#[derive(Clone, Copy, PartialEq)] -pub struct VersionMask { - /// Which bits can be rolled - mask: u16, - /// Enable flag and other control bits - control: u16, +/// Midstate configuration and version rolling (0xA4). +/// +/// - bits 0-15: mask of rollable version bits, applied to block +/// header version bits 28:16 +/// - bits 16-27: reserved, zero in every observation +/// - bits 28-29: midstate generation code; how many midstates the +/// chip generates per job. BM1366 and later: 1 means 8, 2 means +/// 12, 3 means 16. BM1362: only 1 (8 midstates) is used. The +/// meaning of 0 is unobserved. +/// - bit 30: version fix, zero in every observation +/// - bit 31: generate midstates automatically +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MidstateConfig { + /// Mask of rollable version bits. + pub version_mask: u16, + /// Raw 2-bit midstate generation code. + pub midstate_gen: u8, + /// Fix the version field. + pub version_fix: bool, + /// Generate midstates automatically. + pub auto_gen: bool, } -impl VersionMask { - /// Full 16-bit mask for version rolling - const FULL_MASK: u16 = 0xffff; - /// Fixed control pattern used by all implementations to enable version rolling - const ENABLE_ROLLING: u16 = 0x0090; - - /// Create version mask with all lower 16 bits enabled +impl MidstateConfig { + /// Returns the configuration every capture uses: full mask, + /// generation code 1, automatic midstate generation. pub fn full_rolling() -> Self { Self { - mask: Self::FULL_MASK, - control: Self::ENABLE_ROLLING, + version_mask: 0xffff, + midstate_gen: 1, + version_fix: false, + auto_gen: true, } } pub fn encode(&self, dst: &mut BytesMut) { - dst.put_u16_le(self.control); - dst.put_u16_le(self.mask); + let value = (self.auto_gen as u32) << 31 + | (self.version_fix as u32) << 30 + | (self.midstate_gen as u32 & 0x3) << 28 + | self.version_mask as u32; + dst.put_u32(value); } pub fn decode(bytes: [u8; 4]) -> Self { - let raw = u32::from_le_bytes(bytes); + let value = u32::from_be_bytes(bytes); Self { - control: (raw & 0xffff) as u16, - mask: (raw >> 16) as u16, + version_mask: (value & 0xffff) as u16, + midstate_gen: (value >> 28 & 0x3) as u8, + version_fix: value >> 30 & 1 == 1, + auto_gen: value >> 31 & 1 == 1, } } } -impl fmt::Debug for VersionMask { +impl fmt::Debug for MidstateConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let control_str = if self.control == Self::ENABLE_ROLLING { - "ENABLE_ROLLING".to_string() - } else { - format!("{:#06x}", self.control) - }; - f.debug_struct("VersionMask") - .field("mask", &format_args!("{:#06x}", self.mask)) - .field("control", &control_str) + f.debug_struct("MidstateConfig") + .field("version_mask", &format_args!("{:#06x}", self.version_mask)) + .field("midstate_gen", &self.midstate_gen) + .field("version_fix", &self.version_fix) + .field("auto_gen", &self.auto_gen) .finish() } } @@ -970,19 +986,29 @@ mod io_driver_strength_tests { } #[cfg(test)] -mod version_mask_tests { +mod midstate_config_tests { use super::*; - fn round_trip(original: VersionMask) { + fn round_trip(original: MidstateConfig) { let mut buf = BytesMut::new(); original.encode(&mut buf); let bytes: [u8; 4] = buf[..].try_into().unwrap(); - assert_eq!(VersionMask::decode(bytes), original); + assert_eq!(MidstateConfig::decode(bytes), original); } #[test] fn full_rolling() { - round_trip(VersionMask::full_rolling()); + round_trip(MidstateConfig::full_rolling()); + } + + #[test] + fn from_literal_fields() { + round_trip(MidstateConfig { + version_mask: 0x1fff, + midstate_gen: 3, + version_fix: true, + auto_gen: false, + }); } } diff --git a/mujina-miner/src/asic/bm13xx/thread.rs b/mujina-miner/src/asic/bm13xx/thread.rs index f0aea7f8..c5ae508e 100644 --- a/mujina-miner/src/asic/bm13xx/thread.rs +++ b/mujina-miner/src/asic/bm13xx/thread.rs @@ -22,8 +22,8 @@ use super::command::{ SetChipAddress, WriteRegister, }; use super::register::{ - AnalogMux, ChipModel, Core, IoDriverStrength, Log2Difficulty, MiscControl, MiscSettings, - NonceRange, PllDivider, Register, SoftResetControl, TicketMask, VersionMask, + AnalogMux, ChipModel, Core, IoDriverStrength, Log2Difficulty, MidstateConfig, MiscControl, + MiscSettings, NonceRange, PllDivider, Register, SoftResetControl, TicketMask, }; use super::response::Response; use crate::{ @@ -275,7 +275,7 @@ where chip_commands .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, - register: Register::VersionMask(VersionMask::full_rolling()), + register: Register::MidstateConfig(MidstateConfig::full_rolling()), })) .await .context("failed to send version mask")?; @@ -436,7 +436,7 @@ where chip_commands .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, - register: Register::VersionMask(VersionMask::full_rolling()), + register: Register::MidstateConfig(MidstateConfig::full_rolling()), })) .await?; diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 7e0e0415..ef9af0ac 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -26,7 +26,7 @@ use crate::{ bm13xx::{ self, Register, Response, command::{Destination, ReadRegister, RegisterCommand, WriteRegister}, - register::{ChipId, RegisterAddress, VersionMask}, + register::{ChipId, MidstateConfig, RegisterAddress}, thread::BM13xxThread, }, hash_thread::{AsicEnable, BoardPeripherals, HashThread, ThreadRemovalSignal}, @@ -128,7 +128,7 @@ async fn create_from_usb(device: UsbDeviceInfo) -> Result { data_writer .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, - register: Register::VersionMask(VersionMask::full_rolling()), + register: Register::MidstateConfig(MidstateConfig::full_rolling()), })) .await .context("failed to send config command")?; From cd4b6019bbbe7faaa7459b9bcd019fa9bc410080 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Fri, 3 Jul 2026 20:42:34 -0500 Subject: [PATCH 28/38] refactor(bm13xx): type 0x3C as CoreMailbox Register 0x3C gives indirect access to a small register space inside each hashing core. Replace the raw big-endian word with a mailbox register carrying a typed core command, split into an addressing half (all cores, num, core id) and an operation half (write, read done, core register id, value). Decompiled Bitmain firmware confirms the layout. Its read paths clear the write-enable bit, evidence the write-only captures could not provide. Bring-up writes now construct commands from named core registers (overlap monitor, clock delay, core enable) instead of magic words. Core register 0x0d keeps a raw id; no reference names it. --- mujina-miner/src/asic/bm13xx/PROTOCOL.md | 32 +++--- mujina-miner/src/asic/bm13xx/command.rs | 30 ++++- mujina-miner/src/asic/bm13xx/register.rs | 136 +++++++++++++++++++++-- mujina-miner/src/asic/bm13xx/thread.rs | 24 ++-- 4 files changed, 183 insertions(+), 39 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/PROTOCOL.md b/mujina-miner/src/asic/bm13xx/PROTOCOL.md index b62fa262..0b78ecdf 100644 --- a/mujina-miner/src/asic/bm13xx/PROTOCOL.md +++ b/mujina-miner/src/asic/bm13xx/PROTOCOL.md @@ -384,7 +384,7 @@ Key registers used across BM13xx chips: | 0x18 | MISC_CONTROL | UART settings and GPIO pin configuration | | 0x28 | UART_BAUD | UART baud rate configuration | | 0x2C | UART_RELAY | UART relay configuration (multi-chip chains) | -| 0x3C | CORE_REGISTER | Core configuration and control | +| 0x3C | CORE_MAILBOX | Command mailbox for per-core registers | | 0x54 | ANALOG_MUX | Analog mux control (rumored to control temp diode) | | 0x58 | IO_DRIVER_STRENGTH | IO driver strength configuration | | 0x68 | PLL3_PARAMETER | PLL3 configuration (multi-chip chains) | @@ -496,19 +496,23 @@ Controls UART signal relay in multi-chip chains (4 bytes): - Format appears to encode domain boundaries - Example values from S21 Pro: 0x00130003, 0x00180003, etc. -#### 0x3C - CORE_REGISTER -Indirect access to core registers (documented in BM1397): -- **Format**: Upper 16 bits = core register address, Lower 16 bits = value -- **Bit 31**: Always set (0x80) in observed implementations -- Initialization requires 2-3 sequential writes with chip-specific magic values: - -**Broadcast sequence** (2 writes): -- BM1362: `0x80008540`, `0x80008008` -- BM1366: `0x80008540`, `0x80008020` -- BM1368/70: `0x80008B00`, `0x8000800C` or `0x80008018` - -**Per-chip sequence** (adds 3rd write): -- All chips add: `0x800082AA` as final write +#### 0x3C - CORE_MAILBOX +Indirect access to a small register space inside each core. The +32-bit word posted to the mailbox names a core register, carries +a value, and addresses one core or all of them. The word's bit +layout lives on the typed register in the code. Every observed +command is a broadcast write; nothing in the captures reads a +core register or addresses an individual core. + +Core registers written during bring-up, first broadcast, then +repeated per chip with core enable appended: + +- 0x00 clock delay: 0x08 (BM1362), 0x20 (BM1366), 0x0C or 0x18 + (BM1368/70) +- 0x02 core enable: 0xAA on every model, per-chip pass only +- 0x05 clock select: 0x40 (BM1362, BM1366) +- 0x0B overlap monitor: 0x00 (BM1368/70) +- 0x0D unnamed: 0xEE (BM1370, written after mining configuration) #### 0x54 - ANALOG_MUX Controls analog multiplexer, possibly for temperature sensing: diff --git a/mujina-miner/src/asic/bm13xx/command.rs b/mujina-miner/src/asic/bm13xx/command.rs index 02faf8fc..1e49fb86 100644 --- a/mujina-miner/src/asic/bm13xx/command.rs +++ b/mujina-miner/src/asic/bm13xx/command.rs @@ -363,8 +363,8 @@ mod tests { use super::super::codec::FrameCodec; use super::super::register::{ - ChipId, ChipModel, Core, IoDriverStrength, Log2Difficulty, MidstateConfig, MiscControl, - NonceRange, Register, RegisterAddress, SoftResetControl, TicketMask, + ChipId, ChipModel, CoreCommand, IoDriverStrength, Log2Difficulty, MidstateConfig, + MiscControl, NonceRange, Register, RegisterAddress, SoftResetControl, TicketMask, }; use super::*; use crate::asic::bm13xx::crc::crc16; @@ -506,14 +506,15 @@ mod tests { } #[test] - fn write_core_register_sequence() { + fn write_core_command_from_capture() { // From Bitaxe capture: TX: 55 AA 51 09 00 3C 80 00 8B 00 12 - // Core register uses big-endian encoding assert_frame_eq( RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, - // Big-endian: produces bytes 80 00 8B 00 - register: Register::Core(Core(0x80008B00)), + register: Register::CoreMailbox(CoreCommand::write_all( + CoreCommand::OVERLAP_MONITOR, + 0x00, + )), }), &[ 0x55, 0xaa, 0x51, 0x09, 0x00, 0x3c, 0x80, 0x00, 0x8b, 0x00, 0x12, @@ -521,6 +522,23 @@ mod tests { ); } + #[test] + fn write_core_command_bm1362_from_capture() { + // From S19j Pro capture: TX: 55 AA 51 09 00 3C 80 00 85 40 0C + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::CoreMailbox(CoreCommand::write_all( + CoreCommand::CLOCK_SELECT, + 0x40, + )), + }), + &[ + 0x55, 0xaa, 0x51, 0x09, 0x00, 0x3c, 0x80, 0x00, 0x85, 0x40, 0x0c, + ], + ); + } + #[test] fn write_ticket_mask_from_capture() { // From S21 Pro capture: TX: 55 AA 51 09 00 14 00 00 00 FF 08 diff --git a/mujina-miner/src/asic/bm13xx/register.rs b/mujina-miner/src/asic/bm13xx/register.rs index fc90a945..835a420d 100644 --- a/mujina-miner/src/asic/bm13xx/register.rs +++ b/mujina-miner/src/asic/bm13xx/register.rs @@ -24,7 +24,7 @@ pub enum RegisterAddress { MiscControl = 0x18, UartBaud = 0x28, UartRelay = 0x2C, - Core = 0x3C, + CoreMailbox = 0x3C, AnalogMux = 0x54, IoDriverStrength = 0x58, Pll3Parameter = 0x68, @@ -43,7 +43,7 @@ pub enum Register { MiscControl(MiscControl), UartBaud(UartBaud), UartRelay(UartRelay), - Core(Core), + CoreMailbox(CoreCommand), AnalogMux(AnalogMux), IoDriverStrength(IoDriverStrength), Pll3Parameter(Pll3Parameter), @@ -62,7 +62,7 @@ impl Register { RegisterAddress::MiscControl => Register::MiscControl(MiscControl::decode(bytes)), RegisterAddress::UartBaud => Register::UartBaud(UartBaud::decode(bytes)), RegisterAddress::UartRelay => Register::UartRelay(UartRelay::decode(bytes)), - RegisterAddress::Core => Register::Core(Core::decode(bytes)), + RegisterAddress::CoreMailbox => Register::CoreMailbox(CoreCommand::decode(bytes)), RegisterAddress::AnalogMux => Register::AnalogMux(AnalogMux::decode(bytes)), RegisterAddress::IoDriverStrength => { Register::IoDriverStrength(IoDriverStrength::decode(bytes)) @@ -87,7 +87,7 @@ impl Register { Register::MiscControl(_) => RegisterAddress::MiscControl, Register::UartBaud(_) => RegisterAddress::UartBaud, Register::UartRelay(_) => RegisterAddress::UartRelay, - Register::Core(_) => RegisterAddress::Core, + Register::CoreMailbox(_) => RegisterAddress::CoreMailbox, Register::AnalogMux(_) => RegisterAddress::AnalogMux, Register::IoDriverStrength(_) => RegisterAddress::IoDriverStrength, Register::Pll3Parameter(_) => RegisterAddress::Pll3Parameter, @@ -106,7 +106,7 @@ impl Register { Register::MiscControl(r) => r.encode(dst), Register::UartBaud(r) => r.encode(dst), Register::UartRelay(r) => r.encode(dst), - Register::Core(r) => r.encode(dst), + Register::CoreMailbox(r) => r.encode(dst), Register::AnalogMux(r) => r.encode(dst), Register::IoDriverStrength(r) => r.encode(dst), Register::Pll3Parameter(r) => r.encode(dst), @@ -443,16 +443,100 @@ impl UartBaud { } } -/// Transmitted big-endian, unlike the other raw-u32 registers. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Core(pub u32); +/// A command posted to the core mailbox (0x3C). +/// +/// The mailbox gives indirect access to a small register space +/// inside each core. The 32-bit word posted to it names a core +/// register, carries a value, and addresses one core or all of +/// them. +/// +/// - bits 0-7: value written to or read from the core register +/// - bits 8-12: core register id +/// - bit 13: reserved +/// - bit 14: read done +/// - bit 15: write enable, clear on reads +/// - bits 16-23: core id, ignored when addressing all cores +/// - bits 24-30: num, zero in every observation +/// - bit 31: address all cores +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct CoreCommand { + /// Address all cores rather than the one in `core_id`. + pub all: bool, + /// Zero in every observation. + pub num: u8, + /// Core addressed when `all` is clear. + pub core_id: u8, + /// Write the value; clear on reads. + pub write: bool, + /// Read done. + pub rd_done: bool, + /// Core register id. + pub reg: u8, + /// Value written to or read from the core register. + pub value: u8, +} + +impl CoreCommand { + /// Clock delay control register id. + pub const CLOCK_DELAY: u8 = 0x00; + /// Core enable register id. + pub const CORE_ENABLE: u8 = 0x02; + /// Clock select register id. + pub const CLOCK_SELECT: u8 = 0x05; + /// Overlap monitor register id. + pub const OVERLAP_MONITOR: u8 = 0x0B; + + /// Returns a write of one core register, broadcast to every + /// core of the addressed chip. The only command shape observed + /// in captured traffic. + pub fn write_all(reg: u8, value: u8) -> Self { + Self { + all: true, + num: 0, + core_id: 0, + write: true, + rd_done: false, + reg, + value, + } + } -impl Core { pub fn encode(&self, dst: &mut BytesMut) { - dst.put_u32(self.0); + let word = (self.all as u32) << 31 + | (self.num as u32 & 0x7f) << 24 + | (self.core_id as u32) << 16 + | (self.write as u32) << 15 + | (self.rd_done as u32) << 14 + | (self.reg as u32 & 0x1f) << 8 + | self.value as u32; + dst.put_u32(word); } + pub fn decode(bytes: [u8; 4]) -> Self { - Self(u32::from_be_bytes(bytes)) + let word = u32::from_be_bytes(bytes); + Self { + all: word >> 31 & 1 == 1, + num: (word >> 24 & 0x7f) as u8, + core_id: (word >> 16 & 0xff) as u8, + write: word >> 15 & 1 == 1, + rd_done: word >> 14 & 1 == 1, + reg: (word >> 8 & 0x1f) as u8, + value: (word & 0xff) as u8, + } + } +} + +impl fmt::Debug for CoreCommand { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CoreCommand") + .field("all", &self.all) + .field("num", &self.num) + .field("core_id", &self.core_id) + .field("write", &self.write) + .field("rd_done", &self.rd_done) + .field("reg", &format_args!("{:#04x}", self.reg)) + .field("value", &format_args!("{:#04x}", self.value)) + .finish() } } @@ -1035,3 +1119,33 @@ mod soft_reset_control_tests { round_trip(SoftResetControl::core_reset(ChipModel::BM1370)); } } + +#[cfg(test)] +mod core_command_tests { + use super::*; + + fn round_trip(original: CoreCommand) { + let mut buf = BytesMut::new(); + original.encode(&mut buf); + let bytes: [u8; 4] = buf[..].try_into().unwrap(); + assert_eq!(CoreCommand::decode(bytes), original); + } + + #[test] + fn write_all() { + round_trip(CoreCommand::write_all(CoreCommand::CORE_ENABLE, 0xaa)); + } + + #[test] + fn from_literal_fields() { + round_trip(CoreCommand { + all: false, + num: 0x55, + core_id: 0xc3, + write: false, + rd_done: true, + reg: 0x1f, + value: 0xee, + }); + } +} diff --git a/mujina-miner/src/asic/bm13xx/thread.rs b/mujina-miner/src/asic/bm13xx/thread.rs index c5ae508e..180567d5 100644 --- a/mujina-miner/src/asic/bm13xx/thread.rs +++ b/mujina-miner/src/asic/bm13xx/thread.rs @@ -22,8 +22,8 @@ use super::command::{ SetChipAddress, WriteRegister, }; use super::register::{ - AnalogMux, ChipModel, Core, IoDriverStrength, Log2Difficulty, MidstateConfig, MiscControl, - MiscSettings, NonceRange, PllDivider, Register, SoftResetControl, TicketMask, + AnalogMux, ChipModel, CoreCommand, IoDriverStrength, Log2Difficulty, MidstateConfig, + MiscControl, MiscSettings, NonceRange, PllDivider, Register, SoftResetControl, TicketMask, }; use super::response::Response; use crate::{ @@ -318,13 +318,16 @@ where chip_commands .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, - register: Register::Core(Core(0x8000_8B00)), + register: Register::CoreMailbox(CoreCommand::write_all( + CoreCommand::OVERLAP_MONITOR, + 0x00, + )), })) .await?; chip_commands .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, - register: Register::Core(Core(0x8000_800C)), + register: Register::CoreMailbox(CoreCommand::write_all(CoreCommand::CLOCK_DELAY, 0x0C)), })) .await?; @@ -362,19 +365,22 @@ where chip_commands .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Chip(0x00), - register: Register::Core(Core(0x8000_8B00)), + register: Register::CoreMailbox(CoreCommand::write_all( + CoreCommand::OVERLAP_MONITOR, + 0x00, + )), })) .await?; chip_commands .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Chip(0x00), - register: Register::Core(Core(0x8000_800C)), + register: Register::CoreMailbox(CoreCommand::write_all(CoreCommand::CLOCK_DELAY, 0x0C)), })) .await?; chip_commands .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Chip(0x00), - register: Register::Core(Core(0x8000_82AA)), + register: Register::CoreMailbox(CoreCommand::write_all(CoreCommand::CORE_ENABLE, 0xAA)), })) .await?; @@ -400,7 +406,9 @@ where chip_commands .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, - register: Register::Core(Core(0x8000_8DEE)), + // Core register 0x0d has no known name; the value is + // from factory captures. + register: Register::CoreMailbox(CoreCommand::write_all(0x0D, 0xEE)), })) .await?; From f382bc3c2ad488af2ded3ab0840b995d57770b45 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Fri, 3 Jul 2026 21:28:13 -0500 Subject: [PATCH 29/38] refactor(bm13xx): type 0x54 as AnalogMux Register 0x54 carries a small select field in its low bits that routes one of the chip's analog signals, rumored to be the temperature diode, onto the analog mux output. Replace the raw little-endian word with a struct holding the select value, encoded big-endian like the other decomposed registers. A model-keyed constructor owns the selection factory firmware makes, and the bring-up caller sheds a byte-swapped magic constant whose low nibble was the whole payload. References disagree on the field's width, three bits or four; Bitmain's own firmware masks with four, so four wins. --- mujina-miner/src/asic/bm13xx/PROTOCOL.md | 9 ++-- mujina-miner/src/asic/bm13xx/command.rs | 33 ++++++++++++- mujina-miner/src/asic/bm13xx/register.rs | 60 +++++++++++++++++++++++- mujina-miner/src/asic/bm13xx/thread.rs | 2 +- 4 files changed, 96 insertions(+), 8 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/PROTOCOL.md b/mujina-miner/src/asic/bm13xx/PROTOCOL.md index 0b78ecdf..d68c39fb 100644 --- a/mujina-miner/src/asic/bm13xx/PROTOCOL.md +++ b/mujina-miner/src/asic/bm13xx/PROTOCOL.md @@ -515,10 +515,11 @@ repeated per chip with core enable appended: - 0x0D unnamed: 0xEE (BM1370, written after mining configuration) #### 0x54 - ANALOG_MUX -Controls analog multiplexer, possibly for temperature sensing: -- BM1370: Write value 0x00000002 -- BM1362: Write value 0x00000003 -- Purpose not fully documented by manufacturer +Selects which analog signal the chip routes onto its analog mux +output, rumored to feed the temperature diode. A small select +field in the low bits is the whole payload; see the typed +register in the code. Bring-up writes select 3 on BM1362 and 2 on +BM1370. What each selection connects is not documented anywhere. #### 0x58 - IO_DRIVER_STRENGTH Sets the drive strength of each chip output pin. Each output has a diff --git a/mujina-miner/src/asic/bm13xx/command.rs b/mujina-miner/src/asic/bm13xx/command.rs index 1e49fb86..cce0b52f 100644 --- a/mujina-miner/src/asic/bm13xx/command.rs +++ b/mujina-miner/src/asic/bm13xx/command.rs @@ -363,8 +363,9 @@ mod tests { use super::super::codec::FrameCodec; use super::super::register::{ - ChipId, ChipModel, CoreCommand, IoDriverStrength, Log2Difficulty, MidstateConfig, - MiscControl, NonceRange, Register, RegisterAddress, SoftResetControl, TicketMask, + AnalogMux, ChipId, ChipModel, CoreCommand, IoDriverStrength, Log2Difficulty, + MidstateConfig, MiscControl, NonceRange, Register, RegisterAddress, SoftResetControl, + TicketMask, }; use super::*; use crate::asic::bm13xx::crc::crc16; @@ -473,6 +474,34 @@ mod tests { ); } + #[test] + fn write_analog_mux_from_capture() { + // From S21 Pro capture: TX: 55 AA 51 09 00 54 00 00 00 02 18 + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::AnalogMux(AnalogMux::bring_up(ChipModel::BM1370)), + }), + &[ + 0x55, 0xaa, 0x51, 0x09, 0x00, 0x54, 0x00, 0x00, 0x00, 0x02, 0x18, + ], + ); + } + + #[test] + fn write_analog_mux_bm1362_from_capture() { + // From S19j Pro capture: TX: 55 AA 51 09 00 54 00 00 00 03 1D + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::AnalogMux(AnalogMux::bring_up(ChipModel::BM1362)), + }), + &[ + 0x55, 0xaa, 0x51, 0x09, 0x00, 0x54, 0x00, 0x00, 0x00, 0x03, 0x1d, + ], + ); + } + #[test] fn write_misc_control_from_capture() { // From Bitaxe capture: TX: 55 AA 51 09 00 18 F0 00 C1 00 04 diff --git a/mujina-miner/src/asic/bm13xx/register.rs b/mujina-miner/src/asic/bm13xx/register.rs index 835a420d..5d5df705 100644 --- a/mujina-miner/src/asic/bm13xx/register.rs +++ b/mujina-miner/src/asic/bm13xx/register.rs @@ -540,6 +540,42 @@ impl fmt::Debug for CoreCommand { } } +/// Analog mux control (0x54). +/// +/// Selects which analog signal the chip routes onto its analog +/// mux output, rumored to feed the temperature diode. Bring-up +/// writes select 3 on BM1362 and 2 on BM1370; what each selection +/// connects is undocumented. +/// +/// - bits 0-3: diode select +/// - bits 4-31: reserved +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AnalogMux { + /// Analog signal selected onto the mux output. + pub diode_select: u8, +} + +impl AnalogMux { + /// Returns the selection factory firmware makes during + /// bring-up; each model selects a different input. + pub fn bring_up(model: ChipModel) -> Self { + match model { + ChipModel::BM1362 => Self { diode_select: 0x3 }, + _ => Self { diode_select: 0x2 }, + } + } + + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_u32(self.diode_select as u32 & 0xf); + } + + pub fn decode(bytes: [u8; 4]) -> Self { + Self { + diode_select: (u32::from_be_bytes(bytes) & 0xf) as u8, + } + } +} + /// Drive strength of each chip output pin. /// /// Each output has a 4-bit drive strength. Factory firmware runs @@ -758,7 +794,6 @@ macro_rules! raw_u32_register { raw_u32_register! { MiscControl, UartRelay, - AnalogMux, Pll3Parameter, MiscSettings, } @@ -1120,6 +1155,29 @@ mod soft_reset_control_tests { } } +#[cfg(test)] +mod analog_mux_tests { + use super::*; + + fn round_trip(original: AnalogMux) { + let mut buf = BytesMut::new(); + original.encode(&mut buf); + let bytes: [u8; 4] = buf[..].try_into().unwrap(); + assert_eq!(AnalogMux::decode(bytes), original); + } + + #[test] + fn bring_up() { + round_trip(AnalogMux::bring_up(ChipModel::BM1362)); + round_trip(AnalogMux::bring_up(ChipModel::BM1370)); + } + + #[test] + fn from_literal_field() { + round_trip(AnalogMux { diode_select: 0xf }); + } +} + #[cfg(test)] mod core_command_tests { use super::*; diff --git a/mujina-miner/src/asic/bm13xx/thread.rs b/mujina-miner/src/asic/bm13xx/thread.rs index 180567d5..0d09a2f6 100644 --- a/mujina-miner/src/asic/bm13xx/thread.rs +++ b/mujina-miner/src/asic/bm13xx/thread.rs @@ -394,7 +394,7 @@ where chip_commands .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, - register: Register::AnalogMux(AnalogMux(0x02000000)), + register: Register::AnalogMux(AnalogMux::bring_up(ChipModel::BM1370)), })) .await?; chip_commands From a8343edaef79590c7d16fc86905560e619829dcb Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Fri, 3 Jul 2026 22:04:46 -0500 Subject: [PATCH 30/38] refactor(bm13xx): type 0x18 as MiscControl Register 0x18 holds chip-level control bits whose layout shifts between chip generations and whose bits carry only unexplained names in the references, so the value stays an opaque word rather than named fields, the same treatment as the soft reset control. A model-keyed constructor owns the value factory firmware writes during bring-up: a low half word conserved across models under a model-specific high byte. The encoding switches from little to big endian, so values in code now read as the references and captures write them, and the bring-up callers shed a byte-swapped magic constant. --- mujina-miner/src/asic/bm13xx/PROTOCOL.md | 23 ++++++---- mujina-miner/src/asic/bm13xx/command.rs | 16 ++++++- mujina-miner/src/asic/bm13xx/register.rs | 55 +++++++++++++++++++++++- mujina-miner/src/asic/bm13xx/thread.rs | 4 +- 4 files changed, 85 insertions(+), 13 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/PROTOCOL.md b/mujina-miner/src/asic/bm13xx/PROTOCOL.md index d68c39fb..62c1e682 100644 --- a/mujina-miner/src/asic/bm13xx/PROTOCOL.md +++ b/mujina-miner/src/asic/bm13xx/PROTOCOL.md @@ -480,15 +480,20 @@ hash, not just a byte-reversed one, so the mask must match that representation. #### 0x18 - MISC_CONTROL -UART and GPIO pin configuration (32-bit register, documented in BM1366): -- **Reset value**: `0x0000C100` -- **Upper 16 bits (0xC100)**: Always preserved across implementations -- **Lower 16 bits**: Chip-specific configuration -- Common values: - - BM1362: `0x00C100B0` (both broadcast and per-chip) - - BM1366/68: `0x00C10FFF` broadcast, `0x00C100F0` per-chip - - BM1370: `0x00C100F0` (S21 Pro) or `0x00C10FFF` (S21) -- Lower bytes likely control UART pin routing and GPIO functions +Chip-level control bits. The layout shifts between generations +(BM1397 kept its baud divider here; later generations moved baud +configuration to register 0x28) and most bits carry only +unexplained names in the references, so the code keeps the value +opaque. + +Factory firmware writes one model-specific value during +bring-up, broadcast and per chip. The low half word 0xC100, +matching the BM1366 reset value, is conserved everywhere; the +high byte varies: + +- BM1362: 0xB000C100 +- BM1366/68: 0xFF0FC100 broadcast, 0xF000C100 per chip +- BM1370: 0xF000C100 (S21 Pro) or 0xFF0FC100 (S21) #### 0x2C - UART_RELAY Controls UART signal relay in multi-chip chains (4 bytes): diff --git a/mujina-miner/src/asic/bm13xx/command.rs b/mujina-miner/src/asic/bm13xx/command.rs index cce0b52f..551b7190 100644 --- a/mujina-miner/src/asic/bm13xx/command.rs +++ b/mujina-miner/src/asic/bm13xx/command.rs @@ -508,7 +508,7 @@ mod tests { assert_frame_eq( RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, - register: Register::MiscControl(MiscControl(0x00C100F0)), + register: Register::MiscControl(MiscControl::operational(ChipModel::BM1370)), }), &[ 0x55, 0xaa, 0x51, 0x09, 0x00, 0x18, 0xf0, 0x00, 0xc1, 0x00, 0x04, @@ -516,6 +516,20 @@ mod tests { ); } + #[test] + fn write_misc_control_bm1362_from_capture() { + // From S19j Pro capture: TX: 55 AA 51 09 00 18 B0 00 C1 00 14 + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::MiscControl(MiscControl::operational(ChipModel::BM1362)), + }), + &[ + 0x55, 0xaa, 0x51, 0x09, 0x00, 0x18, 0xb0, 0x00, 0xc1, 0x00, 0x14, + ], + ); + } + #[test] fn chain_inactive_from_capture() { // From S21 Pro capture: TX: 55 AA 53 05 00 00 03 diff --git a/mujina-miner/src/asic/bm13xx/register.rs b/mujina-miner/src/asic/bm13xx/register.rs index 5d5df705..74fffc17 100644 --- a/mujina-miner/src/asic/bm13xx/register.rs +++ b/mujina-miner/src/asic/bm13xx/register.rs @@ -406,6 +406,42 @@ impl fmt::Display for Log2Difficulty { } } +/// Miscellaneous control (0x18). +/// +/// Chip-level control bits. The layout shifts between generations +/// (BM1397 kept its baud divider here; later generations moved +/// baud configuration to the fast UART register) and most bits +/// carry only unexplained names in the references, so the value +/// stays opaque. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MiscControl(pub u32); + +impl MiscControl { + /// Returns the value factory firmware writes during bring-up, + /// broadcast and per chip. The low half word is conserved + /// across models; the high byte is model-specific. + pub fn operational(model: ChipModel) -> Self { + match model { + ChipModel::BM1362 => Self(0xB000_C100), + _ => Self(0xF000_C100), + } + } + + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_u32(self.0); + } + + pub fn decode(bytes: [u8; 4]) -> Self { + Self(u32::from_be_bytes(bytes)) + } +} + +impl fmt::Debug for MiscControl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "MiscControl({:#010x})", self.0) + } +} + /// UART baud rate configuration #[derive(Debug, Clone, Copy, PartialEq)] pub enum UartBaud { @@ -792,7 +828,6 @@ macro_rules! raw_u32_register { } raw_u32_register! { - MiscControl, UartRelay, Pll3Parameter, MiscSettings, @@ -1155,6 +1190,24 @@ mod soft_reset_control_tests { } } +#[cfg(test)] +mod misc_control_tests { + use super::*; + + fn round_trip(original: MiscControl) { + let mut buf = BytesMut::new(); + original.encode(&mut buf); + let bytes: [u8; 4] = buf[..].try_into().unwrap(); + assert_eq!(MiscControl::decode(bytes), original); + } + + #[test] + fn operational() { + round_trip(MiscControl::operational(ChipModel::BM1362)); + round_trip(MiscControl::operational(ChipModel::BM1370)); + } +} + #[cfg(test)] mod analog_mux_tests { use super::*; diff --git a/mujina-miner/src/asic/bm13xx/thread.rs b/mujina-miner/src/asic/bm13xx/thread.rs index 0d09a2f6..9edeccd8 100644 --- a/mujina-miner/src/asic/bm13xx/thread.rs +++ b/mujina-miner/src/asic/bm13xx/thread.rs @@ -296,7 +296,7 @@ where chip_commands .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, - register: Register::MiscControl(MiscControl(0x00C100F0)), + register: Register::MiscControl(MiscControl::operational(ChipModel::BM1370)), })) .await?; @@ -359,7 +359,7 @@ where chip_commands .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Chip(0x00), - register: Register::MiscControl(MiscControl(0x00C100F0)), + register: Register::MiscControl(MiscControl::operational(ChipModel::BM1370)), })) .await?; chip_commands From 921d0125c48c0b3641d0eb650d2adb4b0c131389 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Fri, 3 Jul 2026 22:14:17 -0500 Subject: [PATCH 31/38] refactor(bm13xx): type 0x2C as UartRelay Register 0x2C makes the first and last chip of each voltage domain relay the serial lines across the domain gap. Replace the raw little-endian word with a struct holding the two relay-enable bits, one per direction, and the gap count, a timing parameter of unknown units. The struct encodes big-endian like the other decomposed registers, and a constructor covers the one shape captures show: both directions relayed, each domain with its own gap count. No caller constructs the value yet. Single-chip boards never write the register, and multi-chip chain bring-up, which does, is not yet implemented. A test writes a domain-boundary chip's exact frame from a production chain capture. --- mujina-miner/src/asic/bm13xx/PROTOCOL.md | 18 ++++-- mujina-miner/src/asic/bm13xx/command.rs | 16 ++++- mujina-miner/src/asic/bm13xx/register.rs | 79 +++++++++++++++++++++++- 3 files changed, 107 insertions(+), 6 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/PROTOCOL.md b/mujina-miner/src/asic/bm13xx/PROTOCOL.md index 62c1e682..e6bf400f 100644 --- a/mujina-miner/src/asic/bm13xx/PROTOCOL.md +++ b/mujina-miner/src/asic/bm13xx/PROTOCOL.md @@ -496,10 +496,20 @@ high byte varies: - BM1370: 0xF000C100 (S21 Pro) or 0xFF0FC100 (S21) #### 0x2C - UART_RELAY -Controls UART signal relay in multi-chip chains (4 bytes): -- Used on first and last chips in each domain -- Format appears to encode domain boundaries -- Example values from S21 Pro: 0x00130003, 0x00180003, etc. +Turns the first and last chip of each voltage domain into a relay +for the serial lines, carrying them onward to the neighboring +domain. The word pairs two relay-enable bits, one per direction, +with a gap count; the bit layout lives on the typed register in +the code. The gap count times the relay, but what gap it counts +is not established: plausibly idle time between relayed frames, +in the usual serial sense of "gap", but neither units nor +mechanism is documented anywhere. + +In the S21 Pro capture every domain-boundary chip relays both +directions, and each domain gets its own gap count, stepping by 5 +per domain from 0x13 at the far end of the chain to 0x4F nearest +the host. The S19j Pro capture never writes this register; +BM1362 boards may not need the relay. #### 0x3C - CORE_MAILBOX Indirect access to a small register space inside each core. The diff --git a/mujina-miner/src/asic/bm13xx/command.rs b/mujina-miner/src/asic/bm13xx/command.rs index 551b7190..921f433c 100644 --- a/mujina-miner/src/asic/bm13xx/command.rs +++ b/mujina-miner/src/asic/bm13xx/command.rs @@ -365,7 +365,7 @@ mod tests { use super::super::register::{ AnalogMux, ChipId, ChipModel, CoreCommand, IoDriverStrength, Log2Difficulty, MidstateConfig, MiscControl, NonceRange, Register, RegisterAddress, SoftResetControl, - TicketMask, + TicketMask, UartRelay, }; use super::*; use crate::asic::bm13xx::crc::crc16; @@ -548,6 +548,20 @@ mod tests { ); } + #[test] + fn write_uart_relay_from_capture() { + // From S21 Pro capture: TX: 55 AA 41 09 78 2C 00 13 00 03 19 + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Chip(0x78), + register: Register::UartRelay(UartRelay::domain_boundary(0x13)), + }), + &[ + 0x55, 0xaa, 0x41, 0x09, 0x78, 0x2c, 0x00, 0x13, 0x00, 0x03, 0x19, + ], + ); + } + #[test] fn write_core_command_from_capture() { // From Bitaxe capture: TX: 55 AA 51 09 00 3C 80 00 8B 00 12 diff --git a/mujina-miner/src/asic/bm13xx/register.rs b/mujina-miner/src/asic/bm13xx/register.rs index 74fffc17..7e2e5e0c 100644 --- a/mujina-miner/src/asic/bm13xx/register.rs +++ b/mujina-miner/src/asic/bm13xx/register.rs @@ -479,6 +479,58 @@ impl UartBaud { } } +/// UART relay control (0x2C). +/// +/// The first and last chip of each voltage domain relay the +/// serial lines onward to the neighboring domain. The gap count +/// carries its name from the references; it times the relay, but +/// what gap it counts, and in what units, is unknown. Captures +/// give each domain its own value, stepping by 5 per domain and +/// growing toward the host. +/// +/// - bit 0: relay the command line, toward the next chip +/// - bit 1: relay the response line, toward the host +/// - bits 2-15: reserved +/// - bits 16-31: gap count +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UartRelay { + /// Relay timing parameter for the domain; units unknown. + pub gap_count: u16, + /// Relay the response line (toward the host). + pub response_relay: bool, + /// Relay the command line (toward the next chip). + pub command_relay: bool, +} + +impl UartRelay { + /// Returns the value written to domain-boundary chips: both + /// directions relayed, with the domain's gap count. The only + /// shape observed in captured traffic. + pub fn domain_boundary(gap_count: u16) -> Self { + Self { + gap_count, + response_relay: true, + command_relay: true, + } + } + + pub fn encode(&self, dst: &mut BytesMut) { + let word = (self.gap_count as u32) << 16 + | (self.response_relay as u32) << 1 + | self.command_relay as u32; + dst.put_u32(word); + } + + pub fn decode(bytes: [u8; 4]) -> Self { + let word = u32::from_be_bytes(bytes); + Self { + gap_count: (word >> 16) as u16, + response_relay: word >> 1 & 1 == 1, + command_relay: word & 1 == 1, + } + } +} + /// A command posted to the core mailbox (0x3C). /// /// The mailbox gives indirect access to a small register space @@ -828,7 +880,6 @@ macro_rules! raw_u32_register { } raw_u32_register! { - UartRelay, Pll3Parameter, MiscSettings, } @@ -1231,6 +1282,32 @@ mod analog_mux_tests { } } +#[cfg(test)] +mod uart_relay_tests { + use super::*; + + fn round_trip(original: UartRelay) { + let mut buf = BytesMut::new(); + original.encode(&mut buf); + let bytes: [u8; 4] = buf[..].try_into().unwrap(); + assert_eq!(UartRelay::decode(bytes), original); + } + + #[test] + fn domain_boundary() { + round_trip(UartRelay::domain_boundary(0x4f)); + } + + #[test] + fn from_literal_fields() { + round_trip(UartRelay { + gap_count: 0xffff, + response_relay: false, + command_relay: true, + }); + } +} + #[cfg(test)] mod core_command_tests { use super::*; From f4906a7ad369bbe15601e8b7f57741fd563f4661 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Sat, 4 Jul 2026 14:46:43 -0500 Subject: [PATCH 32/38] refactor(bm13xx): close ChipModel over supported models The chip model enum carried a BM1397 variant no code could drive and an unknown-id variant that let unrecognized chips flow into code with no way to act on them. Drop both. Chip id decoding now fails with an error on an unrecognized id, and adding a model becomes a compile-time decision at every match over the enum. --- mujina-miner/src/asic/bm13xx/error.rs | 3 ++ mujina-miner/src/asic/bm13xx/register.rs | 58 ++++++++++++------------ mujina-miner/src/asic/bm13xx/response.rs | 17 ++++++- 3 files changed, 48 insertions(+), 30 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/error.rs b/mujina-miner/src/asic/bm13xx/error.rs index e012f7fb..10c5c7bb 100644 --- a/mujina-miner/src/asic/bm13xx/error.rs +++ b/mujina-miner/src/asic/bm13xx/error.rs @@ -16,6 +16,9 @@ pub enum ProtocolError { #[error("Invalid frame format")] InvalidFrame, + #[error("Unknown chip id: {:02x}{:02x}", .0[0], .0[1])] + UnknownChipId([u8; 2]), + #[error("Buffer too small: need {need} bytes, have {have}")] BufferTooSmall { need: usize, have: usize }, } diff --git a/mujina-miner/src/asic/bm13xx/register.rs b/mujina-miner/src/asic/bm13xx/register.rs index 7e2e5e0c..64122676 100644 --- a/mujina-miner/src/asic/bm13xx/register.rs +++ b/mujina-miner/src/asic/bm13xx/register.rs @@ -11,6 +11,7 @@ use std::fmt; use strum::FromRepr; use super::chip_config::CRYSTAL_MHZ; +use super::error::ProtocolError; use crate::types::Difficulty; /// Register addresses on the wire. @@ -53,9 +54,9 @@ pub enum Register { } impl Register { - pub fn decode(address: RegisterAddress, bytes: [u8; 4]) -> Register { - match address { - RegisterAddress::ChipId => Register::ChipId(ChipId::decode(bytes)), + pub fn decode(address: RegisterAddress, bytes: [u8; 4]) -> Result { + Ok(match address { + RegisterAddress::ChipId => Register::ChipId(ChipId::decode(bytes)?), RegisterAddress::PllDivider => Register::PllDivider(PllDivider::decode(bytes)), RegisterAddress::NonceRange => Register::NonceRange(NonceRange::decode(bytes)), RegisterAddress::TicketMask => Register::TicketMask(TicketMask::decode(bytes)), @@ -75,7 +76,7 @@ impl Register { Register::SoftResetControl(SoftResetControl::decode(bytes)) } RegisterAddress::MiscSettings => Register::MiscSettings(MiscSettings::decode(bytes)), - } + }) } pub(super) fn address(&self) -> RegisterAddress { @@ -131,16 +132,21 @@ impl ChipId { dst.put_u8(self.core_count); dst.put_u8(self.address); } - pub fn decode(bytes: [u8; 4]) -> Self { - Self { - model: ChipModel::from([bytes[0], bytes[1]]), + pub fn decode(bytes: [u8; 4]) -> Result { + Ok(Self { + model: ChipModel::try_from([bytes[0], bytes[1]])?, core_count: bytes[2], address: bytes[3], - } + }) } } -/// Known chip models in the BM13xx family. +/// Chip models the BM13xx stack supports. +/// +/// Deliberately closed: every variant must have decided protocol +/// behavior (nonce packing, PLL bounds), so an unrecognized chip id +/// fails to decode rather than carrying an id the rest of the stack +/// cannot act on. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChipModel { /// BM1362 - Used in Antminer S19 J Pro (126 chips) @@ -151,10 +157,6 @@ pub enum ChipModel { /// BM1370 - Used in Bitaxe Gamma and Antminer S21 Pro /// ~2,040 hash engines organized as 128 domains of ~16 engines each BM1370, - /// BM1397 - Previous generation chip - BM1397, - /// Unknown chip model with raw ID bytes. - Unknown([u8; 2]), } impl ChipModel { @@ -164,8 +166,6 @@ impl ChipModel { Self::BM1362 => [0x13, 0x62], Self::BM1366 => [0x13, 0x66], Self::BM1370 => [0x13, 0x70], - Self::BM1397 => [0x13, 0x97], - Self::Unknown(bytes) => *bytes, } } @@ -178,14 +178,15 @@ impl ChipModel { } } -impl From<[u8; 2]> for ChipModel { - fn from(bytes: [u8; 2]) -> Self { +impl TryFrom<[u8; 2]> for ChipModel { + type Error = ProtocolError; + + fn try_from(bytes: [u8; 2]) -> Result { match bytes { - [0x13, 0x62] => Self::BM1362, - [0x13, 0x66] => Self::BM1366, - [0x13, 0x70] => Self::BM1370, - [0x13, 0x97] => Self::BM1397, - _ => Self::Unknown(bytes), + [0x13, 0x62] => Ok(Self::BM1362), + [0x13, 0x66] => Ok(Self::BM1366), + [0x13, 0x70] => Ok(Self::BM1370), + _ => Err(ProtocolError::UnknownChipId(bytes)), } } } @@ -1035,7 +1036,7 @@ mod chip_id_tests { let mut buf = BytesMut::new(); original.encode(&mut buf); let bytes: [u8; 4] = buf[..].try_into().unwrap(); - assert_eq!(ChipId::decode(bytes), original); + assert_eq!(ChipId::decode(bytes).unwrap(), original); } #[test] @@ -1048,12 +1049,11 @@ mod chip_id_tests { } #[test] - fn unknown_model() { - round_trip(ChipId { - model: ChipModel::Unknown([0x12, 0x34]), - core_count: 0, - address: 0x00, - }); + fn reject_unknown_id() { + assert!(matches!( + ChipId::decode([0x12, 0x34, 0x00, 0x00]), + Err(ProtocolError::UnknownChipId([0x12, 0x34])) + )); } } diff --git a/mujina-miner/src/asic/bm13xx/response.rs b/mujina-miner/src/asic/bm13xx/response.rs index 3ce3d950..eaeb3f63 100644 --- a/mujina-miner/src/asic/bm13xx/response.rs +++ b/mujina-miner/src/asic/bm13xx/response.rs @@ -46,7 +46,7 @@ impl Response { let register_address_repr = bytes.get_u8(); if let Some(register_address) = RegisterAddress::from_repr(register_address_repr) { - let register = Register::decode(register_address, value); + let register = Register::decode(register_address, value)?; Ok(Response::ReadRegister { chip_address, register, @@ -169,6 +169,21 @@ mod tests { codec.decode(&mut buf).expect("Failed to decode frame") } + #[test] + fn reject_register_response_with_unknown_chip_id() { + // A ChipId register read response whose id bytes match no + // supported model; body only, preamble stripped as + // Response::decode expects + let mut buf = BytesMut::from(&[0x12, 0x34, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00][..]); + + let result = Response::decode(&mut buf); + + assert!(matches!( + result, + Err(ProtocolError::UnknownChipId([0x12, 0x34])) + )); + } + #[test] fn decode_nonce_response_from_capture() { // From Bitaxe capture: RX: AA 55 18 00 A6 40 02 99 22 F9 91 From af0067843d1f96f1134026426b6a5d884ac6e8a8 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Sat, 4 Jul 2026 14:55:33 -0500 Subject: [PATCH 33/38] feat(bm13xx): parameterize codec by chip model Chip models pack the nonce result header differently: BM1370 uses a 4-bit job id and a 4-bit subcore id, while BM1362 and BM1366 use a 5-bit job id and a 3-bit subcore id. The decoder hardcoded the BM1370 layout. Build the codec with a chip model and pick the layout by model. An S19 J Pro capture proves the BM1362 layout: its job ids don't fit in 4 bits. No capture covers the BM1366; a reference driver decodes it the same as the BM1362, so it shares that layout. Job encoding still limits job ids to 4 bits. This commit leaves that alone; the limit will change later, when a chain driver starts sending BM1362 jobs. --- mujina-miner/src/asic/bm13xx/codec.rs | 14 ++- mujina-miner/src/asic/bm13xx/command.rs | 9 +- mujina-miner/src/asic/bm13xx/response.rs | 105 ++++++++++++++++------- mujina-miner/src/board/bitaxe.rs | 7 +- 4 files changed, 96 insertions(+), 39 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/codec.rs b/mujina-miner/src/asic/bm13xx/codec.rs index ef090b10..625cf275 100644 --- a/mujina-miner/src/asic/bm13xx/codec.rs +++ b/mujina-miner/src/asic/bm13xx/codec.rs @@ -10,12 +10,20 @@ use std::{fmt, io}; use tokio_util::codec::{Decoder, Encoder}; use super::command::{JobCommand, RegisterCommand}; +use super::register::ChipModel; use super::response::Response; use crate::asic::bm13xx::crc::{crc5, crc5_is_valid, crc16}; use crate::tracing::prelude::*; -#[derive(Default)] -pub struct FrameCodec; +pub struct FrameCodec { + model: ChipModel, +} + +impl FrameCodec { + pub fn new(model: ChipModel) -> Self { + Self { model } + } +} impl Encoder for FrameCodec { type Error = io::Error; @@ -118,7 +126,7 @@ impl Decoder for FrameCodec { let mut decode_buf = BytesMut::from(&src[..FRAME_LEN]); decode_buf.advance(2); // Skip preamble for Response::decode - match Response::decode(&mut decode_buf) { + match Response::decode(&mut decode_buf, self.model) { Ok(response) => { // Only advance if decode was successful src.advance(FRAME_LEN); diff --git a/mujina-miner/src/asic/bm13xx/command.rs b/mujina-miner/src/asic/bm13xx/command.rs index 921f433c..0fcb8c6e 100644 --- a/mujina-miner/src/asic/bm13xx/command.rs +++ b/mujina-miner/src/asic/bm13xx/command.rs @@ -694,7 +694,7 @@ mod tests { version: bitcoin::block::Version::from_consensus(0x20000000), }; - let mut codec = FrameCodec; + let mut codec = FrameCodec::new(ChipModel::BM1370); let mut frame = BytesMut::new(); codec .encode(JobCommand::JobFull(job.clone()), &mut frame) @@ -765,7 +765,7 @@ mod tests { version: *esp_miner_job::wire_tx::VERSION, }; - let mut codec = FrameCodec; + let mut codec = FrameCodec::new(ChipModel::BM1370); let mut frame = BytesMut::new(); codec .encode(JobCommand::JobFull(job.clone()), &mut frame) @@ -781,7 +781,7 @@ mod tests { fn job_full_matches_s19jpro_factory_capture() { let job = job_full_from_wire(&s19jpro_job::wire_tx::FRAME); - let mut codec = FrameCodec; + let mut codec = FrameCodec::new(ChipModel::BM1362); let mut frame = BytesMut::new(); codec .encode(JobCommand::JobFull(job), &mut frame) @@ -824,7 +824,8 @@ mod tests { where FrameCodec: Encoder, { - let mut codec = FrameCodec; + // Encoding does not depend on the chip model + let mut codec = FrameCodec::new(ChipModel::BM1370); let mut frame = BytesMut::new(); codec .encode(cmd, &mut frame) diff --git a/mujina-miner/src/asic/bm13xx/response.rs b/mujina-miner/src/asic/bm13xx/response.rs index eaeb3f63..d53007db 100644 --- a/mujina-miner/src/asic/bm13xx/response.rs +++ b/mujina-miner/src/asic/bm13xx/response.rs @@ -7,7 +7,7 @@ use bitvec::prelude::*; use bytes::{Buf, BytesMut}; use strum::FromRepr; -use super::register::{Register, RegisterAddress}; +use super::register::{ChipModel, Register, RegisterAddress}; use crate::asic::bm13xx::error::ProtocolError; use crate::job_source::GeneralPurposeBits; @@ -28,7 +28,10 @@ pub enum Response { } impl Response { - pub(super) fn decode(bytes: &mut BytesMut) -> Result { + pub(super) fn decode( + bytes: &mut BytesMut, + model: ChipModel, + ) -> Result { let type_and_crc = bytes[bytes.len() - 1].view_bits::(); let type_repr = type_and_crc[5..].load::(); @@ -56,7 +59,7 @@ impl Response { } } Some(ResponseType::Nonce) => { - // BM1370 nonce response format (11 bytes total, including preamble): + // Nonce response format (11 bytes total, including preamble): // Already consumed: preamble (2 bytes) // Remaining: nonce(4) + midstate_num(1) + result_header(1) + version(2) + crc(1) let nonce = bytes.get_u32_le(); @@ -69,10 +72,21 @@ impl Response { let version = GeneralPurposeBits::from(version_bytes); // CRC already consumed - // Extract job_id and subcore_id from result_header - // job_id is a 4-bit field (0-15) at bits 7-4 of result_header - let job_id = (result_header >> 4) & 0x0f; - let subcore_id = result_header & 0x0f; + // The result header packs the job id above the subcore + // id, split per model: BM1362 and BM1366 use a 5-bit + // job id with a 3-bit subcore id, BM1370 a 4-bit job + // id with a 4-bit subcore id. Captures verify the + // BM1362 and BM1370 splits. No capture covers the + // BM1366; it gets the BM1362 split because a + // reference driver decodes both identically. The + // match is deliberately exhaustive: a new model does + // not compile until its split is decided here. + let (job_id, subcore_id) = match model { + ChipModel::BM1362 | ChipModel::BM1366 => { + (result_header >> 3, result_header & 0x07) + } + ChipModel::BM1370 => ((result_header >> 4) & 0x0f, result_header & 0x0f), + }; Ok(Response::Nonce { nonce, @@ -116,7 +130,7 @@ mod tests { #[test] fn decoder_with_exact_frame_size() { - let mut codec = FrameCodec; + let mut codec = FrameCodec::new(ChipModel::BM1370); // Exactly 11 bytes - a complete frame let mut buf = BytesMut::new(); @@ -137,7 +151,8 @@ mod tests { let wire = &[ 0xaa, 0x55, 0x13, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, ]; - let response = decode_frame(wire).expect("decode_frame should return Some for valid frame"); + let response = decode_frame(wire, ChipModel::BM1370) + .expect("decode_frame should return Some for valid frame"); let Response::ReadRegister { chip_address, @@ -163,9 +178,9 @@ mod tests { assert_eq!(address, 0x00); } - fn decode_frame(frame: &[u8]) -> Option { + fn decode_frame(frame: &[u8], model: ChipModel) -> Option { let mut buf = BytesMut::from(frame); - let mut codec = FrameCodec; + let mut codec = FrameCodec::new(model); codec.decode(&mut buf).expect("Failed to decode frame") } @@ -176,7 +191,7 @@ mod tests { // Response::decode expects let mut buf = BytesMut::from(&[0x12, 0x34, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00][..]); - let result = Response::decode(&mut buf); + let result = Response::decode(&mut buf, ChipModel::BM1370); assert!(matches!( result, @@ -190,7 +205,8 @@ mod tests { let wire = &[ 0xaa, 0x55, 0x18, 0x00, 0xa6, 0x40, 0x02, 0x99, 0x22, 0xf9, 0x91, ]; - let response = decode_frame(wire).expect("decode_frame should return Some for valid frame"); + let response = decode_frame(wire, ChipModel::BM1370) + .expect("decode_frame should return Some for valid frame"); let Response::Nonce { nonce, @@ -252,8 +268,8 @@ mod tests { ]; for (wire, exp_nonce, exp_midstate, exp_job_id, exp_subcore, exp_version) in test_cases { - let response = - decode_frame(wire).expect("decode_frame should return Some for valid frame"); + let response = decode_frame(wire, ChipModel::BM1370) + .expect("decode_frame should return Some for valid frame"); let Response::Nonce { nonce, @@ -274,9 +290,40 @@ mod tests { } } + #[test] + fn decode_nonce_response_from_s19jpro_capture() { + // From S19 J Pro capture: RX: AA 55 81 C9 77 D0 00 F2 7C 3E 89 + let wire = &[ + 0xaa, 0x55, 0x81, 0xc9, 0x77, 0xd0, 0x00, 0xf2, 0x7c, 0x3e, 0x89, + ]; + let response = decode_frame(wire, ChipModel::BM1362) + .expect("decode_frame should return Some for valid frame"); + + let Response::Nonce { + nonce, + job_id, + midstate_num, + version, + subcore_id, + } = response + else { + panic!("Expected nonce response"); + }; + + assert_eq!(nonce, 0xd077c981); + assert_eq!(midstate_num, 0x00); + + // Result header 0xf2: job_id 30 needs the 5-bit split; the + // BM1370 packing cannot represent it + assert_eq!(job_id, 30); + assert_eq!(subcore_id, 2); + + assert_eq!(version, GeneralPurposeBits::new([0x7c, 0x3e])); + } + #[test] fn decoder_handles_partial_frames() { - let mut codec = FrameCodec; + let mut codec = FrameCodec::new(ChipModel::BM1370); // Test with incomplete frame (less than 11 bytes) let mut buf = BytesMut::new(); @@ -296,7 +343,7 @@ mod tests { #[test] fn decoder_handles_corrupted_crc() { - let mut codec = FrameCodec; + let mut codec = FrameCodec::new(ChipModel::BM1370); // Valid frame with corrupted CRC (last byte) let mut buf = BytesMut::new(); @@ -315,7 +362,7 @@ mod tests { #[test] fn decoder_finds_frame_after_garbage() { - let mut codec = FrameCodec; + let mut codec = FrameCodec::new(ChipModel::BM1370); // Garbage bytes followed by valid frame let mut buf = BytesMut::new(); @@ -337,7 +384,7 @@ mod tests { #[test] fn decoder_handles_false_start() { - let mut codec = FrameCodec; + let mut codec = FrameCodec::new(ChipModel::BM1370); // Frame that starts with 0xAA but not followed by 0x55 let mut buf = BytesMut::new(); @@ -377,7 +424,7 @@ mod tests { #[test] fn decoder_handles_back_to_back_frames() { - let mut codec = FrameCodec; + let mut codec = FrameCodec::new(ChipModel::BM1370); // Two valid frames back-to-back let mut buf = BytesMut::new(); @@ -403,7 +450,7 @@ mod tests { #[test] fn decoder_handles_real_s21_pro_frames() { - let mut codec = FrameCodec; + let mut codec = FrameCodec::new(ChipModel::BM1370); // Real frames from S21 Pro capture let frames = vec![ @@ -433,7 +480,7 @@ mod tests { #[test] fn decoder_handles_stream_with_lost_bytes() { - let mut codec = FrameCodec; + let mut codec = FrameCodec::new(ChipModel::BM1370); // Simulate a stream where some bytes in the middle are lost let mut buf = BytesMut::new(); @@ -461,7 +508,7 @@ mod tests { #[test] fn decoder_handles_mid_frame_start() { - let mut codec = FrameCodec; + let mut codec = FrameCodec::new(ChipModel::BM1370); // Start reading in the middle of a frame let mut buf = BytesMut::new(); @@ -500,7 +547,7 @@ mod tests { #[test] fn decoder_validates_real_register_responses() { // Test all register read responses are handled correctly - let mut codec = FrameCodec; + let mut codec = FrameCodec::new(ChipModel::BM1370); // Standard chip detection response let mut buf = BytesMut::new(); @@ -526,8 +573,8 @@ mod tests { use crate::asic::bm13xx::test_data::esp_miner_job; // Decode nonce response from hardware capture and verify against test data - let response = - decode_frame(&esp_miner_job::wire_rx::FRAME).expect("Should decode valid frame"); + let response = decode_frame(&esp_miner_job::wire_rx::FRAME, ChipModel::BM1370) + .expect("Should decode valid frame"); let Response::Nonce { nonce, @@ -585,7 +632,7 @@ mod tests { version: *esp_miner_job::notify::VERSION, }; - let mut codec = FrameCodec; + let mut codec = FrameCodec::new(ChipModel::BM1370); let mut tx_frame = BytesMut::new(); codec .encode(JobCommand::JobFull(job.clone()), &mut tx_frame) @@ -596,8 +643,8 @@ mod tests { // length-byte comment in JobFullFormat::encode. assert_eq!(&tx_frame[4..86], &esp_miner_job::wire_tx::FRAME[4..86]); - let rx_response = - decode_frame(&esp_miner_job::wire_rx::FRAME).expect("Should decode RX frame"); + let rx_response = decode_frame(&esp_miner_job::wire_rx::FRAME, ChipModel::BM1370) + .expect("Should decode RX frame"); let Response::Nonce { nonce, diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index ef9af0ac..1095b719 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -26,7 +26,7 @@ use crate::{ bm13xx::{ self, Register, Response, command::{Destination, ReadRegister, RegisterCommand, WriteRegister}, - register::{ChipId, MidstateConfig, RegisterAddress}, + register::{ChipId, ChipModel, MidstateConfig, RegisterAddress}, thread::BM13xxThread, }, hash_thread::{AsicEnable, BoardPeripherals, HashThread, ThreadRemovalSignal}, @@ -97,8 +97,9 @@ async fn create_from_usb(device: UsbDeviceInfo) -> Result { SerialStream::new(&serial_ports[1], 115200).context("failed to open data port")?; let (data_reader, data_writer, _data_control) = data_stream.split(); let tracing_reader = TracingReader::new(data_reader, "Data"); - let mut data_reader = FramedRead::new(tracing_reader, bm13xx::FrameCodec); - let mut data_writer = FramedWrite::new(data_writer, bm13xx::FrameCodec); + let mut data_reader = + FramedRead::new(tracing_reader, bm13xx::FrameCodec::new(ChipModel::BM1370)); + let mut data_writer = FramedWrite::new(data_writer, bm13xx::FrameCodec::new(ChipModel::BM1370)); // Get reset pin const ASIC_RESET_PIN: u8 = 0; From 0307d3303a1a224ae3cdd1c20fa885a0e5bd4e38 Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Sat, 4 Jul 2026 15:26:32 -0500 Subject: [PATCH 34/38] refactor(bm13xx): wrap each response variant in its own struct Give each response kind a named struct, matching the shape the command types already have. The coming reader task routes nonce and register responses onto separate channels, and the channel payloads need to be standalone types rather than enum variants with loose fields. --- mujina-miner/src/asic/bm13xx/response.rs | 79 +++++++++++++----------- mujina-miner/src/asic/bm13xx/thread.rs | 6 +- mujina-miner/src/board/bitaxe.rs | 5 +- 3 files changed, 49 insertions(+), 41 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/response.rs b/mujina-miner/src/asic/bm13xx/response.rs index d53007db..56b99e40 100644 --- a/mujina-miner/src/asic/bm13xx/response.rs +++ b/mujina-miner/src/asic/bm13xx/response.rs @@ -12,19 +12,26 @@ use crate::asic::bm13xx::error::ProtocolError; use crate::job_source::GeneralPurposeBits; #[derive(Debug)] -#[cfg_attr(not(test), allow(dead_code))] pub enum Response { - ReadRegister { - chip_address: u8, - register: Register, - }, - Nonce { - nonce: u32, - job_id: u8, - midstate_num: u8, - version: GeneralPurposeBits, - subcore_id: u8, - }, + ReadRegister(RegisterResponse), + Nonce(NonceResponse), +} + +/// Reply to a register read. +#[derive(Debug)] +pub struct RegisterResponse { + pub chip_address: u8, + pub register: Register, +} + +/// Nonce report from a chip that found passing work. +#[derive(Debug)] +pub struct NonceResponse { + pub nonce: u32, + pub job_id: u8, + pub midstate_num: u8, + pub version: GeneralPurposeBits, + pub subcore_id: u8, } impl Response { @@ -50,10 +57,10 @@ impl Response { if let Some(register_address) = RegisterAddress::from_repr(register_address_repr) { let register = Register::decode(register_address, value)?; - Ok(Response::ReadRegister { + Ok(Response::ReadRegister(RegisterResponse { chip_address, register, - }) + })) } else { Err(ProtocolError::InvalidRegisterAddress(register_address_repr)) } @@ -88,13 +95,13 @@ impl Response { ChipModel::BM1370 => ((result_header >> 4) & 0x0f, result_header & 0x0f), }; - Ok(Response::Nonce { + Ok(Response::Nonce(NonceResponse { nonce, job_id, midstate_num, version, subcore_id, - }) + })) } None => Err(ProtocolError::InvalidResponseType(type_repr)), } @@ -154,10 +161,10 @@ mod tests { let response = decode_frame(wire, ChipModel::BM1370) .expect("decode_frame should return Some for valid frame"); - let Response::ReadRegister { + let Response::ReadRegister(RegisterResponse { chip_address, register, - } = response + }) = response else { panic!("Expected ReadRegister response, got {:?}", response); }; @@ -208,13 +215,13 @@ mod tests { let response = decode_frame(wire, ChipModel::BM1370) .expect("decode_frame should return Some for valid frame"); - let Response::Nonce { + let Response::Nonce(NonceResponse { nonce, job_id, midstate_num, version, subcore_id, - } = response + }) = response else { panic!("Expected nonce response"); }; @@ -271,13 +278,13 @@ mod tests { let response = decode_frame(wire, ChipModel::BM1370) .expect("decode_frame should return Some for valid frame"); - let Response::Nonce { + let Response::Nonce(NonceResponse { nonce, job_id, midstate_num, version, subcore_id, - } = response + }) = response else { panic!("Expected nonce response"); }; @@ -299,13 +306,13 @@ mod tests { let response = decode_frame(wire, ChipModel::BM1362) .expect("decode_frame should return Some for valid frame"); - let Response::Nonce { + let Response::Nonce(NonceResponse { nonce, job_id, midstate_num, version, subcore_id, - } = response + }) = response else { panic!("Expected nonce response"); }; @@ -411,7 +418,7 @@ mod tests { // Third decode should succeed let result = codec.decode(&mut buf); match result { - Ok(Some(Response::ReadRegister { .. })) => {} // Success + Ok(Some(Response::ReadRegister(_))) => {} // Success Ok(Some(other)) => panic!("Expected ReadRegister, got {:?}", other), Ok(None) => panic!( "Expected Some, got None. Buffer len: {}, contents: {:02x?}", @@ -439,12 +446,12 @@ mod tests { // Decode first frame let result1 = codec.decode(&mut buf).unwrap(); - assert!(matches!(result1, Some(Response::ReadRegister { .. }))); + assert!(matches!(result1, Some(Response::ReadRegister(_)))); assert_eq!(buf.len(), 11, "Should have second frame remaining"); // Decode second frame let result2 = codec.decode(&mut buf).unwrap(); - assert!(matches!(result2, Some(Response::Nonce { .. }))); + assert!(matches!(result2, Some(Response::Nonce(_)))); assert_eq!(buf.len(), 0, "Buffer should be empty"); } @@ -472,7 +479,7 @@ mod tests { let result = codec.decode(&mut buf).unwrap(); assert!(result.is_some(), "Should decode real S21 Pro frame"); assert!( - matches!(result, Some(Response::Nonce { .. })), + matches!(result, Some(Response::Nonce(_))), "Should be nonce response" ); } @@ -498,7 +505,7 @@ mod tests { for _ in 0..20 { // Try up to 20 times if let Some(response) = codec.decode(&mut buf).unwrap() { - assert!(matches!(response, Response::Nonce { .. })); + assert!(matches!(response, Response::Nonce(_))); found_valid = true; break; } @@ -539,7 +546,7 @@ mod tests { "Should find valid frame after partial data" ); assert!( - matches!(result, Some(Response::Nonce { .. })), + matches!(result, Some(Response::Nonce(_))), "Should be nonce response" ); } @@ -557,10 +564,10 @@ mod tests { let response = codec.decode(&mut buf).unwrap().unwrap(); match response { - Response::ReadRegister { + Response::ReadRegister(RegisterResponse { chip_address, register, - } => { + }) => { assert_eq!(chip_address, 0x00); assert!(matches!(register, Register::ChipId { .. })); } @@ -576,13 +583,13 @@ mod tests { let response = decode_frame(&esp_miner_job::wire_rx::FRAME, ChipModel::BM1370) .expect("Should decode valid frame"); - let Response::Nonce { + let Response::Nonce(NonceResponse { nonce, job_id, midstate_num, version, subcore_id, - } = response + }) = response else { panic!("Expected nonce response"); }; @@ -646,12 +653,12 @@ mod tests { let rx_response = decode_frame(&esp_miner_job::wire_rx::FRAME, ChipModel::BM1370) .expect("Should decode RX frame"); - let Response::Nonce { + let Response::Nonce(NonceResponse { nonce, job_id: rx_job_id, version: version_rolling, .. - } = rx_response + }) = rx_response else { panic!("Expected Nonce response"); }; diff --git a/mujina-miner/src/asic/bm13xx/thread.rs b/mujina-miner/src/asic/bm13xx/thread.rs index 9edeccd8..5356b845 100644 --- a/mujina-miner/src/asic/bm13xx/thread.rs +++ b/mujina-miner/src/asic/bm13xx/thread.rs @@ -25,7 +25,7 @@ use super::register::{ AnalogMux, ChipModel, CoreCommand, IoDriverStrength, Log2Difficulty, MidstateConfig, MiscControl, MiscSettings, NonceRange, PllDivider, Register, SoftResetControl, TicketMask, }; -use super::response::Response; +use super::response::{NonceResponse, RegisterResponse, Response}; use crate::{ asic::hash_thread::{ BoardPeripherals, HashTask, HashThread, HashThreadCapabilities, HashThreadEvent, @@ -773,7 +773,7 @@ async fn bm13xx_thread_actor( match result { Ok(response) => { match response { - Response::Nonce { nonce, job_id, version, midstate_num, subcore_id } => { + Response::Nonce(NonceResponse { nonce, job_id, version, midstate_num, subcore_id }) => { // Look up the task for this job_id if let Some(task) = chip_jobs.get(job_id) { let template = task.template.as_ref(); @@ -860,7 +860,7 @@ async fn bm13xx_thread_actor( let _ = (midstate_num, subcore_id); // Unused for now } - Response::ReadRegister { chip_address, register } => { + Response::ReadRegister(RegisterResponse { chip_address, register }) => { trace!(chip_address = %format!("0x{:02x}", chip_address), register = ?register, "Register read response"); } } diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 1095b719..361112a8 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -27,6 +27,7 @@ use crate::{ self, Register, Response, command::{Destination, ReadRegister, RegisterCommand, WriteRegister}, register::{ChipId, ChipModel, MidstateConfig, RegisterAddress}, + response::RegisterResponse, thread::BM13xxThread, }, hash_thread::{AsicEnable, BoardPeripherals, HashThread, ThreadRemovalSignal}, @@ -573,10 +574,10 @@ async fn discover_chips( tokio::select! { response = reader.next() => { match response { - Some(Ok(Response::ReadRegister { + Some(Ok(Response::ReadRegister(RegisterResponse { chip_address: _, register: Register::ChipId(ChipId { model, core_count, address }), - })) => { + }))) => { let chip_id = model.id_bytes(); debug!("Discovered chip {:?} ({:02x}{:02x}) at address {address}", model, chip_id[0], chip_id[1]); From cc035ffc8e6ad9a3d3c293a52dc2ee5a7459b79b Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Sat, 4 Jul 2026 17:10:20 -0500 Subject: [PATCH 35/38] feat(bm13xx): add reader task demuxing chip responses One serial wire carries two traffic patterns at once: unsolicited nonce reports and replies to register conversations. A spawned reader task now owns the framed RX stream and routes each frame by kind into one of two bounded channels, nonces on one, register responses on the other. Each channel carries a single kind of traffic, so a consumer handles only the kind it cares about, and neither kind can starve the other. The task alone holds the channel senders, so when the stream ends or fails, from unplug for example, the channels close and consumers see end of stream; channel closure is the only lifecycle signal. Nothing consumes the channels yet; the driver and the actor rewrite arrive next. --- mujina-miner/src/asic/bm13xx/mod.rs | 1 + mujina-miner/src/asic/bm13xx/reader.rs | 160 +++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 mujina-miner/src/asic/bm13xx/reader.rs diff --git a/mujina-miner/src/asic/bm13xx/mod.rs b/mujina-miner/src/asic/bm13xx/mod.rs index 4107fb4f..a8f1ff0a 100644 --- a/mujina-miner/src/asic/bm13xx/mod.rs +++ b/mujina-miner/src/asic/bm13xx/mod.rs @@ -9,6 +9,7 @@ pub mod codec; pub mod command; pub mod crc; pub mod error; +pub mod reader; pub mod register; pub mod response; pub mod thread; diff --git a/mujina-miner/src/asic/bm13xx/reader.rs b/mujina-miner/src/asic/bm13xx/reader.rs new file mode 100644 index 00000000..93a68a33 --- /dev/null +++ b/mujina-miner/src/asic/bm13xx/reader.rs @@ -0,0 +1,160 @@ +//! Demultiplexes the framed RX stream by response kind. +//! +//! One serial wire carries unsolicited nonce reports interleaved +//! with replies to register conversations. [`Reader`] spawns a task +//! that owns the framed RX stream and routes each response onto its +//! own bounded channel. Each channel then carries a single kind of +//! traffic, so a consumer handles only the kind it cares about, and +//! neither kind can starve the other. + +use futures::{Stream, StreamExt}; +use std::io; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +use super::response::{NonceResponse, RegisterResponse, Response}; +use crate::tracing::prelude::*; + +/// Owner of the spawned demux task. +pub struct Reader { + task: JoinHandle<()>, +} + +impl Reader { + /// Spawns a task routing responses from the stream onto the + /// returned channels. + /// + /// The task alone holds the channel senders: when the stream + /// ends or fails, or when a receiver is dropped, the task exits + /// and both channels close. Channel closure is the only + /// lifecycle signal. + pub fn spawn(stream: R) -> (Self, ReaderChannels) + where + R: Stream> + Unpin + Send + 'static, + { + let (nonce_tx, nonces) = mpsc::channel(CHANNEL_CAPACITY); + let (register_tx, register_responses) = mpsc::channel(CHANNEL_CAPACITY); + let task = tokio::spawn(run(stream, nonce_tx, register_tx)); + + ( + Self { task }, + ReaderChannels { + nonces, + register_responses, + }, + ) + } +} + +/// Aborts the task so a discarded reader releases its stream. +impl Drop for Reader { + fn drop(&mut self) { + self.task.abort(); + } +} + +/// Receiving ends of the demuxed response channels. +pub struct ReaderChannels { + pub nonces: mpsc::Receiver, + pub register_responses: mpsc::Receiver, +} + +// Sized so a broadcast read's reply burst from the largest possible +// chain (128 chips) fits the register channel, and so the nonce +// channel absorbs a long conversation's worth of nonces at +// ticket-mask rates. +const CHANNEL_CAPACITY: usize = 256; + +async fn run( + mut stream: R, + nonce_tx: mpsc::Sender, + register_tx: mpsc::Sender, +) where + R: Stream> + Unpin, +{ + while let Some(item) = stream.next().await { + let delivered = match item { + Ok(Response::Nonce(nonce)) => nonce_tx.send(nonce).await.is_ok(), + Ok(Response::ReadRegister(response)) => register_tx.send(response).await.is_ok(), + Err(error) => { + warn!(%error, "Chip response stream failed"); + break; + } + }; + + // A failed send means the receiver is gone + if !delivered { + break; + } + } + + debug!("Chip response reader exiting"); +} + +#[cfg(test)] +mod tests { + use futures::stream; + + use super::*; + use crate::asic::bm13xx::register::{ChipId, ChipModel, Register}; + use crate::job_source::GeneralPurposeBits; + + fn nonce(job_id: u8) -> Result { + Ok(Response::Nonce(NonceResponse { + nonce: 0x12345678, + job_id, + midstate_num: 0, + version: GeneralPurposeBits::new([0, 0]), + subcore_id: 0, + })) + } + + fn register_read(chip_address: u8) -> Result { + Ok(Response::ReadRegister(RegisterResponse { + chip_address, + register: Register::ChipId(ChipId { + model: ChipModel::BM1370, + core_count: 0, + address: chip_address, + }), + })) + } + + #[tokio::test] + async fn routes_responses_by_kind() { + let frames = vec![nonce(1), register_read(2), nonce(3), register_read(4)]; + let (_reader, mut channels) = Reader::spawn(stream::iter(frames)); + + assert_eq!(channels.nonces.recv().await.unwrap().job_id, 1); + assert_eq!(channels.nonces.recv().await.unwrap().job_id, 3); + + let first = channels.register_responses.recv().await.unwrap(); + assert_eq!(first.chip_address, 2); + let second = channels.register_responses.recv().await.unwrap(); + assert_eq!(second.chip_address, 4); + } + + #[tokio::test] + async fn channels_close_when_stream_ends() { + let (_reader, mut channels) = Reader::spawn(stream::iter(vec![nonce(1)])); + + assert!(channels.nonces.recv().await.is_some()); + assert!(channels.nonces.recv().await.is_none()); + assert!(channels.register_responses.recv().await.is_none()); + } + + #[tokio::test] + async fn channels_close_on_stream_error() { + let frames = vec![ + nonce(1), + Err(io::Error::other("device gone")), + // Never delivered; the reader exits on the error + nonce(9), + ]; + let (_reader, mut channels) = Reader::spawn(stream::iter(frames)); + + assert_eq!(channels.nonces.recv().await.unwrap().job_id, 1); + assert!(channels.nonces.recv().await.is_none()); + assert!(channels.register_responses.recv().await.is_none()); + } +} From 5af93099fc0d1a92ade678ba9adde291c2da3b4b Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Sat, 4 Jul 2026 17:48:46 -0500 Subject: [PATCH 36/38] feat(bm13xx): add driver for register conversations Bring-up, verification, and health polling hold long conversations with chips over a protocol without request ids. A new driver owns the command sink, the register response channel, and the timing policy, and offers four verbs: write, read, broadcast read, and write with readback. Reads correlate by content: drain stale responses, send, then accept only a response matching both chip address and register, discarding mismatches, under a per-attempt deadline with bounded retries. Broadcast reads collect replies until a quiet window passes with none new. Verbs take the driver by mutable reference, so conversations cannot interleave on the wire. --- mujina-miner/src/asic/bm13xx/driver.rs | 512 +++++++++++++++++++++++ mujina-miner/src/asic/bm13xx/mod.rs | 1 + mujina-miner/src/asic/bm13xx/register.rs | 4 +- 3 files changed, 515 insertions(+), 2 deletions(-) create mode 100644 mujina-miner/src/asic/bm13xx/driver.rs diff --git a/mujina-miner/src/asic/bm13xx/driver.rs b/mujina-miner/src/asic/bm13xx/driver.rs new file mode 100644 index 00000000..02e2ac48 --- /dev/null +++ b/mujina-miner/src/asic/bm13xx/driver.rs @@ -0,0 +1,512 @@ +//! Register conversations with chips on the serial chain. +//! +//! The BM13xx protocol has no request ids, so [`Driver`] correlates +//! reads by content: a reply must match the requested chip address +//! and register address. The driver owns the command sink, the +//! register response channel, and the timing policy. Verbs take the +//! driver by mutable reference, so conversations cannot interleave +//! on the wire. + +use futures::SinkExt; +use std::marker::PhantomData; +use std::time::Duration; +use thiserror::Error; +use tokio::sync::mpsc; +use tokio::time::timeout; + +use super::command::{ChipCommandSink, Destination, ReadRegister, RegisterCommand, WriteRegister}; +use super::register::{Register, RegisterAddress}; +use super::response::RegisterResponse; +use crate::tracing::prelude::*; + +pub struct Driver { + commands: W, + responses: mpsc::Receiver, + _error: PhantomData E>, +} + +impl Driver +where + W: ChipCommandSink + Unpin, + E: std::error::Error + Send + Sync + 'static, +{ + pub fn new(commands: W, responses: mpsc::Receiver) -> Self { + Self { + commands, + responses, + _error: PhantomData, + } + } + + /// Writes a register value; no reply is expected. + pub async fn write( + &mut self, + destination: Destination, + register: Register, + ) -> Result<(), DriverError> { + self.commands + .send(RegisterCommand::WriteRegister(WriteRegister { + destination, + register, + })) + .await + .map_err(DriverError::Send) + } + + /// Reads a register from one chip. + /// + /// Drains stale responses, sends the read command, and accepts + /// only a reply matching both chip address and register address, + /// discarding mismatches. Each attempt runs under a deadline; + /// exhausting the attempts returns [`DriverError::NoResponse`]. + pub async fn read( + &mut self, + chip_address: u8, + register_address: RegisterAddress, + ) -> Result> { + // A reference driver waits 50 ms per read on a direct UART; + // doubled to allow for USB CDC bridge latency + const ATTEMPT_TIMEOUT: Duration = Duration::from_millis(100); + const ATTEMPTS: u32 = 3; + + self.drain_stale(); + + for attempt in 1..=ATTEMPTS { + self.commands + .send(RegisterCommand::ReadRegister(ReadRegister { + destination: Destination::Chip(chip_address), + register_address, + })) + .await + .map_err(DriverError::Send)?; + + match timeout( + ATTEMPT_TIMEOUT, + self.matching_response(chip_address, register_address), + ) + .await + { + Ok(result) => return result, + Err(_elapsed) => { + debug!(chip_address, register = ?register_address, attempt, "Register read attempt timed out"); + } + } + } + + Err(DriverError::NoResponse { + chip_address, + register_address, + attempts: ATTEMPTS, + }) + } + + /// Reads a register from every chip at once. + /// + /// Drains stale responses, sends a broadcast read, and collects + /// replies for the requested register until a quiet window + /// passes with none new. May return an empty collection; the + /// caller judges completeness. + pub async fn broadcast_read( + &mut self, + register_address: RegisterAddress, + ) -> Result, DriverError> { + // Collection ends after this long with no new reply. At + // 115200 baud a reply frame lasts about a millisecond, so + // this window dominates any plausible gap between chips + // relaying their replies down the chain. + const QUIET_WINDOW: Duration = Duration::from_millis(500); + + self.drain_stale(); + + self.commands + .send(RegisterCommand::ReadRegister(ReadRegister { + destination: Destination::Broadcast, + register_address, + })) + .await + .map_err(DriverError::Send)?; + + let mut replies = Vec::new(); + loop { + match timeout(QUIET_WINDOW, self.responses.recv()).await { + Ok(Some(response)) if response.register.address() == register_address => { + replies.push(response); + } + Ok(Some(response)) => { + warn!(?response, expected = ?register_address, "Discarding mismatched register response"); + } + Ok(None) => return Err(DriverError::ResponsesClosed), + Err(_elapsed) => return Ok(replies), + } + } + } + + /// Writes a register value and verifies it by reading it back. + pub async fn write_readback( + &mut self, + chip_address: u8, + register: Register, + ) -> Result<(), DriverError> { + let address = register.address(); + self.write(Destination::Chip(chip_address), register.clone()) + .await?; + + let actual = self.read(chip_address, address).await?; + if actual != register { + return Err(DriverError::ReadbackMismatch { + expected: register, + actual, + }); + } + + Ok(()) + } + + /// Awaits a response matching the chip and register addresses, + /// discarding others. + async fn matching_response( + &mut self, + chip_address: u8, + register_address: RegisterAddress, + ) -> Result> { + loop { + let Some(response) = self.responses.recv().await else { + return Err(DriverError::ResponsesClosed); + }; + + if response.chip_address == chip_address + && response.register.address() == register_address + { + return Ok(response.register); + } + + warn!(?response, expected_chip = chip_address, expected = ?register_address, "Discarding mismatched register response"); + } + } + + fn drain_stale(&mut self) { + while let Ok(stale) = self.responses.try_recv() { + warn!(?stale, "Discarding stale register response"); + } + } +} + +#[derive(Debug, Error)] +pub enum DriverError { + #[error("command send failed: {0}")] + Send(E), + + #[error( + "no response from chip 0x{chip_address:02x} for {register_address:?} \ + after {attempts} attempts" + )] + NoResponse { + chip_address: u8, + register_address: RegisterAddress, + attempts: u32, + }, + + #[error("register response channel closed")] + ResponsesClosed, + + #[error("readback mismatch: wrote {expected:?}, read {actual:?}")] + ReadbackMismatch { + expected: Register, + actual: Register, + }, +} + +#[cfg(test)] +mod tests { + use std::io; + use std::pin::Pin; + use std::sync::{Arc, Mutex}; + use std::task::{Context, Poll}; + + use futures::Sink; + + use super::super::command::JobCommand; + use super::super::register::{ChipId, ChipModel, Log2Difficulty, TicketMask}; + use super::*; + use crate::types::Difficulty; + + /// Sink recording register commands and signalling each send. + struct RecordingSink { + sent: Arc>>, + events: mpsc::UnboundedSender<()>, + } + + impl Sink for RecordingSink { + type Error = io::Error; + + fn poll_ready(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn start_send(self: Pin<&mut Self>, item: RegisterCommand) -> Result<(), io::Error> { + self.sent.lock().unwrap().push(item); + self.events.send(()).ok(); + Ok(()) + } + + fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + + /// The driver never sends jobs; required by the sink bound only. + impl Sink for RecordingSink { + type Error = io::Error; + + fn poll_ready(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn start_send(self: Pin<&mut Self>, _: JobCommand) -> Result<(), io::Error> { + Ok(()) + } + + fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + + struct Harness { + driver: Driver, + responses: mpsc::Sender, + sent: Arc>>, + sends: mpsc::UnboundedReceiver<()>, + } + + fn harness() -> Harness { + let sent = Arc::new(Mutex::new(Vec::new())); + let (events, sends) = mpsc::unbounded_channel(); + let sink = RecordingSink { + sent: sent.clone(), + events, + }; + let (responses, responses_rx) = mpsc::channel(16); + + Harness { + driver: Driver::new(sink, responses_rx), + responses, + sent, + sends, + } + } + + fn chip_id_response(chip_address: u8) -> RegisterResponse { + RegisterResponse { + chip_address, + register: Register::ChipId(ChipId { + model: ChipModel::BM1370, + core_count: 0, + address: chip_address, + }), + } + } + + fn ticket_mask(difficulty: u64) -> Register { + Register::TicketMask(TicketMask::new(Log2Difficulty::from_difficulty( + Difficulty::from(difficulty), + ))) + } + + #[tokio::test(start_paused = true)] + async fn read_returns_matching_response() { + let mut h = harness(); + + let (result, _) = tokio::join!(h.driver.read(0x08, RegisterAddress::ChipId), async { + h.sends.recv().await; + h.responses.send(chip_id_response(0x08)).await.unwrap(); + }); + + let register = result.unwrap(); + assert!(matches!(register, Register::ChipId(_))); + } + + #[tokio::test(start_paused = true)] + async fn read_discards_mismatched_responses() { + let mut h = harness(); + + let (result, _) = tokio::join!(h.driver.read(0x08, RegisterAddress::ChipId), async { + h.sends.recv().await; + // Wrong chip, then right chip but wrong register, then + // the matching reply + h.responses.send(chip_id_response(0x02)).await.unwrap(); + h.responses + .send(RegisterResponse { + chip_address: 0x08, + register: ticket_mask(256), + }) + .await + .unwrap(); + h.responses.send(chip_id_response(0x08)).await.unwrap(); + }); + + let register = result.unwrap(); + let Register::ChipId(chip_id) = register else { + panic!("Expected ChipId register"); + }; + assert_eq!(chip_id.address, 0x08); + } + + #[tokio::test(start_paused = true)] + async fn read_drains_stale_responses() { + let mut h = harness(); + + // A would-be match delivered before the read must not + // satisfy it + h.responses.send(chip_id_response(0x08)).await.unwrap(); + + let result = h.driver.read(0x08, RegisterAddress::ChipId).await; + + assert!(matches!(result, Err(DriverError::NoResponse { .. }))); + } + + #[tokio::test(start_paused = true)] + async fn read_retries_after_deadline() { + let mut h = harness(); + + let (result, _) = tokio::join!(h.driver.read(0x08, RegisterAddress::ChipId), async { + // Let the first attempt starve; answer the second + h.sends.recv().await; + h.sends.recv().await; + h.responses.send(chip_id_response(0x08)).await.unwrap(); + }); + + assert!(result.is_ok()); + assert_eq!(h.sent.lock().unwrap().len(), 2); + } + + #[tokio::test(start_paused = true)] + async fn read_exhausts_attempts() { + let mut h = harness(); + + let result = h.driver.read(0x08, RegisterAddress::ChipId).await; + + let Err(DriverError::NoResponse { attempts, .. }) = result else { + panic!("Expected NoResponse error"); + }; + // The error reports as many attempts as commands were sent, + // and the policy retried at least once + assert_eq!(h.sent.lock().unwrap().len() as u32, attempts); + assert!(attempts > 1); + } + + #[tokio::test(start_paused = true)] + async fn read_errors_when_responses_close() { + let mut h = harness(); + drop(h.responses); + + let result = h.driver.read(0x08, RegisterAddress::ChipId).await; + + assert!(matches!(result, Err(DriverError::ResponsesClosed))); + // The closed channel short-circuits the remaining attempts + assert_eq!(h.sent.lock().unwrap().len(), 1); + } + + #[tokio::test(start_paused = true)] + async fn write_sends_one_command() { + let mut h = harness(); + + h.driver + .write(Destination::Broadcast, ticket_mask(256)) + .await + .unwrap(); + + let sent = h.sent.lock().unwrap(); + assert_eq!(sent.len(), 1); + let RegisterCommand::WriteRegister(write) = &sent[0] else { + panic!("Expected WriteRegister command"); + }; + assert_eq!(write.destination, Destination::Broadcast); + assert_eq!(write.register, ticket_mask(256)); + } + + #[tokio::test(start_paused = true)] + async fn broadcast_read_collects_until_quiet() { + let mut h = harness(); + + let (result, _) = tokio::join!(h.driver.broadcast_read(RegisterAddress::ChipId), async { + h.sends.recv().await; + for address in [0x00, 0x02, 0x04] { + h.responses.send(chip_id_response(address)).await.unwrap(); + } + // A reply for a different register is not collected + h.responses + .send(RegisterResponse { + chip_address: 0x06, + register: ticket_mask(256), + }) + .await + .unwrap(); + }); + + let replies = result.unwrap(); + let addresses: Vec = replies.iter().map(|r| r.chip_address).collect(); + assert_eq!(addresses, [0x00, 0x02, 0x04]); + + let sent = h.sent.lock().unwrap(); + let RegisterCommand::ReadRegister(read) = &sent[0] else { + panic!("Expected ReadRegister command"); + }; + assert_eq!(read.destination, Destination::Broadcast); + } + + #[tokio::test(start_paused = true)] + async fn broadcast_read_returns_empty_when_silent() { + let mut h = harness(); + + let result = h.driver.broadcast_read(RegisterAddress::ChipId).await; + + assert!(result.unwrap().is_empty()); + } + + #[tokio::test(start_paused = true)] + async fn write_readback_accepts_matching_value() { + let mut h = harness(); + + let (result, _) = tokio::join!(h.driver.write_readback(0x08, ticket_mask(256)), async { + // One send for the write, one for the readback read + h.sends.recv().await; + h.sends.recv().await; + h.responses + .send(RegisterResponse { + chip_address: 0x08, + register: ticket_mask(256), + }) + .await + .unwrap(); + }); + + assert!(result.is_ok()); + } + + #[tokio::test(start_paused = true)] + async fn write_readback_rejects_changed_value() { + let mut h = harness(); + + let (result, _) = tokio::join!(h.driver.write_readback(0x08, ticket_mask(256)), async { + h.sends.recv().await; + h.sends.recv().await; + h.responses + .send(RegisterResponse { + chip_address: 0x08, + register: ticket_mask(1024), + }) + .await + .unwrap(); + }); + + assert!(matches!(result, Err(DriverError::ReadbackMismatch { .. }))); + } +} diff --git a/mujina-miner/src/asic/bm13xx/mod.rs b/mujina-miner/src/asic/bm13xx/mod.rs index a8f1ff0a..2528ffe0 100644 --- a/mujina-miner/src/asic/bm13xx/mod.rs +++ b/mujina-miner/src/asic/bm13xx/mod.rs @@ -8,6 +8,7 @@ pub mod chip_config; pub mod codec; pub mod command; pub mod crc; +pub mod driver; pub mod error; pub mod reader; pub mod register; diff --git a/mujina-miner/src/asic/bm13xx/register.rs b/mujina-miner/src/asic/bm13xx/register.rs index 64122676..c7c796ae 100644 --- a/mujina-miner/src/asic/bm13xx/register.rs +++ b/mujina-miner/src/asic/bm13xx/register.rs @@ -15,7 +15,7 @@ use super::error::ProtocolError; use crate::types::Difficulty; /// Register addresses on the wire. -#[derive(FromRepr, Copy, Clone, Debug)] +#[derive(FromRepr, Copy, Clone, Debug, PartialEq, Eq)] #[repr(u8)] pub enum RegisterAddress { ChipId = 0x00, @@ -35,7 +35,7 @@ pub enum RegisterAddress { } /// A register with its typed payload. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub enum Register { ChipId(ChipId), PllDivider(PllDivider), From f2170098413814826af8f7d8336e9ddea806e63c Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Sun, 5 Jul 2026 10:53:58 -0500 Subject: [PATCH 37/38] refactor(bm13xx): type 0x10 as HashCountingNumber Register 0x10 on version-rolling chips is the hash counting number: it limits how many nonce iterations each core performs before the chip advances to the next rolled version. Zero halts hashing, and stock firmware writes a per-model value that scales inversely with frequency. The old NonceRange model guessed a per-chain nonce-mask semantic; its mask constructors emitted values wrong for this register, and the only correct write bypassed them with a byte-swapped hardcode. Store the register as a plain count encoded big-endian, drop the mask constructors, and write the factory value directly where the chip is initialized. Wire bytes are unchanged and checked against the capture frames. Rename the register's mentions in the protocol notes; correcting their content is a separate change. Computing the value from frequency and chain topology follows separately; this change keeps the factory value byte for byte. --- mujina-miner/src/asic/bm13xx/PROTOCOL.md | 14 ++-- mujina-miner/src/asic/bm13xx/command.rs | 19 ++++- mujina-miner/src/asic/bm13xx/register.rs | 101 +++++++++-------------- mujina-miner/src/asic/bm13xx/thread.rs | 9 +- 4 files changed, 64 insertions(+), 79 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/PROTOCOL.md b/mujina-miner/src/asic/bm13xx/PROTOCOL.md index e6bf400f..a1c86b4e 100644 --- a/mujina-miner/src/asic/bm13xx/PROTOCOL.md +++ b/mujina-miner/src/asic/bm13xx/PROTOCOL.md @@ -379,7 +379,7 @@ Key registers used across BM13xx chips: |----------|------|-------------| | 0x00 | CHIP_ID | Chip identification and configuration | | 0x08 | PLL_DIVIDER | Frequency control registers for hash clock | -| 0x10 | NONCE_RANGE | Controls nonce search range per core | +| 0x10 | HASH_COUNTING_NUMBER | Controls nonce search range per core | | 0x14 | TICKET_MASK | Difficulty mask for share submission | | 0x18 | MISC_CONTROL | UART settings and GPIO pin configuration | | 0x28 | UART_BAUD | UART baud rate configuration | @@ -414,7 +414,7 @@ Controls the hash frequency through PLL configuration: - Byte 2: REF_DIV (reference divider) - Byte 3: POST_DIV flags (bit 1 = fixed to 1) -#### 0x10 - NONCE_RANGE +#### 0x10 - HASH_COUNTING_NUMBER Controls nonce search space distribution (format not fully documented): - Affects how chips divide the 32-bit nonce space - Different values used for different chip counts @@ -669,7 +669,7 @@ Undocumented miscellaneous settings register: - BM1362: Different rate (0x00003011) 7. **Final Configuration** - - Set NONCE_RANGE (0x10) based on chip count + - Set HASH_COUNTING_NUMBER (0x10) based on chip count - Configure remaining registers - Begin frequency ramping @@ -720,9 +720,9 @@ chain) #### Nonce Space Partitioning The 32-bit nonce space (4.3 billion values) is automatically divided: -1. **Between Chips**: Based on chip address and NONCE_RANGE register +1. **Between Chips**: Based on chip address and HASH_COUNTING_NUMBER register - Chip address influences which nonces are searched - - NONCE_RANGE register (0x10) further controls distribution + - HASH_COUNTING_NUMBER register (0x10) further controls distribution - No explicit range assignment needed from software 2. **Between Cores**: Within each chip @@ -734,9 +734,9 @@ The 32-bit nonce space (4.3 billion values) is automatically divided: - Bits 24-0: Actual nonce value searched - Total: 2,048 parallel searches per chip -#### NONCE_RANGE Register Configuration +#### HASH_COUNTING_NUMBER Register Configuration -The NONCE_RANGE register (0x10) uses empirically-determined values to optimize +The HASH_COUNTING_NUMBER register (0x10) uses empirically-determined values to optimize nonce distribution. See discussion at: https://github.com/bitaxeorg/ESP-Miner/pull/167 **Known Values (4-byte little-endian):** diff --git a/mujina-miner/src/asic/bm13xx/command.rs b/mujina-miner/src/asic/bm13xx/command.rs index 0fcb8c6e..46e9ae25 100644 --- a/mujina-miner/src/asic/bm13xx/command.rs +++ b/mujina-miner/src/asic/bm13xx/command.rs @@ -363,8 +363,8 @@ mod tests { use super::super::codec::FrameCodec; use super::super::register::{ - AnalogMux, ChipId, ChipModel, CoreCommand, IoDriverStrength, Log2Difficulty, - MidstateConfig, MiscControl, NonceRange, Register, RegisterAddress, SoftResetControl, + AnalogMux, ChipId, ChipModel, CoreCommand, HashCountingNumber, IoDriverStrength, + Log2Difficulty, MidstateConfig, MiscControl, Register, RegisterAddress, SoftResetControl, TicketMask, UartRelay, }; use super::*; @@ -613,17 +613,28 @@ mod tests { } #[test] - fn write_nonce_range_from_capture() { + fn write_hash_counting_number_from_capture() { // From S21 Pro capture: TX: 55 AA 51 09 00 10 00 00 1E B5 0F assert_frame_eq( RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, - register: Register::NonceRange(NonceRange::multi_chip(65)), + register: Register::HashCountingNumber(HashCountingNumber::from(0x1EB5)), }), &[ 0x55, 0xaa, 0x51, 0x09, 0x00, 0x10, 0x00, 0x00, 0x1e, 0xb5, 0x0f, ], ); + + // From S19 J Pro capture: TX: 55 AA 51 09 00 10 00 00 13 81 08 + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::HashCountingNumber(HashCountingNumber::from(0x1381)), + }), + &[ + 0x55, 0xaa, 0x51, 0x09, 0x00, 0x10, 0x00, 0x00, 0x13, 0x81, 0x08, + ], + ); } #[test] diff --git a/mujina-miner/src/asic/bm13xx/register.rs b/mujina-miner/src/asic/bm13xx/register.rs index c7c796ae..cb5e9d5c 100644 --- a/mujina-miner/src/asic/bm13xx/register.rs +++ b/mujina-miner/src/asic/bm13xx/register.rs @@ -20,7 +20,7 @@ use crate::types::Difficulty; pub enum RegisterAddress { ChipId = 0x00, PllDivider = 0x08, - NonceRange = 0x10, + HashCountingNumber = 0x10, TicketMask = 0x14, MiscControl = 0x18, UartBaud = 0x28, @@ -39,7 +39,7 @@ pub enum RegisterAddress { pub enum Register { ChipId(ChipId), PllDivider(PllDivider), - NonceRange(NonceRange), + HashCountingNumber(HashCountingNumber), TicketMask(TicketMask), MiscControl(MiscControl), UartBaud(UartBaud), @@ -58,7 +58,9 @@ impl Register { Ok(match address { RegisterAddress::ChipId => Register::ChipId(ChipId::decode(bytes)?), RegisterAddress::PllDivider => Register::PllDivider(PllDivider::decode(bytes)), - RegisterAddress::NonceRange => Register::NonceRange(NonceRange::decode(bytes)), + RegisterAddress::HashCountingNumber => { + Register::HashCountingNumber(HashCountingNumber::decode(bytes)) + } RegisterAddress::TicketMask => Register::TicketMask(TicketMask::decode(bytes)), RegisterAddress::MiscControl => Register::MiscControl(MiscControl::decode(bytes)), RegisterAddress::UartBaud => Register::UartBaud(UartBaud::decode(bytes)), @@ -83,7 +85,7 @@ impl Register { match self { Register::ChipId(_) => RegisterAddress::ChipId, Register::PllDivider(_) => RegisterAddress::PllDivider, - Register::NonceRange(_) => RegisterAddress::NonceRange, + Register::HashCountingNumber(_) => RegisterAddress::HashCountingNumber, Register::TicketMask(_) => RegisterAddress::TicketMask, Register::MiscControl(_) => RegisterAddress::MiscControl, Register::UartBaud(_) => RegisterAddress::UartBaud, @@ -102,7 +104,7 @@ impl Register { match self { Register::ChipId(r) => r.encode(dst), Register::PllDivider(r) => r.encode(dst), - Register::NonceRange(r) => r.encode(dst), + Register::HashCountingNumber(r) => r.encode(dst), Register::TicketMask(r) => r.encode(dst), Register::MiscControl(r) => r.encode(dst), Register::UartBaud(r) => r.encode(dst), @@ -248,62 +250,39 @@ impl PllDivider { } } -/// Nonce range configuration for work distribution. +/// Hash counting number, the per-version nonce iteration limit. /// -/// NOTE: We store this as a byte array rather than interpreting it as a u32 -/// because the exact bit-level interpretation is still being reverse-engineered. -/// The values below are empirically observed from production hardware. +/// Limits how many nonce iterations each core performs before the +/// chip advances to the next rolled version and restarts the sweep. +/// Zero halts hashing. Stock firmware writes a per-model calibrated +/// value that scales inversely with frequency; see PROTOCOL.md for +/// the known values and the search-space theory. #[derive(Debug, Clone, Copy, PartialEq)] -pub struct NonceRange { - /// Raw bytes as sent over the wire - bytes: [u8; 4], +pub struct HashCountingNumber { + value: u32, } -impl NonceRange { - // Nonce range values for different chain lengths (captured from hardware) - const SINGLE_CHIP: [u8; 4] = [0xff, 0xff, 0xff, 0xff]; - const SMALL_CHAIN: [u8; 4] = [0xff, 0xff, 0xff, 0x1f]; // 2-8 chips - const MEDIUM_CHAIN: [u8; 4] = [0xff, 0xff, 0xff, 0x0f]; // 9-16 chips - const LARGE_CHAIN: [u8; 4] = [0xff, 0xff, 0xff, 0x07]; // 17-32 chips - const XLARGE_CHAIN: [u8; 4] = [0xff, 0xff, 0xff, 0x03]; // 33-64 chips - const S21_PRO: [u8; 4] = [0x00, 0x00, 0x1e, 0xb5]; // 65-128 chips (empirical) - const DEFAULT_LARGE: [u8; 4] = [0xff, 0xff, 0xff, 0x01]; // >128 chips - - /// Create config for single chip (full range) - pub fn single_chip() -> Self { - Self { - bytes: Self::SINGLE_CHIP, - } - } - - /// Create config for multi-chip chain - pub fn multi_chip(chain_length: usize) -> Self { - let bytes = match chain_length { - 1 => Self::SINGLE_CHIP, - 2..=8 => Self::SMALL_CHAIN, - 9..=16 => Self::MEDIUM_CHAIN, - 17..=32 => Self::LARGE_CHAIN, - 33..=64 => Self::XLARGE_CHAIN, - 65..=128 => Self::S21_PRO, - _ => Self::DEFAULT_LARGE, - }; - Self { bytes } +impl HashCountingNumber { + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_u32(self.value); } - /// Create config from raw 32-bit value (little-endian) - /// Used for exact configuration from protocol captures - pub fn from_raw(value: u32) -> Self { + pub fn decode(bytes: [u8; 4]) -> Self { Self { - bytes: value.to_le_bytes(), + value: u32::from_be_bytes(bytes), } } +} - pub fn encode(&self, dst: &mut BytesMut) { - dst.put_slice(&self.bytes); +impl From for HashCountingNumber { + fn from(value: u32) -> Self { + Self { value } } +} - pub fn decode(bytes: [u8; 4]) -> Self { - Self { bytes } +impl From for u32 { + fn from(hcn: HashCountingNumber) -> Self { + hcn.value } } @@ -1110,29 +1089,23 @@ mod pll_divider_tests { } #[cfg(test)] -mod nonce_range_tests { +mod hash_counting_number_tests { use super::*; - fn round_trip(original: NonceRange) { + #[test] + fn round_trip() { + let original = HashCountingNumber::from(0x1EB5); let mut buf = BytesMut::new(); original.encode(&mut buf); let bytes: [u8; 4] = buf[..].try_into().unwrap(); - assert_eq!(NonceRange::decode(bytes), original); - } - - #[test] - fn single_chip() { - round_trip(NonceRange::single_chip()); + assert_eq!(HashCountingNumber::decode(bytes), original); } #[test] - fn multi_chip() { - round_trip(NonceRange::multi_chip(16)); - } - - #[test] - fn from_raw() { - round_trip(NonceRange::from_raw(0xdeadbeef)); + fn encodes_big_endian() { + let mut buf = BytesMut::new(); + HashCountingNumber::from(0x00001EB5).encode(&mut buf); + assert_eq!(&buf[..], &[0x00, 0x00, 0x1E, 0xB5]); } } diff --git a/mujina-miner/src/asic/bm13xx/thread.rs b/mujina-miner/src/asic/bm13xx/thread.rs index 5356b845..ccb63635 100644 --- a/mujina-miner/src/asic/bm13xx/thread.rs +++ b/mujina-miner/src/asic/bm13xx/thread.rs @@ -22,8 +22,8 @@ use super::command::{ SetChipAddress, WriteRegister, }; use super::register::{ - AnalogMux, ChipModel, CoreCommand, IoDriverStrength, Log2Difficulty, MidstateConfig, - MiscControl, MiscSettings, NonceRange, PllDivider, Register, SoftResetControl, TicketMask, + AnalogMux, ChipModel, CoreCommand, HashCountingNumber, IoDriverStrength, Log2Difficulty, + MidstateConfig, MiscControl, MiscSettings, PllDivider, Register, SoftResetControl, TicketMask, }; use super::response::{NonceResponse, RegisterResponse, Response}; use crate::{ @@ -434,11 +434,12 @@ where debug!("Frequency ramping complete"); - // Final configuration + // Final configuration. The hash counting number is the BM1370 + // factory value observed in captures. chip_commands .send(RegisterCommand::WriteRegister(WriteRegister { destination: Destination::Broadcast, - register: Register::NonceRange(NonceRange::from_raw(0xB51E0000)), + register: Register::HashCountingNumber(HashCountingNumber::from(0x1EB5)), })) .await?; chip_commands From a1ca74864d663d700e1960687a0cfcc3ddf2d9ae Mon Sep 17 00:00:00 2001 From: Ryan Kuester Date: Sun, 5 Jul 2026 19:22:43 -0500 Subject: [PATCH 38/38] WIP docs(bm13xx): rewrite search space distribution Replace the guessed nonce-stride theory in the protocol notes with an evidenced model of how BM13xx chips divide work. A job's search space is the 32-bit nonce field times the 16 rollable version bits. Sub-cores take versions via precomputed midstates, cores own the top nonce bits, and chips start their sweeps at placements seeded by the chip address or set explicitly by the chip nonce offset register, which replaces address placement rather than supplementing it. The hash counting number is a deadline in reference-clock ticks bounding the window each core re-sweeps per batch of rolled versions: zero halts hashing, undersized values shrink the searched space permanently, and oversized ones duplicate work. Ground the model in capture analysis performed for this change: nonce parity matches address-fixed parity at chain scale on two independent BM1366 chains, no fixed bit field anywhere in the nonce carries the chip address, and a threshold of duplicated nonces above the full-slice deadline pins restart-per-batch counting in tick units. Document the full-coverage formula merged in ESP-Miner with an exact integer form computed from the PLL dividers, worked examples, the version epoch and its consequence that factory values require second-scale job refresh, the corrected factory value catalog, and the open questions, notably the unexplained low-bit structure of BM1362 nonces. Also add the chip nonce offset register to the map, correct the initialization sequences to place the hash counting number write after the frequency ramp, and extend hashboard work distribution with extranonce slicing. --- mujina-miner/src/asic/bm13xx/PROTOCOL.md | 755 ++++++++++++++++++++--- 1 file changed, 660 insertions(+), 95 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/PROTOCOL.md b/mujina-miner/src/asic/bm13xx/PROTOCOL.md index a1c86b4e..dbe9c515 100644 --- a/mujina-miner/src/asic/bm13xx/PROTOCOL.md +++ b/mujina-miner/src/asic/bm13xx/PROTOCOL.md @@ -13,6 +13,9 @@ progresses and we gain experience with multi-chip systems. ## Sources - ESP-miner BM1370 implementation +- ESP-miner nonce space work: + https://github.com/bitaxeorg/ESP-Miner/pull/420 and the + experiments in https://github.com/skot/ESP-Miner/pull/167 - CGMiner driver implementations - Emberone-miner BM1362 implementation - BM1397 documentation: https://github.com/skot/BM1397 @@ -379,7 +382,8 @@ Key registers used across BM13xx chips: |----------|------|-------------| | 0x00 | CHIP_ID | Chip identification and configuration | | 0x08 | PLL_DIVIDER | Frequency control registers for hash clock | -| 0x10 | HASH_COUNTING_NUMBER | Controls nonce search range per core | +| 0x0C | CHIP_NONCE_OFFSET | Explicit per-chip nonce space placement | +| 0x10 | HASH_COUNTING_NUMBER | Nonce iterations per rolled version | | 0x14 | TICKET_MASK | Difficulty mask for share submission | | 0x18 | MISC_CONTROL | UART settings and GPIO pin configuration | | 0x28 | UART_BAUD | UART baud rate configuration | @@ -414,11 +418,24 @@ Controls the hash frequency through PLL configuration: - Byte 2: REF_DIV (reference divider) - Byte 3: POST_DIV flags (bit 1 = fixed to 1) +#### 0x0C - CHIP_NONCE_OFFSET +Places a chip's share of the nonce space explicitly. The wire value +sets bit 31 as an enable flag and carries a 16-bit offset in the low +bytes. Only the S21 Pro capture writes it: per chip, after the first +job, with offsets stepping by roughly 0xFFFF / 65 across the 65-chip +chain. The S19 J Pro capture never writes it despite its 126-chip +chain, so chip-address-based placement alone evidently suffices +there. Not yet modeled by mujina. + #### 0x10 - HASH_COUNTING_NUMBER -Controls nonce search space distribution (format not fully documented): -- Affects how chips divide the 32-bit nonce space -- Different values used for different chip counts -- Mechanism remains partially understood through empirical testing +Limits how long each core sweeps nonces before the chip advances +to the next rolled versions and re-sweeps the same window. Zero +halts hashing entirely. The proper value +follows from the topology +and the hash frequency: each core's share of the nonce space, +scaled inversely by frequency. Observed stock firmwares approximate +it with per-model constants. See Search Space Distribution below +for the theory, formula, and known values. #### 0x14 - TICKET_MASK (Nonce Reporting Filter) @@ -634,6 +651,12 @@ Undocumented miscellaneous settings register: - Gradually increase to target - Use register 0x08 for PLL control +5. **Start Mining** + - Write HASH_COUNTING_NUMBER (0x10); the value follows from the + topology and final frequency (see Search Space Distribution) + - Enable version rolling (0xA4) + - Send the first job + ### Multi-Chip Initialization (e.g., S21 Pro, S19 J Pro) 1. **Chain Reset and Discovery** @@ -668,10 +691,18 @@ Undocumented miscellaneous settings register: - BM1370: 3Mbaud (0x00003001) - BM1362: Different rate (0x00003011) -7. **Final Configuration** - - Set HASH_COUNTING_NUMBER (0x10) based on chip count - - Configure remaining registers - - Begin frequency ramping +7. **Frequency Ramp and Start Mining** + - Ramp frequency in steps via register 0x08 + - Write HASH_COUNTING_NUMBER (0x10); the value follows from the + topology and final frequency (see Search Space Distribution) + - Enable version rolling (0xA4) + - Send the first job + +Both captures place the HASH_COUNTING_NUMBER write after the +frequency ramp and immediately before the version-rolling enable. +That position follows from what the register does: it paces version +rolling, and its value depends on the topology and the final +frequency. ## Domain Management in Multi-Chip Chains @@ -702,110 +733,644 @@ Domain 2: Chips 0x14-0x1C (relay: 0x00450003) ## Key Implementation Details -### Job Distribution Across Multiple Chips - -In multi-chip mining systems, job distribution works as follows: - -#### Chip Addressing -- Each chip in a chain is assigned a unique 8-bit address during initialization -- Addresses are typically spaced evenly (e.g., 0, 4, 8, 12... for a 64-chip -chain) -- The address determines which portion of the nonce space each chip searches - -#### Job Broadcasting -- **The same job is sent to ALL chips in the chain** -- Single broadcast command propagates through the entire chain -- Each chip automatically works on a different portion of the nonce space - -#### Nonce Space Partitioning -The 32-bit nonce space (4.3 billion values) is automatically divided: - -1. **Between Chips**: Based on chip address and HASH_COUNTING_NUMBER register - - Chip address influences which nonces are searched - - HASH_COUNTING_NUMBER register (0x10) further controls distribution - - No explicit range assignment needed from software - -2. **Between Cores**: Within each chip - - Core ID encoded in upper nonce bits (typically bits 24-31) - - Each core searches ~33.5 million nonces (4.3B / 128 cores) - -3. **Example**: BM1370 with 128 cores x 16 sub-cores - - Bits 31-25: Main core ID (128 cores) - - Bits 24-0: Actual nonce value searched - - Total: 2,048 parallel searches per chip - -#### HASH_COUNTING_NUMBER Register Configuration - -The HASH_COUNTING_NUMBER register (0x10) uses empirically-determined values to optimize -nonce distribution. See discussion at: https://github.com/bitaxeorg/ESP-Miner/pull/167 - -**Known Values (4-byte little-endian):** -- 1 chip: `0x00001EB5` (Bitaxe single BM1370) -- 65 chips: `0x00001EB5` (S21 Pro - same as single chip!) -- 77 chips: `0x0000115A` (S19k Pro - from ESP-miner) -- 110 chips: `0x0000141C` (S19XP Stock - from ESP-miner) -- 110 chips: `0x00001446` (S19XP Luxos - from ESP-miner) -- 126 chips: `0x00001381` (S19 J Pro BM1362) -- Full range: `0x000F0000` (experimental, searches full 32-bit space?) - -**How It Likely Works:** -While the exact mechanism is undocumented, analysis suggests: -- The value may define a stride/increment for nonce searching -- Combined with chip address to ensure non-overlapping ranges -- Smaller values for more chips ensure better coverage -- Values appear carefully chosen to minimize gaps in search space - -**Example Theory:** -With register value 0x00001EB5 (7,861 decimal): -- Chip might test nonces at intervals of 7,861 -- Starting offset based on chip address -- Ensures even distribution without collision - -Note: The ESP-miner source notes this register is "still a bit of a mystery" -and values are determined through empirical testing rather than documentation. -Multi-chip configurations may require different values than those listed. - -#### Starting Nonce Field -- Always set to 0x00000000 in practice -- Hardware automatically offsets based on chip/core addressing -- Software doesn't need to manually partition the nonce space - -#### Practical Example: 4-Chip Chain -Consider a 4-chip BM1370 chain mining a block: -1. **Job sent**: Same job broadcast to all 4 chips -2. **Chip addresses**: 0x00, 0x40, 0x80, 0xC0 (64 apart) -3. **Nonce space division**: - - Chip 0: Searches nonces where certain bits = 0x00 - - Chip 1: Searches nonces where certain bits = 0x40 - - Chip 2: Searches nonces where certain bits = 0x80 - - Chip 3: Searches nonces where certain bits = 0xC0 -4. **Total parallel operations**: 4 chips x ~2,040 cores = ~8,160 -simultaneous searches +### Search Space Distribution + +A job is broadcast once and every chip in the chain works on it. +Nothing in the job assigns ranges; the job's starting-nonce field is +always zero. The chips carve up the search space themselves, using +their identities and a handful of registers. This section explains +that machinery level by level: first the space itself, then the +hierarchy that parallelizes it, then the registers that place and +pace the sweep. + +#### The Search Space + +For one job, a version-rolling BM13xx chip searches two dimensions: + +- the 32-bit nonce field, 2^32 candidates, and +- the 16 rollable version bits (AsicBoost, BIP320), 2^16 variants, + generated inside the chip once version rolling is enabled via + MIDSTATE_CONFIG (0xA4). + +Together that is 2^48 candidate headers per job. The chips need the +second dimension: a BM1370 at 600 MHz hashes about 1.2 TH/s and +would exhaust the bare nonce space in under 4 ms. Version rolling +stretches that to about four minutes for the full 2^48, which is +what lets a chip stay busy on one job while the host prepares the +next. The host can extend the space further by rolling ntime or the +extranonce, but that happens outside the chip; this section covers +only what the chip does on its own. + +A chip rarely gets that space to itself, though. Outside of +single-chip boards like the Bitaxe, chips share a serial bus in +chains of dozens or more, every one of them hearing the same +broadcast job. The space has to be divided among the chips, and +then again inside each chip among its cores and sub-cores. That +division is the machinery of the rest of this section. + +#### The Parallel Hierarchy + +A hashboard is a chain of chips, each chip an array of cores, each +core a group of sub-cores (some references say "big cores" and +"small cores"). The BM1370 has 128 cores of 16 sub-cores, 2,048 +hashing units. Each level of the hierarchy takes a dimension of the +search space: + +- **Sub-cores share their core's nonce range and differ by + version.** With version rolling enabled, the chip hands each of a + core's sub-cores its own rolled version as a precomputed midstate, + so one pass over the core's nonce range tests 16 versions at once + on a BM1370. Working through all 2^16 versions takes 4,096 such + passes in series + (https://github.com/bitaxeorg/ESP-Miner/pull/420). +- **Cores partition the nonce space by its top bits.** Each core + owns the slice of nonces whose top bits equal its core ID and + counts through the remaining bits. The width of that ID field + follows the model's core count: the BM1370's 128 cores make it + 7 bits wide (bits 31-25), leaving each core a 2^25 slice. The + arrangement shows in the results: every nonce a core reports + carries its ID in those top bits (see Nonce Response). +- **Chips offset where their search starts.** The chip address + seeds each chip's placement within the nonce space, below the + core ID bits. Newer stock firmware can also place a chip + explicitly with CHIP_NONCE_OFFSET (0x0C). The next section works + through the details. + +#### The Nonce as Bit Fields + +At the top of the nonce the layout is exact across the family: the +core ID occupies the top bits, and the field's width follows the +model's core count. The widths below are the BM1370's: -#### Multiple Hash Board Distribution -When a mining system has multiple hash boards, the software MUST prevent -duplicate work: +``` + 31 25 24 0 ++------------+----------------------------+ +| core ID | per-core nonce counter | +| (7 bits) | (25 bits, 2^25 values) | ++------------+----------------------------+ +``` + +For example, the response documented under Nonce Response carries +nonce `0x40A60018`: its top seven bits are `0100000`, core 32, and +the remaining 25 bits, `0x00A60018`, are where that core's counter +stood. Every nonce core 32 ever returns carries those same top +bits. + +On a model with a different core count, only the widths move. A +BM1366 has 112 cores, and seven bits is still the smallest ID +field that can tell 112 cores apart. But a 7-bit field has 128 +possible values, and only 112 of them name a real core. Nonces +whose top bits spell one of the 16 missing IDs have no core that +begins its sweep there. This is also why the coverage arithmetic +later in this section divides by 128 rather than 112: the hardware +partitions the space by bit pattern, so each core's slice is 1/128 +of the space whether or not every pattern has a core behind it. + +The core ID field explains how the cores of one chip divide the +space among themselves. The next question is what keeps separate +chips apart: where in the nonce space does each chip start +counting? Two mechanisms set the starting point: + +- **The chip address seeds it.** Each chip begins its sweep at a + position derived from its address, so the chips of a chain start + spread across the nonce space. Every capture we have assigns + addresses at interval two, for 65-, 77-, and 126-chip chains + alike, so the interval evidently does not derive from the chain + length. +- **CHIP_NONCE_OFFSET (0x0C) sets it explicitly.** Newer stock + firmware writes each chip a 16-bit offset instead of relying on + the address alone. The S21 Pro does this per chip after the + first job: its 65 chips receive offsets stepping by about + 0xFFFF / 65 (chip 0 at 0x0000, the next at 0x03F1, up to 0xFC10 + at the far end), spacing their starting points evenly. + +From its starting point, each core counts nonces upward. +HASH_COUNTING_NUMBER (next section) sets how long it counts: when +the deadline expires, the chip rolls the next batch of versions +and the core sweeps the same span again under them. The register +is neither a ceiling on the nonce's value nor a count of version +rolls; it fixes the length of the sweep window that every version +batch repeats. Two experimental facts pin down this +restart-per-batch reading jointly with the register's units. +First, values above a threshold produce duplicated nonces: +exactly the behavior of a sweep running past the end of its slice +and wrapping within one batch, and inexplicable for a counter +that carried on across batches, which would revisit nothing until +it had covered everything, at any value. Second, the +trial-and-error full-range value (0x000F0000) is, read as a +deadline in crystal ticks, just long enough for a BM1366 core to +sweep its entire slice once per batch at that chip's operating +frequencies; read as a raw nonce count it would cover under a +tenth of a slice and the full-range observation would be +impossible. Together the two observations favor tick units and +restart-per-batch. A chip's coverage is therefore a start and a +window: the address or offset sets where the sweep begins, and +HASH_COUNTING_NUMBER sets how far every batch's sweep extends, +permanently. What a too-small value leaves unswept stays unswept. + +**Address-seeded placement, concretely.** The S19 J Pro leans on +addresses alone: 126 BM1362 chips, addresses 0x00 through 0xFA at +interval two, and not one CHIP_NONCE_OFFSET write in its capture. +An address is 8 bits, and with only even addresses assigned, its +bit 0 is always zero. That leaves 7 meaningful bits, and +experiments on a single BM1366 +(https://github.com/skot/ESP-Miner/pull/167) show they play two +roles: + +``` + chip address + 7 6 5 4 3 2 1 0 + +---+-------------+---+ + | P | position | 0 | + +---+-------------+---+ +``` + +P, the address's top bit, becomes bit 0 of every nonce the chip +produces: chips with P = 0 return only even nonces, chips with +P = 1 only odd ones. The six position bits select one of 64 +further placements within that parity, for 128 distinct placements +in all. + +Where do the position bits land? A tempting model is that the +address becomes a fixed field of the nonce, just as the core ID +does at the top: mirrored into the bottom bits (address bit 7 at +nonce bit 0), it would explain the parity split, and each chip's +nonces would then be congruent, modulo 128, to its mirrored +address. + +Our captures refute that model, and with it any model in which a +chip holds a fixed 7-bit pattern in the nonce's low bits. The +S19 J Pro capture contains 6,662 nonce responses, and their low 7 +bits take all 128 possible values, essentially uniformly. A +126-chip chain of fixed low-bit fields would leave exactly two +7-bit patterns unused, whatever the address-to-pattern mapping; +the two patterns the mirror model forbids appear 107 times, right +at the uniform-random rate (about 104 expected). The S21 Pro's +7,902 responses show the same uniform low bits. The counters +plainly run through the low bits, so the address must place a +chip some other way, perhaps as an arithmetic starting offset +rather than a preserved bit pattern. + +Nor does the address hide anywhere else in the nonce, for example +in a field adjacent to the core ID. A 126-chip fixed field must +leave exactly two patterns of its bit window unused, and a scan of +every window of the S19 J Pro's nonces finds no window with that +signature. The scan did surface real structure, but of a different +kind: BM1362 nonces almost never set bit 7 (1% of responses), and +bits 9-8 take the values 0, 1, and 2 in equal measure but 3 at a +quarter rate, independent of the version field, the response +header, and the neighboring bits. The BM1370's nonces show nothing +similar (every bit unbiased, every window full). Whatever walks +the BM1362's counters through the space skips most of the space's +bit-7 half-blocks; the mechanism is unexplained, and since it +appears uniformly across the whole chain it reflects chip +architecture, not chain placement. + +Two tempting explanations for that structure fail against the +data. Bit 7 pinned at zero looks like the mirror image of address +bit 0, which is zero on every assigned address; but the BM1366 +chain dumps have equally even addresses and their bit 7 runs at +42%, so the mirror is not the mechanism. A sweep truncated by job +replacement (each new job cutting the walk short partway through +the bits 9-8 cycle) predicts that the bits 9-8 value grows with a +nonce's position within its job; measured against the capture's +105 job boundaries, it is flat. The structure does grade across +the family, though: the BM1366 chains show a milder version of +the same depression (bits 7 through 9 all at 38-43%), the BM1362 +the extreme form, and the BM1370 none at all. + +One address bit does survive in the nonce, and it scales to whole +chains. Two further chain dumps from the same experiment thread +(77 and 110 BM1366 chips, address-placed, no explicit offsets) +put nonce parity exactly where address-fixed parity predicts: +17.3% odd against a predicted 16.9% (13 of 77 chips addressed at +or above 0x80), and 42.0% against 41.8% (46 of 110). The +S19 J Pro fits too: 49.0% odd against 49.2% predicted (62 of +126). Numerically that means the counting steps in twos, but the +hardware needs no adder that skips: picture the nonce assembled +from fields, with bit 0 wired from the address's top bit and the +counter occupying other bits. A counter never carries into a bit +that is not part of the counter, so the transplanted bit holds +with no special-casing at all. The working model is therefore a +hybrid: the address's top bit is transplanted into nonce bit 0 +and held fixed, while the remaining address bits set an +arithmetic starting offset for the counting. The S21 Pro breaks +the pattern in a telling way: its parity is free (49.7% odd, +where address-fixed parity would predict 1.5%, only one of its 65 +chips sitting at or above 0x80), which says CHIP_NONCE_OFFSET +does not merely supplement address placement but replaces it. + +The arithmetic-offset reading also explains the one observation +every bit-field model failed: an odd address overlapping both of +its even neighbors. Sweeps that grow from starting points spaced +K apart do exactly that when the sweep length lies between K and +2K: even neighbors, 2K apart, stay disjoint, while an odd +address, K from each, overlaps both. It even suggests why the +S21 Pro bothers with explicit offsets at all: 65 chips at +addresses 0x00 through 0x80 would crowd their implicit starting +points into the lower half of the space, so the firmware +respaces them evenly across it, while the S19 J Pro's 126 chips +nearly fill the address range and its firmware writes no offsets. + +Chip attribution, however, stays out of reach: beyond that single +parity bit, which chip found a nonce is not recoverable from the +nonce. The response's midstate-number byte does not look like the +chip identity either: across the capture its values halve in +frequency with each increment, the signature of a difficulty +count rather than an identifier (see Nonce Response). + +Reading a few chips of the S19 J Pro chain under the two-role +scheme: + +| Chip | Address | P | Position | Placement | +|------|---------|---|----------|--------------------------| +| 0 | 0x00 | 0 | 0 | even nonces, position 0 | +| 1 | 0x02 | 0 | 1 | even nonces, position 1 | +| 63 | 0x7E | 0 | 63 | even nonces, position 63 | +| 64 | 0x80 | 1 | 0 | odd nonces, position 0 | +| 125 | 0xFA | 1 | 61 | odd nonces, position 61 | + +The first 64 chips fill every even-nonce position; the remaining +62 fill all but two odd-nonce positions (the unassigned addresses +0xFC and 0xFE would be odd positions 62 and 63). + +**Explicit placement, concretely.** The S21 Pro's 65 BM1370 chips +(128 cores each) finish their bring-up ramp at 593.75 MHz, and the +capture then shows every chip receiving its own CHIP_NONCE_OFFSET, +stepping by about 0xFFFF / 65: + +| Chip | Address | CHIP_NONCE_OFFSET | Sweep starts | +|------|---------|-------------------|---------------| +| 0 | 0x00 | 0x0000 | at the bottom | +| 1 | 0x02 | 0x03F1 | 1/65th in | +| 63 | 0x7E | 0xF820 | 63/65ths in | +| 64 | 0x80 | 0xFC10 | 64/65ths in | + +By the power-of-two arithmetic above, 65 chips round up to 128, so +128 cores x 128 chips cut the space into 2^14 slices of +2^32 / 2^14 = 2^18 nonces each. HASH_COUNTING_NUMBER then sets how +much of its slice each core visits per batch of versions: + +| HASH_COUNTING_NUMBER | At 593.75 MHz | +|----------------------|---------------------------------------| +| 0 | no hashing at all | +| 0x158E (computed) | the full 2^18 slice per version batch | +| 0x1EB5 (factory) | 1.4x the computed full value | + +The computed row is the full-coverage formula from the next +section: `2^32 / 128 / 128 * (25 / 593.75) * 0.5 = 0x158E`. That +formula divides the space among 128 chip slots, 65 rounded up to +a power of two. But this machine does not place its chips in +power-of-two slots: its explicit offsets space the 65 chips +evenly, at 0xFFFF / 65. Recompute full coverage with 65 as the +divisor and it comes to 0x2A73 (10,867), of which the factory +0x1EB5 covers 72%. Against the power-of-two basis the factory +value would exceed full coverage by 42%, meaning duplicated work; +the sensible reading is that explicitly placed chains slice the +space by actual chip count. + +The cost of treating the value as a constant shows on the Bitaxe +instead. Its firmware writes the same 0x1EB5, a value calibrated +for a 65-chip machine, to a chain of one. With no chain to share +it, each of the single chip's 128 cores owns a full 2^25 slice, +and full coverage at the Bitaxe's roughly 500 MHz computes to +about 0xC0000 (786,000 nonces per version batch). The inherited +7,861 advances each version batch by about 1% of that, so the +chip spends its time rolling versions over a sliver of the nonce +space it could be sweeping. + +Fine print on the evidence behind all of this: + +- Only even chip addresses produce disjoint search ranges; an odd + address overlaps both of its even neighbors. +- The nonce cap is real: with a factory HASH_COUNTING_NUMBER value + a chip looped its bounded slice and never wandered the full + range, while the experimental full-range value swept all of 2^32 + from a single chip regardless of its address. +- Our captures do not show how the explicit 16-bit offset maps + onto the per-core counter; a search for the S21 Pro's offset + stride across the nonce bit windows of its responses found no + alignment. +- The parity finding is confirmed at chain scale on two BM1366 + chains (77 and 110 chips) and is consistent with the BM1362 + aggregate; the disjointness finding remains single-chip BM1366 + evidence, and no per-chip experiment has run on a BM1362. + +#### HASH_COUNTING_NUMBER: Pacing the Sweep + +One question remains: how much of its nonce slice does a core sweep +before the chip moves to the next batch of versions? That is what +HASH_COUNTING_NUMBER sets. Each core sweeps nonces until the +deadline expires; then the chip rolls the next versions and the +core sweeps the same window again (the counter model in the +placement discussion above). + +The consequences, measured on hardware in the experiments above: + +- Zero halts hashing: a zero-length window means no work at all. +- Small values roll versions quickly but re-sweep the same short + window under every batch, leaving the rest of each slice + permanently unvisited. +- 0x000F0000 covered the full 32-bit range on a single BM1366, + found by trial and error: the window that just spans a whole + slice. +- Larger values still produce duplicated nonces: the sweep wraps + its slice within a single batch. + +Coverage matters when the job's search space is finite from the +chip's point of view. Under Stratum v1 the host rolls the +extranonce, so unswept nonce space costs nothing: every hash is +still a fresh header. The host can also roll ntime, gaining a +fresh space for each elapsed second. Under SV2 header-only mining +there is no extranonce, so the nonce and version space, plus what +ntime rolling the clock allows, is the entire per-job space; +partial nonce coverage shrinks it below 2^48 and forces faster +job turnover. + +#### Computing the Value + +ESP-Miner computes the register value for full nonce coverage +(https://github.com/bitaxeorg/ESP-Miner/pull/420, merged; ported to +NerdQAxePlus in +https://github.com/shufps/ESP-Miner-NerdQAxePlus/pull/546): + +``` +cores_up = next_power_of_two(cores_per_chip) +chips_up = next_power_of_two(chain_length) +hcn = (2^32 / cores_up / chips_up) * (25 / freq_mhz) * 0.5 +``` -1. **Time-Based Work Distribution** (most common): +The shape of the formula makes sense if the register is a deadline +measured in crystal ticks. Each core owns a slice of the nonce +space; that is the first factor, the space divided by the bit-field +slots for cores and chips, rounded up to powers of two as a +bit-field partition demands. The sweep of that slice runs on the +hash clock, but the roll trigger apparently counts on the 25 MHz +reference crystal, which is where `25 / freq_mhz` comes from: the +same slice takes more crystal ticks to sweep when the hash clock +is slower. Read that way, + +``` +slice = 2^32 / cores_up / chips_up nonces per core +sweep time = (slice / 2) / hash clock one full pass +hcn = sweep time * 25 MHz the deadline, in ticks +``` + +and the formula's three factors fall out. The factor of a half is +then the parity structure from the placement discussion: the +counters step in twos, covering one parity only, so a complete +pass over a slice takes `slice / 2` iterations. (An alternative +reading puts the half in the pipeline, two hashes per clock; the +arithmetic cannot distinguish them, and no source says.) All of +this is inference; what is certain is the practical consequence +of the frequency term: the value is only correct for the +frequency it was computed at, and must be rewritten whenever the +frequency changes. + +Rounding matters, and it must go down. The deadline has to fire +before a core crosses the end of its slice: a value rounded up +lets the sweep spill into the neighboring slice before the roll, +duplicating work, while a value rounded down only leaves a +sub-tick sliver unswept (one crystal tick spans a couple dozen +nonce iterations at 600 MHz). The merged code floors by way of +its integer cast. + +No intermediate factor needs rounding of its own, because nothing +in the computation is irrational: the hash clock comes from the +same crystal through the PLL, so `25 / freq_mhz` is exactly +`refdiv * postdiv1 * postdiv2 / fbdiv`, and the whole value +reduces to integer arithmetic with a single floor at the end: + +``` +hcn = floor(slice * refdiv * postdiv1 * postdiv2 / (2 * fbdiv)) +``` + +Computing from the divider values costs nothing, floors exactly +once, and uses the frequency the PLL actually achieves by +construction, where a requested target can differ by a percent or +so and, if the achieved clock lands faster, recreates exactly the +overshoot the floor avoids. Float arithmetic on a megahertz value +is also where stray off-by-ones come from: the S21 Pro's computed +value is 0x158F if an intermediate division is rounded, 0x158E +(5,518) computed exactly. + +Could these effects be what the correction terms in the wild are +compensating? For the large scale factors, no: rounding error is +a tick or two and PLL quantization a fraction of a percent, while +the factory values imply factors of tens of percent (40% and 72% +at the two capture points); those look like deliberate coverage +policy. The BM1370's subtracted 268 is subtler. At the four-chip +machine where it was calibrated, the full-coverage value is about +171,000 ticks, and a typical PLL quantization error of 0.1% fast +needs a margin of about 170 ticks, the same order as the +constant, so a frequency-gap reading is plausible there. A +frequency error demands a margin proportional to the value, +though, and 268 is a constant. That is no strike against it in +its home: the firmwares carrying it target machines of one to +eight chips, never long chains, and within that envelope a +constant tuned on one machine can be a serviceable empirical +patch. (The one tension inside the envelope is the single-chip +case, where 0.1% of the value is about a thousand ticks; either +those machines' operating points quantize slow or exact, or they +quietly duplicate a little.) The lesson is about transplanting +it: the constant encodes one machine's measured exposure, not a +law, and says nothing about long chains, where it would amount to +five percent of the value, fifty times any plausible clock error. +Computing exactly from the divider values retires the +frequency-gap exposure entirely; whatever margin still proves +necessary after that is a true per-roll cost, the version-roll +latency reading of the erratum comment. Distinguishing the two +takes a hardware experiment: compute the exact value, subtract +nothing, and watch for duplicates. + +The deadline reading also explains why this is a register at all +rather than a constant baked into the silicon: the right deadline +depends on the chain length and on the ratio of hash clock to +crystal, neither of which a chip can know by itself. The host +knows both, computes the deadline, and programs it. + +Worked through for machines in this document: + +- **S21 Pro**: 65 chips of 128 cores at 593.75 MHz. Both counts + round to 128, so `slice = 2^32 / 128 / 128 = 2^18`, and + `2^18 * (25 / 593.75) * 0.5 = 0x158E`: the computed row of the + earlier table. +- **A 12-chip BM1362 board at 525 MHz** (the EmberOne00's shape), + taking the fit-consistent 64 cores: 12 chips round to 16, so + `slice = 2^32 / 64 / 16 = 2^22`, and + `2^22 * (25 / 525) * 0.5 = 0x18618` (99,864). An order of + magnitude above every factory constant in the table below: + short chains need large values, which is exactly what reusing a + long-chain donor constant gets wrong. +- **The same board at half the clock**, 262.5 MHz, needs double: + 0x30C30 (199,728). Slower cores need a longer deadline to + finish the same slice. + +The BM1370 subtracts an empirically found error term of 2 x 134 +before use (a hardware erratum; duplicates appear otherwise). + +NerdQAxePlus previously drove this register as a "version rolling +frequency" targeting a 25 kHz roll rate. Both views describe the +same mechanism, since a nonce count per version at a given clock is +a version roll rate. Notably, their 25 kHz value (about 7,864) +lands within 0.04% of the S21 Pro factory default (7,861), which +suggests the factory values encode a fixed version-roll rate. + +#### The Version Epoch + +The deadline reading yields a second derived quantity: the version +epoch, how long a chip takes to roll through the entire 2^16 +version space. Each batch lasts exactly HASH_COUNTING_NUMBER +crystal ticks of wall time, and each batch consumes one version +per sub-core, so: + +``` +epoch = (2^16 / sub_cores_per_core) * hcn / 25 MHz +``` + +Frequency enters only through the programmed value, so the +formula reads differently depending on how that value is managed. +Operated correctly, with the value recomputed as 1/frequency per +the full-coverage policy, a faster hash clock shortens every +batch and the wheel turns proportionally faster while sweeping +the same slice per batch. Only a stale register value pins the +epoch while coverage drifts with the clock; that is a +misconfiguration, not a mode, though it is exactly the state of a +firmware that writes a constant and then lets the user change +frequency. The factory epochs below therefore describe each +machine at its designed operating point. Worked for the machines +in this document, with sub-core counts from the ESP-Miner device +tables (BM1366: 8 per core, BM1368 and BM1370: 16 per core; the +BM1362 is hedged as 8, like its generation-mate): + +| Machine | Batches | Value | Epoch | +|--------------------|-------|--------|--------| +| Antminer S21 | 4,096 | 0x15A4 | 0.91 s | +| Antminer S21 Pro | 4,096 | 0x1EB5 | 1.29 s | +| Antminer S19k Pro | 8,192 | 0x115A | 1.46 s | +| Antminer S19 J Pro | 8,192 | 0x1381 | 1.64 s | +| Antminer S19 XP | 8,192 | 0x151C | 1.77 s | + +The factory values, whatever their exact derivation, all set a +version wheel that turns in about a second: near-constant epochs, +with per-batch nonce coverage left to be whatever the machine's +topology and clock make of it. That is the version-roll-rate +reading of the factory constants made concrete, though the +epochs cluster around a second rather than matching exactly. + +Two more consequences fall out. The Bitaxe, inheriting the S21 +Pro's value, rolls its versions on the same 1.29 s wheel; its +deficit is coverage, not pace. And with the computed +full-coverage value (838,860 at 500 MHz) its epoch stretches to +4,096 * 838,860 / 25 MHz = 137 seconds, which is the same number +The Search Space section reached by the other road: 2^47 +visitable header candidates (the parity structure halves the +nominal 2^48) at 1.02 TH/s is 138 seconds. At full coverage, the +epoch IS the time to try everything. + +Because every batch re-sweeps the same window (the counter model +in the placement discussion), one epoch exhausts everything a +chip will ever try against a job: after the wheel wraps, it +re-treads identical headers until the job changes. The +second-scale factory epochs are therefore a requirement, not a +curiosity: firmware running factory values must refresh work at +least that often or its chips duplicate their own work. The +near-match between the factory epochs and a typical job-refresh +cadence may be exactly the design rule the constants encode. + +#### Factory Values + +All observed factory values, from our captures and the catalog +retained in the ESP-Miner sources: + +| Machine | Chip | Chain | Value | +|---------|------|-------|-------| +| Bitaxe Gamma (capture) | BM1370 | 1 | 0x1EB5 | +| Antminer S21 Pro (capture) | BM1370 | 65 | 0x1EB5 | +| Antminer S21 stock | BM1368 | | 0x15A4 | +| Antminer S19 XP stock | BM1366 | 110 | 0x151C | +| Antminer S19 XP Luxos | BM1366 | 110 | 0x1446 | +| Antminer S19k Pro | BM1366 | 77 | 0x115A | +| Antminer S19 J Pro (capture) | BM1362 | 126 | 0x1381 | +| Full range (experiment) | BM1366 | 1 | 0x000F0000 | + +The Bitaxe writes the S21 Pro value because both carry the BM1370: +in these firmwares the value is a per-model constant inherited from +the donor machine's capture, not derived from the actual chain. + +#### What the Captures Determine + +Two capture points fix chain length, final frequency, and value all +at once, and they adjudicate the formula: + +- **S19 J Pro**: 126 chips, final ramp 525 MHz, value 0x1381 + (4,993). The value equals + `2^32 / 128 / 64 / 525 * 25 * 0.2 = 4,993.2` exactly, where 128 + is the next power of two of 126 chips and 64 is a core count + consistent with the fit. In the formula's terms, stock covers + 40% of full. +- **S21 Pro**: 65 chips, final ramp 593.75 MHz, value 0x1EB5 + (7,861). Divided among 128 power-of-two chip slots, the + formula's full-coverage value is 5,518, which the factory value + exceeds by 42%, an overshoot that would mean duplicated work. + Divided among the actual 65 chips it is 10,867, of which the + factory value is 72%. Since this machine spaces its chips + explicitly at 0xFFFF / 65, the actual-count basis is the + consistent one. The coherent reading of both capture points, + interpretation rather than proof: address-placed chains slice + the space by implicit power-of-two address slots, explicitly + placed chains by actual chip count. + +#### Open Questions + +- The register's true units: raw nonce count or reference-clock + ticks. +- Stock firmware's exact arithmetic. The S19 J Pro's stock miner + binary computes this value in no reachable code, so the captures + are the ground truth for what stock machines write. +- How chip address, CHIP_NONCE_OFFSET, and HASH_COUNTING_NUMBER + compose per model, and in particular how the BM1362 partitions a + 126-chip chain when its stock firmware never writes + CHIP_NONCE_OFFSET. +- Whether rewriting the value while work is in flight glitches the + search. References rewrite it only at mining-start boundaries and + on frequency changes. + +#### Multiple Hash Board Distribution +When a mining system has multiple hash boards, the software must +prevent duplicate work across them. Boards can be separated along +any header dimension the pool leaves to the miner: + +1. **Extranonce Distribution**: + - Each board derives its jobs from a disjoint slice of the + extranonce2 space, so every board hashes a different merkle + root (this is mujina's approach) + - Needs the pool to grant enough extranonce2 room. Usually + ample under Stratum v1, but proxies that subdivide the + extranonce between downstream miners can leave little, and + SV2 header-only mining has no extranonce at all, which + forces one of the other dimensions + +2. **Time-Based Work Distribution**: - Each board receives work with a different `ntime` offset - Board 0: ntime + 0 - Board 1: ntime + 1 - Board 2: ntime + 2 - This ensures each board searches a unique block variation -2. **Work Registry**: +3. **Work Registry**: - Software maintains a registry tracking which work is on which board - Each work assignment has a unique ID - Nonce responses are matched back to the correct work/board -3. **Example**: Antminer S19 with 3 hash boards +4. **Example**: Antminer S19 with 3 hash boards - Board 0: Works on block with ntime=X - Board 1: Works on block with ntime=X+1 - Board 2: Works on block with ntime=X+2 - Total: 3 boards x 76 chips x ~100 cores = ~23,000 parallel searches - Each searching a DIFFERENT block variation -4. **No Wasted Work**: +5. **No Wasted Work**: - Every hash calculation is unique across all boards - Software actively manages work distribution - Hardware (chips/cores) handle nonce space division within each board