Skip to content

fix(bm13xx): hcn nonce range - #3

Draft
jayrmotta wants to merge 114 commits into
mainfrom
fix/bm13xx-hcn-nonce-range
Draft

fix(bm13xx): hcn nonce range#3
jayrmotta wants to merge 114 commits into
mainfrom
fix/bm13xx-hcn-nonce-range

Conversation

@jayrmotta

Copy link
Copy Markdown
Owner

No description provided.

rkuester and others added 30 commits February 24, 2026 11:09
Add cases for whole and fractional f64 difficulties in the existing
display test, with comments grouping them by behavior.
from_f64 truncates to integer arithmetic before dividing.
For values >= 1.0, the fractional part is dropped (200.5
becomes 200). For sub-1.0 values, the reciprocal is
truncated (0.003 becomes 1/333, round-tripping as
0.003003).

This test asserts the correct behavior and is marked
#[should_panic] to document the known bug while keeping
CI green.
Implement Div<f64> for U256 by decomposing the float into
its exact rational form (mantissa * 2^exponent) via
num-traits' Float::integer_decode() and performing the
division entirely in integer arithmetic, preserving all
53 bits of mantissa precision.

For negative exponents (fractional divisors), a 512-bit
intermediate avoids overflow when left-shifting the
dividend.

Bring in num-traits 0.2 for the float decomposition.
The previous implementation truncated the f64 to integer
arithmetic before dividing. For values >= 1.0, the
fractional part was dropped (200.5 became 200). For
sub-1.0 values, the reciprocal was truncated (0.003
became 1/333, round-tripping as 0.003003). Delegate to
U256's Div<f64>, which preserves all 53 bits of mantissa
precision.

Remove the #[should_panic] annotation from the preceding
round-trip test, since it now passes.
Stratum v1 pools may send difficulty as either integer or
float values (e.g., 0.001 for low-difficulty vardiff).
The previous implementation used as_u64() which rejected
float values with "difficulty not a number".

Switch the DifficultyChanged event payload and
ProtocolState.difficulty field from u64 to f64, and parse
incoming difficulty with as_f64(). Construct the internal
Difficulty type via Difficulty::from_f64(). Add a guard
for non-finite values as defense-in-depth (JSON has no
NaN/Infinity representation, so serde_json never produces
them).

Add tests for float difficulty parsing.
The SourceState.difficulty field was u64, which truncated
fractional difficulties (e.g. 0.001 became 0). Change to
f64 to preserve the value received from the pool.
The 256-bit target-to-difficulty conversion can introduce
tiny arithmetic residuals in the low-order digits. Round
to 12 significant digits, derived from the conversion's
error bound with four orders of magnitude of margin.
We sometimes add a test that asserts the correct behavior and
mark it #[should_panic] to document a known bug while keeping
CI green. The fix then removes the annotation in a separate
commit. Document this pattern in the Testing section.
The old names were hard to follow because they described relative
position in the clamp range rather than what each bound is for.
Rename to measurement_target and flood_cap_target to match the
constants they derive from.
compute_scheduler_target panics at low hashrates (~5 H/s).
These hashrates are not normal for ASIC mining, but they
can occur in degenerate cases and the scheduler must not
panic regardless.

The root cause is integer truncation in target_for_share_rate:
the two share rate intervals truncate differently, inverting
the clamp bounds.

Replace the clamp ordering invariant test with a broader
sweep of compute_scheduler_target across hashrates from
1 H/s to 100 TH/s. Mark with #[should_panic] to document
the known bug.
hashes_in() used integer arithmetic, which truncated fractional
results to zero at very low hashrates. This caused
target_for_share_rate to return inconsistent values for the two
share rate intervals, inverting the scheduler's clamp bounds and
panicking.

These hashrates are degenerate; no real hardware runs this slow.
But a panic in the scheduler is never acceptable regardless of
input.

Switch hashes_in() to f64 so fractional hash counts (e.g., 0.5
hashes in 100 ms at 5 H/s) are preserved. Guard the case at <= 1.0
hashes per share with U256::MAX, keeping the division safe for all
inputs above 1.0.
Clarify that HashRate is a per-miner type backed by u64,
covering up to ~18.4 EH/s. Note the standard Rust overflow
behavior (debug panics, release wraps).
The free function took (ShareRate, HashRate) and returned a Target.
Moving it to a method on ShareRate follows the to_* conversion
convention and parallels Difficulty::to_target().
Only used by expected_time_to_share; fold the formula in and remove
the standalone function and its test.
Separate from the CI commit so reverting CI doesn't accidentally
unignore .cache/, which may also be used by other tooling.
Add a Podman-based build toolchain image (build.Containerfile) with
pinned Rust compiler, rustfmt, clippy, and just. The base image is
pinned by digest for reproducibility.

The justfile gains two new recipes:
- build-image: builds the toolchain image, skipping if unchanged
- in-container: runs any just recipe inside the toolchain image

The image is tagged with a content hash of the Containerfile so
build-image can detect staleness without rebuilding. This is
necessary because podman save/load (used for CI caching) doesn't
preserve layer cache metadata, so podman build would rebuild from
scratch even with a loaded image. The content-hash tag lets
`podman image exists` skip the build entirely.

Downloaded crates are cached in .cache/ via bind mounts so they
persist between container runs and are accessible to CI caching.
Run `just ci` on pushes to main and pull requests. The justfile's ci
recipe delegates to `in-container "checks"`, keeping the workflow
minimal and the justfile as the single source of truth for what CI
does.

The build toolchain image and cargo caches (compiled dependencies
and downloaded crates) are cached between runs via actions/cache.
Project crate artifacts are pruned before the target cache saves so
only dependency objects are stored.
Update prerequisites to list just and optionally Podman. Replace
the raw cargo commands in "Making Changes" with `just checks`, and
add a "Reproducing CI locally" section explaining `just ci`.
The Drop impl on SerialInner called tcdrain(), a blocking syscall
that waits for all queued output to be transmitted. If unread data
sits in the kernel buffer with no reader, after a test or during
shutdown, tcdrain blocks indefinitely. This hung
transport::serial::tests::test_concurrent_read_write on macOS,
where tcdrain on a PTY blocks forever with no reader. (On Linux,
tcdrain returns immediately once the slave is closed, masking the
bug.)

Removing the Drop impl is safe for real hardware. write() copies
data into the kernel's output buffer; once it returns, the data is
in kernel space. The tty close path drains pending output for USB
serial devices (controlled by the driver's closing_wait parameter,
default 30 seconds). The tcdrain in Drop was redundant with what
close() already provides for real devices.

Fixes: 256foundation#46
Remove dependencies confirmed unused by cargo-machete analysis,
as identified in the dependency audit (discussion 256foundation#8, issue 256foundation#29).

mujina-miner:
  - sha2: bitcoin crate provides SHA-256 via bitcoin_hashes
  - hyper (direct): still available transitively via axum/reqwest
  - modular-bitfield: no usage in source

mujina-dissect:
  - hex: no usage in source
  - thiserror: no usage in source
  - tracing: no usage in source (tracing-subscriber retained)

Also removes sha2, hyper, and modular-bitfield from workspace
dependencies as they are no longer referenced by any member.

Eliminates ~15 exclusive transitive crates.

Refs: 256foundation#29
Move tracing-journald from unconditional [dependencies] to
[target.'cfg(target_os = "linux")'.dependencies] so it is not
compiled on macOS.

Gate the corresponding imports in tracing.rs (env,
tracing_journald, prelude::*) behind cfg(target_os = "linux")
to match, fixing unused-import warnings on macOS builds.
PR 256foundation#32 introduced per-import #[cfg] annotations to suppress macOS
warnings, and subsequent cleanup in the same area was piling up
more scattered cfg blocks. Consolidate all journald logic into a
mod journald gated once on target_os = "linux", with a no-op stub
for other platforms.

Rename init_journald_or_stdout to init since callers should not
need to know the logging strategy.
Merge queues are now enabled on the repo. Add the merge_group
trigger so GitHub runs status checks on the temporary merge
commits that the queue creates.

See "Triggering merge group checks with GitHub Actions" in
the GitHub docs on managing merge queues.
Restructure for a clearer learning path that teaches the workflow
progressively rather than repeating information across sections.
Cut sections that are redundant or too generic to be useful.

Notable content changes:
- Add draft PR guidance for sharing work in progress
- Expand atomic commits section (revertability, bisectability,
  reviewability)
- Fix commit examples to use conventional commit format with
  lowercase subjects matching actual repo practice
- Distinguish feat/fix (behavioral) from other commit types
- Add hyperlinks throughout (Telegram, rustup, good first issue,
  conventional commits, project docs)
- Fix clone directory (mujina, not mujina-miner)
- PR titles should follow conventional commit format
Delete the crate-level Error enum and Result alias. The enum
variants were never pattern-matched; they just carried strings,
duplicating what anyhow provides with better ergonomics.

Convert all call sites to anyhow::Result with .context() and
bail!() for error construction. Board and HashThread traits retain
their typed error returns (BoardError, HashThreadError) unchanged;
only the factory functions and transport layer move to anyhow.
Remove the BoardError and HashThreadError domain error enums.
These types were never pattern-matched by callers; they served
only as structured message formatters before being erased into
anyhow::Error at every trait boundary.

Rust convention is to start with anyhow and introduce typed
errors (via thiserror) when a caller needs to match on a variant
to take different action---retry logic, different shutdown
behavior, etc. Since no caller distinguishes error variants
today, the types are dead weight. If programmatic error matching
becomes necessary, reintroducing a thiserror enum at that point
is straightforward and will be better informed by actual
requirements.

Replace all BoardError and HashThreadError construction sites
with anyhow context(), bail!(), and anyhow!() calls. Change
Board and HashThread trait methods to return anyhow::Result.
Internal helper functions (initialize_chip, task_to_job_full)
also migrate to anyhow::Result.
Remove an earlier Chip trait, ChipError, ChipStats, MiningJob, and
NonceResult that were never implemented. Retain ChipInfo which
board implementations use during chip discovery.
Remove a leftover doc fragment from discover_chips that was
incorrectly attached to send_config_command.
Use the project's tracing prelude wildcard import instead of
importing individual macros from the tracing crate. This avoids
import churn when logging levels change.
Consistent with guideline L.prelude, migrate remaining files to
use crate::tracing::prelude::* instead of importing individual
macros from the tracing crate.
rkuester and others added 30 commits March 31, 2026 15:24
The EMC2101 and EMC2101-R have distinct product IDs (0x16 and
0x28). Name the constants after the actual variants and include
which one was detected in the debug log.
Add a sentence explaining that global trace is noisy and
per-module targeting is the way to deal with it.
The Board trait encouraged each board to implement a struct that
served double duty: it stored internal board state and acted as the
interface to the backplane. The create/initialize/create_hash_threads
method sequence encouraged storing intermediate state in Option fields
between calls, with expect() to enforce sequencing the type system
could not express.

Replace the trait with a concrete BackplaneConnector struct returned
by factory functions. The factory does all initialization in one shot
and returns a fully-constructed value containing board info, hash
threads, a telemetry channel, and an opaque shutdown future. The
backplane destructures this and routes each part to where it belongs.

Board-specific structs (BitaxeBoard, EmberOne00, CpuBoard) become
private to their modules, responsible only for board state. Their
shutdown logic is captured in a boxed future, so the backplane shuts
down boards without knowing their internals.

BoardRegistration moves from the board module to the API module,
where it belongs. The board module defines how boards connect to the
backplane; the API module defines how the API server connects to
boards.
The BackplaneConnector pattern lets the factory function do all the
work inline. The CpuBoard struct was a leftover from when the Board
trait required an implementing type. Without the trait, the factory
can create threads and build the connector directly.
The PLL calculation functions live in bm13xx/thread.rs but the tests
that exercise them were in board/bitaxe.rs, which carried its own
#[cfg(test)] copy. Delete the duplicates and move the tests next to
the real code.
Move logic from BitaxeBoard::new() and BitaxeBoard::initialize()
into create_from_usb(). The factory is now the single orchestrator.

Rename BitaxeBoard to Bitaxe, holding only what the stats monitor
and shutdown need. Fields consumed during initialization stay local
to the factory.

Extract init_power_controller, init_fan_controller, and
discover_chips as free functions. Drop dead code (momentary_reset,
send_config_commands, baud rate constants).

The diff is large but mostly mechanical: code moves between
methods and free functions, with minor adjustments to access
patterns (self.field becomes a parameter or local).
Add run_monitor() on Bitaxe with a single polling interval. Each
tick reads all sensors (EMC2101 and regulator), publishes
BoardTelemetry, and logs a periodic summary.

The EMC2101 is now a non-optional field on Bitaxe. Fan init is a
hard error; if it fails, the board does not start. Shutdown uses
a CancellationToken; the monitor runs the shutdown sequence
(voltage off, hold reset) before exiting.
The EMC2101 reports specific raw values when the external
temperature diode is open circuit (0x3F8) or shorted (0x3FF).
Return these as errors instead of silently converting them to
~127 C temperature readings.
BitaxeAsicEnable now records when the ASIC was last taken out of
reset. The monitor uses this to suppress temperature readings
during the diode settle period after the ASIC powers on.
Classify temperature readings into four categories: I2C/diode
faults, out-of-range, above-emergency, and normal. Any category
except normal increments a consecutive bad-reading counter; after
a threshold, force fan to 100% and shut down the board.

On normal shutdown (cancellation), reduce fan to 25%. On thermal
emergency, leave fan at 100%.
Reorganize the page to lead with what Mujina is and how to try it.
Bring stale sections up to date.

Outline:

- Replace Overview and Supported Hardware with Vision and Current
  Status
- Add a Quick Start that runs the CPU backend without hardware
- Reorder Running: pool, then hardware without a pool, logging, API
- Move the documentation links into a Further Reading index

Current state:

- Add macOS build requirements
- Describe hardware support honestly: what works today, what's in
  progress, what's coming
- Fix the run examples to work from the workspace root
The Saleae Logic 2 capture dissector has been retired. Drop the
package and its workspace member entry. Resurrect from git history
if needed later.
The mujina-dissect tool was the only caller of the `protocol`
submodule's register-name lookup, transaction formatter, and PWM /
temperature decoders. Hoist the register address table out to
module scope as `regs` and remove the rest. Resurrect from git
history if needed.
The mujina-dissect tool was the sole caller of the `PmbusValue`
enum, the `parse_pmbus_value` family, and `TryFrom<u8>` for
`PmbusCommand`. Also removes the now-unused `DEFAULT_VOUT_MODE`
constant. Resurrect from git history if needed.
These module-doc TODOs talked about deduplicating CRC and frame
validation logic against the dissector's copies. With
mujina-dissect gone, there's nothing to dedupe against.
The previous document was written before most of the code existed and
read as a design. The doc and code have diverged significantly since.

Replace it with a short conceptual overview: three subsystems (Boards,
Mining core, API), a diagram of how they connect, and brief sections
on what each one does. Refer to the code for more. Module comments
often have the next level of conceptual detail.
The test used 500 GH/s, but a Bitaxe Gamma's single BM1370 hashes at
roughly 1 TH/s. The mismatch invites a future reader to wonder why a
test named "bitaxe_gamma" uses a number that doesn't describe the
hardware. Update the input and expected difficulty range so the test
matches its name.
The per-thread capability estimate was a fixed ~5 MH/s regardless of
duty cycle. It serves as the fallback before the per-thread estimator
settles, and propagates through the scheduler's operational hashrate
into, among other things, the forced-rate wrapper's share-target
computation. Below 100% duty, the assumed hashrate exceeded actual, so
shares arrived at the configured rate scaled by duty/100.

Scale the estimate by duty_percent so the fallback reflects effective
rate.
Declare a hash thread's expected hashrate at runtime instead of exposing
a static estimate. Add a configure() command that readies a thread for
work and makes it emit ExpectedHashRate, re-emitted whenever the
expectation changes. Drop the hashrate_estimate capability.

Drive source difficulty and the difficulty-too-high warning from the
aggregated declared expecteds, not the old blend of measurement and
static estimate. Measured hashrate drifts across these decision
thresholds as boards ramp up and down, and the transients are awkward to
handle; a rate the board declares for the near future stays put. Keep
measured hashrate for telemetry and health.

Broadcast to sources on topology change exactly as before. Only the value
sent changes here, not the timing.
Send sources an updated hashrate when a thread reports its own expected
hashrate, instead of when a new thread is added. A thread's first report
is also when it becomes eligible for work and picks up the job the
scheduler is holding for each source. Adding a thread now only registers
and configures it.

Compute that hashrate per source as a portion of the aggregate. Today
there is one source, which gets the whole aggregate; registering a second
logs a warning, since splitting is not implemented. Dividing the aggregate
across sources later is a small, local change.

When a source sends a job, skip any thread that has not yet reported its
expected hashrate. Without that figure there is no sensible way to size
its portion of the work, so it waits until its first report.

Rename the periodic timer to reflect that it publishes only API telemetry.
At startup, boards enumerate and report their expected hashrate one by
one. Broadcasting to sources on each report would ratchet pool difficulty
up through a series of partial totals before settling on the real one.
Hold the first broadcast until startup enumeration is provably complete,
then send the assembled total once.

Have each transport announce when it finishes its initial device scan. The
backplane waits for that announcement from every transport, then signals
the scheduler that enumeration is done. The signal travels the same path
as the boards themselves, behind them, so by the time the scheduler sees
it every starting board is already registered.

Hold the scheduler's first broadcast until that signal arrives and every
registered board has reported, or until a fallback timeout fires so a
board that never reports cannot stall startup forever. While holding, the
scheduler still records hashrates and assigns work; only the broadcast to
sources waits.
Replace the conservative symmetric filter, which re-suggested only on a
change past 2x in either direction, with an asymmetric deadband measured
from the pool's difficulty floor. That floor is our own last suggestion. At
least ckpool and HydraPool apply a client's suggested difficulty
immediately and then hold it as a lower bound, raising difficulty above it
on their own but never lowering past it.

Re-suggest eagerly on a drop. Once the right difficulty falls below the
floor the pool is pinned and cannot follow it down, and we no longer need
the conservative filter's grace, because a declared drop in board hashrate
is a real hardware change, not the luck a share-rate dip might be.
Re-suggest lazily on a rise, since the pool climbs on its own, but loosen
the old doubling threshold enough that adding a second identical board
still registers.

Start with these two factors and tune them as the policy proves out
against real pools.
Rate-limit outgoing difficulty suggestions to at most one per interval.
Send the first material change immediately and start a cooldown.

Hold any material change that arrives during the cooldown. When the
cooldown expires, re-test the latest hashrate and send once if it is still
a material change, otherwise stop throttling. A burst of changes coalesces
into one suggestion per interval, and a flurry that settles back inside the
deadband sends nothing at all.

Start with the interval value and tune it as the policy proves out.
The CPU thread declared its expected hashrate from a fixed 5 MH/s
constant scaled by the duty cycle. That constant is wrong on most
hardware: real throughput varies widely across CPUs, and even across
build profiles the same chip differs by more than an order of
magnitude, so the difficulty suggestions derived from it were off.

Measure the rate instead. Hash a synthetic header flat-out for a
short fixed window, then scale the result by the duty cycle. The
probe runs on a blocking thread so it does not stall the async
runtime, and the startup enumeration barrier already hides its
latency.
Add -h/--help and -V/--version to the daemon, which previously parsed
no arguments. The environment variables that configure it (pool, CPU
miner, API address, USB, logging) were undocumented outside scattered
source reads. --help now prints them grouped, each with a brief
explanation, default, and example.

Pull in clap now rather than hand-roll the parsing; the daemon will
grow more flags. The descriptions come from a central registry that
gathers wording currently spread across the modules that read each
variable. Both the listing and where it lives are a stopgap, likely
reworked once a comprehensive configuration system exists.
The error raised when CPU board setup fails referenced
MUJINA_CPU_MINER, which nothing reads. The backend is enabled by
MUJINA_CPUMINER_THREADS. Point the hint at that variable instead.
Compute suggested difficulty as a float and let it fall below 1 instead
of clamping to a minimum of 1. A very slow worker such as the CPU miner
needs a target it can actually meet. Clamping gave it difficulty 1,
which it rarely produces shares for.

The value stays an f64 through the suggest path, and the client decides
the wire encoding. Whole difficulties still serialize as integers, the
form pools expect, and only values below 1 go out as a fraction.
Production boards run far above 1 and are unaffected.
…equency

Register 0x10 (Hash Counting Number) limits the nonce range each big core
searches before signaling job completion. The previous code sent a hardcoded
empirical value captured from a 65–128-chip S21 Pro chain regardless of
actual chain length or frequency, giving a single-chip Bitaxe Gamma far less
than 100% nonce coverage.

Replace it with NonceRangeConfig::computed(), which implements the formula
from ESP-Miner bm1370.c BM1370_set_nonce_space:

  hcn_space    = 2^32 / next_pow2(big_cores) / next_pow2(chip_count)
  hcn_max      = hcn_space × 25 MHz / freq_mhz × 0.5
  hcn_register = floor(hcn_max − 268)

For one BM1370 at 525 MHz: 0xC2FB7, verified against the ESP-Miner reference.

Also fix ChipType::BM1370.core_count() which returned 2048 (all small engines)
instead of 128 (big cores, the correct HCN divisor per ESP-Miner device_config.h).

Discovered chip count and core count now flow from bitaxe.rs discovery through
BM13xxThread::new() and bm13xx_thread_actor() to initialize_chip().

Refs: 256foundation#72
Refs: bitaxeorg/ESP-Miner#420
Add module-level documentation to bm13xx/thread.rs explaining the four
search-space dimensions (nonce × version × ntime × extranonce2), how each
is rolled, and why the HCN register value must track frequency.

Complements the NonceRangeConfig::computed() doc added in the previous commit.

Refs: 256foundation#72
Refs: bitaxeorg/ESP-Miner#420
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.

5 participants