blockyard 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +929 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +191 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +41 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1577 -0
- package/docs/ARCHITECTURE.md +1394 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +847 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +205 -0
- package/docs/INSTALL.md +547 -0
- package/docs/MEASUREMENTS.md +1401 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +212 -0
- package/docs/TROUBLESHOOTING.md +332 -0
- package/docs/USER-GUIDE.md +1262 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +2009 -0
- package/public/donate-qr.png +0 -0
- package/public/index.html +1085 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +1141 -0
- package/public/js/app.js +1386 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2830 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +315 -0
- package/public/js/details3d.js +4342 -0
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +88 -0
- package/public/js/markets.js +395 -0
- package/public/js/mining.js +1416 -0
- package/public/js/panels.js +970 -0
- package/public/js/pricechart.js +189 -0
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +1096 -0
- package/public/js/soundcard.js +459 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +23 -0
- package/scripts/dos-bench.js +56 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +432 -0
- package/scripts/shots.mjs +278 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +210 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2545 -0
- package/server/collect/network.js +295 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +644 -0
- package/server/http/api.js +1319 -0
- package/server/http/explorer.js +418 -0
- package/server/http/games.js +77 -0
- package/server/http/server.js +420 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +673 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/tls/selfsigned.js +160 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +102 -0
|
@@ -0,0 +1,1394 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
This guide is for contributors. It explains how BlockYard is put together, where
|
|
4
|
+
state lives, how data moves from the node to the screen, and which rules the code
|
|
5
|
+
depends on. It covers the reasoning as well as the structure, because most of the
|
|
6
|
+
unusual choices here are responses to measured behaviour of the node being
|
|
7
|
+
monitored.
|
|
8
|
+
|
|
9
|
+
Companion documents:
|
|
10
|
+
|
|
11
|
+
- `docs/RULES.md`: the engineering rules, each with the defect that caused it.
|
|
12
|
+
- `docs/MEASUREMENTS.md`: timings and payload sizes measured against real nodes.
|
|
13
|
+
Check it before you change a poll interval, timeout or payload.
|
|
14
|
+
- `docs/DEFECTS.md`: known gaps, including the ones that are deliberately not fixed.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 1. The big picture
|
|
19
|
+
|
|
20
|
+
BlockYard is a single Node.js process (Node 22 or later) with **no dependencies**.
|
|
21
|
+
It sits between one or more Bitcoin Core nodes and any number of
|
|
22
|
+
browsers:
|
|
23
|
+
|
|
24
|
+
- **Upstream**, it reads each node's JSON-RPC interface. If configured, it also
|
|
25
|
+
follows the node's log file (experimental-node builds only; Bitcoin Core's
|
|
26
|
+
`debug.log` is not parsed, and the log source is off by default). Where an
|
|
27
|
+
address index has been built from a node's block files, it reads that too
|
|
28
|
+
(section 2.8).
|
|
29
|
+
- **Downstream**, it serves a static single-page app (vanilla ES modules, all
|
|
30
|
+
drawing done by hand on `<canvas>`), a JSON API, and a Server-Sent Events stream.
|
|
31
|
+
|
|
32
|
+
```mermaid
|
|
33
|
+
flowchart LR
|
|
34
|
+
subgraph node["Bitcoin node (one per configured node)"]
|
|
35
|
+
RPC["JSON-RPC server<br/>single connection, single thread"]
|
|
36
|
+
LOG["log file<br/>(optional)"]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
subgraph server["BlockYard server (Node, no deps)"]
|
|
40
|
+
LANE["RPC lane<br/>server/rpc/client.js"]
|
|
41
|
+
MON["NodeMonitor<br/>server/collect/monitor.js"]
|
|
42
|
+
TAIL["LogTail + logparse"]
|
|
43
|
+
HIST["History rings<br/>server/store/history.js"]
|
|
44
|
+
MKT["MarketFeed<br/>server/collect/markets.js<br/>(on demand)"]
|
|
45
|
+
HTTP["HTTP: API, static, SSE hub<br/>server/http/*"]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
subgraph browser["browser"]
|
|
49
|
+
APP["app.js state + router"]
|
|
50
|
+
PANELS["panels / charts / 3D viewer"]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
EX["public exchange REST APIs"]
|
|
54
|
+
|
|
55
|
+
MON -->|"batched polls"| LANE --> RPC
|
|
56
|
+
LOG --> TAIL --> MON
|
|
57
|
+
MON --> HIST
|
|
58
|
+
MON -->|"snapshot about 1/s, events"| HTTP
|
|
59
|
+
HIST -->|"series every 20 s"| HTTP
|
|
60
|
+
MKT <-->|"HTTPS, only while a Markets view is open"| EX
|
|
61
|
+
MKT --> HTTP
|
|
62
|
+
HTTP -->|"SSE: snapshot / series / events"| APP
|
|
63
|
+
APP -->|"fetch /api/..."| HTTP
|
|
64
|
+
APP --> PANELS
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Where state lives
|
|
68
|
+
|
|
69
|
+
| State | Location | Lifetime |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| Latest node state (chain info, mempool info, peers, fees, tips, ...) | `NodeMonitor.state`, one per node | memory; rebuilt by polling after a restart |
|
|
72
|
+
| Time series (node, mempool, net, fees, peers, blocks, txflow, rpc, self) | `History` rings, shared by every node, each row tagged with its node id | memory, **snapshotted to disk** |
|
|
73
|
+
| Event feed | `History.events`, newest first, capped | memory, stored in the same snapshot |
|
|
74
|
+
| Block stats map, mining attribution rows | `NodeMonitor.state.blocks`, `NodeMonitor.mining` | memory, bounded (`store.blockMapCap`, a 60-height attribution queue) |
|
|
75
|
+
| Market tickers, candles, depth snapshots | `MarketFeed` | memory; about an hour of depth history |
|
|
76
|
+
| Users, sessions | `data/users.json`, `data/sessions.json` | disk (atomic writes; session tokens are stored hashed) |
|
|
77
|
+
| Audit trail | `data/audit.jsonl` plus rotated `audit.N.jsonl` | disk, rotated by size |
|
|
78
|
+
| Display settings (effects, finish, the games' options) | `config/blockyard.json`, via `GET`/`POST /api/settings` | disk; the browser keeps a `localStorage` copy so boards still draw when the server is unreachable |
|
|
79
|
+
| Address index (rows per address and transaction) | the directory named by a node's `addressIndex` (section 2.8) | disk, built once (by the server in the background on its first start, or by `scripts/index-build.js`), then followed block by block |
|
|
80
|
+
| Browser cache | `state.byNode` in `app.js` | the tab's lifetime |
|
|
81
|
+
|
|
82
|
+
### What is persisted
|
|
83
|
+
|
|
84
|
+
Everything lives under `store.dir`. The default is `data/` in the repo;
|
|
85
|
+
`BLOCKYARD_DATA` overrides it.
|
|
86
|
+
|
|
87
|
+
- **`history.json`** holds every ring plus the event log. `History.save()` writes
|
|
88
|
+
it with tmp, `fsync`, then rename, so a crash leaves either the old file or the
|
|
89
|
+
new one, never half of one. Autosave runs every `store.snapshotEveryMs` (default
|
|
90
|
+
120 s) while the store has unsaved rows, and once more at shutdown. On load, rows
|
|
91
|
+
older than `store.retentionHours` (default 72) are pruned. Each ring holds at most
|
|
92
|
+
`store.ringCapacity` rows (default 20,000). Downsampling happens when data is
|
|
93
|
+
read (`Ring.series(field, { since, bucketMs, agg })`), so charts receive a few
|
|
94
|
+
hundred points instead of the raw ring.
|
|
95
|
+
- **The ledger** (`server/store/ledger.js`) is a durable store for coinbase
|
|
96
|
+
attribution rows, keyed by height. It has two engines behind one interface:
|
|
97
|
+
`node:sqlite` (WAL, `synchronous=FULL`) when the runtime has it, and an
|
|
98
|
+
append-only `fsync`'d JSONL file otherwise. Aggregation (`aggregate()`) is
|
|
99
|
+
written in JavaScript over the same row shape, so both engines give the same
|
|
100
|
+
answers. The module and its crash-safety behaviour are covered by
|
|
101
|
+
`test/ledger.test.js`. The monitor's live attribution view is currently built
|
|
102
|
+
from its bounded in-memory maps.
|
|
103
|
+
- **Pool labels** are read from `pool-map.json` and `pool-aliases.json` (edited
|
|
104
|
+
by hand). A pool map ships in `config/pool-map.json` (from mempool.space's
|
|
105
|
+
mining-pools data set, MIT, 151 pools); `data/pool-map.json`, written by
|
|
106
|
+
`scripts/pool-map.js`, overrides it, and `BLOCKYARD_POOL_MAP` overrides both.
|
|
107
|
+
The aliases file is optional. A tag no map knows is shown exactly as the miner
|
|
108
|
+
wrote it.
|
|
109
|
+
|
|
110
|
+
### What is streamed
|
|
111
|
+
|
|
112
|
+
One SSE connection per tab (`GET /api/stream?node=<id>`) carries three event types:
|
|
113
|
+
|
|
114
|
+
| event | cadence | content |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| `snapshot` | at most one per second per node, coalesced | the read model from `NodeMonitor.snapshot()`: sync, tip, mempool aggregates, peers summary, net, attribution, the latest 40 blocks, log health, `health.quality` |
|
|
117
|
+
| `series` | every 20 s, only while clients are connected | chart series from `NodeMonitor.seriesView()` |
|
|
118
|
+
| `events` | batched as they happen | feed rows (routine log chatter is stored but not pushed) |
|
|
119
|
+
|
|
120
|
+
Large or slowly changing datasets are **not** in the snapshot. Each one has its
|
|
121
|
+
own endpoint and is fetched on its own schedule:
|
|
122
|
+
|
|
123
|
+
- the mempool scatter and cell list: `/api/mempool`
|
|
124
|
+
- the dense next-block view: `/api/mempool/dense`
|
|
125
|
+
- peer rows: `/api/peers`
|
|
126
|
+
- the block template: `/api/nextblock`
|
|
127
|
+
- block history beyond 40: `/api/blocks?limit=`
|
|
128
|
+
|
|
129
|
+
The test for whether a field belongs in the snapshot: does it change every second?
|
|
130
|
+
If it doesn't, it doesn't go in the snapshot (RULES 6).
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## 2. The server
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
server/
|
|
138
|
+
main.js boot, wiring, shutdown, logger
|
|
139
|
+
config.js defaults + config/local.json + BLOCKYARD_* env overrides, validation
|
|
140
|
+
netinfo.js bind planning, CIDR parsing and membership
|
|
141
|
+
rpc/client.js the serialized RPC lane and the JSON-RPC client
|
|
142
|
+
rpc/allowlist.js which RPC methods the web UI may call; gated node actions
|
|
143
|
+
collect/monitor.js NodeMonitor: poll tiers, log absorption, read model
|
|
144
|
+
collect/sync.js the sync bar's data contract (pure)
|
|
145
|
+
collect/logtail.js log follower (rotation, truncation, partial lines)
|
|
146
|
+
collect/logparse.js log line parsers (pure; target an EXPERIMENTAL node's grammar, not Core's)
|
|
147
|
+
collect/mining.js coinbase decoding and pool ledger folding (pure)
|
|
148
|
+
collect/gbt.js the block being built, assembled from the mempool (pure)
|
|
149
|
+
collect/nextblock.js template summary and package analysis (pure)
|
|
150
|
+
collect/markets.js exchange feed (tickers, candles, order books, spot price)
|
|
151
|
+
store/ring.js ring buffer, CounterRate, read-time downsampling
|
|
152
|
+
store/history.js named series, per-node views, atomic snapshots
|
|
153
|
+
store/ledger.js durable attribution store (sqlite or JSONL)
|
|
154
|
+
store/audit.js size-rotated audit trail
|
|
155
|
+
auth/users.js scrypt-hashed user store
|
|
156
|
+
auth/sessions.js sessions, CSRF, rate limiting, login guard
|
|
157
|
+
http/server.js request pipeline: gate, auth, CSRF, routing
|
|
158
|
+
http/api.js the route table
|
|
159
|
+
http/sse.js the SSE hub
|
|
160
|
+
http/static.js static files, CSP, nonce + build-id rewriting
|
|
161
|
+
http/explorer.js block / transaction pages over RPC; address pages from the local index
|
|
162
|
+
chain/blockfile.js Core's blk/rev files read directly: XOR key, record framing, undo decoding (pure, read-only)
|
|
163
|
+
chain/tx.js raw transaction / block decoder in Core's verbose field names (pure)
|
|
164
|
+
chain/index/rows.js the 21-byte index row, built lean from block and undo bytes (pure)
|
|
165
|
+
chain/index/build.js the full build: heights, scan on a worker pool, check, sort, manifest
|
|
166
|
+
chain/index/worker.js the build worker: scan one file pair, or sort one bucket
|
|
167
|
+
chain/index/heights.js block hash -> height table in a SharedArrayBuffer, shared by the workers
|
|
168
|
+
chain/index/store.js IndexStore: base segments + layers + live tail, binary-searched lookups
|
|
169
|
+
chain/index/live.js LiveIndex: follows the chain over RPC, logs, rolls back, folds, merges
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### 2.1 Boot (`server/main.js`)
|
|
173
|
+
|
|
174
|
+
`boot({ configFile, log })` builds a single `app` object and returns it. Tests call
|
|
175
|
+
it directly; running the file as a script calls it and prints the banner.
|
|
176
|
+
|
|
177
|
+
1. `loadConfig()` merges defaults, the config file, and `BLOCKYARD_*` environment
|
|
178
|
+
variables, then validates the result. The config file is `config/local.json`
|
|
179
|
+
unless `BLOCKYARD_CONFIG` names another file or `none`. Invalid or unsafe
|
|
180
|
+
combinations are fatal. One example: enabling node write actions while accounts
|
|
181
|
+
are off, unless that is also explicitly allowed.
|
|
182
|
+
2. TLS is decided before any listener exists. If a certificate and key are
|
|
183
|
+
configured, every listener is HTTPS and the session cookie becomes `Secure`.
|
|
184
|
+
3. The history snapshot is loaded and autosave starts.
|
|
185
|
+
4. Users and sessions are loaded. If accounts are enabled and no user exists, a
|
|
186
|
+
first admin is created and its password is printed once.
|
|
187
|
+
5. In open mode, a warning is logged that names the bound addresses and what an
|
|
188
|
+
anonymous viewer can read.
|
|
189
|
+
6. `npm run dev` (`BLOCKYARD_FAKE_NODE=1`) starts an in-process fake node and
|
|
190
|
+
replaces the node list with it, so a dev run never polls a real node.
|
|
191
|
+
7. One `NodeMonitor` is created per configured node. A node whose datadir is
|
|
192
|
+
missing is skipped with a logged reason rather than kept as a permanently
|
|
193
|
+
offline panel. `wireMonitor()` connects each monitor's events to the SSE hub
|
|
194
|
+
and coalesces pushes to one snapshot per second.
|
|
195
|
+
8. `MarketFeed` is created if `markets.enabled` is set. It stays idle until the Markets
|
|
196
|
+
API is requested with the **Enable market polling** setting on (off by default; read from
|
|
197
|
+
the shared settings file per request, `marketsPollingOn` in `http/api.js`).
|
|
198
|
+
9. One HTTP(S) server is created per bound address, all sharing the same `app`.
|
|
199
|
+
An address missing at boot is skipped with a warning. Boot is fatal only when
|
|
200
|
+
none of the configured addresses exist.
|
|
201
|
+
10. Housekeeping timers start: self-telemetry, session sweeps, the 20 s series push.
|
|
202
|
+
11. For every distinct `addressIndex` directory in the node list, one `LiveIndex`
|
|
203
|
+
follower is started (fed by a node with a local `datadir` where there is one)
|
|
204
|
+
and registered with the explorer; it polls the node's tip every 30 s. A
|
|
205
|
+
follower that cannot open its index logs why, and the address page says the
|
|
206
|
+
same; nothing else waits on it (section 2.8). A directory with no finished
|
|
207
|
+
index (no `manifest.json`) is **built here, in the background**, unless the
|
|
208
|
+
node says `addressIndexBuild: "manual"` or has no `datadir` to read: worker
|
|
209
|
+
threads inside this process, the build's own `RpcClient` on a second lane
|
|
210
|
+
(its `getblockhash` batches once starved behind the monitor's multi-second
|
|
211
|
+
reads), paced by the monitor's lane telemetry, progress as the
|
|
212
|
+
`address-index-building` quality flag, `index` events at start, finish and
|
|
213
|
+
failure, and the follower started on completion (section 2.8).
|
|
214
|
+
`app.shutdown()` stops timers, closes streams, stops monitors, saves history
|
|
215
|
+
and sessions, and closes the listeners. A build in flight is not resumed:
|
|
216
|
+
the next start begins it again.
|
|
217
|
+
|
|
218
|
+
`boot({ log })` and `loadConfig({ ifaces, now })` are **seams**: tests inject a
|
|
219
|
+
logger, a fake interface list, or a fake clock instead of intercepting
|
|
220
|
+
`process.stdout` or the environment (RULES 22, 24).
|
|
221
|
+
|
|
222
|
+
### 2.2 `NodeMonitor` and the poll tiers
|
|
223
|
+
|
|
224
|
+
`NodeMonitor` (`server/collect/monitor.js`) owns one node. It polls the node's RPC
|
|
225
|
+
on a set of **tiers**, absorbs parsed log events, and turns both into `state`, ring
|
|
226
|
+
rows, feed events and `health.quality` flags. It also produces the read model,
|
|
227
|
+
`snapshot()`.
|
|
228
|
+
|
|
229
|
+
Each tier makes **one batched RPC request**: many methods in one JSON array, sent
|
|
230
|
+
on one connection. Each tier has its own coalescing key and priority in the lane.
|
|
231
|
+
Lower priority numbers run first.
|
|
232
|
+
|
|
233
|
+
| Tier | Default interval | Methods | Lane priority | Feeds |
|
|
234
|
+
|---|---|---|---|---|
|
|
235
|
+
| `fast` | 4 s | `getblockchaininfo`, `getmempoolinfo`, `getconnectioncount`, `getnettotals`, `uptime` | 0 | sync bar, tip, mempool counters, bandwidth rate, new-tip detection, reorg detection |
|
|
236
|
+
| `mid` | 15 s | `getnetworkinfo`, `getmininginfo`, `getchaintips`, `estimatesmartfee` for 1/2/6/24/144 blocks, plus `getpeerinfo` in RPC-only mode | 2 | network info, fees, side tips |
|
|
237
|
+
| `pool` | 20 s | `getrawmempool true` (heavy timeout) | 6 | mempool distribution and cells, the dense next-block set |
|
|
238
|
+
| `slow` | 60 s | `getindexinfo`, `getchaintxstats 120`, and `gettxoutsetinfo muhash` **only for a node whose `getindexinfo` reports a synced `coinstatsindex`** (the first run asks `getindexinfo` alone, so the answer is known before the question is put; without the index that call walks the whole UTXO set — 41 s measured, every minute, on the node's one RPC thread — so the figures are flagged `utxo-unindexed` instead) | 5 | UTXO set, indexes, tx rate |
|
|
239
|
+
| `rare` | 15 min | `getpeerinfo`, `getdeploymentinfo`, `getrpcinfo`, `getaddrmaninfo`, `listbanned` | 7 | peer table, deployments, address book, ban table |
|
|
240
|
+
|
|
241
|
+
Some reads are not on a timer:
|
|
242
|
+
|
|
243
|
+
- **New tip:** `getblockstats` runs for the new heights (at most the newest 24 in
|
|
244
|
+
a burst). Mining attribution is queued for those heights: `getblock <hash> 1`
|
|
245
|
+
plus `getrawtransaction <coinbase> 2`, one block per tick, newest first, and
|
|
246
|
+
never during initial block download.
|
|
247
|
+
- **Block template:** assembled here, from the verbose mempool the pool tier
|
|
248
|
+
already reads — **no RPC call of its own**. Core publishes `depends`, the
|
|
249
|
+
ancestor sizes and fees, and `fees.chunk`/`chunkweight` (its own cluster-mempool
|
|
250
|
+
linearization) in `getrawmempool(true)`, which is everything the selection needs.
|
|
251
|
+
`/api/nextblock` serves it; it is as fresh as the pool tier's last read (20 s)
|
|
252
|
+
and takes ~50-70 ms of *our* CPU. Measured against the node's own
|
|
253
|
+
`getblocktemplate` on the same pool: 0.03% apart on fees (`collect/gbt.js`).
|
|
254
|
+
- **Explorer and console:** requests from the explorer and the read-only RPC
|
|
255
|
+
console go through the same lane (see 2.4).
|
|
256
|
+
|
|
257
|
+
Tier scheduling:
|
|
258
|
+
|
|
259
|
+
- **Staggered start.** The first runs are offset by 0, 0.7, 1.2, 1.6 and 2.6 s,
|
|
260
|
+
so boot does not send five requests at once to a single-threaded server.
|
|
261
|
+
- **Adaptive cadence.** `effectiveTierMs(name)` stretches a tier to the larger of
|
|
262
|
+
2x the lane's average latency and 1.5x that tier's last run. The result is
|
|
263
|
+
capped, and the cap is never below the configured base (RULES 12). Once the
|
|
264
|
+
node speeds up, cadence returns to the configured value on its own.
|
|
265
|
+
- **Heavy-tier skip.** While average RPC latency is above 4x `rpc.slowLatencyMs`,
|
|
266
|
+
the `pool`, `slow` and `rare` tiers are skipped, except every fifth attempt.
|
|
267
|
+
This keeps the cheap `fast` tier fresh. The skip is announced as a quality flag.
|
|
268
|
+
- **No overlap.** A tier that is still running is not started again.
|
|
269
|
+
|
|
270
|
+
What the UI receives about once a second is `snapshot()`. It is assembled on
|
|
271
|
+
demand from `state`, the rate counters, the log state and `rpc.telemetry()`.
|
|
272
|
+
Beyond the raw figures, it carries the reasoning the UI needs to be honest:
|
|
273
|
+
|
|
274
|
+
- `sync` (from `collect/sync.js`) keeps two figures apart. `pct` is
|
|
275
|
+
blocks/headers, which fills the bar. `verificationProgress` is the node's own
|
|
276
|
+
estimate, drawn as a separate tick. The object also carries rate windows, an ETA
|
|
277
|
+
with its basis, caveats, and `strip`, the pre-assembled facts for the one-line
|
|
278
|
+
header.
|
|
279
|
+
- `hashrateEstEh` is withheld during IBD, with `hashrateNote` giving the reason.
|
|
280
|
+
- `net.downloadMeasured` and `net.uploadMeasured` gate on counters that actually
|
|
281
|
+
move, so a counter stuck at zero is drawn as absent rather than as an idle link.
|
|
282
|
+
- `health.cadence` reports the configured and effective interval of every tier.
|
|
283
|
+
`health.quality` lists the named gaps: what the monitor cannot currently tell you,
|
|
284
|
+
and why.
|
|
285
|
+
- `log.health` reports parser coverage and how long the log has been quiet.
|
|
286
|
+
|
|
287
|
+
### 2.3 Collectors
|
|
288
|
+
|
|
289
|
+
- **`collect/sync.js`**: the sync state machine (`unknown`, `ibd`, `catching_up`,
|
|
290
|
+
`synced`, `stalled`, `reorg`) and the ETA rules. It refuses to estimate an ETA
|
|
291
|
+
without a measured rate over at least 60 s of samples. `computeSync` takes
|
|
292
|
+
`peerBestHeight` (the monitor passes the highest `synced_headers`, or
|
|
293
|
+
`startingheight`, over `getpeerinfo`): a node is `stalled` only when its peers
|
|
294
|
+
report a tip above the one it holds; a long gap with the peers agreeing is
|
|
295
|
+
`synced` with a caveat; with no peer heights at all it is `stalled` after
|
|
296
|
+
7200 s, because a 40-minute gap happens about once in fifty on the network. It
|
|
297
|
+
is pure and unit-tested.
|
|
298
|
+
- **`collect/logtail.js`**: follows a file by polling, with `fs.watch` used only
|
|
299
|
+
as a wake-up. On rotation it drains the old inode to EOF before switching to the
|
|
300
|
+
new file. If the file shrinks, it restarts at 0. A partial last line is carried
|
|
301
|
+
over to the next read. At start it backfills the last `log.tailBytes`.
|
|
302
|
+
- **`collect/logparse.js`**: pure parsers for the node's log lines. **These target an
|
|
303
|
+
experimental node implementation's log grammar and do not support Bitcoin Core's
|
|
304
|
+
`debug.log`**: measured 2026-09-13, Core lines return unstructured `raw` events with no
|
|
305
|
+
fields and a fallback timestamp. The log source is off by default and should stay off
|
|
306
|
+
against Core. Labelled lines
|
|
307
|
+
are scanned **field by field**, so a new field costs only that field, which is
|
|
308
|
+
kept verbatim in `extraFields`, rather than the whole line (RULES 16). `SHAPES`
|
|
309
|
+
and `RULE_TO_SHAPE` drive per-shape liveness flags. Coverage is published as
|
|
310
|
+
`log.health.ratio` and held to a threshold against a frozen real sample (RULES 15).
|
|
311
|
+
- **`collect/mining.js`**: coinbase scriptSig push decoding, tag extraction, and
|
|
312
|
+
folding rows into per-pool counters. It never invents pool names.
|
|
313
|
+
- **`collect/gbt.js`**: assembles the block being built from `getrawmempool(true)`,
|
|
314
|
+
in the shape a `getblocktemplate` reply has, so every consumer below reads it
|
|
315
|
+
unchanged. Greedy over the node's own chunk feerate, each transaction taken with
|
|
316
|
+
its unselected ancestors. Pure.
|
|
317
|
+
- **`collect/nextblock.js`**: turns that template into the next-block card: header
|
|
318
|
+
figures, a fixed-bucket feerate histogram, and ancestor packages built from
|
|
319
|
+
`depends`. Pure, and still able to read a real `getblocktemplate` reply.
|
|
320
|
+
- **`collect/markets.js`**: `MarketFeed`, the only outbound connection that is not
|
|
321
|
+
a node. It is covered in detail below.
|
|
322
|
+
|
|
323
|
+
#### The market feed
|
|
324
|
+
|
|
325
|
+
The browser's CSP only allows connections to its own origin
|
|
326
|
+
(`connect-src 'self'`), so exchange data is fetched by the server. The feed reads
|
|
327
|
+
the public, unauthenticated REST endpoints of five exchanges: Coinbase, Kraken,
|
|
328
|
+
Bitstamp, Bitfinex and OKX.
|
|
329
|
+
|
|
330
|
+
- **Starts on demand.** `GET /api/markets` and `/api/markets/depth` call
|
|
331
|
+
`touch()`. The first touch starts three timers:
|
|
332
|
+
- tickers every `markets.tickerMs` (15 s)
|
|
333
|
+
- hourly candles every `markets.candleMs` (5 min, the latest 168)
|
|
334
|
+
- order books every `markets.bookMs` (30 s)
|
|
335
|
+
- **Stops when idle.** Polling parks after `markets.idleAfterMs` (10 min) without
|
|
336
|
+
a request. A monitor nobody is watching makes no exchange traffic.
|
|
337
|
+
- **Depth snapshots.** Each book poll turns every exchange's book into cumulative
|
|
338
|
+
depth on a fixed grid: $50 levels, ±12% around the median mid price.
|
|
339
|
+
`depthOf()` returns `null` past the end of a truncated book instead of
|
|
340
|
+
extending it. About an hour of snapshots is kept, and `depthView(ago)` realigns
|
|
341
|
+
an older snapshot onto the current grid for the "then" line and the change bars.
|
|
342
|
+
- **Spot price.** `spot()` supplies USD figures to the explorer. It uses the
|
|
343
|
+
median of fresh tickers if the feed is running. Otherwise it makes two USD
|
|
344
|
+
ticker reads, cached for a minute. The explorer waits at most 1.5 s for it
|
|
345
|
+
(`withUsd` in `api.js`).
|
|
346
|
+
- **Honest ages.** Every figure keeps its age. A failing exchange is reported
|
|
347
|
+
with its error, never zero-filled. Tickers older than three intervals are marked
|
|
348
|
+
`stale`.
|
|
349
|
+
|
|
350
|
+
Polling is off by default — the **Enable market polling** Display setting turns it on, and a
|
|
351
|
+
request with it off parks the feed. `BLOCKYARD_MARKETS=0` (or `markets.enabled: false`)
|
|
352
|
+
removes the feed altogether.
|
|
353
|
+
|
|
354
|
+
### 2.4 The RPC lane (`server/rpc/client.js`)
|
|
355
|
+
|
|
356
|
+
**The central constraint:** the node's RPC server services **one connection at a
|
|
357
|
+
time on one thread**. A bare `getblockcount` has been measured at 40 s on a node
|
|
358
|
+
doing initial block download (MEASUREMENTS 1). A dashboard where every tab polls
|
|
359
|
+
on its own would be a denial of service against the node it exists to watch. So
|
|
360
|
+
every RPC request, from every poll tier, every user, the explorer and the console,
|
|
361
|
+
goes through **one `Lane` per node**.
|
|
362
|
+
|
|
363
|
+
```mermaid
|
|
364
|
+
flowchart TD
|
|
365
|
+
subgraph callers
|
|
366
|
+
F["fast tier, key node:fast, prio 0"]
|
|
367
|
+
M["mid tier, prio 2"]
|
|
368
|
+
X["explorer, prio 3, maxWait 45 s"]
|
|
369
|
+
P["pool tier, prio 6"]
|
|
370
|
+
R["rare tier, prio 7"]
|
|
371
|
+
U["console / actions, unkeyed"]
|
|
372
|
+
end
|
|
373
|
+
callers -->|"submit(job, key, priority, maxWaitMs)"| Q{"breaker open?"}
|
|
374
|
+
Q -->|yes| REJ["reject: breaker"]
|
|
375
|
+
Q -->|no| CO{"same key already pending?"}
|
|
376
|
+
CO -->|yes| SUP["older job rejected as stale<br/>newest wins"]
|
|
377
|
+
CO -->|no| PEND["pending Map"]
|
|
378
|
+
SUP --> PEND
|
|
379
|
+
PEND --> D["_drain: lowest priority number first,<br/>insertion order within a priority"]
|
|
380
|
+
D --> B2{"breaker open now?"}
|
|
381
|
+
B2 -->|yes| REJ
|
|
382
|
+
B2 -->|no| ST{"waited longer than its budget?"}
|
|
383
|
+
ST -->|yes| DROP["drop as stale, do not ask"]
|
|
384
|
+
ST -->|no| SP["wait until spacingMs since last start"]
|
|
385
|
+
SP --> RUN["one HTTP POST, Connection: close<br/>(single call or batch array)"]
|
|
386
|
+
RUN --> DONE["resolve / reject, then drain next"]
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Properties:
|
|
390
|
+
|
|
391
|
+
- **One in flight.** `busy` gates `_drain()`. The node could not use more than one
|
|
392
|
+
connection anyway, and a second request would only queue inside the node, where
|
|
393
|
+
the monitor cannot see or cancel it.
|
|
394
|
+
- **Minimum spacing.** `spacingMs = max(rpc.minIntervalMs, 1000 / rpc.maxRatePerSec)`.
|
|
395
|
+
The defaults (250 ms, 4 per second) both come to 250 ms between request starts.
|
|
396
|
+
- **Batching.** `RpcClient.batch(calls)` sends a JSON array, which the node
|
|
397
|
+
answers on one connection. Replies are matched back to calls by id. A method
|
|
398
|
+
that fails inside a batch is reported for that method only; the rest of the
|
|
399
|
+
batch still succeeds.
|
|
400
|
+
- **Coalescing by key.** Poll jobs carry a key such as `<node>:fast`. A new job
|
|
401
|
+
with the same key replaces a pending one, and the old one is rejected with
|
|
402
|
+
`kind: 'stale'`. User-initiated calls carry no key, so each one is answered.
|
|
403
|
+
- **Priority.** Without it, a 60+ second heavy batch once starved the cheap poll
|
|
404
|
+
that feeds the sync bar. With it, `fast` always runs next.
|
|
405
|
+
- **Stale drop.** A job still queued after its budget (`rpc.staleDropMs`, 12 s by
|
|
406
|
+
default; explorer jobs 45 s) is dropped rather than run. The answer would
|
|
407
|
+
describe a moment that has already passed, but would be displayed as current.
|
|
408
|
+
- **Circuit breaker.** After `breakerThreshold` (3) consecutive failures (timeout,
|
|
409
|
+
transport, auth or parse errors, but not RPC-level errors and not stale drops),
|
|
410
|
+
the lane refuses new and queued work for `breakerCooldownMs` (30 s). The breaker
|
|
411
|
+
is checked both at submit and at dequeue. `breakerState()` records which call
|
|
412
|
+
opened it and what it blocked.
|
|
413
|
+
- **Timeouts.** `rpc.timeoutMs` is 90 s and `heavyTimeoutMs` is 300 s, sized from
|
|
414
|
+
measurements rather than guesses (RULES 1). Failures and timeouts are also timed,
|
|
415
|
+
so a node that stalls for 90 s shows up in the latency statistics that adaptive
|
|
416
|
+
cadence reads.
|
|
417
|
+
- **No keep-alive.** Holding a socket open between requests would hold the node's
|
|
418
|
+
only service slot.
|
|
419
|
+
- **Cookie auth.** Credentials are resolved lazily and re-read once on a 401,
|
|
420
|
+
because the node regenerates its cookie every time it starts.
|
|
421
|
+
|
|
422
|
+
### 2.5 The RPC allowlist (`server/rpc/allowlist.js`)
|
|
423
|
+
|
|
424
|
+
The read-only console (`POST /api/rpc`) may only call methods that
|
|
425
|
+
`classifyMethod()` allows. The decision order:
|
|
426
|
+
|
|
427
|
+
1. **Explicit deny list**, checked first. It covers wallet key derivation (even
|
|
428
|
+
methods that start with `get`), spends, wallet state, peer control, chain
|
|
429
|
+
mutators, and heavy reads that would monopolise the node (`rescanblockchain`,
|
|
430
|
+
`scantxoutset`, `verifychain`, ...).
|
|
431
|
+
2. **Deny prefixes** such as `send`, `set`, `import`, `sign` -- and, on a node that
|
|
432
|
+
adds vendor-prefixed commands of its own, their vendor-prefixed forms too.
|
|
433
|
+
3. **Allow prefixes** that read as reads: `get`, `list`, `estimate`, `decode*`,
|
|
434
|
+
... A node's vendor-prefixed **read** verbs are admitted by name; the bare vendor
|
|
435
|
+
prefix deliberately is not, so a future vendor-prefixed `setban` cannot slip in.
|
|
436
|
+
4. **Anything else is denied.** The reply says which file to edit.
|
|
437
|
+
|
|
438
|
+
Node **writes** do not go through this file. They are named `ACTIONS` behind
|
|
439
|
+
`POST /api/action`. An action must be enabled (`BLOCKYARD_ENABLE_ACTIONS=1`) and
|
|
440
|
+
listed in `actions.allow`. The caller must hold the action's role and send a
|
|
441
|
+
typed confirmation equal to the action's name. With accounts off, actions also
|
|
442
|
+
need `BLOCKYARD_ALLOW_WRITES_WITHOUT_AUTH=1`, which is checked again inside the
|
|
443
|
+
route. Every action is audited.
|
|
444
|
+
|
|
445
|
+
### 2.6 HTTP (`server/http/*`)
|
|
446
|
+
|
|
447
|
+
**Request pipeline** (`server.js`, one server per bound address):
|
|
448
|
+
|
|
449
|
+
```mermaid
|
|
450
|
+
flowchart LR
|
|
451
|
+
A["request"] --> G{"server.allowCidrs set<br/>and address outside it?"}
|
|
452
|
+
G -->|yes| G403["403"]
|
|
453
|
+
G -->|no| O{"OPTIONS?"}
|
|
454
|
+
O -->|yes| O204["204"]
|
|
455
|
+
O -->|no| S{"GET /api/stream?"}
|
|
456
|
+
S -->|yes| SSE["auth or anonymous, rate check,<br/>404 for an unknown node,<br/>then hand the response to the SSE hub"]
|
|
457
|
+
S -->|no| R{"route matches?"}
|
|
458
|
+
R -->|no| STAT["static file, 404 page or 405"]
|
|
459
|
+
R -->|yes| AU["open mode: anonymous viewer, rate-limited per address<br/>accounts mode: session required, rate-limited per user"]
|
|
460
|
+
AU --> CEIL{"route needs admin<br/>and role is not admin?"}
|
|
461
|
+
CEIL -->|yes| C403["403 (checked in both modes)"]
|
|
462
|
+
CEIL -->|no| BODY["read body, 1 MB cap"] --> CSRF{"session present and route has csrf?<br/>token must come from header or body, never the cookie"}
|
|
463
|
+
CSRF --> H["handler(ctx, app)"] --> J["JSON response + security headers"]
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
- **`api.js`** is a declarative route table: `{ method, path, auth, csrf, body,
|
|
467
|
+
handler }`. `auth` is `none`, `any` or `admin`. Handlers are thin: most call
|
|
468
|
+
`pickNode(ctx, app)` (from `?node=`, falling back to the primary node) and
|
|
469
|
+
return part of that monitor's read model. The main groups:
|
|
470
|
+
- health, build, session: `/api/health`, `/api/build`, `/api/about`, `/api/me`,
|
|
471
|
+
`/api/login`, `/api/logout`, `/api/logout-all`
|
|
472
|
+
- the read model: `/api/state`, `/api/sync`, `/api/mempool`,
|
|
473
|
+
`/api/mempool/dense`, `/api/peers`, `/api/net`, `/api/mining`,
|
|
474
|
+
`/api/nextblock`, `/api/blocks`, `/api/series`, `/api/events`, `/api/nodes`,
|
|
475
|
+
`/api/telemetry`, `/api/config`
|
|
476
|
+
- drill-down: `/api/block`, `/api/tx`
|
|
477
|
+
- explorer: `/api/x/search`, `/api/x/block`, `/api/x/tx`, `/api/x/address`
|
|
478
|
+
- markets: `/api/markets`, `/api/markets/depth`
|
|
479
|
+
- console and actions: `/api/rpc`, `/api/actions`, `/api/action`
|
|
480
|
+
- configuration written by the app: `/api/config/node/test` and
|
|
481
|
+
`/api/config/node` (the node-connection form; credentials go only to the
|
|
482
|
+
endpoint already configured), `/api/settings` (display settings, stored in
|
|
483
|
+
`config/blockyard.json`). State-changing routes in open mode refuse
|
|
484
|
+
cross-site requests by `Origin` / `Sec-Fetch-Site`.
|
|
485
|
+
- admin: `/api/users*`, `/api/password`, `/api/audit`
|
|
486
|
+
- **`sse.js`** (`StreamHub`): each client has at most one pending snapshot and one
|
|
487
|
+
pending series frame, and the newest replaces the older one. Event rows are
|
|
488
|
+
batched, capped at 800 per client, and trimmed if a client falls behind. Writes
|
|
489
|
+
are flushed on `setImmediate`. A comment ping every 15 s keeps intermediaries from
|
|
490
|
+
closing idle streams and detects dead sockets. A slow client gets fewer updates,
|
|
491
|
+
never an unbounded queue.
|
|
492
|
+
- **`static.js`**: serves files from `public/`.
|
|
493
|
+
- **Containment** is checked on the `realpath`, so both `../` traversal and
|
|
494
|
+
symlinks planted inside `public/` are refused.
|
|
495
|
+
- **HTML is rewritten per response** (`renderHtml`): a fresh script nonce
|
|
496
|
+
replaces `%BLOCKYARD_NONCE%`, the build id replaces `%BLOCKYARD_BUILD%`, and asset URLs get
|
|
497
|
+
`?v=<build>`.
|
|
498
|
+
- **The build id** is computed from the sizes and mtimes of files under
|
|
499
|
+
`public/`. The page compares the build it loaded with `/api/build` and tells
|
|
500
|
+
the user when the tab is out of date.
|
|
501
|
+
- Every response carries the same security headers. HSTS is added only on TLS
|
|
502
|
+
listeners.
|
|
503
|
+
- **`explorer.js`**: block and transaction pages built from this node's own RPC;
|
|
504
|
+
address pages from the local address index.
|
|
505
|
+
- **Batched.** Each page is one or two batched lane requests at priority 3.
|
|
506
|
+
Fetching 25 transactions one call at a time would take several seconds at
|
|
507
|
+
250 ms spacing.
|
|
508
|
+
- **Cached.** Confirmed transaction summaries are kept in an LRU of 3,000.
|
|
509
|
+
- **Cheap reads only.** `getblock` verbosity 2 is never used (it is megabytes
|
|
510
|
+
per block on this node).
|
|
511
|
+
- **Addresses.** Core has no address index at any setting, so `xAddress` reads
|
|
512
|
+
the index a node names in `addressIndex` (section 2.8): count, balance,
|
|
513
|
+
received and sent, and the transactions newest first with the net change
|
|
514
|
+
each made, 25 a page, and the unspent outputs (each output the index says
|
|
515
|
+
paid the address, checked with `gettxout`, for histories of at most 100
|
|
516
|
+
transactions; longer ones get `utxos: null` and a `utxoNote`). The reply
|
|
517
|
+
carries `index.tip`, `index.behind`, `index.following`, `index.stale` and
|
|
518
|
+
`index.postTip`. Without an index the reply says `indexed: false` with a
|
|
519
|
+
**null** count, never a fabricated zero, and the two insight-style RPCs Core
|
|
520
|
+
refuses are remembered as refused for ten minutes rather than re-sent on
|
|
521
|
+
every view. While the server is building the index, the same shape carries
|
|
522
|
+
`indexBuilding` (phase, done, total, rows, eta, paused) from the status
|
|
523
|
+
object `main.js` registers with `registerIndexBuild`.
|
|
524
|
+
- **Errors are sentences.** Handlers return `{ ok: false, error, hint }` rather
|
|
525
|
+
than throwing.
|
|
526
|
+
|
|
527
|
+
### 2.7 Auth (`server/auth/*`)
|
|
528
|
+
|
|
529
|
+
- **Sign-in by default** (since 2026-09-15; it shipped open in 0.0.9), bound to
|
|
530
|
+
`127.0.0.1`. With `auth.enabled: false` (`BLOCKYARD_AUTH=0`), every request is
|
|
531
|
+
served as a frozen anonymous user with role `viewer`. That ceiling is hardcoded,
|
|
532
|
+
not configurable. Admin routes return 403 in both modes, CSRF is not needed
|
|
533
|
+
because there is no cookie to ride, and rate limits apply per client address.
|
|
534
|
+
- **Accounts** (the default; `BLOCKYARD_AUTH=1` restores them after an override):
|
|
535
|
+
- scrypt password hashes (upgraded to current parameters on login)
|
|
536
|
+
- session tokens that are stored hashed, with idle and absolute TTLs
|
|
537
|
+
- double-submit CSRF (the token in a header or body is compared with the
|
|
538
|
+
server-side session, never with the cookie; RULES 3)
|
|
539
|
+
- a per-username lockout (`LoginGuard`) and a separate per-address limit on
|
|
540
|
+
`/api/login`
|
|
541
|
+
- roles `viewer` < `operator` < `admin`
|
|
542
|
+
- **Auditing.** Logins, RPC calls, denials and actions are appended to the audit
|
|
543
|
+
trail. Credentials are removed from entries before they are written.
|
|
544
|
+
|
|
545
|
+
`node scripts/manage-users.js` administers users from the command line.
|
|
546
|
+
|
|
547
|
+
### 2.8 The address index (`server/chain/*`)
|
|
548
|
+
|
|
549
|
+
Bitcoin Core cannot answer "which transactions touched this address": the
|
|
550
|
+
insight-style `getaddresstxids` and `getaddressbalance` are refused at every
|
|
551
|
+
setting, and `scantxoutset` reads the whole UTXO set for a balance only
|
|
552
|
+
(measured: 26.5 s for 40 addresses, holding the node's one RPC thread). So
|
|
553
|
+
BlockYard builds the index itself, the way `electrs` does, from the node's own
|
|
554
|
+
files. The numbers are in `docs/MEASUREMENTS.md` §28-30 and the history in
|
|
555
|
+
`docs/DEFECTS.md`.
|
|
556
|
+
|
|
557
|
+
- **Reading the files** (`chain/blockfile.js`). `blocks/blkNNNNN.dat` holds the
|
|
558
|
+
blocks as received; `revNNNNN.dat` holds the undo data written when each block
|
|
559
|
+
was connected, which names every spent output's value and script. Both are
|
|
560
|
+
XOR-obfuscated at rest since Core v28 (`blocks/xor.dat`). Undo records are
|
|
561
|
+
paired with blocks in connection order and checksum-verified. The files are
|
|
562
|
+
opened for reading and nothing else, and the reader copes with the newest file
|
|
563
|
+
still being appended to. `chain/tx.js` decodes raw transactions and blocks into
|
|
564
|
+
Core's verbose field names, checked field-for-field against `getblock <hash> 3`
|
|
565
|
+
(`scripts/decode-check.js`).
|
|
566
|
+
- **The row** (`chain/index/rows.js`): 21 bytes per (script, transaction that
|
|
567
|
+
touched it) -- 8 bytes of `sha256(scriptPubKey)`, a 3-byte height, a 2-byte
|
|
568
|
+
position in the block, and the signed net satoshis the transaction moved for
|
|
569
|
+
that script -- big-endian, so byte order is sort order. A script paid and spent
|
|
570
|
+
in one transaction is one row. Spends come from the undo record, so no UTXO
|
|
571
|
+
replay. The rows are built lean, straight from the bytes, and checked
|
|
572
|
+
row-for-row against rows from the full decoder.
|
|
573
|
+
- **The build** (`chain/index/build.js`, `node scripts/index-build.js --out <dir>`):
|
|
574
|
+
block hashes for every height in batches of `getblockhash` (`heights.js`, a
|
|
575
|
+
`SharedArrayBuffer` table every worker reads); every blk/rev pair scanned on a
|
|
576
|
+
worker pool (`worker.js`), rows partitioned by the key's first byte into 256
|
|
577
|
+
bucket files; a check that every height is indexed exactly once, or the build
|
|
578
|
+
stops rather than publish a hole; each bucket sorted into `seg-XX.rows` plus a
|
|
579
|
+
sparse `seg-XX.idx` (one key per 4,096 rows); and a manifest written last, so an
|
|
580
|
+
index without one is unfinished. Measured on the whole chain: 29 min 45 s on 16
|
|
581
|
+
workers, 5.89 billion rows, 123.7 GB (§30). The build reads ~880 GB and writes
|
|
582
|
+
~120 GB, so `--out` should be a different device from the block files.
|
|
583
|
+
- **Lookups** (`chain/index/store.js`, `IndexStore`). The sparse keys of the 256
|
|
584
|
+
base segments and of every layer are held in memory; a lookup binary-searches
|
|
585
|
+
them and reads only the row blocks that can hold its key, then merges the base,
|
|
586
|
+
the layers and the live tail in height order. Measured: 0.25 ms median first
|
|
587
|
+
touch, 0.03 ms warm; a 2.3 M-transaction address summed in 83 ms. **No file is
|
|
588
|
+
held open**: a lookup opens the one file it reads and closes it (three
|
|
589
|
+
syscalls on a 0.25 ms lookup). The store used to keep one descriptor per
|
|
590
|
+
segment and layer, 256 and more, which is the whole soft limit on a stock
|
|
591
|
+
macOS (`ulimit -n` 256) before the server has opened a socket.
|
|
592
|
+
- **The build inside the server** (`main.js`, using `buildIndex`, `defaultWorkers`
|
|
593
|
+
and `rpcPacer` from `build.js`). A configured directory with no
|
|
594
|
+
`manifest.json` is built in the background on the server's first start, unless
|
|
595
|
+
`addressIndexBuild: "manual"`. Workers: `addressIndexWorkers` if set, else half
|
|
596
|
+
of `defaultWorkers()` (`cpus − 4`, one per ~2.5 GB, at most 16) capped at 4,
|
|
597
|
+
because the node shares the disk. The build has its own `RpcClient` on a second
|
|
598
|
+
lane — on the monitor's one-in-flight lane its `getblockhash` batches sat
|
|
599
|
+
behind multi-second mempool and block reads and both starved. It is **paced by
|
|
600
|
+
the node's own answers**: `rpcPacer` reads the monitor lane's telemetry before
|
|
601
|
+
each file is handed to a worker, holds (re-checking every 10 s) while the node
|
|
602
|
+
is failing, its breaker is open or its average latency is above
|
|
603
|
+
`rpc.slowLatencyMs` (5 s), and eases to one file per 250 ms above 40% of it.
|
|
604
|
+
Measured on the first Mac install: a build at full speed on the node's disk
|
|
605
|
+
turned its RPC into 18 s answers and 90 s timeouts. Progress is the
|
|
606
|
+
`address-index-building` quality flag (phase, done of total, rows, ETA from
|
|
607
|
+
the phase's own rate, `paused while the node's RPC is slow`), refreshed at most
|
|
608
|
+
every 5 s; `index` events mark start, finish and failure; on finish the follower
|
|
609
|
+
starts and address pages go live with no restart; failure leaves
|
|
610
|
+
`address-index-build-failed` naming the command to run by hand. There is no
|
|
611
|
+
resume: a build stopped with the server starts over next time.
|
|
612
|
+
- **Following the chain** (`chain/index/live.js`, `LiveIndex`). The base is
|
|
613
|
+
immutable and covers the chain to the block it was built at. The follower polls
|
|
614
|
+
the node's tip, rolls the tail back to the fork if a block it holds is no longer
|
|
615
|
+
on the node's chain, and fetches each new block with `getblock <hash> 3` over
|
|
616
|
+
RPC (so it works for a node whose files are elsewhere), turning it into the same
|
|
617
|
+
rows (`verboseBlockRows`, checked against the file builder). Every block and
|
|
618
|
+
every rollback is appended to `<index>/live.log` -- CRC-framed, replayed on
|
|
619
|
+
restart, truncated at the first torn record -- **before** it is served. Blocks
|
|
620
|
+
100 deep are folded, 144 at a time, into immutable `layers/L<from>-<to>` and the
|
|
621
|
+
log is rewritten without them; past 32 layers they are merged. A reorganisation
|
|
622
|
+
deeper than the tail is not repaired: the index reports itself stale and the
|
|
623
|
+
page says to rebuild.
|
|
624
|
+
- **Configuration.** `addressIndex: "<dir>"` on a node entry, with
|
|
625
|
+
`addressIndexBuild: "manual"` to keep the server from building a missing one
|
|
626
|
+
and `addressIndexWorkers` for the background build's thread count (the
|
|
627
|
+
installer writes the number given; 1 on a spinning disk). One index serves
|
|
628
|
+
every node on the same chain (the store refuses a manifest for another chain);
|
|
629
|
+
the directory must be writable by the service, because the follower writes
|
|
630
|
+
`live.log` and `layers/` inside it. The explorer opens a store once and reopens
|
|
631
|
+
it when a rebuild replaces the manifest.
|
|
632
|
+
- **Scripts.** `scripts/blockfile-measure.js` (what a full read costs, `--verify`
|
|
633
|
+
against the node), `scripts/index-bench.js` (SQLite against sorted flat files,
|
|
634
|
+
§29), `scripts/index-build.js` (the build, by hand), `scripts/index-benchmark.js`
|
|
635
|
+
(lookup latency and `--verify` balances against `scantxoutset`, §30),
|
|
636
|
+
`scripts/setup.js` (the installer: reads the node's `bitcoin.conf`, proves the
|
|
637
|
+
node with `scripts/check.js`, writes the config, builds the index or leaves it
|
|
638
|
+
to the server) and `scripts/check.js` (the same checks against every configured
|
|
639
|
+
node, every RPC call timed — measure a slow node alone before blaming what runs
|
|
640
|
+
beside it).
|
|
641
|
+
- **Tests**: `test/chain-blockfile.test.js`, `test/chain-tx.test.js`,
|
|
642
|
+
`test/chain-index.test.js`, `test/chain-index-live.test.js`.
|
|
643
|
+
|
|
644
|
+
---
|
|
645
|
+
|
|
646
|
+
## 3. The browser
|
|
647
|
+
|
|
648
|
+
`public/index.html` is the whole application shell: a header, a nav, and one
|
|
649
|
+
`<section class="page" data-page="...">` per page. All scripts are ES modules
|
|
650
|
+
loaded from the same origin. There is no build step and no framework.
|
|
651
|
+
|
|
652
|
+
| Module | Role |
|
|
653
|
+
|---|---|
|
|
654
|
+
| `app.js` | state, API client, SSE client, hash router, render dispatch, background refresh, watchdogs, the sync strip, the Overview page |
|
|
655
|
+
| `panels.js` | Chain, Mempool, Peers, Network, Events, Node & RPC, Admin pages |
|
|
656
|
+
| `mining.js` | Block flow, the Block space viewer (both modes), the Mining page and next-block panels |
|
|
657
|
+
| `charts.js` | the canvas chart kit: `lineChart`, `histogram`, `scatter`, `meter`, `stackedBars`, `sparkline`, and `paint()` |
|
|
658
|
+
| `explorer.js` | explorer pages (pure renderers, one fetch per route) |
|
|
659
|
+
| `markets.js`, `pricechart.js`, `depthchart.js` | Markets page: flat price chart with axes and crosshair, the same candles on the 3D board, the depth chart |
|
|
660
|
+
| `kiosk.js` | the 3D Markets board and the Block space board side by side, with a full-screen button |
|
|
661
|
+
| `goggles.js` | the 2D treemap maps (squarified) |
|
|
662
|
+
| `blockpack.js`, `feepalette.js`, `blockscene3d.js`, `details3d.js`, `agents.js` | the 3D engine (section 4) |
|
|
663
|
+
| `settings.js` | display settings: `DEFAULTS`, the `PANEL` rows of the settings dialog, `normalise()`, and the option builders (`spaceOptions`, `enabledEffects`, ...) the boards read; stored on the server (`/api/settings`) with a `localStorage` copy |
|
|
664
|
+
| `about.js` | the About page (version, system and node info) |
|
|
665
|
+
| `tetris.js` / `tetrust.js`, `breakout.js` / `blockout.js`, `arkanoid.js` / `blockanoid.js`, `tetsound.js` | the Diversions: pure game rules in the first file of each pair, the tab drawn on the 3D engine in the second, and Tetrust's sound |
|
|
666
|
+
| `x86.js`, `dospc.js`, `soundcard.js`, `dosworker.js`, `dosaudio.js`, `dosio.js`, `dosgame.js`, `doom.js`, `quake.js` | the DOS Diversions: an i386 interpreter, the PC around it (DOS/4GW for DOOM, the go32 stub and CWSDPMI for Quake), a Sound Blaster Pro 2 with an OPL3, the worker the machine runs in, the AudioWorklet it plays through, the pure keyboard/config/text-mode helpers, the shared tab, and each game's own few lines (section 3.4) |
|
|
667
|
+
| `fmt.js` | formatters: decimal units (as the node prints them), `–` for anything absent |
|
|
668
|
+
| `login.js` | the login page (a separate file because of the CSP) |
|
|
669
|
+
|
|
670
|
+
### 3.1 `app.js`
|
|
671
|
+
|
|
672
|
+
**State.** A single exported `state` object. The main fields:
|
|
673
|
+
|
|
674
|
+
- `snap`, `series`, `events`: what is currently displayed.
|
|
675
|
+
- `byNode`: a per-node cache of snapshot and series, so switching back to a node
|
|
676
|
+
is instant.
|
|
677
|
+
- `node`: the node being watched.
|
|
678
|
+
- `page` and `xroute`: the current page and explorer subroute.
|
|
679
|
+
- `viewerMode`: the Block space viewer mode, remembered in `localStorage`.
|
|
680
|
+
- `mempoolDist`, `denseBlock`: the pool data the viewers draw.
|
|
681
|
+
|
|
682
|
+
**Data flow.**
|
|
683
|
+
|
|
684
|
+
```mermaid
|
|
685
|
+
sequenceDiagram
|
|
686
|
+
participant B as app.js
|
|
687
|
+
participant S as server
|
|
688
|
+
B->>S: GET /api/me, /api/nodes, /api/config, /api/build
|
|
689
|
+
B->>S: GET /api/state?node=... (backgroundRefresh)
|
|
690
|
+
B->>S: GET /api/events?limit=200
|
|
691
|
+
B->>S: EventSource /api/stream?node=...
|
|
692
|
+
loop about once a second
|
|
693
|
+
S-->>B: event: snapshot (state.snap = s; render())
|
|
694
|
+
end
|
|
695
|
+
loop every 20 s
|
|
696
|
+
S-->>B: event: series (merged into the cache, never replaced)
|
|
697
|
+
B->>S: GET /api/state (only while the tab is visible and not paused)
|
|
698
|
+
end
|
|
699
|
+
S-->>B: event: events (feed rows, capped at 1,500)
|
|
700
|
+
Note over B: page-scoped fetches on their own schedules:<br/>/api/mempool (30 s on the viewer pages),<br/>/api/nextblock (20 s on Mining and Block space, 60 s on Overview),<br/>/api/peers (15 s on Peers), /api/markets (while Markets is open)
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
**Rendering is event-driven.** `render()` runs when a frame arrives, on a page
|
|
704
|
+
change, on resize, and after a page-scoped fetch completes. It switches on
|
|
705
|
+
`state.page` and calls that page's renderer with `(snapshot, state, helpers)`.
|
|
706
|
+
`helpers` is how the page modules get `api`, `toast`, `setText`, `canvas`, the
|
|
707
|
+
chart kit and the fetch helpers without importing `app.js` (which would be a
|
|
708
|
+
circular import).
|
|
709
|
+
|
|
710
|
+
Continuous animation does **not** go through `render()`. Each 3D canvas runs its
|
|
711
|
+
own `requestAnimationFrame` loop, which stops as soon as the picture is still
|
|
712
|
+
(section 4.7).
|
|
713
|
+
|
|
714
|
+
**Router.** The route is the URL hash: `#overview`, `#space`, `#chain`, ...
|
|
715
|
+
`#explorer` has subroutes (`#explorer/block/<height|hash>[/<page>]`,
|
|
716
|
+
`#explorer/tx/<txid>`, `#explorer/address/<addr>[/<page>]`), which `setPage()`
|
|
717
|
+
splits into `state.page = 'explorer'` and `state.xroute`. Every explorer page is
|
|
718
|
+
therefore a shareable link.
|
|
719
|
+
|
|
720
|
+
**Liveness and honesty rules in the client.**
|
|
721
|
+
|
|
722
|
+
- **Data is never nulled.** `state.snap` and `state.series` are never set to
|
|
723
|
+
`null`. Canvases are wiped in exactly one place, `switchNode()`, because the
|
|
724
|
+
pixels on screen belong to a different node.
|
|
725
|
+
- **Charts draw through `paint(canvas, { when, draw, placeholder })`.** A missing
|
|
726
|
+
sample marks a populated chart stale with a pill. It never erases it.
|
|
727
|
+
- **A watchdog runs every 15 s.** If nothing has arrived for 90 s, it tries to
|
|
728
|
+
recover the stream. If the node was removed from the config, it switches to a
|
|
729
|
+
node that exists. Otherwise it says in a banner that the figures are old. The
|
|
730
|
+
stream badge changes to `stale` when the snapshot is old.
|
|
731
|
+
- **Build check.** Every 5 minutes the tab compares the build it loaded with the
|
|
732
|
+
server's current build and says when it is running replaced code.
|
|
733
|
+
- **"Refresh now"** on the pool viewers is enabled only while that viewer's board
|
|
734
|
+
is at rest (`viewerIdle(canvas)`).
|
|
735
|
+
|
|
736
|
+
### 3.2 Page modules
|
|
737
|
+
|
|
738
|
+
- **`mining.js`**
|
|
739
|
+
- **`blockFlow()`**: the block being built on the left, the chain tip in the
|
|
740
|
+
centre, recent blocks to the right. The arrival animation is keyed on the
|
|
741
|
+
chain's tip height and plays only when the page actually saw the tip change,
|
|
742
|
+
never on first paint. The rail's speed comes from the measured average block
|
|
743
|
+
gap.
|
|
744
|
+
- **`renderBlockSpace()`**: the `#space` page. The pool viewer at panel size,
|
|
745
|
+
plus the next-block and tip panels.
|
|
746
|
+
- **Viewer modes** (`VIEWER_MODES`). Simple (id `1`) shows the richest cells as
|
|
747
|
+
cubes and the tail as equal aggregate pieces on a 44-unit board. Detailed
|
|
748
|
+
(id `2`) shows every transaction in the next block's worth, from
|
|
749
|
+
`/api/mempool/dense`, as low slabs on a 96-unit board (`DENSE_OPTS`:
|
|
750
|
+
`resolution: 96, slab: 1.2, order: 'diagonal', gridStep: 8`). Both modes use
|
|
751
|
+
full transaction ids, so switching modes moves tiles rather than emptying and
|
|
752
|
+
refilling the board, and a click on a tile opens it in the explorer.
|
|
753
|
+
- **One viewer everywhere.** `poolViewer()` is used on Overview, Block space,
|
|
754
|
+
Mempool and Kiosk, so the viewer cannot drift apart between pages.
|
|
755
|
+
- **`explorer.js`**: pure `data -> HTML` renderers, plus `renderExplorer()`, which
|
|
756
|
+
fetches once per route and repaints only when the output would change, so a
|
|
757
|
+
half-typed search survives the once-a-second frames.
|
|
758
|
+
- **`markets.js`**
|
|
759
|
+
- **The two boards.** It lays out candles as `board3d` tiles with a custom
|
|
760
|
+
camera (`CAMERA_3D`, section 4.8). `pricechart.js` draws the flat, readable
|
|
761
|
+
chart with axes and a crosshair.
|
|
762
|
+
- **The depth chart** (`depthchart.js`) draws cumulative bids and asks per
|
|
763
|
+
exchange, the combined line, the combined line N minutes ago, and change bars
|
|
764
|
+
on a symmetric log axis.
|
|
765
|
+
- **`kiosk.js`** reuses `renderMarketsBoard` and `poolViewer` unchanged.
|
|
766
|
+
|
|
767
|
+
### 3.3 CSP constraints
|
|
768
|
+
|
|
769
|
+
The CSP (`server/http/static.js`) is same-origin only:
|
|
770
|
+
|
|
771
|
+
```
|
|
772
|
+
default-src 'self'; script-src 'self' 'nonce-<per response>'; style-src 'self';
|
|
773
|
+
img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';
|
|
774
|
+
base-uri 'self'; form-action 'self'; object-src 'none'
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
What this means for front-end code:
|
|
778
|
+
|
|
779
|
+
- **No `style="..."` attributes**, in HTML files or in markup injected through
|
|
780
|
+
`innerHTML`. `style-src` has no `'unsafe-inline'` and there is no
|
|
781
|
+
`style-src-attr`. For data-driven sizes, write `data-w`, `data-left` or `data-h`
|
|
782
|
+
and call `applyDataSizes(root)`, which writes the number through the CSSOM
|
|
783
|
+
(`el.style.width = ...`). CSP allows that, and a value that never becomes an
|
|
784
|
+
attribute cannot be an injection point. Static styling belongs in classes in
|
|
785
|
+
`public/css/app.css`. CSS custom properties set with `style.setProperty()` are
|
|
786
|
+
fine.
|
|
787
|
+
- **No inline scripts** unless they carry the per-response nonce. In practice
|
|
788
|
+
every script is an external module.
|
|
789
|
+
- **No CDN, no third-party fetch.** External data (exchange prices) is fetched by
|
|
790
|
+
the server.
|
|
791
|
+
- **Escape all text** that comes from the node, a log line or an exchange with
|
|
792
|
+
`fmt.esc()` before putting it into markup.
|
|
793
|
+
|
|
794
|
+
`test/csp.test.js` and `test/web-contract.test.js` enforce these rules.
|
|
795
|
+
|
|
796
|
+
---
|
|
797
|
+
|
|
798
|
+
### 3.4 The DOS Diversions: Wolfenstein 3D, DOOM and Quake
|
|
799
|
+
|
|
800
|
+
The shareware `WOLF3D.EXE` v1.4, `DOOM.EXE` v1.9 and `QUAKE.EXE` v1.06 run unmodified on a PC emulated in
|
|
801
|
+
the browser.
|
|
802
|
+
Nothing is ported and no dependency is used; every layer is this repository's own:
|
|
803
|
+
|
|
804
|
+
```
|
|
805
|
+
wolf3d.js / doom.js / quake.js -> dosgame.js (page) --scancodes, mouse, run/pause--> dosworker.js (Worker)
|
|
806
|
+
^ |
|
|
807
|
+
| frames (320x200 indices + palette), text-mode cells, stats | createPC() dospc.js
|
|
808
|
+
+----------------------------------------------------------------------+ createCpu() x86.js
|
|
809
|
+
dosaudio.js (AudioWorklet) <--stereo PCM over a MessagePort-----------------+ soundcard.js (SB Pro 2 + OPL3)
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
- **`x86.js`** is a user-mode i386 with an x87, interpreted. No paging or rings: a DOS extender's
|
|
813
|
+
program runs in protected mode, and segment registers carry a base and a size. EIP is
|
|
814
|
+
kept linear and converted at the edges (a pushed return address, a loaded jump target) against
|
|
815
|
+
the code segment's base, ESP is an offset in SS: DOS/4GW's segments are all at 0, DJGPP's at the
|
|
816
|
+
program's memory block. A code segment whose descriptor is 16-bit decodes 16-bit (DJGPP's
|
|
817
|
+
start-up and exit run small 16-bit helpers in DOS memory). **Real mode** is a switch
|
|
818
|
+
(`cpu.realMode`) for Wolfenstein 3D: a segment's base is its value times sixteen, code is 16-bit,
|
|
819
|
+
the stack pointer is SP and wraps inside its segment, the string instructions count with SI, DI
|
|
820
|
+
and CX, and an interrupt pushes a 16-bit frame and goes through the vector table at 0:0. Real-mode
|
|
821
|
+
code runs through `step()` rather than the decoded cache: it needs about a third of the speed it
|
|
822
|
+
gets. The page's CSP forbids eval, so there
|
|
823
|
+
is no JIT; speed comes from keeping every value an int32 (a `>>> 0` above 2^31 is a double and,
|
|
824
|
+
in a closure variable, an allocation), lazy flags recorded in an `Int32Array`, one try/catch
|
|
825
|
+
around the loop rather than each instruction, and 32-bit fast paths for the instructions
|
|
826
|
+
compilers emit most. The FPU keeps its stack in a Float64Array and converts operands through
|
|
827
|
+
typed-array views. **Decoded instructions are cached**: each is decoded once into three int32s
|
|
828
|
+
(a handler number with its registers packed beside it, a displacement, an immediate) in an
|
|
829
|
+
Int32Array per 4 KB page, and `run()` dispatches on the handler number with one switch; the forms
|
|
830
|
+
it does not specialise run through `step()`. Not closures -- a closure per instruction made every
|
|
831
|
+
call megamorphic and ran slower than the interpreter. Writes into a page holding decoded code
|
|
832
|
+
clear only the instructions they overlap (DOOM and Quake both patch constants into their own span
|
|
833
|
+
drawers on every call), and the machine calls `cpu.invalidate()` after it writes memory directly.
|
|
834
|
+
The ALU operations have a handler each (their flag bookkeeping inline, not one shared routine
|
|
835
|
+
branching on the operation), so do the x87 forms Quake runs most, and the loop forms a memory
|
|
836
|
+
operand's address inline before the switch: the switch is too big for V8 to inline helpers into
|
|
837
|
+
every case, and those calls were most of what was left. About 141 million instructions a second on
|
|
838
|
+
Quake headless on one core and 115-118 in a Chromium worker; Quake's timedemo 52.5 fps of wall time
|
|
839
|
+
(MEASUREMENTS §32-35).
|
|
840
|
+
- **`dospc.js`** is the machine, and it plays whichever DOS extender the program was bound to.
|
|
841
|
+
`boot()` tells them apart by the file: an EXE with neither an LE nor a COFF image inside is a
|
|
842
|
+
plain DOS program, and `bootMZ` loads it as DOS did -- a PSP, the image after it with its segment
|
|
843
|
+
relocations applied, the rest of conventional memory its block, the vector table filled with
|
|
844
|
+
pointers at stubs the machine answers (a program that installs its own handler gets it called),
|
|
845
|
+
and DOS's allocator behind INT 21h 48h/49h/4Ah, which Wolfenstein 3D uses to shrink itself and
|
|
846
|
+
claim the rest. `loadLE` finds DOOM's LE executable inside the DOS/4GW
|
|
847
|
+
stub, loads it at +1 MB and applies its fixups; `parseCoff`/`bootCoff` find Quake's COFF image
|
|
848
|
+
behind the go32 stub and do what that stub leaves behind -- a memory block with the sections in
|
|
849
|
+
it, selectors based at it, a transfer buffer with the PSP right below it (DJGPP's libc finds the
|
|
850
|
+
PSP by subtracting 100h), and the "stubinfo" crt0 reads through FS. The extenders themselves never
|
|
851
|
+
run; this file answers INT 21h (files from an in-memory, case-insensitive directory tree, written
|
|
852
|
+
files handed to the host on close, and a real system file table, because DJGPP's `fstat` walks
|
|
853
|
+
it), INT 31h DPMI (descriptors with base and size, memory blocks, protected-mode vectors,
|
|
854
|
+
simulated real-mode interrupts), INT 10h/16h/33h, and the hardware a DOS game programs directly:
|
|
855
|
+
the 8259s, the 8254 (with the BIOS tick count kept in step with it, which DJGPP's `uclock` reads),
|
|
856
|
+
the keyboard controller, and a VGA with planar memory, unchained mode, write mode 1's latched copy
|
|
857
|
+
(Wolfenstein 3D and DOOM copy between pages with it) and CRTC page flipping (how DOOM and
|
|
858
|
+
Wolfenstein 3D draw) as well as the linear window (how Quake does). The clock is injected (`now()`): wall
|
|
859
|
+
time in the worker, instruction count in tests, so a headless boot is the same run every time.
|
|
860
|
+
- **`soundcard.js`** is a Sound Blaster Pro 2 at 220h/IRQ 7/DMA 1 — the DSP's command set and the
|
|
861
|
+
8237 DMA controller it pulls samples through — and an OPL3 modelled as operators with
|
|
862
|
+
documented envelope rates. `tick(t)` produces output for the machine time that passed and raises
|
|
863
|
+
the end-of-block interrupt from inside the same loop.
|
|
864
|
+
- **`dosworker.js`** takes a game's name, fetches its files (`dosio.js` `GAMES`), and runs the
|
|
865
|
+
machine in ~10 ms slices, yielding between them so input arrives. It sends a frame when the CRTC
|
|
866
|
+
start address or the palette changed, or once the linear window has been written and a slice has
|
|
867
|
+
passed without more writes (never half of Quake's copy). The frame buffer bounces between the
|
|
868
|
+
worker and the page so no frame allocates. Savegames and configs are kept in IndexedDB, one
|
|
869
|
+
database a game.
|
|
870
|
+
- **`dosgame.js`** is a game's tab: it draws, captures input, pauses when the tab is not on screen
|
|
871
|
+
(the worker's clock stops, so nothing moves), and uses a ScriptProcessor when `audioWorklet` is
|
|
872
|
+
unavailable — a plain-HTTP LAN address is not a secure context. `doom.js` and `quake.js` give it
|
|
873
|
+
names, key lists and switches, and so does `wolf3d.js`.
|
|
874
|
+
|
|
875
|
+
The game files are served from `games/<game>_dos/` by `server/http/games.js`
|
|
876
|
+
(`/games/<game>/<path>`: a game it names, at most one directory and 8.3 names of `.EXE`, `.WAD`,
|
|
877
|
+
`.PAK`, `.CFG` and `.WL1`, behind the session when accounts are on) rather than from `public/`, whose every
|
|
878
|
+
file feeds the build id.
|
|
879
|
+
|
|
880
|
+
## 4. The 3D engine
|
|
881
|
+
|
|
882
|
+
The 3D viewer turns a set of transactions, or any caller-supplied tiles, into
|
|
883
|
+
square tiles on a grid. It animates them between layouts without collisions and
|
|
884
|
+
draws them with hand-written canvas polygons. It is split into files by
|
|
885
|
+
responsibility:
|
|
886
|
+
|
|
887
|
+
| File | Responsibility | Canvas? |
|
|
888
|
+
|---|---|---|
|
|
889
|
+
| `blockpack.js` | the square packer (`packBlock`, `BlockLayout`, `packStable`, `sideFor`, `ditheredSide`, `vsizeForSide`), an original implementation | no, pure |
|
|
890
|
+
| `feepalette.js` | the 128 feerate bands (a geometric series from 0.1 to 2,000 sat/vB; sky blue to purple, neighbours stepped in tone) and their colour ramp (`feeColor`, `feeShade`) | no, pure |
|
|
891
|
+
| `blockscene3d.js` | projection, the sphere, transition planning, sampling, scene building (faces, paint order, shadows, idle-effect lighting) | no, pure |
|
|
892
|
+
| `details3d.js` | the renderer: canvas sizing, the fit, ground and grid, axes, stars, the rAF loop, idle-effect scheduling, hover, public entry points | yes |
|
|
893
|
+
| `agents.js` | the `AGENTS` registry: the idle effects that are something moving rather than a pattern (`{ build, frame, draw }` per kind; see `docs/EFFECTS-AGENTS.md`) | draw only |
|
|
894
|
+
|
|
895
|
+
Keeping geometry and choreography pure is what makes the collision-free and
|
|
896
|
+
constant-view invariants testable without a browser.
|
|
897
|
+
|
|
898
|
+
### 4.1 Pipeline
|
|
899
|
+
|
|
900
|
+
```mermaid
|
|
901
|
+
flowchart TD
|
|
902
|
+
IN["cells (mempool3d / block3d / render3d)<br/>or laid tiles (board3d)"] --> TX["toTxs: split the aggregate tail into<br/>equal pieces that pack as whole squares"]
|
|
903
|
+
TX --> PK["packBlock: square tiles, first fit, rows upward<br/>(rescaled in 5% steps until the block fits the grid)"]
|
|
904
|
+
PK --> SIG{"layout signature changed?"}
|
|
905
|
+
SIG -->|no| KEEP["leave the running animation alone"]
|
|
906
|
+
SIG -->|"yes, and a transition is in flight"| PARK["park as pending; plan it when this one lands"]
|
|
907
|
+
SIG -->|"yes"| PLAN["planTransition(prev, next)<br/>hold / move / enter / exit, lanes, landings"]
|
|
908
|
+
PLAN --> LOOP["rAF loop"]
|
|
909
|
+
LOOP --> FA["frameAt(plan, t, view)<br/>sampleTween per tile"]
|
|
910
|
+
FA --> BS["buildScene(tiles, view)<br/>faces, paint order, shadows, fxAt lighting"]
|
|
911
|
+
BS --> PF["paintFrame<br/>background, stars, constant transform (obliqueFit),<br/>ground, shadows, glow layer, axes, cubes, price line, cycles, ball"]
|
|
912
|
+
PF --> Q{"settled and no effect running?"}
|
|
913
|
+
Q -->|no| LOOP
|
|
914
|
+
Q -->|yes| IDLE["park the loop; schedule the next idle effect"]
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
**Packing (`packBlock`, `public/js/blockpack.js`)** is an original square packer:
|
|
918
|
+
|
|
919
|
+
- `vbytesPerUnit` is chosen so a full block needs slightly less than the grid's area. A
|
|
920
|
+
transaction's side is the rounded square root of its area in grid units, with a small
|
|
921
|
+
allowance so the many transactions just over one unit keep their share, at least 1 and
|
|
922
|
+
at most the grid width. `vsizeForSide` is its exact inverse, which the renderer uses to
|
|
923
|
+
cut the aggregate tail into whole squares.
|
|
924
|
+
- **Detailed** uses area-true sides instead (`ditheredSide`, `dither: true`): a transaction
|
|
925
|
+
of u units is drawn at floor(√u) or one more, the larger with the probability that makes
|
|
926
|
+
its expected area exactly u, the coin a hash of its txid so its side never changes between
|
|
927
|
+
refreshes. Nearest-side rounding drew a live block of mostly 140 vB transactions (1.27
|
|
928
|
+
units each, drawn as one) at 82% of its true area, so a full block stopped short of the
|
|
929
|
+
top of the board.
|
|
930
|
+
- Squares are placed first-fit into rows scanning upward, in the order given
|
|
931
|
+
(richest first). Row 0 is the expensive end, and it is drawn at the bottom.
|
|
932
|
+
- Resting squares never overlap. The collision-free proof depends on this.
|
|
933
|
+
- The renderer draws **one block's worth** (`takeOneBlock`), so the grid comes out
|
|
934
|
+
full.
|
|
935
|
+
|
|
936
|
+
`packStable()` (keep survivors in place) exists and is tested, but the renderer
|
|
937
|
+
packs fresh every time, because stable packing left the resting board ragged.
|
|
938
|
+
|
|
939
|
+
**Aggregate tail.** The backend sends small transactions as one aggregate cell.
|
|
940
|
+
The renderer splits it into equal pieces whose sides are whole grid units. The mempool's
|
|
941
|
+
aggregate also carries `strata` (the tail grouped by feerate, richest first), and each piece
|
|
942
|
+
takes the feerate of the stratum it falls in, so the tail shows its own spread of colours.
|
|
943
|
+
Pieces are renamed `aggregate@x,y` by slot, so an unchanged slot keeps its
|
|
944
|
+
identity and does not animate.
|
|
945
|
+
|
|
946
|
+
### 4.2 `planTransition` — rise, travel, drop
|
|
947
|
+
|
|
948
|
+
Each tile in the next layout is compared with the previous layout by `txid`:
|
|
949
|
+
|
|
950
|
+
- **hold**: same position and size, so it does not move.
|
|
951
|
+
- **move**: same id, different position or size.
|
|
952
|
+
- **enter**: a new id.
|
|
953
|
+
- **exit**: an id that has gone.
|
|
954
|
+
|
|
955
|
+
```mermaid
|
|
956
|
+
flowchart LR
|
|
957
|
+
subgraph "phase 1: RISE"
|
|
958
|
+
R1["movers lift straight up out of their own slots<br/>(each starts within riseStagger)"]
|
|
959
|
+
R2["exits fly up and off screen,<br/>each with its own start and acceleration"]
|
|
960
|
+
end
|
|
961
|
+
subgraph "phase 2: TRAVEL"
|
|
962
|
+
T1["each mover shifts at its OWN lane altitude,<br/>in two legs (x then y, or y then x)"]
|
|
963
|
+
end
|
|
964
|
+
subgraph "phase 3: DROP"
|
|
965
|
+
D1["movers fall straight into their new slots<br/>under gravity and bounce to rest"]
|
|
966
|
+
D2["arrivals fall in from off screen,<br/>one gravity arc straight into their bounce"]
|
|
967
|
+
end
|
|
968
|
+
R1 --> T1 --> D1
|
|
969
|
+
R2 -.-> T1
|
|
970
|
+
T1 -.-> D2
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
**Why this cannot collide.** It takes three steps:
|
|
974
|
+
|
|
975
|
+
1. Resting slots never overlap, so no two tiles meet while rising straight up.
|
|
976
|
+
2. During travel, two movers whose swept footprints overlap are given **disjoint
|
|
977
|
+
altitude intervals**, and every mover travels above the tallest resting cube
|
|
978
|
+
under its sweep. Tiles in different lanes are separated vertically; tiles in
|
|
979
|
+
the same lane never occupy the same ground.
|
|
980
|
+
3. New slots never overlap, so no two tiles meet while dropping straight down.
|
|
981
|
+
|
|
982
|
+
The swept footprint includes the corner of the two-leg route, because the route is
|
|
983
|
+
L-shaped, not diagonal.
|
|
984
|
+
|
|
985
|
+
**Lane assignment.** Movers are sorted biggest first, so big cubes get the low
|
|
986
|
+
lanes. Each mover's base altitude starts at
|
|
987
|
+
`max(laneGap, liftMin * (0.55..1.45 by hash))`. It is raised above resting cubes
|
|
988
|
+
under its sweep, then placed first-fit in a gap between overlapping lanes already
|
|
989
|
+
assigned. Exits rise to `exitTo` and arrivals fall from `enterFrom`, both above
|
|
990
|
+
every lane. The off-screen part of a flight exists only in the drawing
|
|
991
|
+
(`entry` → `visualBase` → `offscreenLift`), so the real altitudes the proof depends
|
|
992
|
+
on are unchanged.
|
|
993
|
+
|
|
994
|
+
**Landings** use gravity and mass:
|
|
995
|
+
|
|
996
|
+
- `GRAVITY` is set so a fall of 12 units takes 0.6 s. A fall from `h` takes
|
|
997
|
+
`sqrt(2h/g)`, so higher drops take longer and no two tiles finish together.
|
|
998
|
+
Falls are timed as if from at most 24 units.
|
|
999
|
+
- Each tile's coefficient of restitution comes from its size (`restitutionOf`:
|
|
1000
|
+
heavier means a deader bounce) plus a small per-tile hash.
|
|
1001
|
+
- `bounceDrop` is a `1 - u^2` fall followed by sine-arc hops, each `e^2` as high
|
|
1002
|
+
and `e` as long as the one before, until a hop would be under 1% of the drop.
|
|
1003
|
+
- A tile flashes briefly (`lock`) when it lands.
|
|
1004
|
+
- The plan's `settleAt` is the last landing plus `lockMs`.
|
|
1005
|
+
|
|
1006
|
+
**Timing defaults** (`TRANSITION`, all overridable through `options.transition`):
|
|
1007
|
+
|
|
1008
|
+
| key | default | meaning |
|
|
1009
|
+
|---|---|---|
|
|
1010
|
+
| `rise` / `travel` / `drop` | 3000 / 11000 / 6000 ms | phase lengths |
|
|
1011
|
+
| `riseStagger` | 1300 ms | lift-off spread, kept inside the rise phase |
|
|
1012
|
+
| `dropStagger` | 3500 ms | drop start spread (safe: each tile drops into its own slot) |
|
|
1013
|
+
| `entryMs` | 1300 ms | how long before its drop an arrival starts falling in from off screen |
|
|
1014
|
+
| `lockMs` | 260 ms | the landing flash |
|
|
1015
|
+
| `laneGap` | 1.35 | minimum vertical gap unit between lanes |
|
|
1016
|
+
| `liftMin` | 24 | base lift height |
|
|
1017
|
+
| `heightCap`, `maxGrowth` | 0.22, 0.15 | bound the overhead-camera constant `risePerUnit` |
|
|
1018
|
+
|
|
1019
|
+
`frameAt(plan, now, view)` samples every tween (`sampleTween`) and passes the live
|
|
1020
|
+
tiles to `buildScene`. It returns `{ ops, bounds, settled, tiles }`.
|
|
1021
|
+
|
|
1022
|
+
### 4.3 Projection and the camera
|
|
1023
|
+
|
|
1024
|
+
`project(gx, gy, gz, view)` maps grid coordinates to board-space pixels:
|
|
1025
|
+
|
|
1026
|
+
- **Grid.** Square and viewed straight on, with no isometric diamond and no
|
|
1027
|
+
rotation. `flipY` puts row 0 at the bottom.
|
|
1028
|
+
- **Sphere.** With `dome > 0`, the board is a patch of a real sphere: its centre
|
|
1029
|
+
is raised by `dome`, its corners lie on the plane, and the same sphere continues
|
|
1030
|
+
past the board. `capZ` adds the sphere's height at every point, `surfaceNormal`
|
|
1031
|
+
gives the direction tiles fly along, and `domeLight` shades each tile by the
|
|
1032
|
+
slope under it.
|
|
1033
|
+
- **Oblique camera** (the default). Height is a fixed screen offset:
|
|
1034
|
+
`x += z * ox * unit` and `y -= z * oy * unit`. `oblique.dy` shortens the board's
|
|
1035
|
+
depth for a lower camera. Every cube shows its top and its west and south faces,
|
|
1036
|
+
and nothing changes size with height. Flight altitude is compressed into the room
|
|
1037
|
+
available at each tile's position (`flightRoom`, capped by `oblique.flight`), so
|
|
1038
|
+
nothing in flight leaves the frame except arrivals and departures, which are
|
|
1039
|
+
meant to.
|
|
1040
|
+
- **Overhead pinhole.** Setting `oblique: null` selects a pinhole camera about a
|
|
1041
|
+
vanishing point. Lifted tiles swell (`liftBoost`), and `settleGrowth` stops
|
|
1042
|
+
airborne tiles from growing into each other.
|
|
1043
|
+
|
|
1044
|
+
### 4.4 `buildScene` — faces, order, shadows, lighting
|
|
1045
|
+
|
|
1046
|
+
For each tile, `tileFaces` produces the top quad and the side faces that face the
|
|
1047
|
+
camera. A side is emitted when the top edge has moved against the side's outward
|
|
1048
|
+
normal; one rule covers both cameras. Tiles large enough get a Tetris-cell finish:
|
|
1049
|
+
|
|
1050
|
+
- **bevel**: lit on the far and left edges.
|
|
1051
|
+
- **face**: the tile's fee colour.
|
|
1052
|
+
- **well**: a hollow with walls lit the opposite way and a darker floor.
|
|
1053
|
+
- **rim**: a thin light line around the well.
|
|
1054
|
+
|
|
1055
|
+
Detail thresholds (`facetMinUnits`, `crownMinUnits`) come from the constant board
|
|
1056
|
+
transform, so a tile's level of detail cannot change mid-flight.
|
|
1057
|
+
|
|
1058
|
+
**Paint order.** No depth buffer is used; depth comes from paint order alone.
|
|
1059
|
+
|
|
1060
|
+
- **`obliqueOrder`** (oblique camera, the default). For each pair of cubes whose
|
|
1061
|
+
on-screen hulls actually overlap (by more than half a unit, tested with
|
|
1062
|
+
separating axes), it finds the axis that separates them in world space. It tries
|
|
1063
|
+
the footprint row first, then the column, then height, and paints the nearer
|
|
1064
|
+
cube later. The pairwise facts are sorted topologically. Groups of cubes that
|
|
1065
|
+
form a cycle (strongly connected components) are ordered as a group, and cubes
|
|
1066
|
+
within a group are ordered by a smooth depth measure, so a cycle cannot be cut
|
|
1067
|
+
differently from one frame to the next. Ties fall back to a diagonal order by
|
|
1068
|
+
footprint only. The cost is quadratic in overlapping pairs.
|
|
1069
|
+
- **`diagonalOrder`** (`order: 'diagonal'`, used by the Detailed mode). Resting tiles paint
|
|
1070
|
+
from the far corner inward, then airborne tiles, lowest first. It is `O(n log n)`
|
|
1071
|
+
and valid when tiles are low slabs whose footprints never overlap, which is the
|
|
1072
|
+
dense board with thousands of tiles.
|
|
1073
|
+
- **Overhead pinhole.** Tiles are sorted by the height of their tops.
|
|
1074
|
+
|
|
1075
|
+
**Shadows.** These are plain `rgba(0,0,0,a)` polygons:
|
|
1076
|
+
|
|
1077
|
+
- **Flight shadows** (`shadowOps`) fall at the tile's true footprint, spreading
|
|
1078
|
+
and paling as it climbs. They fade in over the first 1.5 units of a lift and out
|
|
1079
|
+
over the last 1.5 units of a landing, and they leave with a tile that flies off
|
|
1080
|
+
screen.
|
|
1081
|
+
- **Resting shadows** (`restingShadowOps`) are short, pointing down and right away
|
|
1082
|
+
from an upper-left light, and longer for taller cubes.
|
|
1083
|
+
- **Cast shadows** from a cube in flight onto the tops of cubes beneath it.
|
|
1084
|
+
- **Order.** Under the oblique camera, all shadows paint before any cube, because
|
|
1085
|
+
they lie on the floor.
|
|
1086
|
+
|
|
1087
|
+
**Idle effects** (`fxAt`). While the board is at rest, one effect plays at a time.
|
|
1088
|
+
They light **resting** tiles only (`z <= 0.02`), returning
|
|
1089
|
+
`{ glow, outline, lift, color }` (plus `hide` and `scale`, which the agents use).
|
|
1090
|
+
There are thirty kinds (`FX_KINDS` in `details3d.js`, one switch each in
|
|
1091
|
+
`settings.js`): twenty-three **fields**, pure functions of a tile's position and
|
|
1092
|
+
the effect's clock, and seven **agents** (`lightcycle`, `ball`, `centipede`,
|
|
1093
|
+
`tractor`, `missile`, `boulderdash`, `stormball`), which have a position and a
|
|
1094
|
+
route and light the cubes they pass through `fx.heads`. The full catalogue,
|
|
1095
|
+
with what was removed, is `docs/EFFECTS-AGENTS.md`. The original eight:
|
|
1096
|
+
|
|
1097
|
+
| effect | what it does |
|
|
1098
|
+
|---|---|
|
|
1099
|
+
| `ripple` | a ring spreading from a point |
|
|
1100
|
+
| `outline` | an energy front tracing cube outlines, with a trail |
|
|
1101
|
+
| `tide` | a wave that lifts cubes as it passes |
|
|
1102
|
+
| `cascade` | a flash from the richest transaction to the cheapest |
|
|
1103
|
+
| `twinkle` | scattered glints |
|
|
1104
|
+
| `scan` | a sweeping line |
|
|
1105
|
+
| `lightcycle` | two trails walking the grid lines edge to edge, riding cube tops (`cyclePath`, `cellTops`, `pathHeights`) |
|
|
1106
|
+
| `ball` | a lightning ball entering from off screen along a grid line (`ballPath`) |
|
|
1107
|
+
|
|
1108
|
+
`light: 'viewer'` switches to lighting from the camera: faces toward the viewer
|
|
1109
|
+
are brightest, edges are drawn light, bevels and all shadows are omitted, and
|
|
1110
|
+
brightness falls off toward the left of the board.
|
|
1111
|
+
|
|
1112
|
+
### 4.5 `paintFrame` — the draw order
|
|
1113
|
+
|
|
1114
|
+
```mermaid
|
|
1115
|
+
flowchart TD
|
|
1116
|
+
A["setTransform identity; fill background"] --> B{"opts.space?"}
|
|
1117
|
+
B -->|yes| B1["star field (seeded per canvas size, each star twinkling on its own period)"]
|
|
1118
|
+
B -->|no| C
|
|
1119
|
+
B1 --> C["setTransform(the CONSTANT fit: obliqueFit, or corner-to-corner for the pinhole)"]
|
|
1120
|
+
C --> D["drawGrid, ground part:<br/>space: translucent black board<br/>otherwise: lit plate deck (cached Path2D) over the whole panel, faint phosphor lines"]
|
|
1121
|
+
D --> E["ops with face = shadow"]
|
|
1122
|
+
E --> F["glow layer returned by drawGrid: neon grid inside the board, edge glow,<br/>board edge traced on the sphere, idle-effect floor marks, one-block line<br/>(floorLine: just one neon line along the front edge)"]
|
|
1123
|
+
F --> G["axes: price levels and hour ticks (if opts.axes)"]
|
|
1124
|
+
G --> H["cube faces in paint order: fill, then stroke if edges"]
|
|
1125
|
+
H --> I["price line (axes.line), axis labels"]
|
|
1126
|
+
I --> J["the agent effects (light cycles, lightning ball, ...) over the cubes"]
|
|
1127
|
+
```
|
|
1128
|
+
|
|
1129
|
+
`obliqueFit(pw, ph, gridW, gridH, opts)` returns `{ k, tx, ty, rect }`. It centres
|
|
1130
|
+
the board with the same margin (`headroom + dome`) on opposite sides. With
|
|
1131
|
+
`oblique.anchor === 'bottom'`, it places the board along the bottom edge, with a
|
|
1132
|
+
strip below for hour labels and room on the right for price labels. `rect` is the
|
|
1133
|
+
panel's extent in grid units. The textured ground covers all of it, and arrivals
|
|
1134
|
+
start entirely outside it.
|
|
1135
|
+
|
|
1136
|
+
### 4.6 Render options
|
|
1137
|
+
|
|
1138
|
+
Callers use `render3d(canvas, cells, options)` or one of its wrappers:
|
|
1139
|
+
|
|
1140
|
+
- `mempool3d(canvas, dist, options)`: one block's worth of pool cells.
|
|
1141
|
+
- `block3d(canvas, visual, economy, options)`: a block. `blockVbytes` is the
|
|
1142
|
+
weight limit divided by 4.
|
|
1143
|
+
- `board3d(canvas, tiles, options)`: caller-laid tiles.
|
|
1144
|
+
|
|
1145
|
+
Options are merged over `DEFAULTS` in `details3d.js`.
|
|
1146
|
+
|
|
1147
|
+
| Option | Default | Meaning |
|
|
1148
|
+
|---|---|---|
|
|
1149
|
+
| `resolution` | 44 | grid units across (and up) for packed cells |
|
|
1150
|
+
| `blockVbytes` | 1,000,000 | vbytes one full grid represents; sets `vbytesPerUnit` |
|
|
1151
|
+
| `oblique` | `{ ox: 0.13, oy: 0.32, headroom: 10, flight: 120 }` | oblique camera. `ox`/`oy` are the screen offset per unit of height; `dy` (default 1) is the board depth scale for a lower camera; `headroom` is the reserved margin; `flight` is the maximum flight altitude; `anchor: 'bottom'` pins the board to the bottom edge. `null` selects the overhead pinhole. |
|
|
1152
|
+
| `dome` | 5 | sphere rise at the board centre, in grid units (0 is flat) |
|
|
1153
|
+
| `space` | off | star field and a translucent black board instead of the textured deck; the loop keeps running (about 30 fps) for the twinkle while the canvas is visible |
|
|
1154
|
+
| `floorLine` | off | with `space`: no board grid, just one neon line along the front edge |
|
|
1155
|
+
| `light` | `'upper-left'` | where the lamp hangs, for both the dome's slope shading and the side faces: `'overhead'` (straight above: no slope in shade, every side alike), `'upper-left'`, `'upper-right'`, `'front'` — see `LIGHTS` in `blockscene3d.js`. `'viewer'` is the separate camera-lit mode: light edges, no bevel, no shadows. `settings.js` sets this from `space.light`, and Block space ships `'overhead'`. |
|
|
1156
|
+
| `neon` | off | each block a dim solid body in its own colour under lit tubes on every visible edge — a halo, a tube in the block's hue and a thin near-white core. Flattens the tile (no facets, no crown): the tubes are the detail. |
|
|
1157
|
+
| `neonSource`, `neonColour`, `neonBrightness` | `'temperature'`, `'#3d8bff'`, 1 | the tubes take the block's own colour or one chosen hex; brightness (0.2–2) scales both their alpha and their width |
|
|
1158
|
+
| `sheen` | off | a specular highlight on the lit edge of each top face and a dark roll-off on the far one, plus a highlight up the lit side. Works at every level of detail. |
|
|
1159
|
+
| `stars`, `galaxy`, `galaxyAt` | off, off, `'bottom-left'` | the star field, whether it is laid on turning spiral arms, and where the nucleus sits (`'center'` or a corner). `starDensity`, `starBrightness`, `nebulae`, `galaxies`, `dust`, `clusters`, `starColours`, `starGlints` tune it. `stars` defaults to `space` when unset. |
|
|
1160
|
+
| `fxKinds` | all | which idle effects may play, as a list of `FX_KINDS`. An empty list schedules none. `settings.js` builds it from the per-effect switches (`enabledEffects`): the `effects` group for the block board (`SPACE_FX`, every effect but the two drawn on a price line) and the `marketEffects` group for the price board (`MARKET_FX`, the twelve that translate to a candle chart); each group's `noRepeat` (default 12) keeps an effect from playing again until that many others have. |
|
|
1161
|
+
| `still` | off | draw the tiles where they are, with no choreography at all — not even the planner's per-tile stagger. It governs the **tiles**; a board with a sky keeps its loop regardless (see 4.7). |
|
|
1162
|
+
| `maxDpr` | none | cap the device-pixel ratio for this canvas. The star count follows the pixel count, so a panel-sized galaxy at 1x is a quarter of the work of one at 2x. |
|
|
1163
|
+
| `orderMemo` | none | a `Map` the caller keeps per canvas; `obliqueOrder` uses it to hold a tangle's relative order steady between frames (see 4.7). `render3d` supplies its own. |
|
|
1164
|
+
| `slab` | off | cap every tile's height at this value (low slabs for dense boards) |
|
|
1165
|
+
| `order` | (oblique order) | `'diagonal'`: the fast diagonal paint order for non-overlapping slabs |
|
|
1166
|
+
| `gridW`, `gridH` | `resolution` | board size in grid units; used with laid tiles (`board3d`) |
|
|
1167
|
+
| `laid` | none | set by `board3d`: the tiles to draw, bypassing packing |
|
|
1168
|
+
| `axes` | none | `{ y, zTop, z: [{ z, label, color?, strong? }], x: [{ x, label }], line: [{ x, z }] }`: `y` is the row the axes stand on; `z` are price levels drawn across the board; `x` are hour ticks; `line` is a glowing polyline (the close price) |
|
|
1169
|
+
| `transition` | `TRANSITION` | per-caller timing overrides (see 4.2) |
|
|
1170
|
+
| `grid`, `gridStep` | `true`, 4 | draw the ground and grid; plate size in cells |
|
|
1171
|
+
| `edges`, `seamAlpha` | `true`, 0.38 | stroke face outlines; the seam's strength |
|
|
1172
|
+
| `idleFx`, `idleEvery`, `idleFirst` | `true`, `[5000, 9000]`, `[800, 1600]` | idle effects and the delay ranges before the next and the first one |
|
|
1173
|
+
| `facetPx`, `crownPx` | 9, 18 | device-pixel thresholds for tile detail |
|
|
1174
|
+
| `unit`, `zUnit`, `persp`, `vanish`, `edgeMargin` | 6, 6, 0.55, centre, 0.06 | base scale and pinhole-camera parameters |
|
|
1175
|
+
| colours | see `DEFAULTS` | `background`, `floor`, `spaceFloor`, `gridGlow`, `gridColor`, `gridEdgeColor`, `neonCell`, `neonHalo`, `neonGlow`, `neonLine`, `blockLineColor` |
|
|
1176
|
+
|
|
1177
|
+
**Tile fields.** For `board3d`, and in the tiles `packBlock` produces:
|
|
1178
|
+
|
|
1179
|
+
| Field | Meaning |
|
|
1180
|
+
|---|---|
|
|
1181
|
+
| `txid` | stable identity. The same id in the next layout is the same tile and moves rather than leaving and re-arriving. |
|
|
1182
|
+
| `x`, `y` | grid position of the footprint's lower-left cell (row 0 at the bottom) |
|
|
1183
|
+
| `s` | footprint side in grid units |
|
|
1184
|
+
| `tall` | height; defaults to `s` (a cube) |
|
|
1185
|
+
| `floor` | resting altitude; the tile floats this high (a market candle at its price) |
|
|
1186
|
+
| `color` | `#rrggbb`. Every face colour is derived from it, so it must be a six-digit hex string. |
|
|
1187
|
+
| `label` | hover text; if absent, hover shows txid, vsize and feerate |
|
|
1188
|
+
| `rate`, `vsize` | set by the packer; used by the built-in hover text and the `cascade` effect |
|
|
1189
|
+
|
|
1190
|
+
The engine adds these while animating; callers should not set them: `z`, `alpha`,
|
|
1191
|
+
`lock`, `entry`, `landV`, `fallFrom`, `fxz`, `boost`.
|
|
1192
|
+
|
|
1193
|
+
Other exports:
|
|
1194
|
+
|
|
1195
|
+
- `viewerIdle(canvas)`: true when the board is drawn, settled, and has no pending
|
|
1196
|
+
layout.
|
|
1197
|
+
- `triggerIdle(canvas, kind)`: plays an idle effect now; used by tests and demos.
|
|
1198
|
+
- `hitTest(canvas, clientX, clientY)` and `hitOps(ops, x, y)`: the topmost drawn
|
|
1199
|
+
face at a point. Only answers while the board is settled.
|
|
1200
|
+
|
|
1201
|
+
### 4.7 Invariants
|
|
1202
|
+
|
|
1203
|
+
Each of these has a test that fails if it is violated.
|
|
1204
|
+
|
|
1205
|
+
- **The board transform and the camera are constants.** The fit depends only on
|
|
1206
|
+
the panel size and the fixed grid (`gridW`, `gridH`, `resolution`), never on the
|
|
1207
|
+
packed extent or what is in flight. `risePerUnit` comes from the grid and the
|
|
1208
|
+
height cap, never from the current lane stack. When either varied, the board
|
|
1209
|
+
slid down on refresh, or the first paint drew cube lips several times too tall.
|
|
1210
|
+
To add something to the fit, first confirm it is a property of the board and
|
|
1211
|
+
not of the current transition (RULES 26). Tests: "the view transform is a
|
|
1212
|
+
constant", "the camera is a constant of the board, not of the round", "the grid
|
|
1213
|
+
is centred on the panel ... the view never slides".
|
|
1214
|
+
- **Plain rgba only.** No `ctx.clip()`, no `ctx.globalAlpha`, no
|
|
1215
|
+
`globalCompositeOperation`, no `shadowBlur`. Software rasterisers (headless
|
|
1216
|
+
browsers, VMs, blocklisted GPUs) silently drop fills drawn through a clip and
|
|
1217
|
+
handle alpha and blur inconsistently; this blanked the map once. Transparency
|
|
1218
|
+
goes inside `rgba()`, glows are layered strokes, and depth is paint order.
|
|
1219
|
+
`test/viewer-canvas-rules.test.js` scans the source; `test/details3d.test.js`
|
|
1220
|
+
and `test/never-clip.test.js` record what actually reaches a context.
|
|
1221
|
+
- **No tile is ever see-through.** Arrivals and departures are solid and enter or
|
|
1222
|
+
leave the frame whole. A translucent cube over solid ones reads as a ghost.
|
|
1223
|
+
- **No two tiles intersect** at any sampled moment of a transition. The test
|
|
1224
|
+
samples a whole flight and intersects every pair of boxes.
|
|
1225
|
+
- **Idle effects only at rest.** A transition cancels the current effect. `fxAt`
|
|
1226
|
+
never touches airborne tiles. Effects are scheduled only with a real DOM, never
|
|
1227
|
+
under `prefers-reduced-motion`, and never while the canvas is hidden or moving.
|
|
1228
|
+
There are 30 (`FX_KINDS`), each a pure function of the tile and the effect's
|
|
1229
|
+
clock — board-level choices are hashed from the effect's seed, never from
|
|
1230
|
+
`Math.random`, so an effect replays identically and is asserted rather than
|
|
1231
|
+
watched. An agent never mutates a tile: `hide` and `scale` are applied per
|
|
1232
|
+
frame to a copy, so the board is correct again the moment the effect stops.
|
|
1233
|
+
Every one has a switch in `settings.js`, and a test holds the effect
|
|
1234
|
+
list, the defaults and the panel rows to the same list in the same order.
|
|
1235
|
+
- **The loop parks when — and only when — nothing is moving.** When a frame is
|
|
1236
|
+
settled, nothing is dirty and no effect is running, the rAF loop stops:
|
|
1237
|
+
repainting a still picture only burns power. Boards with a star field keep a
|
|
1238
|
+
throttled loop (about 30 fps) for the twinkle and the galaxy's turn, and stop
|
|
1239
|
+
while the canvas is not displayed. **`still` governs the tiles, never the sky.**
|
|
1240
|
+
It once also returned before `requestAnimationFrame` — and the wake that revives
|
|
1241
|
+
a parked loop was gated behind it too — so Tetrust's sky, which sets `still`
|
|
1242
|
+
because it has no tiles to choreograph, repainted only when its page happened to
|
|
1243
|
+
call `board3d` again: measured at zero repaints in three seconds, and 87 after
|
|
1244
|
+
the fix. Test: `test/still-sky.test.js`.
|
|
1245
|
+
- **A tangle keeps the order it had last frame.** Where drawn outlines overlap,
|
|
1246
|
+
paint order is a constraint graph; contradictory cycles are resolved as groups
|
|
1247
|
+
(strongly connected components). Ordering a group by depth alone threw away the
|
|
1248
|
+
pair decisions inside it, so a cube flying past could pull a settled pair into a
|
|
1249
|
+
tangle — or let it out — and the pair swapped without either of them moving.
|
|
1250
|
+
With `orderMemo` the members of a tangle keep their previous relative order, and
|
|
1251
|
+
only cubes with no previous frame fall in by depth. Replayed over a 634-frame
|
|
1252
|
+
transition of 90 cubes: 19 order flickers to none. Test:
|
|
1253
|
+
`test/order-memo.test.js`.
|
|
1254
|
+
- **A layout does not restart a transition.** An unchanged layout leaves the
|
|
1255
|
+
running plan alone. A changed layout that arrives mid-flight waits as `pending`.
|
|
1256
|
+
The first paint never animates.
|
|
1257
|
+
- **Per-canvas state.** All renderer state lives in a `WeakMap` keyed by canvas.
|
|
1258
|
+
A module-level animation handle once made two viewers on one page cancel each
|
|
1259
|
+
other.
|
|
1260
|
+
- **Reduced motion** draws the same final layout without the flight.
|
|
1261
|
+
|
|
1262
|
+
### 4.8 Using the engine for other data
|
|
1263
|
+
|
|
1264
|
+
`board3d` is the general entry point. The Markets page is the reference caller:
|
|
1265
|
+
|
|
1266
|
+
- **Tiles.** It lays out one candle per exchange-hour (`txid` = exchange + hour),
|
|
1267
|
+
with volume tiles on the floor in front and bodies and wicks floating at their
|
|
1268
|
+
price (`floor`).
|
|
1269
|
+
- **Axes.** It passes `axes` for price levels, hour labels and the close line.
|
|
1270
|
+
- **Camera.** `CAMERA_3D` overrides the defaults:
|
|
1271
|
+
|
|
1272
|
+
```js
|
|
1273
|
+
{
|
|
1274
|
+
oblique: { ox: 0.07, oy: 0.95, dy: 0.3, headroom: /* price band */, flight: 10, anchor: 'bottom' },
|
|
1275
|
+
dome: 0, gridStep: 2, space: true, floorLine: true, light: 'viewer',
|
|
1276
|
+
transition: { rise: 700, travel: 2200, drop: 1400, riseStagger: 300, dropStagger: 600, entryMs: 600 },
|
|
1277
|
+
}
|
|
1278
|
+
```
|
|
1279
|
+
|
|
1280
|
+
A new hour gives every candle a new `x`, so the whole chart slides one slot using
|
|
1281
|
+
the same choreography as the block board, only faster.
|
|
1282
|
+
|
|
1283
|
+
---
|
|
1284
|
+
|
|
1285
|
+
## 5. Testing
|
|
1286
|
+
|
|
1287
|
+
`npm test` runs `node --test "test/**/*.test.js"`. The glob is quoted on purpose:
|
|
1288
|
+
passing the bare `test/` directory does not work on some Node 22 releases. Every
|
|
1289
|
+
test is written with `node:test` and `node:assert`, with nothing to install.
|
|
1290
|
+
|
|
1291
|
+
### Infrastructure
|
|
1292
|
+
|
|
1293
|
+
- **`test/dom-stub.js`** (`installDom()`): a minimal `document`/`window` with a
|
|
1294
|
+
proxy 2D context, a tiny parser for the `data-*` attributes the app itself
|
|
1295
|
+
writes, a CSSOM stub, and a controllable `requestAnimationFrame`. Importing
|
|
1296
|
+
`public/js/app.js` under it reproduces the browser's module-evaluation check.
|
|
1297
|
+
A missing import name parses cleanly but throws during evaluation, and in the
|
|
1298
|
+
browser that blanks the whole page with nothing in the server log. The stub
|
|
1299
|
+
cannot check layout, the CSS cascade, or real `EventSource` behaviour.
|
|
1300
|
+
- **`test/helpers/http.js`** (`withApp({ nodes, config, auth, tlsFiles, log }, fn)`):
|
|
1301
|
+
boots the **real** app in-process against N fake nodes on free loopback ports.
|
|
1302
|
+
Config goes in a temp file, the bootstrap admin password comes back on
|
|
1303
|
+
`app.bootstrap`, and `app.shutdown()` always runs. It **never touches
|
|
1304
|
+
`process.env`**: Node runs a file's top-level tests concurrently, so environment
|
|
1305
|
+
changes race between tests (RULES 24). Assertions about environment variables
|
|
1306
|
+
live in `test/config-env.test.js`, which boots nothing.
|
|
1307
|
+
- **`scripts/fake-node.js`** (`FakeNode`, `startFakeNode`): a stand-in node that
|
|
1308
|
+
serves enough JSON-RPC (including batches) and writes a log in the node's
|
|
1309
|
+
format. It can simulate IBD. It deliberately reproduces the real node's quirks:
|
|
1310
|
+
`getpeerinfo` returns `[]` with non-zero connections, `getnettotals` is zero, and
|
|
1311
|
+
mempool entries have the observed field set. `npm run dev` runs it in-process.
|
|
1312
|
+
- **`scripts/smoke.sh`** (`npm run smoke`): boots the real server on spare ports
|
|
1313
|
+
with a fake node, then checks the HTTP contract with curl: auth, CSRF, the SSE
|
|
1314
|
+
frame, the RPC guard, headers, the nonce, the build id, the login throttle, and
|
|
1315
|
+
open mode. It refuses to run if a port is already taken (otherwise it could test
|
|
1316
|
+
someone else's server), uses a single cleanup trap for exit and signals, and pins
|
|
1317
|
+
`BLOCKYARD_CONFIG=none` and `BLOCKYARD_BIND=127.0.0.1` so it never inherits a
|
|
1318
|
+
deployment's config.
|
|
1319
|
+
- **`scripts/doc-counts.js`** (`npm run counts`, `counts:check`, `counts:fix`):
|
|
1320
|
+
derives the test count quoted in the docs by scanning top-level `test(...)`
|
|
1321
|
+
declarations. `test/doc-counts.test.js` checks the scanner against the count the
|
|
1322
|
+
real `node --test` reporter prints, and fails on nested declarations the scanner
|
|
1323
|
+
would miss. Do not type the count into a document by hand.
|
|
1324
|
+
- **Fixtures**: `test/fixtures/` holds **real** node log lines, taken from actual
|
|
1325
|
+
logs and not typed from memory (RULES 7) -- all of them from the experimental node,
|
|
1326
|
+
which is why Core log support is not claimed anywhere. A frozen 90-minute sample holds parser
|
|
1327
|
+
coverage above its threshold.
|
|
1328
|
+
- **`npm run render:live`** (`scripts/live-render-check.mjs`): runs the page
|
|
1329
|
+
renderers under the DOM stub against a running monitor's real responses.
|
|
1330
|
+
|
|
1331
|
+
### What the guard tests protect
|
|
1332
|
+
|
|
1333
|
+
| Test | Guards |
|
|
1334
|
+
|---|---|
|
|
1335
|
+
| `privacy.test.js` | no tracked file contains this machine's username, resolvable hostname or interface addresses (derived at run time, never written down); no overlay-network addresses; RFC 1918 and routable addresses only where explicitly reviewed; the systemd unit uses a placeholder account. Use `192.0.2.x`, `198.51.100.x` or `203.0.113.x` in examples. |
|
|
1336
|
+
| `csp.test.js` | no `'unsafe-inline'` and no `style-src-attr`; one nonce per response, in `script-src` only; HSTS only over TLS; no `style=` in shipped HTML or injected markup; data-driven sizes go through the CSSOM |
|
|
1337
|
+
| `viewer-canvas-rules.test.js`, `never-clip.test.js`, `details3d.test.js` | no `clip`, `globalAlpha`, composite modes or `shadowBlur`, checked both in the source and in what reaches the context |
|
|
1338
|
+
| `never-blank.test.js` | charts never erase data they already show; stale data gets a pill; only `resetCanvas` in `switchNode` clears a canvas |
|
|
1339
|
+
| `web-contract.test.js`, `app-boot.test.js`, `browser-render.test.js` | every element id the JS looks up exists; every nav page has a section and a renderer; `app.js` evaluates; every page renders full, mid-IBD and all-null snapshots without throwing, and says something when it has nothing |
|
|
1340
|
+
| `blockscene3d.test.js`, `details3d.test.js`, `viewer-modes.test.js` | no intersections during a transition, constant transform and camera, paint-order stability, shadows, landings, idle behaviour, loop parking, hover only at rest, both viewer modes |
|
|
1341
|
+
| `rpc-lane.test.js` | priority, coalescing, stale drop, breaker semantics, the unkeyed-job recursion bug, cadence stretching and its floor |
|
|
1342
|
+
| `open-access.test.js`, `http-app.test.js`, `tls.test.js`, `cidr.test.js`, `audit-and-kdf.test.js` | the viewer ceiling, admin 403 in open mode, refused writes, sessions and CSRF, TLS, CIDR gate failure direction, audit rotation, scrypt |
|
|
1343
|
+
| `logparse.test.js`, `bench-log.test.js`, `shape-liveness.test.js`, `log-core-unsupported.test.js` | parsers against real experimental-node lines, coverage thresholds, per-shape liveness flags, and the standing proof that Core's format is NOT parsed |
|
|
1344
|
+
| `chain-blockfile.test.js`, `chain-tx.test.js`, `chain-index.test.js`, `chain-index-live.test.js` | block/undo file framing and XOR, the decoder against Core's verbose output (`test/fixtures/chain-tx.json`), lean rows against full-decoder rows, the store's lookups, and the follower's log replay, rollback and folding |
|
|
1345
|
+
| `agents.test.js`, `effects.test.js` | every registered agent builds, frames, draws, publishes heads, replays from a seed, leaves tiles untouched and keeps working on a flat board; every effect lights something, every kind is reachable and none takes more than twice an even share |
|
|
1346
|
+
|
|
1347
|
+
A green `npm test` is necessary but not sufficient (RULES 5). Before calling a
|
|
1348
|
+
change done, run `npm run dev` and look at the page, or run `npm run smoke`.
|
|
1349
|
+
|
|
1350
|
+
---
|
|
1351
|
+
|
|
1352
|
+
## 6. Conventions for contributors
|
|
1353
|
+
|
|
1354
|
+
- **Zero dependencies.** Use Node builtins, `node:test`, hand-written canvas, and
|
|
1355
|
+
no CDN. `package.json` has empty `dependencies` and `devDependencies` and should
|
|
1356
|
+
stay that way. If a dependency looks necessary, build less UI instead (RULES 2).
|
|
1357
|
+
- **Read-only by default.** Every new RPC call goes through the lane and must be
|
|
1358
|
+
classified as a read by the allowlist. Node writes exist only as named, audited,
|
|
1359
|
+
opt-in actions. Do not add a per-user or concurrent poll, or anything that
|
|
1360
|
+
bypasses `server/rpc/client.js`. Prefer adding a method to an existing tier's
|
|
1361
|
+
batch over adding a tier. Anything expensive for the node should be fetched on
|
|
1362
|
+
demand and shared between viewers.
|
|
1363
|
+
- **Honest data.**
|
|
1364
|
+
- Absent is shown as absent. A figure the node did not provide is `null` in the
|
|
1365
|
+
payload and `–` on screen, with a reason in `health.quality` or next to the
|
|
1366
|
+
figure. Never substitute a zero, and check whether a number is physically
|
|
1367
|
+
plausible, not just whether it is zero (RULES 8).
|
|
1368
|
+
- Two similar figures stay two figures; do not merge or `max()` them (RULES 9).
|
|
1369
|
+
- Stale must look stale: keep old data on screen, and mark it with its age.
|
|
1370
|
+
- Log-derived figures report their own coverage (RULES 15).
|
|
1371
|
+
- **Measure before tuning.** Intervals, timeouts, caps and payload budgets come
|
|
1372
|
+
from measurements recorded in `docs/MEASUREMENTS.md` with a way to reproduce
|
|
1373
|
+
them. A comment that justifies a number states the measurement, not an intuition.
|
|
1374
|
+
- **Keep payloads in their place.** Nothing that changes more slowly than once a
|
|
1375
|
+
second goes in the SSE snapshot (RULES 6). Chart series use the series event or
|
|
1376
|
+
`/api/series`; bulky datasets get their own endpoint and schedule.
|
|
1377
|
+
- **Keep pure code pure.** Parsers, the sync model, mining decoders, next-block
|
|
1378
|
+
analysis, packing, projection and choreography have no I/O and no canvas, so
|
|
1379
|
+
they can be tested headless. Put I/O and drawing at the edges.
|
|
1380
|
+
- **Tests observe through seams**: `boot({ log })`,
|
|
1381
|
+
`loadConfig({ ifaces, now })`, injected `fetchImpl` and `now` in `MarketFeed`.
|
|
1382
|
+
They never replace process globals and never mutate `process.env` in a file that
|
|
1383
|
+
boots the app.
|
|
1384
|
+
- **Respect the CSP**: no inline styles or scripts; use `data-*` plus CSSOM, or
|
|
1385
|
+
classes. Escape every external string.
|
|
1386
|
+
- **The 3D rules**: no clip, alpha, composite or blur; per-canvas state; anything
|
|
1387
|
+
the view transform depends on must be a constant of the board; idle effects only
|
|
1388
|
+
at rest; the loop parks when still.
|
|
1389
|
+
- **Privacy**: the repository is public. Use generic paths (`<datadir>`,
|
|
1390
|
+
`/path/to/node`) and documentation IP ranges in docs, fixtures and examples.
|
|
1391
|
+
`test/privacy.test.js` enforces this.
|
|
1392
|
+
- **Documentation**: a new rule goes in `docs/RULES.md` together with the defect
|
|
1393
|
+
that caused it. Known gaps go in `docs/DEFECTS.md`, including why each is still
|
|
1394
|
+
open. Test counts are generated (`npm run counts:fix`), never typed.
|