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. diff --git a/mujina-miner/src/asic/bm13xx/PROTOCOL.md b/mujina-miner/src/asic/bm13xx/PROTOCOL.md index 3ba6f344..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 @@ -219,7 +222,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 +267,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 @@ -379,17 +382,18 @@ 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 | +| 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 | | 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) | -| 0xA4 | VERSION_MASK | Version rolling mask configuration | -| 0xA8 | INIT_CONTROL | Initialization control register | +| 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) | ### Register Details @@ -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) -#### 0x10 - NONCE_RANGE -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 +#### 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 +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) @@ -480,71 +497,127 @@ 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. -#### 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. - -#### 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: +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: -**Broadcast sequence** (2 writes): -- BM1362: `0x80008540`, `0x80008008` -- BM1366: `0x80008540`, `0x80008020` -- BM1368/70: `0x80008B00`, `0x8000800C` or `0x80008018` +- BM1362: 0xB000C100 +- BM1366/68: 0xFF0FC100 broadcast, 0xF000C100 per chip +- BM1370: 0xF000C100 (S21 Pro) or 0xFF0FC100 (S21) -**Per-chip sequence** (adds 3rd write): -- All chips add: `0x800082AA` as final write +#### 0x2C - UART_RELAY +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 +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: -- 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 -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: - 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) - -#### 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 +#### 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 +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: @@ -558,7 +631,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 @@ -578,10 +651,16 @@ 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** - - Write 0xFFFF0090 to register 0xA4 three times + - Write 0x9000FFFF to register 0xA4 three times - Broadcast read register 0x00 (command 0x52) - Count responding chips @@ -612,10 +691,18 @@ Undocumented miscellaneous settings register: - BM1370: 3Mbaud (0x00003001) - BM1362: Different rate (0x00003011) -7. **Final Configuration** - - Set NONCE_RANGE (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 @@ -646,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 NONCE_RANGE register - - Chip address influences which nonces are searched - - NONCE_RANGE 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 - -#### NONCE_RANGE Register Configuration - -The NONCE_RANGE 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): -1. **Time-Based Work Distribution** (most common): +``` +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 +``` + +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 @@ -807,7 +1428,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/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/chip_config.rs b/mujina-miner/src/asic/bm13xx/chip_config.rs new file mode 100644 index 00000000..fee9b2d2 --- /dev/null +++ b/mujina-miner/src/asic/bm13xx/chip_config.rs @@ -0,0 +1,354 @@ +//! 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::register::{ChipModel, PllDivider}; +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(PllDivider::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). +pub(super) 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 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: &[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 + /// 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/codec.rs b/mujina-miner/src/asic/bm13xx/codec.rs new file mode 100644 index 00000000..625cf275 --- /dev/null +++ b/mujina-miner/src/asic/bm13xx/codec.rs @@ -0,0 +1,166 @@ +//! 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::register::ChipModel; +use super::response::Response; +use crate::asic::bm13xx::crc::{crc5, crc5_is_valid, crc16}; +use crate::tracing::prelude::*; + +pub struct FrameCodec { + model: ChipModel, +} + +impl FrameCodec { + pub fn new(model: ChipModel) -> Self { + Self { model } + } +} + +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, self.model) { + 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..46e9ae25 --- /dev/null +++ b/mujina-miner/src/asic/bm13xx/command.rs @@ -0,0 +1,861 @@ +//! 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 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 { + 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::{ + AnalogMux, ChipId, ChipModel, CoreCommand, HashCountingNumber, IoDriverStrength, + Log2Difficulty, MidstateConfig, MiscControl, Register, RegisterAddress, SoftResetControl, + TicketMask, UartRelay, + }; + 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_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::MidstateConfig(MidstateConfig::full_rolling()), + }), + &[ + 0x55, 0xaa, 0x51, 0x09, 0x00, 0xa4, 0x90, 0x00, 0xff, 0xff, 0x1c, + ], + ); + } + + #[test] + fn write_soft_reset_defaults_from_capture() { + // From Bitaxe capture: TX: 55 AA 51 09 00 A8 00 07 00 00 03 + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::SoftResetControl(SoftResetControl::defaults(ChipModel::BM1370)), + }), + &[ + 0x55, 0xaa, 0x51, 0x09, 0x00, 0xa8, 0x00, 0x07, 0x00, 0x00, 0x03, + ], + ); + } + + #[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_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 + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::MiscControl(MiscControl::operational(ChipModel::BM1370)), + }), + &[ + 0x55, 0xaa, 0x51, 0x09, 0x00, 0x18, 0xf0, 0x00, 0xc1, 0x00, 0x04, + ], + ); + } + + #[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 + 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_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 + assert_frame_eq( + RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::CoreMailbox(CoreCommand::write_all( + CoreCommand::OVERLAP_MONITOR, + 0x00, + )), + }), + &[ + 0x55, 0xaa, 0x51, 0x09, 0x00, 0x3c, 0x80, 0x00, 0x8b, 0x00, 0x12, + ], + ); + } + + #[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 + // 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_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::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] + 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; + + // 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::new(ChipModel::BM1370); + 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::new(ChipModel::BM1370); + 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::new(ChipModel::BM1362); + 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, + { + // 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) + .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/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/error.rs b/mujina-miner/src/asic/bm13xx/error.rs index 3cf9c7c3..10c5c7bb 100644 --- a/mujina-miner/src/asic/bm13xx/error.rs +++ b/mujina-miner/src/asic/bm13xx/error.rs @@ -11,14 +11,14 @@ 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, + #[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 }, - - #[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..2528ffe0 100644 --- a/mujina-miner/src/asic/bm13xx/mod.rs +++ b/mujina-miner/src/asic/bm13xx/mod.rs @@ -3,16 +3,23 @@ //! 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; pub mod crc; +pub mod driver; pub mod error; -pub mod protocol; +pub mod reader; +pub mod register; +pub mod response; pub mod thread; +pub mod topology; #[cfg(test)] pub mod test_data; // Re-export commonly used types -pub use protocol::{FrameCodec, Register, Response}; - -// Re-export the protocol handler -pub use protocol::BM13xxProtocol; +pub use codec::FrameCodec; +pub use register::Register; +pub use response::Response; diff --git a/mujina-miner/src/asic/bm13xx/protocol.rs b/mujina-miner/src/asic/bm13xx/protocol.rs deleted file mode 100644 index e6af515f..00000000 --- a/mujina-miner/src/asic/bm13xx/protocol.rs +++ /dev/null @@ -1,2719 +0,0 @@ -//! BM13xx protocol implementation for chip communication. -//! -//! This module handles the encoding and decoding of commands and responses -//! for BM13xx family chips (BM1366, BM1370, etc). - -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::crc::{crc5, crc5_is_valid, crc16}; -use super::error::ProtocolError; -use crate::job_source::GeneralPurposeBits; -use crate::tracing::prelude::*; -use crate::types::Difficulty; - -/// 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(()) - } -} - -/// 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 -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct PllConfig { - /// VCO control flag (0x40 for VCO < 2400 MHz, 0x50 for VCO >= 2400 MHz) - pub flag: u8, - /// Feedback divider (0xa0-0xef = 160-239) - pub fb_div: u8, - /// Reference divider (1 or 2) - pub ref_div: u8, - /// Post divider encoded value - 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 - 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 }; - - Self { - flag, - fb_div, - ref_div, - post_div, - } - } -} - -impl From for PllConfig { - 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, - } - } -} - -impl From for [u8; 4] { - fn from(config: PllConfig) -> Self { - [config.flag, config.fb_div, config.ref_div, config.post_div] - } -} - -/// Known chip types in the BM13xx family -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChipType { - /// 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 type with raw ID bytes - Unknown([u8; 2]), -} - -impl ChipType { - /// 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, - } - } - - /// Get expected hash engine count for this chip type, 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 ChipType { - 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(chip_type: ChipType) -> Self { - chip_type.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 NonceRangeConfig { - /// Raw bytes as sent over the wire - bytes: [u8; 4], -} - -impl NonceRangeConfig { - // 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(), - } - } -} - -impl From for [u8; 4] { - fn from(config: NonceRangeConfig) -> Self { - config.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 - } -} - -impl From for [u8; 4] { - fn from(mask: TicketMask) -> Self { - mask.to_wire_bytes() - } -} - -/// 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 BaudRate { - /// 115200 baud - Baud115200, - /// 1 Mbaud - Baud1M, - /// 3 Mbaud (common for multi-chip) - Baud3M, - /// Custom baud rate with raw register value - Custom(u32), -} - -impl From for [u8; 4] { - fn from(baud: BaudRate) -> Self { - let value = match baud { - // From esp-miner BM1370/BM1366/BM1368 default baud config - BaudRate::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, - // From S21 Pro captures (BM1370 multi-chip chains) - BaudRate::Baud3M => 0x00003001, - BaudRate::Custom(val) => val, - }; - value.to_le_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], - } - } -} - -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), - ] - } -} - -impl IoDriverStrength { - /// Get the raw bytes for testing - pub fn as_bytes(&self) -> [u8; 4] { - (*self).into() - } -} - -/// 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, - } - } -} - -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() - } -} - -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 { - 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, -} - -#[derive(Clone)] -pub enum Register { - ChipId { - chip_type: ChipType, // Chip type identifier - core_count: u8, // Core configuration byte - address: u8, // Assigned chip address - }, - PllDivider(PllConfig), - NonceRange(NonceRangeConfig), - TicketMask(TicketMask), - MiscControl { - raw_value: u32, - }, - UartBaud(BaudRate), - UartRelay { - raw_value: u32, // Domain relay configuration (complex format) - }, - Core { - raw_value: u32, - }, - AnalogMux { - raw_value: u32, - }, - IoDriverStrength(IoDriverStrength), - Pll3Parameter { - raw_value: u32, - }, - VersionMask(VersionMask), - InitControl { - raw_value: u32, - }, - MiscSettings { - raw_value: u32, - }, -} - -impl Register { - pub fn decode(address: RegisterAddress, bytes: &[u8; 4]) -> Register { - let raw_value = u32::from_le_bytes(*bytes); - match address { - RegisterAddress::ChipId => Register::ChipId { - chip_type: ChipType::from([bytes[0], bytes[1]]), - core_count: bytes[2], - address: bytes[3], - }, - RegisterAddress::PllDivider => Register::PllDivider(raw_value.into()), - RegisterAddress::NonceRange => Register::NonceRange(NonceRangeConfig { 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 => BaudRate::Baud115200, - 0x00000130 => BaudRate::Baud1M, - 0x00003001 => BaudRate::Baud3M, - other => BaudRate::Custom(other), - }; - Register::UartBaud(baud) - } - RegisterAddress::UartRelay => Register::UartRelay { raw_value }, - RegisterAddress::Core => Register::Core { raw_value }, - RegisterAddress::AnalogMux => Register::AnalogMux { raw_value }, - 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 }) - } - RegisterAddress::InitControl => Register::InitControl { raw_value }, - RegisterAddress::MiscSettings => Register::MiscSettings { raw_value }, - } - } - - /// 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_data(&self, dst: &mut BytesMut) { - match self { - Register::ChipId { - chip_type, - core_count, - address, - } => { - dst.put_slice(&chip_type.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 { - match self { - Register::ChipId { - chip_type, - core_count, - address, - } => f - .debug_struct("ChipId") - .field("chip_type", chip_type) - .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() - } - } - } -} - -#[repr(u8)] -enum CommandFlagsType { - Job = 1, - Command = 2, -} - -#[repr(u8)] -enum CommandFlagsCmd { - 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 -} - -#[derive(Debug)] -pub enum Command { - /// 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 { - broadcast: bool, - chip_address: u8, - register_address: RegisterAddress, - }, - /// Write a register to chip(s) - WriteRegister { - broadcast: bool, - chip_address: u8, - register: Register, - }, - /// 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 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, -} - -/// 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 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 encode(&self, dst: &mut BytesMut) { - match self { - Command::SetChipAddress { chip_address } => { - dst.put_u8(Self::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) - } - Command::ChainInactive => { - dst.put_u8(Self::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 - } - Command::ReadRegister { - broadcast, - chip_address, - register_address, - } => { - dst.put_u8(Self::build_flags( - CommandFlagsType::Command, - *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(*chip_address); - dst.put_u8(*register_address as u8); - } - Command::WriteRegister { - broadcast, - chip_address, - register, - } => { - dst.put_u8(Self::build_flags( - CommandFlagsType::Command, - *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(*chip_address); - dst.put_u8(register.address() as u8); - register.encode_data(dst); - } - Command::JobFull { job_data } => { - dst.put_u8(Self::build_flags( - CommandFlagsType::Job, - false, // Jobs are never broadcast - 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); - - // 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); - } - Command::JobMidstate { job_data } => { - dst.put_u8(Self::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 { - 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: Command, dst: &mut BytesMut) -> Result<(), Self::Error> { - const PREAMBLE: [u8; 2] = [0x55, 0xaa]; - dst.put_slice(&PREAMBLE); - - let start_pos = dst.len(); - command.encode(dst); - - // 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); - } - } - - // Log the encoded frame for debugging - 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], - Command::WriteRegister { - broadcast: true, - chip_address: 0x00, - register: Register::VersionMask(_), - } - )); - - // Verify chain inactive command - let chain_inactive_pos = commands - .iter() - .position(|c| matches!(c, Command::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], - Command::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 { - Command::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, - Command::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 Command::WriteRegister { - chip_address, - register: Register::IoDriverStrength(strength), - .. - } = first_boundary - { - assert_eq!(*chip_address, 0x08); // 5th chip (index 4) * 2 - let strength_bytes: [u8; 4] = (*strength).into(); - // Expected bytes from hardware capture - assert_eq!(strength_bytes, [0x00, 0xf1, 0x11, 0x11]); - } - } - - #[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(); - - // Test single chip - full range - let commands = protocol.configure_nonce_ranges(1); - assert_eq!(commands.len(), 1); - if let Command::WriteRegister { - register: Register::NonceRange(config), - broadcast: true, - .. - } = &commands[0] - { - let config_bytes: [u8; 4] = (*config).into(); - assert_eq!(config_bytes, [0xff, 0xff, 0xff, 0xff]); - } - - // Test S21 Pro configuration (65 chips) - let commands = protocol.configure_nonce_ranges(65); - assert_eq!(commands.len(), 1); - if let Command::WriteRegister { - register: Register::NonceRange(config), - .. - } = &commands[0] - { - let config_bytes: [u8; 4] = (*config).into(); - assert_eq!(config_bytes, [0x00, 0x00, 0x1e, 0xb5]); - } - - // Test small chain - let commands = protocol.configure_nonce_ranges(8); - if let Command::WriteRegister { - register: Register::NonceRange(config), - .. - } = &commands[0] - { - let config_bytes: [u8; 4] = (*config).into(); - assert_eq!(config_bytes, [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, - Command::WriteRegister { - register: Register::NonceRange { .. }, - .. - } - ) - }); - - assert!(nonce_range_cmd.is_some()); - - if let Some(Command::WriteRegister { - register: Register::NonceRange(config), - .. - }) = nonce_range_cmd - { - let config_bytes: [u8; 4] = (*config).into(); - assert_eq!(config_bytes, [0x00, 0x00, 0x1e, 0xb5]); // S21 Pro value - } - } -} - -#[cfg(test)] -mod command_tests { - use super::*; - - #[test] - fn read_register() { - assert_frame_eq( - Command::ReadRegister { - broadcast: true, - chip_address: 0, - register_address: RegisterAddress::ChipId, - }, - &[0x55, 0xaa, 0x52, 0x05, 0x00, 0x00, 0x0a], - ); - } - - #[test] - fn write_register_chip_address() { - assert_frame_eq( - Command::WriteRegister { - broadcast: false, - chip_address: 0x01, - register: Register::ChipId { - chip_type: ChipType::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( - Command::WriteRegister { - broadcast: true, // 0x51 = broadcast - chip_address: 0x00, - 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( - Command::WriteRegister { - broadcast: true, - chip_address: 0x00, - register: Register::InitControl { - raw_value: 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( - Command::WriteRegister { - broadcast: true, - chip_address: 0x00, - register: Register::MiscControl { - raw_value: 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( - Command::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( - Command::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( - Command::WriteRegister { - broadcast: true, - chip_address: 0x00, - register: Register::Core { - raw_value: 0x80008B00, // Big-endian: produces bytes 80 00 8B 00 - }, - }, - &[ - 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( - Command::WriteRegister { - broadcast: true, - chip_address: 0x00, - 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( - Command::WriteRegister { - broadcast: true, - chip_address: 0x00, - register: Register::NonceRange(NonceRangeConfig::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( - Command::JobFull { - job_data: 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], 86); // Total length - 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( - Command::JobFull { - job_data: job.clone(), - }, - &mut frame, - ) - .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" - ); - } - - fn assert_frame_eq(cmd: Command, expect: &[u8]) { - 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(" ") - } - - #[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"); - - // 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" - ); - } -} - -#[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 { - chip_type, - core_count, - address, - } = register - else { - panic!("Expected ChipId register, got {:?}", register); - }; - - assert_eq!(chip_type, ChipType::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( - Command::JobFull { - job_data: job.clone(), - }, - &mut tx_frame, - ) - .expect("Should encode JobFull command"); - - assert_eq!( - tx_frame.as_ref(), - &esp_miner_job::wire_tx::FRAME, - "TX frame should match hardware capture" - ); - - 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 into_bytes_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]); - } - - #[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. - -/// 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 {} - } - - /// Helper to create a broadcast write command - fn broadcast_write(&self, register: Register) -> Command { - Command::WriteRegister { - broadcast: true, - chip_address: 0x00, - register, - } - } - - /// 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 { - broadcast: false, - chip_address, - register, - } - } - - /// Get 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 - } - - /// 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 { - raw_value: INIT_CONTROL_VALUE, - })); - - // Step 3: Configure misc control - commands.push(self.broadcast_write(Register::MiscControl { - raw_value: MISC_CONTROL_MULTI_CHIP, - })); - - // Step 4: Set chain inactive for address assignment - commands.push(Command::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 { - chip_address: address, - }); - } - - // 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, - })); - - // 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 { - raw_value: 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 { - raw_value: 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 = NonceRangeConfig::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) -> Command { - Command::ReadRegister { - broadcast: false, - chip_address, - register_address: register, - } - } - - /// Set UART baud rate on all chips - pub fn set_baudrate(&self, baudrate: BaudRate) -> Command { - Command::WriteRegister { - broadcast: true, - chip_address: 0x00, - 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 { - // 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 - return Err(ProtocolError::ReadOnlyRegister(register)); - } - RegisterAddress::PllDivider => Register::PllDivider(value.into()), - RegisterAddress::NonceRange => Register::NonceRange(NonceRangeConfig { - bytes: 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 }) - } - RegisterAddress::MiscControl => Register::MiscControl { raw_value: value }, - RegisterAddress::UartBaud => Register::UartBaud(BaudRate::Custom(value)), - RegisterAddress::UartRelay => Register::UartRelay { raw_value: value }, - RegisterAddress::Core => Register::Core { raw_value: value }, - RegisterAddress::AnalogMux => Register::AnalogMux { raw_value: 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 }) - } - RegisterAddress::Pll3Parameter => Register::Pll3Parameter { raw_value: 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 }, - }; - - Ok(Command::WriteRegister { - broadcast: false, - chip_address, - register: register_value, - }) - } - - /// Create a broadcast command to discover all chips. - pub fn discover_chips() -> Command { - Command::ReadRegister { - broadcast: true, - chip_address: 0, - register_address: RegisterAddress::ChipId, - } - } -} 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()); + } +} diff --git a/mujina-miner/src/asic/bm13xx/register.rs b/mujina-miner/src/asic/bm13xx/register.rs new file mode 100644 index 00000000..cb5e9d5c --- /dev/null +++ b/mujina-miner/src/asic/bm13xx/register.rs @@ -0,0 +1,1312 @@ +//! 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 super::chip_config::CRYSTAL_MHZ; +use super::error::ProtocolError; +use crate::types::Difficulty; + +/// Register addresses on the wire. +#[derive(FromRepr, Copy, Clone, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum RegisterAddress { + ChipId = 0x00, + PllDivider = 0x08, + HashCountingNumber = 0x10, + TicketMask = 0x14, + MiscControl = 0x18, + UartBaud = 0x28, + UartRelay = 0x2C, + CoreMailbox = 0x3C, + AnalogMux = 0x54, + IoDriverStrength = 0x58, + Pll3Parameter = 0x68, + MidstateConfig = 0xA4, + SoftResetControl = 0xA8, + MiscSettings = 0xB9, +} + +/// A register with its typed payload. +#[derive(Debug, Clone, PartialEq)] +pub enum Register { + ChipId(ChipId), + PllDivider(PllDivider), + HashCountingNumber(HashCountingNumber), + TicketMask(TicketMask), + MiscControl(MiscControl), + UartBaud(UartBaud), + UartRelay(UartRelay), + CoreMailbox(CoreCommand), + AnalogMux(AnalogMux), + IoDriverStrength(IoDriverStrength), + Pll3Parameter(Pll3Parameter), + MidstateConfig(MidstateConfig), + SoftResetControl(SoftResetControl), + MiscSettings(MiscSettings), +} + +impl Register { + 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::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)), + RegisterAddress::UartRelay => Register::UartRelay(UartRelay::decode(bytes)), + RegisterAddress::CoreMailbox => Register::CoreMailbox(CoreCommand::decode(bytes)), + RegisterAddress::AnalogMux => Register::AnalogMux(AnalogMux::decode(bytes)), + RegisterAddress::IoDriverStrength => { + Register::IoDriverStrength(IoDriverStrength::decode(bytes)) + } + RegisterAddress::Pll3Parameter => Register::Pll3Parameter(Pll3Parameter::decode(bytes)), + RegisterAddress::MidstateConfig => { + Register::MidstateConfig(MidstateConfig::decode(bytes)) + } + RegisterAddress::SoftResetControl => { + Register::SoftResetControl(SoftResetControl::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::HashCountingNumber(_) => RegisterAddress::HashCountingNumber, + Register::TicketMask(_) => RegisterAddress::TicketMask, + Register::MiscControl(_) => RegisterAddress::MiscControl, + Register::UartBaud(_) => RegisterAddress::UartBaud, + Register::UartRelay(_) => RegisterAddress::UartRelay, + Register::CoreMailbox(_) => RegisterAddress::CoreMailbox, + Register::AnalogMux(_) => RegisterAddress::AnalogMux, + Register::IoDriverStrength(_) => RegisterAddress::IoDriverStrength, + Register::Pll3Parameter(_) => RegisterAddress::Pll3Parameter, + Register::MidstateConfig(_) => RegisterAddress::MidstateConfig, + Register::SoftResetControl(_) => RegisterAddress::SoftResetControl, + 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::HashCountingNumber(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::CoreMailbox(r) => r.encode(dst), + Register::AnalogMux(r) => r.encode(dst), + Register::IoDriverStrength(r) => r.encode(dst), + Register::Pll3Parameter(r) => r.encode(dst), + Register::MidstateConfig(r) => r.encode(dst), + Register::SoftResetControl(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]) -> Result { + Ok(Self { + model: ChipModel::try_from([bytes[0], bytes[1]])?, + core_count: bytes[2], + address: bytes[3], + }) + } +} + +/// 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) + /// 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, +} + +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], + } + } + + /// 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 TryFrom<[u8; 2]> for ChipModel { + type Error = ProtocolError; + + fn try_from(bytes: [u8; 2]) -> Result { + match bytes { + [0x13, 0x62] => Ok(Self::BM1362), + [0x13, 0x66] => Ok(Self::BM1366), + [0x13, 0x70] => Ok(Self::BM1370), + _ => Err(ProtocolError::UnknownChipId(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 { + /// Builds a [`PllDivider`] with `flag` derived from the dividers. + pub fn new(fb_div: u8, ref_div: u8, post_div: u8) -> Self { + 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, + 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], + } + } +} + +/// Hash counting number, the per-version nonce iteration limit. +/// +/// 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 HashCountingNumber { + value: u32, +} + +impl HashCountingNumber { + pub fn encode(&self, dst: &mut BytesMut) { + dst.put_u32(self.value); + } + + pub fn decode(bytes: [u8; 4]) -> Self { + Self { + value: u32::from_be_bytes(bytes), + } + } +} + +impl From for HashCountingNumber { + fn from(value: u32) -> Self { + Self { value } + } +} + +impl From for u32 { + fn from(hcn: HashCountingNumber) -> Self { + hcn.value + } +} + +/// 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) + } +} + +/// 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 { + /// 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), + } + } +} + +/// 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 +/// 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, + } + } + + pub fn encode(&self, dst: &mut BytesMut) { + 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 { + 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() + } +} + +/// 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 +/// 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 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 { + /// Returns the baseline strength: every output at 1. + pub fn normal() -> Self { + Self { + command_out: 0x1, + busy_out: 0x1, + reset_out: 0x1, + clock_out: 0x1, + response_out: 0x1, + high_bits: 0, + } + } + + /// 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 { + Self { + clock_out: 0xf, + ..Self::normal() + } + } + + pub fn encode(&self, dst: &mut BytesMut) { + // 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 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, + } + } +} + +/// 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 MidstateConfig { + /// Returns the configuration every capture uses: full mask, + /// generation code 1, automatic midstate generation. + pub fn full_rolling() -> Self { + Self { + version_mask: 0xffff, + midstate_gen: 1, + version_fix: false, + auto_gen: true, + } + } + + pub fn encode(&self, dst: &mut BytesMut) { + 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 value = u32::from_be_bytes(bytes); + Self { + 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 MidstateConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + 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() + } +} + +/// 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 { + ($($(#[$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! { + Pll3Parameter, + 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); + } + + 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).unwrap(), original); + } + + #[test] + fn known_model() { + round_trip(ChipId { + model: ChipModel::BM1362, + core_count: 80, + address: 0x42, + }); + } + + #[test] + fn reject_unknown_id() { + assert!(matches!( + ChipId::decode([0x12, 0x34, 0x00, 0x00]), + Err(ProtocolError::UnknownChipId([0x12, 0x34])) + )); + } +} + +#[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, + }); + } + + #[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)] +mod hash_counting_number_tests { + use super::*; + + #[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!(HashCountingNumber::decode(bytes), original); + } + + #[test] + fn encodes_big_endian() { + let mut buf = BytesMut::new(); + HashCountingNumber::from(0x00001EB5).encode(&mut buf); + assert_eq!(&buf[..], &[0x00, 0x00, 0x1E, 0xB5]); + } +} + +#[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 midstate_config_tests { + use super::*; + + fn round_trip(original: MidstateConfig) { + let mut buf = BytesMut::new(); + original.encode(&mut buf); + let bytes: [u8; 4] = buf[..].try_into().unwrap(); + assert_eq!(MidstateConfig::decode(bytes), original); + } + + #[test] + fn 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, + }); + } +} + +#[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)); + } +} + +#[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::*; + + 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 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::*; + + 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/response.rs b/mujina-miner/src/asic/bm13xx/response.rs new file mode 100644 index 00000000..56b99e40 --- /dev/null +++ b/mujina-miner/src/asic/bm13xx/response.rs @@ -0,0 +1,693 @@ +//! 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::{ChipModel, Register, RegisterAddress}; +use crate::asic::bm13xx::error::ProtocolError; +use crate::job_source::GeneralPurposeBits; + +#[derive(Debug)] +pub enum Response { + 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 { + 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::(); + + 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(RegisterResponse { + chip_address, + register, + })) + } else { + Err(ProtocolError::InvalidRegisterAddress(register_address_repr)) + } + } + Some(ResponseType::Nonce) => { + // 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 + + // 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(NonceResponse { + 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::new(ChipModel::BM1370); + + // 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, ChipModel::BM1370) + .expect("decode_frame should return Some for valid frame"); + + let Response::ReadRegister(RegisterResponse { + 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], model: ChipModel) -> Option { + let mut buf = BytesMut::from(frame); + let mut codec = FrameCodec::new(model); + 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, ChipModel::BM1370); + + 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 + let wire = &[ + 0xaa, 0x55, 0x18, 0x00, 0xa6, 0x40, 0x02, 0x99, 0x22, 0xf9, 0x91, + ]; + let response = decode_frame(wire, ChipModel::BM1370) + .expect("decode_frame should return Some for valid frame"); + + let Response::Nonce(NonceResponse { + 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, ChipModel::BM1370) + .expect("decode_frame should return Some for valid frame"); + + let Response::Nonce(NonceResponse { + 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 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(NonceResponse { + 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::new(ChipModel::BM1370); + + // 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::new(ChipModel::BM1370); + + // 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::new(ChipModel::BM1370); + + // 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::new(ChipModel::BM1370); + + // 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::new(ChipModel::BM1370); + + // 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::new(ChipModel::BM1370); + + // 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::new(ChipModel::BM1370); + + // 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::new(ChipModel::BM1370); + + // 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::new(ChipModel::BM1370); + + // 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(RegisterResponse { + 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, ChipModel::BM1370) + .expect("Should decode valid frame"); + + let Response::Nonce(NonceResponse { + 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::new(ChipModel::BM1370); + 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, ChipModel::BM1370) + .expect("Should decode RX frame"); + + let Response::Nonce(NonceResponse { + 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/test_data.rs b/mujina-miner/src/asic/bm13xx/test_data.rs index 1d67706f..e787e2a3 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 @@ -14,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. @@ -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, + ]; + } +} diff --git a/mujina-miner/src/asic/bm13xx/thread.rs b/mujina-miner/src/asic/bm13xx/thread.rs index 6e874bdd..ccb63635 100644 --- a/mujina-miner/src/asic/bm13xx/thread.rs +++ b/mujina-miner/src/asic/bm13xx/thread.rs @@ -13,11 +13,19 @@ 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::protocol::{self, Log2Difficulty, TicketMask}; +use super::command::{ + ChainInactive, ChipCommandSink, Destination, JobCommand, JobFullFormat, RegisterCommand, + SetChipAddress, WriteRegister, +}; +use super::register::{ + AnalogMux, ChipModel, CoreCommand, HashCountingNumber, IoDriverStrength, Log2Difficulty, + MidstateConfig, MiscControl, MiscSettings, PllDivider, Register, SoftResetControl, TicketMask, +}; +use super::response::{NonceResponse, RegisterResponse, Response}; use crate::{ asic::hash_thread::{ BoardPeripherals, HashTask, HashThread, HashThreadCapabilities, HashThreadEvent, @@ -125,7 +133,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, @@ -133,9 +141,9 @@ impl BM13xxThread { removal_rx: watch::Receiver, ) -> Self where - R: Stream> + Unpin + Send + 'static, - W: Sink + Unpin + Send + 'static, - W::Error: std::fmt::Debug, + R: Stream> + Unpin + Send + 'static, + W: ChipCommandSink + Unpin + 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); @@ -241,17 +249,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: ChipCommandSink + Unpin, + E: std::error::Error + Send + Sync + 'static, { - use protocol::{Command, Register}; - // Enable the ASIC if let Some(ref mut asic_enable) = peripherals.asic_enable { debug!("Enabling ASIC"); @@ -263,32 +269,16 @@ 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<()> - where - W: Sink + Unpin, - W::Error: std::fmt::Debug, - { - chip_commands - .send(Command::WriteRegister { - broadcast, - chip_address: 0x00, - register, - }) - .await - .map_err(|e| anyhow!("{e:?}")) - } - // 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(WriteRegister { + destination: Destination::Broadcast, + register: Register::MidstateConfig(MidstateConfig::full_rolling()), + })) + .await + .context("failed to send version mask")?; tokio::time::sleep(std::time::Duration::from_millis(5)).await; } @@ -297,150 +287,141 @@ where // Pre-configuration registers debug!("Sending pre-configuration registers"); - send_reg( - chip_commands, - true, - Register::InitControl { - raw_value: 0x00000700, - }, - ) - .await?; - send_reg( - chip_commands, - true, - Register::MiscControl { - raw_value: 0x00C100F0, - }, - ) - .await?; + chip_commands + .send(RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::SoftResetControl(SoftResetControl::defaults(ChipModel::BM1370)), + })) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::MiscControl(MiscControl::operational(ChipModel::BM1370)), + })) + .await?; chip_commands - .send(Command::ChainInactive) + .send(RegisterCommand::ChainInactive(ChainInactive)) .await - .map_err(|e| anyhow!("{e:?}")) .context("failed to send ChainInactive")?; chip_commands - .send(Command::SetChipAddress { chip_address: 0x00 }) + .send(RegisterCommand::SetChipAddress(SetChipAddress { + chip_address: 0x00, + })) .await - .map_err(|e| anyhow!("{e:?}")) .context("failed to send SetChipAddress")?; // 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?; + chip_commands + .send(RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::CoreMailbox(CoreCommand::write_all( + CoreCommand::OVERLAP_MONITOR, + 0x00, + )), + })) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::CoreMailbox(CoreCommand::write_all(CoreCommand::CLOCK_DELAY, 0x0C)), + })) + .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(WriteRegister { + destination: Destination::Broadcast, + register: Register::TicketMask(ticket_mask), + })) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister(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 { - raw_value: 0xF0010700, - }, - ) - .await?; - send_reg( - chip_commands, - false, - Register::MiscControl { - raw_value: 0x00C100F0, - }, - ) - .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, - }, - ) - .await?; + chip_commands + .send(RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Chip(0x00), + register: Register::SoftResetControl(SoftResetControl::core_reset(ChipModel::BM1370)), + })) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Chip(0x00), + register: Register::MiscControl(MiscControl::operational(ChipModel::BM1370)), + })) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Chip(0x00), + register: Register::CoreMailbox(CoreCommand::write_all( + CoreCommand::OVERLAP_MONITOR, + 0x00, + )), + })) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Chip(0x00), + register: Register::CoreMailbox(CoreCommand::write_all(CoreCommand::CLOCK_DELAY, 0x0C)), + })) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Chip(0x00), + register: Register::CoreMailbox(CoreCommand::write_all(CoreCommand::CORE_ENABLE, 0xAA)), + })) + .await?; // Additional settings - send_reg( - chip_commands, - true, - Register::MiscSettings { - raw_value: 0x80440000, - }, - ) - .await?; - send_reg( - chip_commands, - true, - Register::AnalogMux { - raw_value: 0x02000000, - }, - ) - .await?; - send_reg( - chip_commands, - true, - Register::MiscSettings { - raw_value: 0x80440000, - }, - ) - .await?; - send_reg( - chip_commands, - true, - Register::Core { - raw_value: 0x8000_8DEE, - }, - ) - .await?; + chip_commands + .send(RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::MiscSettings(MiscSettings(0x80440000)), + })) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::AnalogMux(AnalogMux::bring_up(ChipModel::BM1370)), + })) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::MiscSettings(MiscSettings(0x80440000)), + })) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + // Core register 0x0d has no known name; the value is + // from factory captures. + register: Register::CoreMailbox(CoreCommand::write_all(0x0D, 0xEE)), + })) + .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(WriteRegister { + destination: Destination::Broadcast, + register: Register::PllDivider(*pll_config), + })) .await .context("PLL ramp failed")?; @@ -453,19 +434,20 @@ where debug!("Frequency ramping complete"); - // Final configuration - send_reg( - chip_commands, - true, - Register::NonceRange(protocol::NonceRangeConfig::from_raw(0xB51E0000)), - ) - .await?; - send_reg( - chip_commands, - true, - Register::VersionMask(protocol::VersionMask::full_rolling()), - ) - .await?; + // 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::HashCountingNumber(HashCountingNumber::from(0x1EB5)), + })) + .await?; + chip_commands + .send(RegisterCommand::WriteRegister(WriteRegister { + destination: Destination::Broadcast, + register: Register::MidstateConfig(MidstateConfig::full_rolling()), + })) + .await?; tokio::time::sleep(std::time::Duration::from_millis(150)).await; @@ -477,7 +459,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; @@ -499,7 +481,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(); @@ -521,7 +503,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, @@ -534,7 +516,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; @@ -584,11 +566,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( - best_fb_div, - best_ref_div, - post_div, - )) + Some(PllDivider::new(best_fb_div, best_ref_div, post_div)) } /// Internal actor task for BM13xxThread. @@ -602,7 +580,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, @@ -611,9 +589,9 @@ async fn bm13xx_thread_actor( mut chip_commands: W, mut peripherals: BoardPeripherals, ) where - R: Stream> + Unpin, - W: Sink + Unpin, - W::Error: std::fmt::Debug, + R: Stream> + Unpin, + W: ChipCommandSink + Unpin, + 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 @@ -693,7 +671,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 +724,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(); @@ -796,7 +774,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(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(); @@ -883,7 +861,7 @@ async fn bm13xx_thread_actor( let _ = (midstate_num, subcore_id); // Unused for now } - protocol::Response::ReadRegister { chip_address, register } => { + Response::ReadRegister(RegisterResponse { chip_address, register }) => { trace!(chip_address = %format!("0x{:02x}", chip_address), register = ?register, "Register read response"); } } @@ -906,7 +884,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/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()); + } +} diff --git a/mujina-miner/src/board/bitaxe.rs b/mujina-miner/src/board/bitaxe.rs index 2108e665..361112a8 100644 --- a/mujina-miner/src/board/bitaxe.rs +++ b/mujina-miner/src/board/bitaxe.rs @@ -23,7 +23,13 @@ use crate::{ api_client::types::{BoardTelemetry, Fan, PowerMeasurement, TemperatureSensor}, asic::{ ChipInfo, - bm13xx::{self, BM13xxProtocol, protocol::Command, thread::BM13xxThread}, + bm13xx::{ + self, Register, Response, + command::{Destination, ReadRegister, RegisterCommand, WriteRegister}, + register::{ChipId, ChipModel, MidstateConfig, RegisterAddress}, + response::RegisterResponse, + thread::BM13xxThread, + }, hash_thread::{AsicEnable, BoardPeripherals, HashThread, ThreadRemovalSignal}, }, hw_trait::{ @@ -92,8 +98,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; @@ -119,17 +126,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 = Command::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(WriteRegister { + destination: Destination::Broadcast, + register: Register::MidstateConfig(MidstateConfig::full_rolling()), + })) .await .context("failed to send config command")?; time::sleep(Duration::from_millis(5)).await; @@ -454,9 +456,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 +525,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 target")?; + tps546 + .clear_faults() .await - .context("failed to set core voltage")?; + .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; @@ -546,7 +556,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) @@ -561,13 +574,13 @@ async fn discover_chips( tokio::select! { response = reader.next() => { match response { - Some(Ok(bm13xx::Response::ReadRegister { + Some(Ok(Response::ReadRegister(RegisterResponse { chip_address: _, - register: bm13xx::Register::ChipId { chip_type, core_count, address } - })) => { - let chip_id = chip_type.id_bytes(); + register: Register::ChipId(ChipId { model, core_count, address }), + }))) => { + 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, diff --git a/mujina-miner/src/peripheral/tps546.rs b/mujina-miner/src/peripheral/tps546.rs index be3cd625..3c2d0b59 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: @@ -115,9 +115,11 @@ 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 }, } -/// TPS546D24A driver +/// TPS546 driver pub struct Tps546 { i2c: I2C, config: Tps546Config, @@ -137,7 +139,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?; @@ -150,11 +152,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()) @@ -495,64 +497,52 @@ 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); + /// 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 + )); + } - // Clear any faults before turning on - self.clear_faults().await?; - debug!("Cleared faults before turn-on"); + let value = self.encode_voltage(volts).await?; + self.write_word(PmbusCommand::VoutCommand, value).await?; + debug!("VOUT_COMMAND set to {:.2}V", volts); + Ok(()) + } - // Turn on output - self.write_byte(PmbusCommand::Operation, pmbus::Operation::On.as_u8()) - .await?; + /// 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(()) + } - // 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); - } + /// 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(()) + } - // 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); - } + /// 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(()) } @@ -784,7 +774,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 ---"); 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;