DepthCharge

// Design & architecture

DepthCharge

A desk-top hardware market-data terminal: an ESP32-S3 driving a 64×64 HUB75 LED matrix behind smoked acrylic, rendering a live limit-order-book ladder from three venues through one portable C++20 book engine — with every behaviour proven on a desktop replay harness before it touches the panel.

A narrative rendering of DepthCharge's golden-source documents — ARCHITECTURE.md (the constitution: the event contract, the book, the frozen invariants and the decisions behind them), README.md (what it is and how to run it) and ROADMAP.md (milestones and status). Those repo documents are authoritative; this page consolidates them into one read. Status here reflects M5 stage D — Anvil and Kraken shipped, Binance in flight. For the mechanism underneath — the classes that exist today, what a call does in order, what each type costs in bytes, and where the design is under strain — see the companion detailed design page, which renders the repo's docs/DESIGN.html.

What DepthCharge is

DepthCharge is an order book you can glance at. A 64×64 RGB LED matrix sits behind smoked acrylic — so the panel vanishes into black when idle — and renders a live ladder: asks stacking red downward into the touch, bids green below it, bar length carrying resting size, the spread showing as the dark gap between the two, trade prints flashing white where they land, and a last-price sparkline running along the bottom. A rotary encoder on the edge cycles symbol, venue and price zoom. The trade prints come with a caveat that took two venues to notice: only Anvil interleaves fills on the book socket. Kraken and Binance both put trades on a separate stream nothing subscribes, so the ring has been Anvil-only since M4 — which is either a design statement that belongs beside the liveness one, or debt with a byte budget attached, and is on the backlog as exactly that question.

The last of those visual states is the one the whole design is organised around. When the data cannot be trusted — the socket dropped, a checksum failed, a sequence hole opened — the panel greys out and names the reason. An object that lives permanently on a desk is read in a glance, and a ladder frozen on last-known-good is indistinguishable from a quiet market. That is the single output DepthCharge refuses to produce.

There is a date attached to that. On 16 August 2026 the upstream feed went silent for two minutes fifty-six seconds on a TCP connection that stayed perfectly healthy throughout — no error, no disconnect, plain HTTPS to the same host answering in half a second. A ladder that kept its colours would have shown a three-minute-old market as live, for the whole of it.

Grey is only half of it, though, and the other half is harder. A feed can be arriving, parsing and rendering perfectly while the book on screen is a hundred seconds behind the market — that happened on 11 August 2026 with every counter on the board reading healthy. So the header also carries the book's age, printed as a number and never as a colour, because stopped, wrong and old are three different failures and only the first two grey a panel.

DepthCharge is the consumer side of a wire whose producer side is Anvil, my own C++20 matching engine. The interface between them is Anvil's versioned protocol document, vendored into this repo as a snapshot — and DepthCharge v1 requires zero changes to Anvil. That is the point of building it as a separate repository: it makes DepthCharge the protocol's second independent client, which is a materially different claim from two programs sharing a header.

It has since paid out in both directions, which is the part a shared header could not have produced. DepthCharge's measurements went to Anvil as a handover document; Anvil answered the same day, shipped four of the items and rebuilt its backlog around the same IDs, so both repositories now cite one set of letters. One of those items was a correction to the protocol document itself: the staleness rule DepthCharge had adopted depended on Anvil's summary frames continuing over a completely idle book, and the document said the opposite. Measured behaviour contradicting a vendored contract is a standing exception, and an exception nobody is tracking is how a client ends up wrong about a venue — so it was recorded with an expiry, fixed upstream, and closed by re-pinning the snapshot rather than by annotating it. Running the other way, two couplings only a second client would find are now on record: the firmware pins a TLS root, so a certificate-chain change upstream is a firmware update rather than a transparent one, and the liveness watchdog is sized against a broadcast cadence Anvil's future incremental feed would have to keep. Neither surfaces as an error on Anvil's side. Both surface as a dead panel here.

// the pipeline
engine/ — portable C++20 · no I/O · no ESP-IDF · no FreeRTOSvenue wsTLS · off-engineVenueAdapterper venueFeedEventthe one boundaryBookEnginewindow + tailDisplaySnapshot3-slot mailboxRendererHUB75 / consoletransporthost buildtarget buildharness/ — desktopreplay file → the same engine → console laddergolden expectations under doctest / ctestbench · captured traces are the ground truththis is where correctness livesfirmware/ — ESP32-S3core 0 · feed task: net + adapter + bookcore 1 · render task: HUB75 DMAlinks engine/ as-is — unchanged from the hostthey meet at DisplaySnapshot and nowhere elseone writer per state · the feed task is never blocked by the render task
One path from wire to pixels. engine/ is portable C++20 with no I/O, no ESP-IDF and no FreeRTOS in it, so the identical adapter and book code runs under a desktop replay file and on the ESP32 — the seam that lets every behaviour be proven at the desk before it reaches the panel.

Three venues, in ascending order of wire difficulty

The venue order is a curriculum, not a preference. Each one adds exactly one hard problem to the previous, and each is absorbed by an adapter rather than by the book.

VenueWhat it publishesThe new problem
AnvilFull top-N snapshots plus per-fill trades, over WebSocketNone — it is the test rig. The only venue controlled end-to-end, so it is where the transport, the book and the panel are shaken out first.
KrakenReal L2 deltas, plus a 1 Hz heartbeatSelf-verification: a CRC32 checksum over the top ten levels. A mismatch is not an error to log — it is a gap, and the panel goes grey. The heartbeat turned out to matter as much as the checksum, because it is the first venue where a silent book and a dead feed are different things.
BinanceBuffered diff stream, bracketed against a REST seed. No checksum, and no liveness signal at all.Sequence-gap recovery — and, unexpectedly, inventing the oracle. Its U/u bracketing detects lost or misordered messages and says nothing about whether the resulting book is correct, so the venue's own partial-depth stream is run alongside as an audit channel. A correct-looking client seeded at the obvious depth scored 82% against it while the bracketing stayed clean throughout.

Crypto venues, specifically, for three reasons that all point the same way: markets that run 24/7 suit an object that is permanently powered on a desk; L2 depth is free and unauthenticated, so no credentials ever live on the microcontroller; and Anvil's order flow being synthetic is irrelevant to correctness, because the panel consumes wire semantics rather than market truth.

The one boundary type

Three venues means three JSON dialects, three sequencing schemes and one checksum. All of it is quarantined inside the adapters, because FeedEvent is the only type permitted to cross from an adapter into the engine. The book is written once, against one vocabulary.

using PriceTicks = int64_t;   // price in integer ticks; per-symbol tick size
using Qty        = int64_t;   // quantity in integer venue steps; per-symbol step
using Seq        = uint64_t;  // adapter-normalised, monotonic per (venue, symbol)

enum class Side      : uint8_t { Bid, Ask };
enum class GapReason : uint8_t { SeqGap, ChecksumFail, Disconnect, Overflow, Resync };

struct BookLevel { PriceTicks px; Qty qty; };

// Borrowed view of a Snapshot's levels; valid only for the duration of the
// sink call that delivered the event. A consumer that defers copies.
struct LevelSpan { const BookLevel* data; uint32_t size; };

struct FeedEvent {
    enum class Kind : uint8_t { Snapshot, Delta, Trade, Gap };
    Kind kind; Seq seq;
    PriceTicks px; Qty qty; Side side; GapReason reason;
    LevelSpan bids, asks;   // Snapshot only
};

Five rules give that struct its shape:

  • Integers, never floats. Prices and quantities are scaled by a per-symbol tick_size and quantity scale. Exact integer equality is the whole point — keys never drift and replays stay deterministic. Where a venue publishes no such metadata (Anvil does not), DepthCharge declares it in a SymbolSpec and the adapter verifies every wire price is exactly representable at that scale. A mismatch is a reported error, never a silent rounding. Floats appear only at the display-formatting edge. It is Anvil's rule, inherited verbatim.
  • A parameter that has only ever held its identity value has not been tested. The quantity field spent two milestones as a divisor — wire units per step — and every scaling path in the tree was correct only because Anvil quotes whole units and the value was always 1. Kraken's step is finer than the wire unit and is not representable that way at all: there is no integer you can divide by to scale a fractional quantity to an exact integer. A divisor that must become a multiplier, and whose multiplier is always a power of ten, is a decimal scale in disguise — so it is declared as one, mirroring the price side. At the identity value a divisor, a multiplier and a scale all agree, which is why three milestones of goldens would have pinned any of them.
  • Snapshot replaces; Delta amends. A Snapshot discards all prior levels for the symbol. Depth beyond the venue's stated N is unknown, not zero — a distinction the book keeps even though the panel can only draw the top of the book.
  • Snapshot levels are borrowed, not owned. A snapshot conveys its levels as two LevelSpans pointing into adapter-owned staging storage, valid only for the duration of the sink call that delivered them. That keeps FeedEvent flat, trivially copyable and allocation-free while still being the only boundary type. The cost is an explicit lifetime rule: a consumer that defers must copy.
  • Seq is the adapter's problem. Each adapter normalises its venue's native scheme into one monotonic sequence per stream. Binance brackets with its U/u fields; Kraken has no sequence at all, so its adapter synthesises one and converts a CRC failure into Gap{ChecksumFail}. Anvil turned out to need the same treatment, for a reason worth its own section.

What the wire actually did

The constitution was written before the first capture, and the first capture amended it twice. Both amendments came from measuring a real venue rather than reading its documentation, which is the argument for capturing traces before writing an adapter against them.

Anvil's sequence numbers do not sequence

Every Anvil frame carries a seq, and the original design read it as a per-stream ordering guarantee. It is not: it is a single global engine counter shared across every ticker and every frame type, so any one socket receives a sparse — and non-monotonic — subsequence of it. The M0 capture measured 42 backward steps in five minutes of entirely healthy data, with no reset across a reconnect. An adapter written to the documented intent would have declared a gap roughly nine times a minute and greyed the panel on a feed that was working perfectly.

So the Anvil adapter synthesises its own Seq from receive order and never raises Gap{SeqGap} at all. That is safe for exactly one reason, and it is worth being explicit about it: Anvil's snapshot and book frames are idempotent full replaces, so a missed frame costs one stale render tick, not a corrupted book. The same shortcut would be indefensible on a delta venue, where a missed frame silently corrupts the book — which is why Kraken's adapter leans on its CRC32 over the top ten levels and Binance's on real U/u bracketing.

Absence of data is the only disconnect signal

Anvil emits no gap or error frame of any kind, so Gap{Disconnect} has to be synthesised transport-side: either a socket close, or a watchdog on silence. Pinning that rule in the constitution — rather than leaving it to each implementation — is what makes the host replay and the firmware transport provably the same contract. The replay reads the same rule off the captured rx_ns timestamps.

One boundary case had to be pinned rather than inferred, and it is a nice illustration of where a replay stops being a simulation. A capture that ends with thirty seconds of silence replays as live right up to the last frame, because the watchdog is edge-triggered by the arrival of the next frame and there is no next frame — while the panel, which has a clock, would have greyed. A file has no now, and the capture tool does not record when it stopped listening, so the replay genuinely cannot tell a dead feed from a finished recording. The rule is therefore that trailing silence is reported, never inferred: the end of a trace is the end of the timeline unless a caller that knows better says otherwise. It is off by default, so no committed golden moves.

Which silence means the feed is dead

The harder question is what that watchdog should count, and it took three answers to get right. The first was byte arrival, which is the obvious reading and the wrong one: a transport delivering frames the parser then rejects is stopped, and bytes were still arriving. So the constitution was amended to say liveness is defined by events reaching the book, not by bytes — and that survived two re-measurements at Anvil, because a venue broadcasting on a timer stops sending bytes exactly when it stops sending book updates. One constant worked, and it worked for a reason nobody had yet had to name.

Kraken separated the two, and separated them by 9×. A quiet pair went 25.8 seconds without a book event while the feed was entirely healthy — a market can be closed, and a book that has not changed is not old. That is not a threshold that needs raising; it is a signal carrying no information at all. A quiet market and a silently dead subscription are identical on the wire, so no threshold on book silence can ever be correct. The middle answer is a trap of its own: counting any application frame measures Kraken's 1 Hz heartbeat, so a data-armed watchdog sits on the heartbeat's own noise floor and reports a venue alive when it is telling you nothing except that.

The ruling that came out of it is the third answer. Staleness counts the venue’s declared liveness signal — Kraken's heartbeat, Anvil's fixed-cadence summary frame, both of which keep firing over an idle book because they are on a timer rather than on order flow. And the threshold is measured at runtime rather than declared, because both venues' cadences are configuration a client cannot read back: it is a multiple of the inter-arrival median the connection itself observes. liveness_clock.hpp holds it, and the firmware's old byte-counting watchdog was deleted rather than retuned.

A 25-hour soak said whether that was worth it. Twice, the socket stayed up and believed healthy while the feed was gone — and the liveness watchdog beat the transport's own detection by 141 and 291 seconds. Counting every event across that soak and the runs after it, four of seven socket losses were half-open, so it is the common case rather than a curiosity. Without the calibrated threshold the panel shows a coloured, frozen ladder for minutes, which is the one output the constitution refuses.

The cost is stated where the win is, because the win is conditional on the venue speaking when the book does not — and Binance does not. Its depth streams are change-driven, it publishes no heartbeat and no summary, and its only unsolicited traffic is a WebSocket ping emitted below the subscription. That ping proves the socket and never proves the feed, so at that venue the mechanism does not degrade, it goes blind: a subscription dropped server-side on a socket that stays up presents as perfect health. M5's answer on parity is therefore no, with the reduced claim written out rather than smoothed over.

Old is a third failure, and it gets a number rather than a colour

Greying covers stopped and wrong. It has no concept of old, and old turned out to be the defect that mattered. On a bench run where every instrument in the firmware read healthy — no parse errors, no dropped frames, both cores 85% idle — the panel was displaying a state the web client had already had 96 to 98 seconds earlier. A frozen bid that had looked like a bug was the board faithfully replaying a real stretch of history in which the bid genuinely had not moved.

So DisplaySnapshot gained age_ms and a has_age flag for no-reading-yet. It is queuing lag and nothing else — not time since the last frame, not time since the book last changed — estimated as a deficit against the liveness signal's own baseline over a sliding window. Two rules keep it honest. It is rendered as a number and nothing branches on it, because a colour for old would re-open the question the palette split closed structurally. And the baseline is latched once per connection rather than tracked, because a rolling median adapts to exactly the backlog the meter exists to detect: a socket throttled to a quarter of the broadcast rate settles its median at 4× the interval within one window, after which the meter reads zero while the true lag climbs past a hundred seconds.

The blind spot that leaves is proved rather than hoped. A connection already behind when it latched cannot know it, because the venue broadcasts at 2 Hz and I am two minutes behind and the venue broadcasts at 0.5 Hz and I am current are byte-identical on the wire — so the baseline is printed beside every age, and the one measurement that would close it is deferred with its reason attached rather than left as a gap. Injecting a known backlog at each venue then bounds what the meter sees: it tracks a socket backlog at 1.00×, because the liveness signal shares a TCP stream with the data and is delayed with it, and it is entirely blind to a feed backlog, where a venue's publisher falls behind while the WebSocket layer keeps its own timer. That second case is constructible only at Binance — the same venue, for the same reason, as the grey threshold's blind spot.

// Gap is data, not an error
Gap{Disconnect}transport · close or liveness watchdogGap{SeqGap}adapter · Binance U/u discontinuityGap{ChecksumFail}adapter · Kraken CRC32 mismatchGap{Overflow}feed · delta-venue reassembly bufferGap{Resync}book · construction or rejected deltaGapone eventStale(reason)panel greys · banner names itLiveladder tracks the bookGap ⇒ staleSnapshot ⇒ liveno venue is required to send one:Anvil emits no gap frame at all, soGap{Disconnect} is synthesisedtransport-side, by close or watchdogstale until the next Snapshot
Every way the feed can stop being trustworthy — a dropped socket, a checksum mismatch, a sequence hole, a reassembly buffer overrun — arrives at the book as the same Gap event and greys the panel until a fresh Snapshot lands. A ladder frozen on last-known-good is indistinguishable from a quiet market, which is the one output the design refuses to produce. All five reasons were written down at M0, before any adapter existed; two real venues later, every one of them is raised by shipping code and none has been added. Note what is not in this figure: the render hand-off. It drops superseded frames silently, because each one is a whole book state and the next is already on its way, so Overflow is feed-side by construction and never a hand-off event.

The book engine, built in two stages

The target design is a tick-indexed dense window: a contiguous array of quantities addressed by (px − anchor) over the hot band around mid, re-anchored by a bounded copy when the touch drifts out of the window, with a cold tail in a map for levels outside it. On the ESP32 the window lives in internal SRAM and the tail in PSRAM; on the host it is all plain heap. It is deliberately the consumer-side twin of the windowed dense array named in Anvil's own future work.

None of that was built for Anvil, on purpose. Phase 1 targets a snapshots-only venue, which needs no book maintenance at all — adopt the latest snapshot, keep a ring of recent trades, and that is the engine. The dense window was to land with the first delta venue at M4, when there would finally be something to justify it.

M4 came, and the honest report is that half of it landed and half of it did not. Two different things had been travelling under one name. The storage half — the tick-indexed array that makes a level lookup constant-time instead of a scan — is still unbuilt, and it is unbuilt on arithmetic rather than by omission: an ordered insert over a side of 25 levels, at a measured 1.55 changed levels per message and around 26 messages a second, is on the order of a thousand small shifts a second against a core that spends its time on TLS. Adding two structures and a boundary between them to remove that would be optimising work nobody has measured a cost for. The trigger is named instead of assumed — a venue whose subscribed depth is deep enough for the scan to appear in a profile — and the constitution says so where it promises the design, because a future session will come looking.

The half that did land is the render window: which levels of a deeper-than-the-panel book earn one of 27 rows. That was proposed with three candidate policies, two of them price axes, and a measurement taken before any of it was written killed both. Only about one adjacent level pair in five sits a single tick from its neighbour, and one side of 25 levels spans 182 ticks on a liquid pair and 6,613 on a quiet one — so a 27-row panel used as a 27-tick price axis would render four levels of twenty-five on the first, and on the second exactly one: the touch. The three policies that shipped select by rank instead, and every one of them draws only levels the book actually holds. The rejected design is recorded with its price rather than as a dead end — two 32-bit present masks, eight bytes on the snapshot — because what blocks it is one field, not a flaw.

And at the depth that ships, the policy choice is not a choice: Kraken subscribes 25 levels into a 27-row panel, so all three produce byte-identical windows and nothing is ever dropped. That is asserted as a property rather than noted, because it stops being true the moment somebody raises the subscribed depth — which is the real question, since the venue's checksum only ever covers the top ten.

The output is a DisplaySnapshot: top ~27 levels a side, a recent-trade ring, last price, symbol id, the book age described above, and a status of Live | Stale(reason). It reaches the render side through SnapshotChannel — declared in snapshot_channel.hpp — a wait-free single-producer/single-consumer mailbox holding three snapshot slots and one 32-bit atomic word. publish and consume each swap their own slot into that word with a single exchange, so the writer's slot, the reader's slot and the ready slot are always three distinct objects — the three indices are always a permutation of {0,1,2} — and neither task is ever inside the frame the other is using. That is what lets publish be a fixed-size copy plus one atomic operation: no loop, no lock, and nothing the render task can hold that the feed task has to wait for. On a microcontroller a render stall is the common case rather than the rare one.

That is a shipped and proven mechanism, not design intent — and it is deliberately neither a seqlock nor a two-slot double buffer. Both let the reader copy a slot the writer is writing and then discard the result: the tear happens, and the version check only stops it being drawn. The seqlock was not dismissed on theory but built and measured — one of exactly that shape delivered 4.8 million frames with zero tears reaching the consumer, and ThreadSanitizer flagged it on the first frame. So the objection was never that it misbehaves on today's compiler — it is that the copy is a data race, and the only way to keep both a seqlock and a clean sanitiser report is a suppression sitting on the single cross-core path in the project, on a target compiler generation nobody here controls. Two slots need no experiment to rule out: the reader is holding one, so a writer alternating across two must eventually land on the one being read. The third slot costs 2,352 bytes and removes the possibility rather than detecting it after the fact.

The evidence is committed alongside it: test_snapshot_channel.cpp races two threads over 100,000 frames whose every field is stamped from the frame's own version — so a delivered frame is either whole or provably spliced, and the checker names the field where it split — and harness/tsan.sh produces the committed clean ThreadSanitizer report. Invariant #4 moved from promise to measurement.

// the display budget — 64 rows
headersymbol · last px · tick directionasks — 27 levelsred, deepest at the top, stacking into the touchthe spreadthe dark gap; trade prints flash white herebids — 27 levelsgreen, best at the top, falling away belowsparklinelast price across the recent window64 rows · 64 columnsbar length = resting size
DisplaySnapshotis sized by the panel, not the venue: roughly 27 levels a side is what remains once the header, the spread band and the sparkline strip have taken their rows. Depth beyond the venue's stated N is unknown, not zero — the book keeps the distinction even though the panel can only draw the top of it.

The frozen invariants

Eight rules are marked in the constitution as not refactorable through. They exist because the work is executed by a series of independent agentic sessions, each of which reads the constitution and none of which can see the others' reasoning — so the properties that must survive contact with a fresh context are written down with their justification attached, not left to be inferred from the code.

  • engine/ builds on the host with zero ESP-IDF, FreeRTOS or Arduino includes — and, since M3, for the target too. Every line of book logic has to be exercisable by ctest on the desk; this is the seam that made Anvil's demo layer cheap to build, inherited deliberately. The host check proved the engine builds on the host, and that was quietly read as proving it builds — which does not follow. A generated per-header translation unit is now also compiled with the PlatformIO xtensa toolchain, which pins what the engine may use: today that is GCC 8.4, so no <span>, <ranges>, <concepts> or <bit>, no floating-point from_chars and no constexpr algorithms. Every engine header passes. The one that was re-measured rather than assumed is <concepts>: the header is genuinely absent, but a C++20 concept costs a single -fconcepts flag rather than the toolchain migration everyone had assumed. It is still declined, on a sharper ground — that flag gives GCC 8.4 the Concepts TS and not C++20, so host and target would compile the same spelling under different dialects, which is a silent divergence in the one property this invariant exists to protect.
  • FeedEvent is the only type crossing adapter → engine. Venue mess — JSON dialects, sequence schemes, checksums — stays quarantined; the book is written once against one vocabulary. Three venues in, it has held: Kraken's CRC32, its truncation rule and Binance's U/u bracketing all resolved into vocabulary the constitution already had. The question "does this need a new event kind or a new gap reason?" has now been asked four times and answered no four times.
  • Integer ticks and steps everywhere; no floating point ever touches book data. Exact equality, no key drift, deterministic replay. Floats may appear only at the display-formatting edge — and, since M4, in the two clocks that estimate liveness and age. That is a deliberate reading rather than a breach: they are instruments, not book data, and the cost was measured rather than waved through, because the engine acquired its first floating-point instructions to get them.
  • The feed task is never blocked by the render task. The hand-off is a wait-free latest-value mailbox: publishing never waits on the render task, and a superseded DisplaySnapshot drops silently. Silently is correct, because it is lossless at the book level — every published frame is a complete render state rather than a delta, so a render side that skips v10 to v13 loses no book information, only intermediate trade-ring samples, which are best-effort. Gap{Overflow} is therefore a feed-side signal, reserved for a venue reassembly buffer at a delta venue, and is correctly raised nowhere in the Anvil-only phase. Per-event cost stays bounded independent of consumer speed.
  • Stale is a first-class rendered state. Any gap, disconnect or resync greys the panel until a fresh snapshot. A frozen ladder that looks live is the one unacceptable output — the entire honesty of the object depends on it. This is the invariant that has moved most, and every move narrowed it rather than relaxing it. It gained a second half at M3 — honesty is not recovery, so a socket that is open and silent past five minutes is treated as dead and recycled, because #5 said nothing about the panel ever going green again. It stopped being a rule and became a type at the same milestone: the renderer emits an Ink — a role — and a Palette maps roles to colour, so a stale panel carrying a hue is a compile error rather than a review catch. And it acquired one named exception, six rows of black under the header text, bounded and written down beside the guarantee it dents.
  • No feature merges without replay coverage. New adapter behaviour or book logic ships with a trace and a golden expectation. Multi-session agentic work converges only when red and green are objective. M4 added the condition that makes it mean something: a check may be counted as coverage only once it has been deliberately broken and observed to fail. A check that has never been seen to go red is a claim about the world, not evidence about the code.
  • Allocation-free steady state. After connect and first snapshot, the feed-to-render path performs no heap allocation — for determinism, for embedded heap health, and to keep host benches honest about target behaviour. On the target it reads as no net allocation and no fragmentation drift rather than no malloc anywhere in the stack, because the first library nobody here wrote made the strong form unachievable — and that narrowing is stated with what it permits, so bounded balanced churn of one fixed-size block is in and an unbounded one is still out.
  • One writer per state. Only the feed task mutates the book; only the render task reads the display snapshot. No third participant, and no locks around the book itself.

Decisions already made

DecisionWhy
A separate repo from AnvilThe boundary is a versioned wire contract, not shared code — which is exactly what makes DepthCharge the protocol's second independent client. Build and deploy isolation follows the portfolio-wide one-repo-per-deploy pattern.
One repo for engine + firmware + hardwareFirmware, carrier board and enclosure versions have to travel together (the MorayGlow pattern). What normally makes that hurt is untestable firmware-coupled logic, and the host-buildable engine seam removes it.
Anvil stays untouched in v1The things DepthCharge would like from Anvil — a chaos flag for deterministic gap testing, a sequenced incremental L2 feed — sit on Anvil's backlog as non-blockers. Even the WebSocket Origin allowlist turned out to need nothing: M0 confirmed the deployed upgrade accepts a client sending no Origin header, so neither the capture tool nor the firmware needs one, and a nominated header stays on the shelf as a fallback if that policy ever changes.
Portable C++ first, target secondEvery milestone proves on the host harness before it touches the ESP32. Captured replay files are the ground-truth artefacts; the panel is where working code is displayed, not where it is debugged.
doctest + ctest for the harnessFamiliarity with the Anvil toolchain. The JSON parsing choice was deliberately left open — the harness may use a heavyweight parser, but whatever parses on the hot firmware path has to respect the allocation-free invariant — and M3 closed it in a way worth recording: there are now two parsers, and the heavyweight one is the specification. Not a reference to agree with in spirit; its observable behaviour, accidents included, is the contract, and three divergences are deliberate and asserted as such. Equivalence is executed rather than reviewed — one test compiles into both binaries and holds them to one table.

Where the code lives

The layout enforces the seam. Transport — sockets, TLS — lives outside the engine; adapters accept received frames as bytes and emit events, so the identical adapter logic runs under a Python-captured replay file, a host WebSocket client, or the board's own client on the target. That last one is hand-written, and not by preference: the stock library decided nine design questions in a row — no configurable reconnect timeout, no error codes to read, a blocking stop that sleeps inside a library task, a framing bug that glued one stray byte onto the front of clean JSON — and at the ninth the verdict changed from routing around it to removing it. The replacement is a minimal client over esp-tls; the soak that graded it ran one socket for 10.9 hours where the library had been dying every six to ten minutes.

PathOwns
engine/Portable C++20 library: types, FeedEvent, the book, and the adapters' logic (frame → events). No I/O, no ESP-IDF, no FreeRTOS.
harness/Host executables: replay runner, golden tests, console renderer, bench.
firmware/PlatformIO project: Wi-Fi, TLS WebSocket transport, tasks, HUB75 driver glue. Links engine/ as-is.
hardware/KiCad carrier board, enclosure CAD and STLs, BOM.
tools/Capture scripts and dev tooling — the one place Python is allowed.
docs/Milestone briefs (the work orders) and vendored protocol snapshots.

How the work converges

DepthCharge is built by agentic sessions against a constitution. Each milestone gets a disposable brief in docs/briefs/ that specifies the work; the constitution is what every session reads first and must not violate; and the definition of done is the harness going green. That only functions if red and green are objective, which is where the strongest rule comes from: no feature merges without replay coverage. New adapter behaviour or book logic ships with a captured or synthesised trace and a golden expectation, or it does not ship.

The console ladder is what makes that legible. dc_ladder replays a captured trace through the real engine — adapter, book, DisplaySnapshot, renderer — and draws the ladder in a terminal, optionally paced by the capture clock. The trace worth watching is the reconnect one: 382 frames in, the feed goes quiet for 4.5 seconds, the ladder greys with a STALE — disconnect banner, and the resync snapshot brings it back. That is the panel's most important behaviour, demonstrated months before there is a panel.

Two tracks run in parallel and share no dependencies: the agentic software track, and a bench track where the soldering iron, KiCad and the printer are the tools. M1 and M2 could not block each other if they tried, which is what keeps the project moving in evening-sized pieces.

The recurring way a green suite is wrong

Three venues in, the constitution's decision log has a dominant theme, and it is not about C++. It is that a measurement answering a nearby question keeps getting read as answering the one that mattered. It has been recorded nine times, in nine different subsystems, and it is written down as a class rather than nine anecdotes because each instance was argued from scratch by a session that could not see the others.

The most expensive instance is worth stating plainly. The board could not keep up with Anvil, and three sessions established why: the byte rate was a flat ceiling regardless of message size, the panel came and went without moving it, the TCP receive window and the transatlantic round trip multiplied out to almost exactly the observed rate. The board was convicted, the receive path instrumented, liblwip.a rebuilt from source to raise the window, and a protocol change promoted to the thing that makes the hardware work at all. Every measurement was honest and every number still holds. Then somebody priced the stream: 98% of the wire was book frames, and between consecutive frames a median of one level changed. The venue had been asked for every resting level and the panel drew twenty-seven of them. One query parameter took the stream to 27% of its size, and the answer had never been a lever — it was a question nobody had asked the venue.

The rules that came out of it are cheap and they are enforced rather than remembered. A green check must be shown capable of going red — a test may be counted as coverage only once the behaviour has been deliberately broken and the check observed to fail; three checks on this project had passed for the wrong reason, and in all three the instrument was healthy and pointed somewhere else. Where a client's assumption and a venue's behaviour could coincide, the synthetic case is mandatory and the captured trace is decoration, because a real trace is the strongest evidence available for what a venue does and no evidence at all for what the client does when the venue does something else. And a report must distinguish nothing was wrong from nothing was checked, which is why the oracle written for Binance reports vacuous and not-exercisable as states of their own — it printed green against every mutant while grading nothing on its first run.

The same instinct shows up as a positive design rule: a venue's lack of a capability gets a value and a note, never a missing field. Kraken's checksum validates ten levels, so that venue declares ten. Anvil publishes no checksum at all, so it declares zero and the report says it in words — because a missing validation line reads as not applicable, and not applicable is heard as not a concern. The project arrived at that answer independently eight times before anyone named it.

Where it is now

MilestoneWhat it deliversStatus
M0 · Trace + harnessLive Anvil traces captured and vendored; replay harness parses them; golden-test and CMake skeleton.Done
M1 · Console ladder off replayFeedEvent types real; Anvil adapter; phase-1 book; console ladder renders a replay; goldens green.Done
M2 · Panel smoke testESP32-S3 DevKit, 64×64 HUB75 and PSU wired; the HUB75 DMA demo runs on the bench — pin map, 2.6 A draw and signal integrity recorded.Done
M3 · Live Anvil on the panelFirmware net and render tasks; the wait-free SnapshotChannel; a hand-written WebSocket client after the library's ninth design decision; the pull-the-Wi-Fi test greys the panel and rejoins unaided.Done · Aug 2026
M4 · Kraken adapterReal deltas with CRC32 verification; the rank-selected render window; the liveness clock and the age meter. The panel renders a Kraken book off the wire, greys within a self-calibrated threshold, holds colour through 26 s of legitimate book silence, and heals from a checksum failure rather than greying for good.Done · Aug 2026
M5 · Binance adapterDiff stream bracketed against a REST seed, with a re-seed schedule and an oracle the venue does not supply. Through stage D: the wire priced, the adapter and its healing path built, the liveness policy made per-venue, and the seed fetch given a task of its own.In progress
M6–M7 · Carrier PCB, enclosureKiCad carrier around a WROOM-1-N16R8 with level shifters and USB-C power; printed enclosure, smoked acrylic front, encoder modes.Planned
MP · Portfolio portalThe DepthCharge pages on this site — project page, concept art, and the two write-ups: this one and the detailed design — tracked as a roadmap milestone and executed in the portfolio repo.Stages 1–3 done

Explicitly out of scope for v1: order entry of any kind, historical persistence, more than three venues, any web UI — Anvil already has one — and battery power. Deliberately left unspecified, for sessions to decide and briefs to record: file decomposition inside the engine, class internals, and console renderer aesthetics. The constitution draws the line between what must not drift and what nobody should be arguing about.

← BACK TO

DepthCharge