Skip to content

feat(bm13xx): multi-chip groundwork for EmberOne00 and Bitmain chains#77

Closed
rkuester wants to merge 38 commits into
256foundation:mainfrom
rkuester:feat-bm13xx-multichip
Closed

feat(bm13xx): multi-chip groundwork for EmberOne00 and Bitmain chains#77
rkuester wants to merge 38 commits into
256foundation:mainfrom
rkuester:feat-bm13xx-multichip

Conversation

@rkuester

@rkuester rkuester commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Restructures the BM13xx ASIC layer from a single-chip implementation shaped around the Bitaxe into a foundation that can drive multi-chip chains. The first target is the EmberOne00 (12x BM1362), with Bitmain machines behind it: the S19j Pro and S19k Pro class today, and newer models as they're understood. The chain and topology model is built for chains of any length, and the protocol work is grounded in captured factory traffic from long-chain Bitmain hardware. No board bring-up is included yet; this is the protocol, register, and driver groundwork the chain work builds on.

What's here

  • The monolithic protocol module is split into focused submodules: commands, responses, registers, and a codec parameterized by chip model.
  • Registers get semantic types with named bit fields (misc control, UART relay, analog mux, core mailbox, midstate config, soft reset, hash counting number), replacing raw u32 constants.
  • A chain and topology domain model describes chip addressing and how work is partitioned across chains of any length.
  • A reader task demultiplexes chip responses from the shared serial bus, and a driver layers request/response register conversations on top of it.
  • PLL calculation moves into a ChipConfig, using a new unit-aware Frequency type.
  • TPS546 voltage control is decomposed into PMBus primitives, with two fixes (ON_OFF_CONFIG CP bit dropped, family naming in logs and docs).
  • PROTOCOL.md grows substantially: register semantics and the version-rolling search-space model, covering how chip address, nonce offset, and the hash counting number distribute work across a chain, with corrections to earlier assumptions.
  • Wire-format fixes found along the way: the JobFull length byte now matches factory firmware, and domain-boundary drive strength is corrected.

Frame encoders and decoders are covered by unit tests that check against bytes from captured factory traffic, including S21 Pro and S19j Pro chains. The Bitaxe Gamma path has been exercised on hardware during development.

Status

Draft: history will still be amended. The final documentation commit is labeled WIP while its search-space rewrite is under review. Queued next on this branch: computing the hash counting number from the achieved PLL dividers instead of the fixed constant now in use, and chain-level configuration for bring-up.

rkuester added 30 commits July 2, 2026 12:22
The written style rules cover doc comment mechanics but never
specified mood or punctuation for summary lines, and the tree has
drifted into a mix of imperative and third-person summaries, with
and without terminal periods. Adopt Rust's API documentation
conventions (RFC 505 and RFC 1574): a summary is a complete
sentence in third-person present tense, ending with a period. The
essentials are summarized inline so a reader gets the basics
without chasing the RFCs.
The BM1362/BM1366/BM1370/... values in this enum are chip models,
not types in any broader sense. Rename the enum to ChipModel and
the Register::ChipId.chip_type field to model for consistency.
Pure mechanical rename; no behavior change.
Introduce a Frequency type storing Hz as u64 internally, following
std::time::Duration's pattern. Provides from_hz, from_khz, from_mhz
constructors and hz, khz, mhz accessors.
Introduce a ChipConfig carrying per-chip-model defaults (model,
frequency range, PLL search bounds) and factory functions for
BM1362 and BM1370. calculate_pll becomes a ChipConfig method
driven by the chip's PllParams.

When multiple PLL configurations yield the same output frequency,
prefer the one with the lowest VCO. S19 J Pro serial captures
show VCO stays low during the ramp, whereas the prior first-match
approach drifted higher. A ramp-walk test asserts our VCO is
never higher than the captured VCO at any step.

Remove the old protocol-level frequency struct and its
out-of-range error; the PLL search now takes its place, rejecting
targets outside the model's frequency range and reporting failure
instead of silently falling back to a default divider
configuration. Bitaxe's embedded PLL calculator is untouched here
and migrates to ChipConfig later.
With CP set, the regulator gates output on both the CONTROL pin and
the OPERATION command; noise on the CONTROL pin can glitch the
regulator off. Without CP, only the software OPERATION command
drives on/off.
The driver resolves both D24A and D24S variants at runtime via
device ID, so labeling it as a D24A-specific driver is misleading.
Refer to the TPS546 family consistently in the module doc, struct
doc, and log messages.
Add three methods that map directly to individual PMBus operations:

- set_vout_target: writes VOUT_COMMAND with range validation
- enable_output: writes OPERATION = ON and fails unless it reads
  back
- disable_output: writes OPERATION = OFF (immediate) and fails
  unless it reads back

These let callers compose exactly the operations they need: board
init calls set_vout_target then clear_faults then enable_output;
shutdown calls disable_output; voltage-frequency ramping calls
set_vout_target alone. The existing set_vout method stays; callers
migrate next.
Init now calls set_vout_target then clear_faults then enable_output
in sequence; shutdown calls disable_output. The monolithic set_vout
helper cleared faults implicitly and only logged the result of its
post-enable readbacks; the primitives now confirm their own
OPERATION writes and fail init when the enable does not take.

The old status-word diagnostics are gone: a one-shot status peek
during soft-start is ambiguous, and continuous power-state
monitoring is future work.
The monolithic set_vout method has no callers after Bitaxe migrated
to composing set_vout_target, clear_faults, and enable_output
directly. Drop it.
Emit 54 (0x36) instead of 86 for the JobFull length byte. Factory
firmware captures use 54 on both BM1362 (S19 J Pro) and BM1370 (S21
Pro), and BM1362 EmberOne hardware has mined successfully with 54 in
prior prototypes. esp-miner on Bitaxe sends 86 instead; BM1370 seems
to tolerate both. Record a structural hypothesis for this specific
value in an inline comment.

In the tests that compared full-frame bytes against esp-miner
captures, compare only the job-data range (bytes 4..86), excluding
the length byte and the trailing CRC16, which intentionally diverge.
Add a job frame captured from S19 J Pro factory firmware as a
fixture, and assert that encoding its fields reproduces the captured
frame down to the length byte and CRC16.
Drop job_full_encoding_matches_hardware_capture, a longtime
duplicate of job_full_matches_esp_miner_capture.
The wire protocol has two frame families with different framing
rules: TYPE=2 register ops use CRC5 with a self-describing length
byte, and TYPE=1 jobs use CRC16 with a fixed conventional length.
One Command enum covered both, forcing the encoder to re-inspect
the variant at runtime to pick the right CRC.

Give each family its own enum and FrameCodec an Encoder impl per
type, so CRC selection becomes compile-time dispatch. The hash
thread still drives register ops and jobs through a single
underlying sink because Framed satisfies Sink<RegisterCommand>
and Sink<JobCommand> simultaneously.
Some register value types already named themselves after the
register they fill (TicketMask, VersionMask, IoDriverStrength).
Rename PllConfig, NonceRangeConfig, and BaudRate to follow the
same convention.
Register had three variant shapes: struct variants with inline
fields, tuple variants wrapping typed values, and variants
carrying a bare { raw_value: u32 } field. That inconsistency
forced every Register dispatch method to match arms of three
different shapes and required a hand-rolled Debug impl.

Wrap every Register variant in a single struct of the same name,
and give every register value type the same encode and decode
interface. Dispatch becomes uniform, and the hand-rolled Debug
impl becomes a derive.
The hash thread took any Sink whose error implemented Debug and then
rendered sink errors by hand. Require the Error trait instead: ?
then auto-converts into anyhow::Result, and the generic parameter
still avoids tying callers to any concrete error type.
Replace the broadcast/chip_address pair on ReadRegister and
WriteRegister with a single Destination enum (Broadcast | Chip). One
value is simpler than two correlated fields, and as a side benefit
the illegal state (broadcast with a nonzero address) is no longer
representable. Wire bytes are unchanged.

SetChipAddress and ChainInactive are not affected: their addressing
is fixed by the variant. Unroll the send_reg helper at the same time,
since every call site is being rewritten anyway.
Give each RegisterCommand and JobCommand variant a dedicated struct
that carries its own encode method, collapsing the enum encode
methods into one-line dispatchers.

Wire bytes unchanged.
Replace the positional build_flags helper with a CommandFlags
struct that matches other frame-element encoders.
The protocol module held register definitions and payloads,
command framing, response decoding, and the serial codec in one
file. Move each into its own sibling module.
Only discover_chips retained a live caller. Inline its body at the
Bitaxe discovery site and drop the rest as dead code.
The IO driver strength register holds a 4-bit drive strength for
each of the chip's output pins: command, busy, reset, and clock
toward the next chip, and response toward the host. Factory
firmware runs every pin at strength 1 and raises the clock output
to maximum on the last chip of each voltage domain, the chip that
drives the clock across the domain gap.

Our value for domain-boundary chips came from reading capture
bytes in the wrong order, so it strengthened an unnamed high field
instead of the clock output: factory firmware sends 00 01 F1 11
where we sent 00 F1 11 11.

Name the drive strength fields so the register layout is recorded
in the type, correct the boundary value, and check both values
byte for byte against frames from the factory captures. Record the
field layout in the protocol notes as well.
Add round-trip tests to verify each register payload's encode
and decode fns invert.

UartBaud::Baud1M is broken: encode and decode use different
constants. Mark its test #[should_panic] and fix later.
The hash thread takes its chip-bound sink as a generic with two
parallel `Sink` bounds, one per command family. The pair repeats
verbatim at every signature that drives the chain and runs past
100 characters, burying the parameter that matters behind
where-clause boilerplate.

Introduce `ChipCommandSink` as a single-name supertrait of the two
`Sink` impls, with a blanket impl that auto-applies to any type
already carrying both bounds. Call sites collapse to one name.
Replace magic numbers in the PLL divider constructor with named
constants for the VCO threshold and flag bytes, and share one
crystal frequency constant with the PLL search loop.

Move the flag test next to the constructor. The old test recomputed
the expected flag with the same constants the constructor uses, so
it could only fail in lockstep. The new one asserts hand-picked
flags for VCOs below, at, and above the threshold.
A BM13xx hash board organizes chips along two orthogonal axes:
serial chain order and voltage-domain grouping. TopologySpec
captures the static wiring (which domain each chain position
belongs to). Chain is the live model that owns the Chip and
Domain objects, holds enumeration addresses, and carries per-chip
runtime stats.

Factory methods on TopologySpec cover the common layouts: one
domain per chip (EmberOne), a single domain for all chips
(Bitaxe), equal-sized cycling domains (S21 Pro), and an explicit
mapping with contiguous-domain validation for unusual wiring.

Address assignment uses the interval-2 convention seen in firmware
captures across the family. Tests pin the assignment against
hardware constants from S21 Pro, S19 J Pro, and Bitaxe captures,
and reject chip counts that overflow the 8-bit address space.
Register 0xA8 drives soft resets inside the chip. Every write in
the captures is either the register's hardware reset default or
the default plus the bits that assert a reset on that model of
chip. Until now the register was a raw placeholder whose name and
values were copied from a reference implementation without
understanding.

Replace the placeholder, which encoded its value little-endian,
with a typed register that encodes big-endian, so values in the
code read like the values in the captures and references. Add two
constructors, both taking the chip model, to cover the operations
we have evidence for: the reset default, broadcast during
bring-up, and the core reset, written to each chip just before its
cores are configured. Test both operations on both models against
the exact bytes from the captures.

Stop short of decomposing the register into bit fields: the models
disagree on the layout, and most bits have no reliable name in any
reference. Record what is known of the bit maps on the type and in
the protocol document.
Register 0xA4 configures how the chip generates midstates and
rolls version bits. Our old type modeled it as a 16-bit mask plus
a magic 16-bit enable constant, byte-swapped relative to the
values in the captures and references. Read big-endian, the magic
constant is just two flags and a small field: automatic midstate
generation, a version-fix flag, and a 2-bit code for how many
midstates the chip generates per job.

Rename the type to match its wider job and decompose the value
into those fields. Keep the constructor for the one configuration
every capture uses, so the bytes on the wire do not change. Store
the generation code raw rather than as a midstate count, because
the mapping from code to count differs by chip model; the doc
comment records the mapping.

Thin the protocol document's section on this register down to the
narrative, following the rule that bit layouts live on the typed
register.
Register 0x3C gives indirect access to a small register space
inside each hashing core. Replace the raw big-endian word with a
mailbox register carrying a typed core command, split into an
addressing half (all cores, num, core id) and an operation half
(write, read done, core register id, value). Decompiled Bitmain
firmware confirms the layout. Its read paths clear the
write-enable bit, evidence the write-only captures could not
provide.

Bring-up writes now construct commands from named core registers
(overlap monitor, clock delay, core enable) instead of magic
words. Core register 0x0d keeps a raw id; no reference names it.
Register 0x54 carries a small select field in its low bits that
routes one of the chip's analog signals, rumored to be the
temperature diode, onto the analog mux output. Replace the raw
little-endian word with a struct holding the select value,
encoded big-endian like the other decomposed registers. A
model-keyed constructor owns the selection factory firmware
makes, and the bring-up caller sheds a byte-swapped magic
constant whose low nibble was the whole payload.

References disagree on the field's width, three bits or four;
Bitmain's own firmware masks with four, so four wins.
Register 0x18 holds chip-level control bits whose layout shifts
between chip generations and whose bits carry only unexplained
names in the references, so the value stays an opaque word rather
than named fields, the same treatment as the soft reset control.
A model-keyed constructor owns the value factory firmware writes
during bring-up: a low half word conserved across models under a
model-specific high byte. The encoding switches from little to
big endian, so values in code now read as the references and
captures write them, and the bring-up callers shed a byte-swapped
magic constant.
rkuester added 8 commits July 3, 2026 23:28
Register 0x2C makes the first and last chip of each voltage
domain relay the serial lines across the domain gap. Replace the
raw little-endian word with a struct holding the two relay-enable
bits, one per direction, and the gap count, a timing parameter of
unknown units. The struct encodes big-endian like the other
decomposed registers, and a constructor covers the one shape
captures show: both directions relayed, each domain with its own
gap count.

No caller constructs the value yet. Single-chip boards never
write the register, and multi-chip chain bring-up, which does, is
not yet implemented. A test writes a domain-boundary chip's exact
frame from a production chain capture.
The chip model enum carried a BM1397 variant no code could drive
and an unknown-id variant that let unrecognized chips flow into
code with no way to act on them. Drop both. Chip id decoding now
fails with an error on an unrecognized id, and adding a model
becomes a compile-time decision at every match over the enum.
Chip models pack the nonce result header differently: BM1370
uses a 4-bit job id and a 4-bit subcore id, while BM1362 and
BM1366 use a 5-bit job id and a 3-bit subcore id. The decoder
hardcoded the BM1370 layout. Build the codec with a chip model
and pick the layout by model. An S19 J Pro capture proves the
BM1362 layout: its job ids don't fit in 4 bits. No capture
covers the BM1366; a reference driver decodes it the same as
the BM1362, so it shares that layout.

Job encoding still limits job ids to 4 bits. This commit leaves
that alone; the limit will change later, when a chain driver
starts sending BM1362 jobs.
Give each response kind a named struct, matching the shape the
command types already have. The coming reader task routes nonce
and register responses onto separate channels, and the channel
payloads need to be standalone types rather than enum variants
with loose fields.
One serial wire carries two traffic patterns at once: unsolicited
nonce reports and replies to register conversations. A spawned
reader task now owns the framed RX stream and routes each frame
by kind into one of two bounded channels, nonces on one, register
responses on the other. Each channel carries a single kind of
traffic, so a consumer handles only the kind it cares about, and
neither kind can starve the other.

The task alone holds the channel senders, so when the stream ends
or fails, from unplug for example, the channels close and
consumers see end of stream; channel closure is the only
lifecycle signal. Nothing consumes the channels yet; the driver
and the actor rewrite arrive next.
Bring-up, verification, and health polling hold long
conversations with chips over a protocol without request ids.
A new driver owns the command sink, the register response
channel, and the timing policy, and offers four verbs: write,
read, broadcast read, and write with readback.

Reads correlate by content: drain stale responses, send, then
accept only a response matching both chip address and register,
discarding mismatches, under a per-attempt deadline with bounded
retries. Broadcast reads collect replies until a quiet window
passes with none new. Verbs take the driver by mutable
reference, so conversations cannot interleave on the wire.
Register 0x10 on version-rolling chips is the hash counting number:
it limits how many nonce iterations each core performs before the
chip advances to the next rolled version. Zero halts hashing, and
stock firmware writes a per-model value that scales inversely with
frequency. The old NonceRange model guessed a per-chain nonce-mask
semantic; its mask constructors emitted values wrong for this
register, and the only correct write bypassed them with a
byte-swapped hardcode.

Store the register as a plain count encoded big-endian, drop the
mask constructors, and write the factory value directly where the
chip is initialized. Wire bytes are unchanged and checked against
the capture frames. Rename the register's mentions in the protocol
notes; correcting their content is a separate change. Computing the
value from frequency and chain topology follows separately; this
change keeps the factory value byte for byte.
Replace the guessed nonce-stride theory in the protocol notes with
an evidenced model of how BM13xx chips divide work. A job's search
space is the 32-bit nonce field times the 16 rollable version
bits. Sub-cores take versions via precomputed midstates, cores own
the top nonce bits, and chips start their sweeps at placements
seeded by the chip address or set explicitly by the chip nonce
offset register, which replaces address placement rather than
supplementing it. The hash counting number is a deadline in
reference-clock ticks bounding the window each core re-sweeps per
batch of rolled versions: zero halts hashing, undersized values
shrink the searched space permanently, and oversized ones
duplicate work.

Ground the model in capture analysis performed for this change:
nonce parity matches address-fixed parity at chain scale on two
independent BM1366 chains, no fixed bit field anywhere in the
nonce carries the chip address, and a threshold of duplicated
nonces above the full-slice deadline pins restart-per-batch
counting in tick units. Document the full-coverage formula merged
in ESP-Miner with an exact integer form computed from the PLL
dividers, worked examples, the version epoch and its consequence
that factory values require second-scale job refresh, the
corrected factory value catalog, and the open questions, notably
the unexplained low-bit structure of BM1362 nonces. Also add the
chip nonce offset register to the map, correct the initialization
sequences to place the hash counting number write after the
frequency ramp, and extend hashboard work distribution with
extranonce slicing.
@rkuester rkuester closed this Jul 7, 2026
@rkuester
rkuester deleted the feat-bm13xx-multichip branch July 7, 2026 22:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant