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.
Files changed (122) hide show
  1. package/CHANGELOG.md +929 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +191 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +41 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1577 -0
  9. package/docs/ARCHITECTURE.md +1394 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +847 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +205 -0
  15. package/docs/INSTALL.md +547 -0
  16. package/docs/MEASUREMENTS.md +1401 -0
  17. package/docs/RULES.md +681 -0
  18. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  19. package/docs/SECURITY-AUDIT.md +258 -0
  20. package/docs/SECURITY.md +212 -0
  21. package/docs/TROUBLESHOOTING.md +332 -0
  22. package/docs/USER-GUIDE.md +1262 -0
  23. package/package.json +53 -5
  24. package/public/404.html +9 -0
  25. package/public/css/app.css +2009 -0
  26. package/public/donate-qr.png +0 -0
  27. package/public/index.html +1085 -0
  28. package/public/js/about.js +112 -0
  29. package/public/js/agents.js +1141 -0
  30. package/public/js/app.js +1386 -0
  31. package/public/js/arkanoid.js +806 -0
  32. package/public/js/blockanoid.js +347 -0
  33. package/public/js/blockout.js +347 -0
  34. package/public/js/blockpack.js +428 -0
  35. package/public/js/blockscene3d.js +2830 -0
  36. package/public/js/breakout.js +224 -0
  37. package/public/js/charts.js +635 -0
  38. package/public/js/depthchart.js +315 -0
  39. package/public/js/details3d.js +4342 -0
  40. package/public/js/doom.js +31 -0
  41. package/public/js/dosaudio.js +48 -0
  42. package/public/js/dosgame.js +389 -0
  43. package/public/js/dosio.js +186 -0
  44. package/public/js/dospc.js +1353 -0
  45. package/public/js/dosworker.js +196 -0
  46. package/public/js/explorer.js +405 -0
  47. package/public/js/feepalette.js +149 -0
  48. package/public/js/fmt.js +162 -0
  49. package/public/js/goggles.js +886 -0
  50. package/public/js/kiosk.js +41 -0
  51. package/public/js/login.js +88 -0
  52. package/public/js/markets.js +395 -0
  53. package/public/js/mining.js +1416 -0
  54. package/public/js/panels.js +970 -0
  55. package/public/js/pricechart.js +189 -0
  56. package/public/js/quake.js +20 -0
  57. package/public/js/settings.js +1096 -0
  58. package/public/js/soundcard.js +459 -0
  59. package/public/js/tetris.js +226 -0
  60. package/public/js/tetrust.js +356 -0
  61. package/public/js/tetsound.js +175 -0
  62. package/public/js/theme.js +235 -0
  63. package/public/js/wolf3d.js +22 -0
  64. package/public/js/x86.js +1978 -0
  65. package/public/login.html +33 -0
  66. package/scripts/blockfile-measure.js +156 -0
  67. package/scripts/browser-check.mjs +286 -0
  68. package/scripts/check.js +173 -0
  69. package/scripts/decode-check.js +81 -0
  70. package/scripts/doc-counts.js +109 -0
  71. package/scripts/donate-qr.py +23 -0
  72. package/scripts/dos-bench.js +56 -0
  73. package/scripts/fake-node.js +534 -0
  74. package/scripts/index-bench.js +216 -0
  75. package/scripts/index-benchmark.js +117 -0
  76. package/scripts/index-build.js +40 -0
  77. package/scripts/live-render-check.mjs +89 -0
  78. package/scripts/manage-users.js +132 -0
  79. package/scripts/motion-check.mjs +138 -0
  80. package/scripts/pool-map.js +157 -0
  81. package/scripts/setup.js +432 -0
  82. package/scripts/shots.mjs +278 -0
  83. package/scripts/smoke.sh +327 -0
  84. package/scripts/tls.js +31 -0
  85. package/scripts/ui.js +174 -0
  86. package/server/auth/sessions.js +221 -0
  87. package/server/auth/users.js +243 -0
  88. package/server/chain/blockfile.js +234 -0
  89. package/server/chain/index/build.js +210 -0
  90. package/server/chain/index/heights.js +36 -0
  91. package/server/chain/index/live.js +276 -0
  92. package/server/chain/index/rows.js +145 -0
  93. package/server/chain/index/store.js +154 -0
  94. package/server/chain/index/worker.js +109 -0
  95. package/server/chain/tx.js +310 -0
  96. package/server/collect/gbt.js +229 -0
  97. package/server/collect/logparse.js +765 -0
  98. package/server/collect/logtail.js +189 -0
  99. package/server/collect/markets.js +333 -0
  100. package/server/collect/mining.js +333 -0
  101. package/server/collect/monitor.js +2545 -0
  102. package/server/collect/network.js +295 -0
  103. package/server/collect/nextblock.js +275 -0
  104. package/server/collect/sync.js +386 -0
  105. package/server/config.js +644 -0
  106. package/server/http/api.js +1319 -0
  107. package/server/http/explorer.js +418 -0
  108. package/server/http/games.js +77 -0
  109. package/server/http/server.js +420 -0
  110. package/server/http/sse.js +176 -0
  111. package/server/http/static.js +212 -0
  112. package/server/main.js +673 -0
  113. package/server/netinfo.js +253 -0
  114. package/server/rpc/allowlist.js +130 -0
  115. package/server/rpc/client.js +414 -0
  116. package/server/store/audit.js +148 -0
  117. package/server/store/history.js +220 -0
  118. package/server/store/ledger.js +290 -0
  119. package/server/store/ring.js +173 -0
  120. package/server/tls/selfsigned.js +160 -0
  121. package/server/util/fmt.js +29 -0
  122. package/systemd/blockyard.service +102 -0
@@ -0,0 +1,1401 @@
1
+ # Measurements of the node, from this box
2
+
3
+ Every number here was taken with `curl` against a running node on 2026-09-08.
4
+ Reproduce with the commands given. **If you are about to add a poll, a panel or a
5
+ timeout, check here first** — each of these changed a design decision, and
6
+ re-deriving them costs real time.
7
+
8
+ Two nodes were live during this work:
9
+
10
+ | name | RPC | datadir | chain | log |
11
+ |---|---|---|---|---|
12
+ | production | `127.0.0.1:8331` | `<datadir>` | main | `logs/main/bitcoin.main.log` (systemd stdout, logrotated) |
13
+ | bench | `127.0.0.1:8461` | `/mnt/2tbssd/bench/data` | main, **doing IBD** | `console.log` in the run dir, **not** `data/main/debug.log` (see §11) |
14
+
15
+ Auth is cookie: `<datadir>/<chain>/.cookie`, regenerated every start, deleted on
16
+ stop. Resolve fresh, re-resolve on 401; a cached cookie is a restart away from
17
+ being wrong.
18
+
19
+ ## 1. The RPC server is single-connection, single-thread
20
+
21
+ Confirmed by the node's own `docs/RPC_LIVE_NODE.md` (slice 11), which also
22
+ explains why its `waitforblock` refuses to wait indefinitely and why
23
+ `rescanblockchain` blocks every other RPC while it runs.
24
+
25
+ Latency of `getblockcount` on the **bench node during IBD**: **40.4 s**.
26
+ Same method on the **synced production node** under benchmark load: **7.0 s**.
27
+ Same method on the synced node when idle: **~0 s** (batched with two others: 3 ms).
28
+
29
+ Consequences already implemented:
30
+
31
+ - `rpc.timeoutMs` is 90 s, not 20 s. A 20 s ceiling declares a busy-but-healthy
32
+ node unreachable.
33
+ - Poll tiers **stretch** to at least 2× observed RPC latency
34
+ (`monitor.effectiveTierMs`), capped, and recover automatically when the node
35
+ speeds up.
36
+ - Poll jobs **coalesce by key** and are **dropped rather than queued** if they
37
+ cannot be answered inside `rpc.staleDropMs` (12 s). A queued poll answers a
38
+ question about a moment that has already passed.
39
+
40
+ ## 2. Batching works, and it is the cheapest thing available
41
+
42
+ A top-level JSON array is answered on **one** connection (`docs/RPC_LIVE_NODE.md`
43
+ slice 11: "Batches never throw HTTP errors"). Verified:
44
+
45
+ ```bash
46
+ A=$(cat <datadir>/main/.cookie)
47
+ curl -s --user "$A" http://127.0.0.1:8331/ -d '[
48
+ {"jsonrpc":"1.0","id":"a","method":"getdifficulty","params":[]},
49
+ {"jsonrpc":"1.0","id":"b","method":"getconnectioncount","params":[]},
50
+ {"jsonrpc":"1.0","id":"c","method":"uptime","params":[]}]'
51
+ # -> [{...127450789715843.1},{"result":13},{"result":51321}] in 3 ms
52
+ ```
53
+
54
+ Ten methods per tier therefore costs ~1 connection instead of 10. Every tier in
55
+ `collect/monitor.js` is one batch.
56
+
57
+ ## 3. `getpeerinfo` returns `[]` while `getconnectioncount` says 13
58
+
59
+ ```
60
+ getconnectioncount -> 13
61
+ getnetworkinfo -> connections: 13, connections_in: 0, connections_out: 13
62
+ getpeerinfo -> []
63
+ ```
64
+
65
+ The node's `docs/RPC_LIVE_NODE.md` slice 2 describes a shared peer table that
66
+ should fill this, and `rpc_fill_peer_slot(...)` is called in `main.c` — but the
67
+ deployed build (`deploy-20260907a`) publishes nothing. **This is a
68
+ node-side gap, not a monitor bug.** The monitor therefore:
69
+
70
+ - renders peer **identity and activity from the log** (relay legs, block serving,
71
+ connects/drops),
72
+ - shows `getpeerinfo` verbatim in its own panel so the gap is visible,
73
+ - raises the `peerinfo-empty` quality flag naming the counts.
74
+
75
+ Do not "fix" this by hiding the empty table or by inventing peers.
76
+
77
+ ## 4. `getnettotals` reports zero bytes
78
+
79
+ ```
80
+ getnettotals -> {"totalbytesrecv":0,"totalbytessent":0,
81
+ "uploadtarget":{"target":0,"serve_historical_blocks":true,...}}
82
+ ```
83
+
84
+ The byte counters are per-peer `TCP_INFO` values held by the forked download
85
+ worker (slice 6). Result: **upload rate is genuinely unavailable.** The Network
86
+ page says so in words rather than charting a zero.
87
+
88
+ Downlink and disk-write throughput **are** available, from the log:
89
+
90
+ ```
91
+ [dlc] -- network recv this tick: 6.6KB (674.1B/s) | total recv: 1.8MB ||
92
+ disk write this tick: 0.0B (0.0B/s) | total written: 1.7MB --
93
+ ```
94
+
95
+ Units are **decimal**: 4096 bytes prints as `4.0KB`. `parseSize` decodes the same
96
+ way. A 1024-based decoder here would make every bandwidth figure wrong by 2.4%.
97
+
98
+ ## 5. `getrawmempool verbose` shape and size
99
+
100
+ ```
101
+ 4,743 txs -> 660,151 bytes in 21 ms
102
+ entry keys: vsize, weight, time, fees{base}
103
+ ```
104
+
105
+ Not present, therefore **not shown**: `depends`, `ancestors`, `descendants`,
106
+ `ancestorcount`, `modifiedfees`, `fees.prioritiserved`, `currentlimit`,
107
+ `replaceable`, `withdrawreason`, `replaced-by`. The Mempool page lists these
108
+ under "fields this node does not report" instead of rendering empty boxes.
109
+
110
+ Peak pool observed in the log: **17,288 tx** (~2.4 MB verbose) — hence the 60 s
111
+ tier, not the 15 s one.
112
+
113
+ ## 6. `getblock verbosity 2` is unusable for block analytics
114
+
115
+ Returns the full hex of every transaction: **11,254,837 bytes for one block,
116
+ 83 ms**, and — the real problem — **no `fee` or `deltafee` per transaction**,
117
+ which Core includes. So per-block fee data cannot come from `getblock`.
118
+
119
+ `getblockstats` is the right source and is complete and fast:
120
+
121
+ ```
122
+ getblockstats 965993 [totalfee,txs,...] -> 4 ms
123
+ {totalfee:1109919, txs:6041, total_size:1601834, total_weight:3991625,
124
+ avgfee:183, medianfee:46, maxfee:84983, subsidy:312500000,
125
+ feerate_percentiles:[0,0,0,1,2], ins:7338, outs:13167, swtxs:5975,
126
+ utxo_increase:5829, ...}
127
+ ```
128
+
129
+ All block charts use it. 12 heights per batch.
130
+
131
+ ## 7. Fast reads that are safe at any reasonable cadence
132
+
133
+ ```
134
+ getdifficulty 3 ms getmempoolinfo fast getchaintips 3 ms
135
+ estimatesmartfee 4 ms getmininginfo fast getnettotals fast
136
+ gettxoutsetinfo 6 ms (muhash + txouts; NOT the 60-83 s reload the docs
137
+ mention — that is `gettxout`'s old read-only view)
138
+ getindexinfo 524 ms getchaintxstats fast uptime fast
139
+ ```
140
+
141
+ `getindexinfo` at ~0.5 s is the only slow one in the slow tier.
142
+
143
+ ## 8. Real IBD numbers (bench node, 2026-09-08 ~01:06–01:09Z)
144
+
145
+ This is the dataset the sync bar is designed and tested against:
146
+
147
+ ```
148
+ time rpc_s blocks headers height% vprogress ibd
149
+ 2026-09-08T01:06:08Z 1 647688 965953 67.05% 0.670517 True
150
+ 2026-09-08T01:06:34Z 1 648283 965953 67.11% 0.671133 True +595
151
+ 2026-09-08T01:06:59Z 0 648723 965953 67.16% 0.671589 True +440
152
+ 2026-09-08T01:07:24Z 0 649123 965953 67.20% 0.672003 True +400
153
+ 2026-09-08T01:07:49Z 0 649531 965953 67.24% 0.672425 True +408
154
+ 2026-09-08T01:08:14Z 0 649906 965953 67.28% 0.672814 True +375
155
+ 2026-09-08T01:08:39Z 0 650105 965953 67.30% 0.673020 True +199
156
+ 2026-09-08T01:09:04Z 0 650263 965953 67.32% 0.673183 True +158
157
+ 2026-09-08T01:09:29Z 0 650266 965953 67.32% 0.673186 True +3
158
+ ```
159
+
160
+ Three findings, each of which is now encoded:
161
+
162
+ 1. **`verificationprogress` ≈ `blocks/headers` during IBD** (67.05% vs 0.670517).
163
+ They are still kept as two separate figures — but the divergence caveat stays
164
+ quiet when they agree, tested in `test/sync.test.js`.
165
+ 2. **The rate decelerates hard**: 595 → 3 blocks per 25 s sample as validation
166
+ catches the download. One window's ETA would be a number about to be wrong, so
167
+ the sync model computes a 2-minute rate *and* a 10-minute rate, uses the recent
168
+ one, and reports `rateTrend: decelerating` with a caveat saying the ETA will
169
+ get **longer**.
170
+ 3. Headers were **constant at 965,953** while blocks advanced, i.e. the
171
+ headers-first shape means `blocks/headers` is the correct 0→100% measure.
172
+
173
+ ## 9. Log formats
174
+
175
+ `test/fixtures/log-samples.txt` holds frozen real lines; `test/logparse.test.js`
176
+ asserts against them. Formats that differ from what you might assume:
177
+
178
+ - `-- peers banned this run: 95 of 123 --` — *not* `peers banned:`.
179
+ - Tags include **underscores** (`[tx_accept]`) and **indices** (`[mux:11]`,
180
+ `[dl:7]`). A `[a-z0-9]+` tag regex silently loses `tx_accept`.
181
+ - `[block] stored height=… hash=0000000000000000.. bytes=… tx=… (via IP:port)` —
182
+ the hash is truncated with `..`, and `via` is the only place that names which
183
+ peer served a block.
184
+ - Timestamps are **local time with no offset**: `2026-09-07 09:24:39.281`.
185
+ Build with explicit `new Date(y,m-1,d,...)`; `Date.parse` of a non-ISO string is
186
+ implementation-defined.
187
+ - `uptime=00:14:15:21` is `DD:HH:MM:SS` = 51,321 s — which matched the node's own
188
+ `uptime` RPC exactly, confirming the format.
189
+ - **A build can rewrite these lines.** The bench build of 2026-09-08 03:02 changed
190
+ `[dlc]` so comprehensively that the rules written against the production build
191
+ matched **30 of 1,702 lines** in its log, and **1 of its 1,006** `[dlc]` lines.
192
+ Same facts, new grammar — see §11. `test/bench-log.test.js` now keeps a
193
+ representative frozen sample and fails on the ratio, not only on a regex.
194
+
195
+ ## 11. The second build: bench node 2026-09-08 03:02, and what its log says
196
+
197
+ The bench node was rebuilt during this work (`v0.0.1 built Sep 8 2026 03:02`, run
198
+ 03:02–04:31Z archived as `console.run17c.log`). It disagrees with every
199
+ measurement above in three specific ways, each re-run by hand:
200
+
201
+ **`getpeerinfo` publishes rows now — and they still are not the traffic.**
202
+
203
+ ```
204
+ getconnectioncount -> 5
205
+ getpeerinfo -> 5 rows, each carrying bytessent / bytesrecv
206
+ sum(bytessent) -> 1153 sum(bytesrecv) -> 3232
207
+ getnettotals -> {totalbytesrecv: 3232, totalbytessent: 1153}
208
+ stalest lastrecv -> 4443 s ago
209
+ log for the same run -> [dlc] -- recv 11.2MB/s ... elapsed 1:11:11
210
+ ```
211
+
212
+ 11.2 MB/s for 71 minutes is ~47 GB. The peer table reported **3,232 bytes**, and
213
+ those bytes **sum exactly to `getnettotals`**, on peers last heard from 74 minutes
214
+ ago. So the newest build publishes a peer table for the control channel only; the
215
+ block bytes still live in the forked download worker and still appear nowhere in
216
+ RPC. Production (`deploy-20260907a`) was re-checked in the same minute and still
217
+ answers `[]` with `getconnectioncount: 13`.
218
+
219
+ **Per-peer throughput exists only in the log**, in a line the production build
220
+ does not print at all:
221
+
222
+ ```
223
+ [dlc] w1 84.215.4.221:8333 chunks=54 blocks=2160 (+10 blk/s, 941.2KB/s)
224
+ [dlc] w2 164.90.253.129:8333 chunks=0 blocks=0 (+0 blk/s, 0.0B/s) [early-kill, last 0.0B/s, peer BANNED]
225
+ ```
226
+
227
+ 256 such lines in the 90-minute run, naming a peer, a rate and a block count.
228
+ That is the whole per-peer bandwidth panel, and no RPC call returns any of it.
229
+
230
+ **The `[dlc]` tick changed grammar**, so `bandwidth` now arrives from two shapes:
231
+
232
+ ```
233
+ prod [dlc] -- network recv this tick: 4.0KB (405.0B/s) | total recv: 2.3MB ||
234
+ disk write this tick: 0.0B (0.0B/s) | total written: 1.9MB --
235
+ bench [dlc] -- recv 11.2MB/s (avg 10.5MB/s) | write 11.2MB/s (avg 10.7MB/s) |
236
+ floor 32.0 KB/s (median 353.4) | banned 6/121 | events 0 rot 0 ... --
237
+ ```
238
+
239
+ The bench form drops the running totals (so the totals panels show `–`, not 0)
240
+ and prints `(median 353.4)` **with no unit**. Both readings are defensible — KB/s
241
+ would make it a peer speed, B/s would make it 1000× smaller — so the value is
242
+ kept as the printed text in `poolMedianText` and `poolMedian` stays `null`.
243
+
244
+ **Three progress figures, not one.** The bench build prints its own sync
245
+ arithmetic twice, and they disagree by design:
246
+
247
+ | source | line | measured |
248
+ |---|---|---|
249
+ | download worker | `[dlc] == elapsed 1:28:22 \| eta 00:08:38:19 \| overall: 397852/966011 stored (41.19%) \| applied=396275 lag=0 ==` | node's own eta **8h38m** |
250
+ | applying thread | `[utxo_live] catchup progress: height=396273/966010 (41.0%) 23.8 blk/s ... eta 00:06:38:48` | node's own eta **6h38m**, 42.3 ms/blk |
251
+ | monitor | blocks/headers + measured rate over ≥60 s windows | separate, see §8 |
252
+
253
+ All three are stored and labelled separately. Rules 4 and 9 apply: the node's ETA
254
+ is a fourth opinion, not a correction to ours.
255
+
256
+ **Clock widths differ by line**: `elapsed 1:22:01` is `H:MM:SS` while
257
+ `eta 00:07:54:48` is `DD:HH:MM:SS`. Decoding the first as four fields would read
258
+ 1 h 22 m as 1 day 22 min. `parseClock()` counts fields instead of assuming.
259
+
260
+ **Where the file is.** This node writes its log to the run directory's
261
+ `console.log`. `<datadir>/main/debug.log` exists but is **144 bytes — three `node
262
+ start` lines — and had not moved since boot**, which is what the monitor had
263
+ open (fd 22) for two hours while every log-derived panel of this node sat still.
264
+
265
+ **And it is buffered.** At 04:50 the replacement run's `console.log` sat frozen at
266
+ **2,719 bytes for 18 minutes while the node's chain advanced ~15,000 blocks**
267
+ (397k → 412,606). stdout is block-buffered when it is a file, so lines arrive in
268
+ bursts: delayed here, never lost. A "log is stale" warning that did not check the
269
+ chain height would have accused the config of being wrong while being right about
270
+ nothing.
271
+
272
+ ## 12. Quiet gaps in a healthy node's log (why a stale gate cannot be short)
273
+
274
+ Across 1,840 lines of the synced production node's log, the longest gap between
275
+ consecutive lines was **1,182 s (~20 min)**. A 5-minute "the log stopped moving"
276
+ gate would flag a healthy idle node every few minutes, which trains people to
277
+ ignore it. The default `log.staleMs` is 30 min, per-node `logStaleMs` tightens it
278
+ where the node is chatty, and the flag reports the chain delta observed during the
279
+ silence rather than asserting a cause.
280
+
281
+ ## 13. Parser coverage, measured on both builds
282
+
283
+ Share of tagged log lines that a parser claimed (a `raw` event is a miss):
284
+
285
+ | corpus | before this change | after |
286
+ |---|---|---|
287
+ | bench run `console.run17c.log` (1,997 tagged lines) | 1.5% (30 lines; `[dlc]` 1/1006) | **94.8%** |
288
+ | production `bitcoin.main.log` (1,931 tagged lines) | 82.4% | **90.5%** |
289
+
290
+ The remaining misses are one-shot `[config]`, `[boot]`, `[wallet]`, `[tor]` and
291
+ `[serve]` lines that carry no figure the monitor wants. Coverage is now asserted
292
+ in `test/bench-log.test.js` against a frozen representative sample, at ≥0.85, and
293
+ surfaced live as `log.health.ratio` on the Node panel.
294
+
295
+
296
+ ## 14. Methods the node documents as refusing
297
+
298
+ `getmempoolcluster`, `loadtxoutset`, `getblockfilter`/`scanblocks`/
299
+ `getdescriptoractivity` (outside the undo window), `getblockfrompeer`,
300
+ `preciousblock`, `pruneblockchain`, `submitheader`, `exportasmap`,
301
+ `enumeratesigners`, `getopenrpcinfo`. A refusal from these is expected behaviour,
302
+ and `NODE_REFUSES` in `server/rpc/allowlist.js` says so in the reply rather than
303
+ letting it look like a monitor fault.
304
+
305
+ ## 15. Third build in four hours: the RPC grew a spine, and the log grew a field
306
+
307
+ Every RPC claim above is scoped to a build, because in a single day the same host
308
+ gave three different answers to the same three calls. This is the record.
309
+
310
+ | build / time | `getconnectioncount` | `getpeerinfo` | `getnettotals` recv |
311
+ |---|---|---|---|
312
+ | deployed to production, 04:16 and again 05:20 | 13 → 17 | **`[]`** | **0** |
313
+ | bench, built 03:02, sampled 04:16 | 5 | 5 rows, bytes summing to 3,232 | 3,232 |
314
+ | bench, rebuilt ~05:47, sampled 05:51 | 5 | **21 rows**, largest 201,608,074, a download-worker marker field present | 2,116,236,872 |
315
+
316
+ `getpeerinfo`'s 21 rows sum to 1,487,577,978 = **70.29%** of what `getnettotals`
317
+ reports received. The missing third is traffic from peers no longer in the table,
318
+ so the rows are a subset, not a breakdown — surfaced as `peerinfo-partial`.
319
+
320
+ **Down rate is now derivable from RPC on the newest build.** Sampled twice, 90 s
321
+ apart, on the bench node mid-IBD:
322
+
323
+ ```
324
+ getnettotals delta over 90s : 11.56 MB/s blocks 407.43/s
325
+ node's own log, same window : recv 11.2MB/s (avg 9.0 → 10.1MB/s across the window)
326
+ ```
327
+
328
+ **3% apart.** §4's conclusion ("bandwidth comes from the log") is now build-scoped,
329
+ not absolute, and §11's "3 KB against 47 GB" is equally real for the build it
330
+ measured. Both were measured today; neither is a universal.
331
+
332
+ **Up rate is still not there, and the arithmetic proves it.** Same process
333
+ lifetime: **12,896,531,244 bytes in against 1,129 bytes out**, with 21 peers
334
+ connected (later 14.0 GB against ~1 KB with 5). A node that pulled 12.9 GB of
335
+ blocks necessarily sent `getdata` for them; an 11,000,000:1 ratio is a statement
336
+ about the counter, not the traffic. So `outBps` is withheld and the reason is
337
+ published (`upload-unmeasurable`). A rate computed as `Δ(1129)/Δt` would have
338
+ rendered as a calm `0 B/s` upload on a node doing 11 MB/s in the other direction.
339
+
340
+ **RPC cannot tell you which regime you are in.** Both the build that reports 12.9 GB
341
+ and the build that reports 0 answer the same vendor `subversion` string and the same
342
+ `version: 1`. The only build attestation on this box is the build's own log banner
343
+ (`… LOG START … built Sep 8 2026 03:02 …`). So "trust the RPC instead of the log" is
344
+ not a safe global stance: the log is how you know whether
345
+ the RPC's zeros mean zero or mean *unpublished*.
346
+
347
+ **Update 2026-09-11 (§27):** true of every build up to this date, and no longer of
348
+ `deploy-20260910ag`. Its `getnetworkinfo` carries the build's own commit attestation
349
+ field, "860b8fdd", and a dirty flag reading false. Older builds omit the field, so this
350
+ paragraph still describes them.
351
+
352
+ **And the log moved again, an hour after its parser was written.** At 05:42:27 the
353
+ tick line gained a field:
354
+
355
+ ```
356
+ [dlc] -- recv 81.2MB/s (avg 108.7MB/s) | write 62.8MB/s (avg 80.4MB/s) | floor 32.0 KB/s (median 5.1) | banned 8/114 | staged 1 --
357
+ ```
358
+
359
+ 26 of 26 tick lines in that run matched nothing, because the rule anchored on the
360
+ end of the line. The global `log-unparsed` gate (<5% of lines) never fired — the
361
+ run overall still parsed ~24% — so a shape can die completely while the ratio looks
362
+ survivable. Coverage per rule, not just coverage overall, is the actual lesson; see
363
+ rule 15 and DEFECTS.
364
+
365
+ ## 16. Cheap RPC reads worth having (measured on production, 2026-09-08)
366
+
367
+ | method | ms | bytes | note |
368
+ |---|---|---|---|
369
+ | `getaddrmaninfo` | 11 | 300 | peer book: 52,877 tried (ipv4 36,482 / ipv6 9,046 / onion 6,304 / i2p 1,045) |
370
+ | `getchainstates` | 2 | 336 | headers + per-state blocks/difficulty/`validated` |
371
+ | `listbanned` | 3 | 36 | **`[]` while the same node's log said `banned 8/114`** — worker bans are not in the stored ban table |
372
+ | `uptime` | 2 | 39 | seconds |
373
+ | `getmemoryinfo` | 2 | — | refused: `getmemoryinfo "stats" reports Bitcoin Core's SECURE ALLOCATOR…` |
374
+ | `getlogging` | 2 | — | does not exist; the method is `logging`, which is denied as a mutator |
375
+
376
+ There is **no `getlogevents`** and no build-specific stats method (171 methods,
377
+ identical lists on both builds), so the log buffer is not reachable over RPC:
378
+ `[dlc]`'s write rate, `[tx_accept]`'s reject breakdown, `[check]`'s archive holes
379
+ and `[utxo_live]`'s validation stalls have no RPC equivalent at any cadence.
380
+
381
+ **Update 2026-09-11 (§27):** `deploy-20260910ag` serves **165** methods and has gained a
382
+ build-specific one, a vendor download-info method with no Core counterpart. It covers
383
+ the download's state, not the log's reject, write-rate or hole figures, and there is
384
+ still no `getlogevents`. The 171-name
385
+ list above was never saved, so the six dropped methods cannot be named. The 165-name list
386
+ is in `docs/rpc-methods-2026-09-11.txt`.
387
+
388
+ ## 17. Fourth build, and the parsers that died while nobody was looking
389
+
390
+ At 06:21:35 the production node logged `[serve] shutting down (signal 15):
391
+ tip=965914 outbound_legs=0` and came back as `deploy-20260908a`. Two
392
+ things were true the moment it returned:
393
+
394
+ **1. The `[dlc] ==` progress line had changed parenthetical, and the rigid rule
395
+ matched 185 of 185 → 0 of 185:**
396
+
397
+ ```
398
+ old: … | in flight 279 of window 4096 through 391761 (oldest gap 0s at 388878, 99.93% landed) | applied=388876 lag=1 ==
399
+ new: … | in flight 0 of window 4096 through 322920 (no gap, 100.00% landed) | applied=322920 lag=0 ==
400
+ ```
401
+
402
+ `eta` also prints `--:--:--:--` when the node has no estimate — decoded as `null`,
403
+ never as zero.
404
+
405
+ **2. Production was suddenly talking to something RPC cannot see.** 646 lines in
406
+ half an hour:
407
+
408
+ ```
409
+ [serve] inbound 127.0.0.1:34208 accepted -> child pid 3425602 (2/245 inbound)
410
+ [serve] inbound 127.0.0.1:34208 v2 handshake failed -- dropping
411
+ ```
412
+
413
+ 323 inbound connections from **this host**, accepted and dropped before BIP324
414
+ completed. `getpeerinfo` cannot show a peer that never negotiated, and
415
+ `getconnectioncount` never counted them. The monitor aggregates them
416
+ (`inbound-handshake-failing`, with the per-host breakdown) instead of putting 323
417
+ identical rows in the feed — the finding is the rate and the source, not the
418
+ individual socket.
419
+
420
+ **Coverage, live, before and after field-scanning these two lines:**
421
+
422
+ | corpus | rigid rules | field-scanned |
423
+ |---|---|---|
424
+ | bench `console.log` (947 tagged lines) | 68.2% | **90.3%** |
425
+ | production `bitcoin.main.log` (3,689 tagged lines) | 73.6% | **89.7%** |
426
+
427
+ The remaining misses are one-shot `[config]`, `[boot]`, `[wallet]`, `[tor]`,
428
+ `[serve]` boot chatter and `[dl]` internal-progression lines.
429
+
430
+ Four grammars changed in one day on two builds, and each time the *global* parse
431
+ ratio stayed high enough to look survivable — 24%, 68%, 74%. That is the argument
432
+ for scanning labelled lines field by field, which is now how the tick and progress
433
+ lines are parsed: a new field costs that field (`extraFields`, values kept verbatim:
434
+ `staged 38 commit 6288`) instead of costing the line.
435
+
436
+ ## 18. Production deploy-20260908a: the RPC gap closed, and a port trap reloaded
437
+
438
+ Fixed at 07:52 after an outage the monitor reported correctly the whole time
439
+ (`main online false`, with the reason, no invented numbers).
440
+
441
+ **What actually broke was two lines in the node's `bitcoin.conf`.** The 06:21 deploy
442
+ rewrote that file to four lines, dropping `rpcport=8331` and `port=8332`, so the
443
+ daemon fell back to Core's defaults and both halves of the default layout collided
444
+ with something else on this box:
445
+
446
+ 1. P2P wanted **8333**, which the benchmark's Core oracle (`bitcoind`, pid 106094)
447
+ holds on `127.0.0.1`–`16`. Boot died with `[boot] lsock failed: Address already
448
+ in use` at 06:32:02, having loaded the archive fine (`tip=966028 (0.00s)`).
449
+ 2. The 06:35 workaround bound P2P to `192.0.2.10`, which fixed the collision but
450
+ left **RPC on 8332** — while the monitor, the systemd unit and this node's own
451
+ `config/bitcoin.conf` all say RPC is **8331**. Result: RPC answering on a port
452
+ nobody was looking at, and a monitor reporting `ECONNREFUSED` truthfully.
453
+
454
+ The node's own conf header records this exact failure from 2026-08-26 ("rpcport MUST
455
+ differ from port= … the embedded RPC server loses that bind and comes up RPC-less") —
456
+ a third repeat. Restoring the two lines fixed both symptoms; the node came up with
457
+ `[rpc] JSON-RPC server on 127.0.0.1:8331` at 07:53:24 and the monitor's breaker
458
+ closed within a minute. Backup kept as `bitcoin.conf.pre-rpcport-restore-20260908-0752`.
459
+
460
+ **Two timings worth knowing before blaming the monitor:**
461
+
462
+ - **RPC binds ~100 s after systemd reports `running`.** Boot 07:51:41 → RPC 07:53:24.
463
+ A probe at 40 s sees `ECONNREFUSED` and no cookie file; neither means failure.
464
+ - **`[utxo_live] init` blocks chain RPCs for ~40 s** (07:53:24 → 07:54:06 measured).
465
+ The monitor's slow tier landed inside that window and reported
466
+ `rpc timeout after 90000ms` — while `uptime`, `getconnectioncount`,
467
+ `getblockcount` and `getnettotals` each answered in **0.15 ms** moments later.
468
+ Cheap methods answering while chain-backed ones hang is the signature; the lane's
469
+ timeout and breaker are the right response, not a bug.
470
+
471
+ **And the gap that started this whole investigation is now closed on production.**
472
+ Same host, new build, 2 minutes after boot:
473
+
474
+ | | deploy-20260907a (04:16, 05:20) | **deploy-20260908a** (07:56) |
475
+ |---|---|---|
476
+ | `getconnectioncount` | 13–17 | 6 |
477
+ | `getpeerinfo` | **`[]`** | **4 rows** |
478
+ | sum of `bytesrecv` | 0 | **1,036,340 — exactly `getnettotals` recv** |
479
+ | `getnettotals` sent | 0 | **310,937** |
480
+ | download-worker marker field in rows | — | absent (the bench build has it) |
481
+
482
+ So on the build production runs *now*, RPC-only mode can back the bandwidth and peer
483
+ panels. Two caveats the data still shows: rows (4) were fewer than
484
+ `getconnectioncount` (6), and `subversion` is still the same vendor string on
485
+ every build including the two that reported 0 bytes — so the log banner remains the
486
+ only way to know which regime you are in, which is why `BLOCKYARD_LOG_SOURCE=0` states
487
+ its losses instead of assuming they are gone.
488
+
489
+ **Update 2026-09-11 (§27):** on `deploy-20260910ag` both caveats moved. Rows equal
490
+ connections (9 of 9, 09:22Z), and `getnetworkinfo` attests the build
491
+ (commit attestation "860b8fdd"). Rows now sum to 99.99% of `getnettotals` because that
492
+ total no longer counts closed peers, not because it covers them.
493
+
494
+ ## 19. Cadences, measured — because a stale-warning needs a number, not a feeling
495
+
496
+ Four grammars changed today and the corpus parse ratio flagged none of them (rule 16).
497
+ The fix is a per-shape liveness flag, which needs one thing I did not have: how often
498
+ each line *should* arrive. Measured on both live nodes (production, 355–640
499
+ occurrences per shape):
500
+
501
+ | shape | median | p95 | max | watched? | gate |
502
+ |---|---|---|---|---|---|
503
+ | heartbeat | 65 s | 152 s | 1657 s | yes | 20 min |
504
+ | relay legs | 64 s | 120 s | 1842 s | yes | 16 min |
505
+ | orphans | 65 s | 152 s | 1657 s | yes | 20 min |
506
+ | accepts and rejects | 33 s | 86 s | 1652 s | yes | 12 min |
507
+ | address gossip | 22 s | 87 s | 902 s | yes | 12 min |
508
+ | bandwidth tick | 10 s | 17 s | 17 s | IBD only | 10 min |
509
+ | download progress | 10 s | 17 s | 17 s | IBD only | 10 min |
510
+ | **`[dl] updating utxo`** | **460 s** | **1868 s** | **3669 s** | **no** | — |
511
+ | **`[dl] header mirror`** | **502 s** | **2054 s** | **3669 s** | **no** | — |
512
+
513
+ Gate = p95 × 8, clamped to [10, 30] min. The last two rows are the ones that make the
514
+ flag trustworthy rather than noise: an irregular line watched on a tight gate is
515
+ wrong often enough to be ignored, and an ignored warning is worse than none. The
516
+ maxima of ~1,650 s are the restart windows, where *no* lines arrive — the flag
517
+ requires other lines to be arriving, so a down node does not also produce a format
518
+ warning.
519
+
520
+ Shapes also stay unarmed until their rule matches once, which is how build
521
+ differences are handled with no build table: the bench build never emits `heartbeat`,
522
+ production emits a bandwidth tick twice in hours because it is synced.
523
+
524
+ **Event time, not wall clock**, deliberately: the bench node block-buffers its
525
+ stdout (§11), so a tail delivers minutes of history in one burst. Measuring silence
526
+ against arrival time would cry wolf at every flush; measured against the log's own
527
+ timestamps it is ~0 s across a burst, which is the truth.
528
+
529
+ ### Four unparsed shapes found while building this, and one lesson about ratios
530
+
531
+ Production was at 73.4% coverage; after parsing what was actually missing it is at
532
+ **82.8%** (bench 85.7%). The unparsed shapes were:
533
+
534
+ ```
535
+ 418 [txrelay] addrv2 gossip: +3 address(es) to the book
536
+ 130 [dl] outbound top-up: 4 dial(s) failed, first 93.201.114.79:8333: connect: Operation now in progress
537
+ 43 [dl] updating utxo: applied 1 block(s), now at height 966054, live=165330654 (0.21s)
538
+ 41 [dl] header mirror +1 from the archive (now 966055, archive tip 966054)
539
+ 52 [dial] 31.21.210.206:8333: dialing in the background (ipv4)
540
+ 2 [dial] no global IPv6 route on this host: ipv6 peers are unreachable (cjdns unaffected)
541
+ ```
542
+
543
+ The ratio had *fallen* 16 points for a completely harmless reason — gossip chatter
544
+ grew — after it had stayed *healthy* while three rules died. A corpus number is
545
+ useless in both directions; it is reported now because it is cheap, and nothing
546
+ depends on it.
547
+
548
+ Two of those are figures the monitor did not have: `[dl] updating utxo` is
549
+ **validation throughput** (`applied 1 block in 0.21 s` → 4.76 blk/s), kept as a third
550
+ rate beside the download rate and the catch-up rate, never averaged with either
551
+ (rule 9); and `[dial] no global IPv6 route` explains an ipv6 peer count of zero
552
+ better than the count does — flagged as `ipv6-unreachable`, "a host capability, not a
553
+ node fault".
554
+
555
+ `updating utxo` and `header mirror` are parsed but deliberately *not* watched, per the
556
+ table above — so the bursty-rate lesson is encoded where the data that produced it
557
+ lives, not in a comment nobody re-reads.
558
+
559
+ ## 20. One ring per series had been averaging two daemons since the second node was added
560
+
561
+ Found while trying to answer a different question ("did the RPC breaker open while the
562
+ node was healthy?") — which turned out to be **unanswerable from the history**, because
563
+ no ring row said which node it came from. The measured mix, two live nodes, 2026-09-08:
564
+
565
+ | ring | rows | from production | from bench |
566
+ |---|---|---|---|
567
+ | `peers` | 5,005 | 2,308 (`connections >= 15`) | 1,816 (`connections <= 6`) |
568
+ | all 9 rings | ~45,000 | no marker | no marker |
569
+
570
+ So every chart on the Nodes/Network/Peers pages was drawing a bucketed aggregate of
571
+ two different daemons, and `last()`/`tail()` were returning whichever node wrote most
572
+ recently. With `agg: 'last'` per bucket, a bucket holding a production point and a
573
+ bench point rendered as *the node the chart is labelled with* — which is not a
574
+ misreading by the viewer, it is a wrong value from the store.
575
+
576
+ Now: writes are stamped (`node: <id>`) and reads are filtered through
577
+ `History.forNode(id)`. One ring per series is kept deliberately — capacity and
578
+ retention are configured per series, and per-node rings would multiply memory by node
579
+ count and orphan a removed node's history. The cost is that two nodes halve each
580
+ other's reach back in time, which is asserted in a test so it stays a decision rather
581
+ than becoming a surprise.
582
+
583
+ **The consequence of fixing it, stated rather than hidden:** every row written before
584
+ this change is unattributed — 14,177 `node`, 18,022 `peers`, 12,619 `net`, 16,384
585
+ `rpc` — and a node-filtered read **excludes** them. Attributing them would need a
586
+ guess about which daemon produced them, and a guessed owner is the same error one
587
+ degree further out. So per-node charts refill from the restart: minutes for the 1 h
588
+ ranges, and up to a day and a week for the long ones. `history.summary()` now reports
589
+ `unattributed` and `nodes` per ring so that gap is visible from the API instead of
590
+ being discovered as "where did my history go".
591
+
592
+ ## 21. What monitoring the benchmark cost, and why it stopped
593
+
594
+ The benchmark node (`bench`) was removed from the default configuration on
595
+ 2026-09-08. Not because its data was wrong — because on this box the act of watching
596
+ it was load, and it could not be read reliably enough to be worth that load.
597
+
598
+ **Cost to the node we actually care about.** The node's RPC services one connection on
599
+ one thread. Every poll of the bench node is a slot the benchmark cannot use. This repo
600
+ already knew that: `main.js` deliberately strips the real bench node out of
601
+ `npm run dev` and `scripts/smoke.sh`, with a comment saying a benchmark disturbed by a
602
+ test suite is a nasty class of interference. Running the bench node as a permanently
603
+ polling-monitored node was the same mistake with better branding.
604
+
605
+ **Reliability, measured over roughly one hour of it being watched:**
606
+
607
+ | measurement | bench | main, same monitor, same code |
608
+ |---|---|---|
609
+ | RPC average latency | ~18–32 s | ≤ 22 ms |
610
+ | RPC timeouts | 90 s, on fast *and* slow tiers | 0 |
611
+ | failed tier runs | 25 | 0 |
612
+ | breaker trips | several (flapping `online`) | 0 |
613
+ | restarts during the window | 9+ (its harness cycles it) | the deploys |
614
+ | log continuity | truncated at every restart; block-buffered, minutes frozen then a burst | continuous, logrotated |
615
+
616
+ A node that is being benchmarked by someone else on the same machine is not a
617
+ telemetry source with a little jitter; it is a different operating environment, and the
618
+ monitor kept reporting it as if it were the first kind.
619
+
620
+ **And it had already corrupted the production view once.** Before series rows carried
621
+ a node id, the `peers` ring held 2,308 production rows interleaved with 1,816 bench
622
+ rows and drew them as one line (§20). The rings are per-node now, but the incident is
623
+ the reason this file treats "which node produced this row" as a first-class fact
624
+ rather than a refinement.
625
+
626
+ **What was lost, honestly:** live visibility of a second IBD in progress — the thing
627
+ that motivated §8's rate/ETA work, and the reason the sync bar's refusal to guess an
628
+ ETA was ever tested against real numbers. That knowledge stays in this file and in
629
+ `test/`'s frozen bench fixtures, which is where it does its job. If a future session
630
+ needs live bench eyes again, `config.js` carries the exact `config/local.json` block to
631
+ bring it back, along with the two traps that bit us: the real log is `console.log` in
632
+ the run directory (not `<datadir>/main/debug.log`, a 144-byte stub), and it is
633
+ block-buffered, so silence means "flushed late" as often as it means "stopped".
634
+
635
+ ## 22. Per-response page rewriting, the block map, and the cost of a login attempt (2026-09-09)
636
+
637
+ Numbers behind the changes made on 2026-09-09, measured on this box rather than
638
+ estimated. The method for each is in parentheses, because three of them are the kind
639
+ of claim that silently becomes false when a file grows.
640
+
641
+ ### HTML rewriting per response is free
642
+
643
+ The build stamp and the CSP nonce both require rewriting every HTML response. Cost:
644
+
645
+ | measurement | value | how |
646
+ |---|---|---|
647
+ | `index.html` on disk | 21,341 B | `Buffer.byteLength` |
648
+ | after rewriting (nonce + 3 versioned asset URLs) | 21,391 B | same |
649
+ | `renderHtml()` per request | **0.009 ms** | 3,000 iterations after 200 warm-up |
650
+ | `securityHeaders()` per request | 0.0019 ms | same |
651
+ | combined throughput ceiling | ~95,000 rewrites/s | 1000 / (sum) |
652
+
653
+ So the per-response rewrite is not a performance decision, and the `?v=` stamp does
654
+ not need a build step or a manifest. It also means HTML can no longer answer `304`:
655
+ the body is not the file on disk any more, and a cached body would carry a nonce the
656
+ new CSP refuses. Assets keep their ETag/`304` path (asserted in
657
+ `test/http-app.test.js`), which is where the bytes actually are.
658
+
659
+ ### The in-memory block map
660
+
661
+ `store.blockMapCap` became configurable, so the default needed a cost:
662
+
663
+ | measurement | value |
664
+ |---|---|
665
+ | 12,000 block rows | **3.6 MB heap** (~310 B/row) |
666
+ | 3,000 rows (the old hard-coded cap) | ~0.9 MB, i.e. the old cap was saving ~2.7 MB |
667
+
668
+ Measured by filling a `Map` with rows shaped exactly as `fetchBlockStats()` builds
669
+ them and differencing `process.memoryUsage().heapUsed` (`test/monitor-shapes.test.js`,
670
+ which prints the figure and asserts an upper bound so the comment cannot rot). The old
671
+ 3,000-row cut bought 2.7 MB and threw away blocks that the 72 h retention would have
672
+ kept — and `gapSec` for the oldest survivor then described a block whose predecessor
673
+ had been dropped, silently.
674
+
675
+ ### One login attempt
676
+
677
+ The login throttle exists because an attempt is not free:
678
+
679
+ | measurement | value |
680
+ |---|---|
681
+ | `scrypt(N=16384, r=8, p=1, keylen=32)` | **20 ms**, 16.0 MB (`128·r·N`) |
682
+ | the comment in `server/auth/users.js` before this | claimed "~50 ms" — corrected to the measured 20 ms |
683
+ | throttle bucket | capacity 10, refill 0.5/s, per address |
684
+ | 22–24 attempts in one burst | first refusal at attempt ~6–10, ≥10 of the burst refused (`test/http-app.test.js`, `scripts/smoke.sh` asserts ≥8 of 22) |
685
+
686
+ `LoginGuard` locks a *username* after 8 failures in 5 min; the per-user token bucket
687
+ does not apply pre-auth. So before this change a distributed grind — many addresses, a
688
+ few attempts each — sat under every threshold in the code while each attempt cost
689
+ 20 ms of the request thread and 16 MB of its memory.
690
+
691
+ ### Audit log budget
692
+
693
+ `audit.jsonl` now rotates by size: `store.auditMaxBytes` 8 MiB, `store.auditKeep` 5.
694
+ Worst-case footprint on disk is therefore **48 MiB** (current + 5), against "unbounded"
695
+ before. The reason for a hard bound at all is that this disk also holds the history
696
+ snapshots and the node's datadir: an unbounded audit log's failure mode is not "no
697
+ audit", it is "no node".
698
+
699
+ ### Things that were *not* measured, and therefore were not done
700
+
701
+ - **No verbosity-2 block fetch, anywhere.** The drill-down asks `getblock` verbosity 1
702
+ plus `getblockstats`. §6 measured 11 MB of hex per block for verbosity 2 on this
703
+ node, and the fake node now *refuses* verbosity 2 so that a test can fail if the
704
+ monitor ever asks (`test/drilldown.test.js`).
705
+ - **No per-tier circuit breakers.** Still one breaker per node. What was added is the
706
+ telemetry to answer which method opened it, how long it will block, and which calls
707
+ were in the lane when it opened. The granularity question stays unimplemented
708
+ because the contention it would relieve has never been measured (DEFECTS).
709
+ - **No fee in the transaction view.** Computing it means fetching every input's
710
+ prevout — N more turns on a one-threaded RPC server — and §6 records that this node
711
+ omits `fee`/`deltafee` from its verbose reply anyway. The field is named in
712
+ `notReported` instead (rule 8).
713
+
714
+ ## 23. The node started publishing getnettotals without restarting (2026-09-09)
715
+
716
+ Switched this monitor to RPC-only (`BLOCKYARD_LOG_SOURCE=0`) at 17:31Z to settle what the
717
+ mode actually costs on the running build. Two claims died in the next few minutes, one of
718
+ them mine.
719
+
720
+ ### getnettotals went from 0/0 to real, mid-uptime
721
+
722
+ ```
723
+ 09:36Z getnettotals totalbytesrecv 0 totalbytessent 0 (also 0 on 09-08, repeatedly)
724
+ 17:36Z getnettotals totalbytesrecv 23,955,131 totalbytessent 164,323
725
+ 17:36Z+25s 23,955,307 170,419 (delta 176 B / 6,096 B)
726
+ 17:44Z getconnectioncount 17 | getpeerinfo -> [] (still)
727
+ ```
728
+
729
+ No restart happened in between — see the process tree below. So "does this node publish
730
+ byte counters?" is not only a question about **which build** (the 03:02 bench build
731
+ answered 11.56 MB/s against the 11.2 MB/s in its own log while the production build
732
+ answered 0, both reporting the same vendor subversion string); it is a question
733
+ about **when**, answerable only by asking again. Anything in this repo that caches the
734
+ answer — a flag string, a provenance row, a panel that stays empty out of habit — is a
735
+ staleness bug with documentation attached. Peer identity did **not** change: `getpeerinfo`
736
+ still answers `[]` with 17 connections, so the peer panel remains log-only on this build
737
+ even though bandwidth no longer is.
738
+
739
+ Practical consequence for the mode itself: RPC-only on *this* build keeps bandwidth and
740
+ loses per-peer bytes, per-peer identity, relay legs, the accept/reject breakdown, disk
741
+ writes, the worker ban count, the node's own ETA, archive holes and `sync_failing`. That
742
+ list is what `log-source-disabled` and `/api/net`'s `unavailable` must enumerate, and both
743
+ now do.
744
+
745
+ ### The 16:00:49 process is a grandchild, not a restart
746
+
747
+ I reported `ps -o lstart` at 16:00:49 as evidence of a daemon restart. Wrong, and the
748
+ ppid chain is the correction:
749
+
750
+ ```
751
+ 2034867 ppid 1 started 08:50:56 the daemon (serve .../data)
752
+ 2057444 ppid 2034867 started 08:52:26 forked download worker
753
+ 241094 ppid 2057444 started 16:00:49 coinstats fold worker
754
+ ```
755
+
756
+ A fork of a daemon inherits its command line, so `pgrep`/`ps` filtering on the command
757
+ line cannot tell a daemon from its children — only `ppid` can. This matters for
758
+ attribution: the entire post-reindex window is **one build in one process**, which is
759
+ exactly what makes the 0/0 → 23.9 MB observation above a statement about a process
760
+ rather than a comparison of builds. Any lifetime or teardown measurement that assumed a
761
+ restart at 16:00:49 was slicing one run in half.
762
+
763
+ ### What stopped being a fabricated zero
764
+
765
+ `inBps` was computed from `getnettotals` deltas and shipped whatever came out, including
766
+ 0 while the counter was identically 0 — a chart reading "idle node" drawn from a counter
767
+ that had never counted anything. The gate now mirrors the one upload already had:
768
+ `downloadMeasured` requires a nonzero, moving counter (or a log rate, when the tail is
769
+ on), and the panel shows `–` otherwise. Measured both ways: totals 0 → `inBps` null;
770
+ totals advancing (23,955,131 → 23,955,307) → `inBps` 7.24 B/s. The same code had been
771
+ printing `node log [dlc] tick lines` as `/api/net`'s provenance while the tail was closed
772
+ by configuration; provenance is per mode now, because a source the process refused to
773
+ open is not a source.
774
+
775
+ ## 24. Block size was empty because we asked for a field that does not exist (2026-09-09)
776
+
777
+ Reported as "Block size never updates". It never updated because it never had a value:
778
+ `BLOCKSTATS_FIELDS` asked `getblockstats` for `size`, `weight` and `strippedsize`. Those
779
+ are `getblock` fields. `getblockstats` has never provided them, and it answers a request
780
+ for a statistic it lacks by **omitting it** — no error, nothing in the response, no flag.
781
+ Every other figure on the row (fees, txs, percentiles, utxo increase) filled in normally,
782
+ so the panel looked healthy apart from one permanently empty chart.
783
+
784
+ Same height, two requests, through the monitor's own read-only console (2026-09-09):
785
+
786
+ ```
787
+ getblockstats 966253 ["size","weight",...] -> 31 keys, none of them size/weight/strippedsize
788
+ getblockstats 966253 [...,"total_size","total_weight","mediantxsize","swtotal_size","swtxs"]
789
+ -> total_size 1,579,815 total_weight 3,991,545
790
+ mediantxsize 221 swtotal_size 1,403,359 swtxs 4,103
791
+ getblockstats 966252 -> total_size 1,628,281 total_weight 3,991,846
792
+ 24h of the size series before the fix -> 0 non-null samples in 400 buckets
793
+ ```
794
+
795
+ `total_weight` at 3,991,545 against the 4,000,000 cap is the sanity check that the field
796
+ is the right one.
797
+
798
+ Three consequences kept, not just the mapping:
799
+
800
+ - **The number is labelled with its basis, in the payload.** `total_size` is the sum of
801
+ transaction sizes; the serialized block also carries the 80-byte header and the
802
+ txid-count varint. The row ships `sizeBasis` and the Chain page prints it under the
803
+ chart, because "size" is a name that invites a reader to assume identity.
804
+ - **Absence names itself.** A reply without `total_size` sets `sizeMissing`; the slot
805
+ says so instead of rendering an empty chart that reads as a broken panel.
806
+ - **The fields this node does publish were being thrown away.** `mediantxsize`,
807
+ `avgtxsize`, `swtotal_size` and `swtxs` were never in the request, so they were never
808
+ in the reply either. They are stored and ringed now.
809
+
810
+ The lesson is about the shape of the bug rather than its size: an RPC allowlist and a
811
+ field list are both claims about a node, and a claim answered by silence looks exactly
812
+ like a claim that is fine. A test now pins the list — `'size'`, `'weight'` and
813
+ `'strippedsize'` may not come back — because the failure mode is silence, not error.
814
+ Measured live after the fix: heights 966253–966255 carry 1,579,815 / 1,501,896 /
815
+ 1,505,279 bytes.
816
+
817
+ ## 25. Who mined it: coinbase attribution, and the map that names it (2026-09-09)
818
+
819
+ Cost of the two reads per block, measured through the monitor's own read-only console:
820
+ `getblock <hash> 1` = 259,891 bytes in 8 ms (exact `size`/`weight`/`strippedsize` plus the
821
+ txid list; `tx[0]` is the coinbase), `getrawtransaction <coinbase> 2` = 2,915 bytes in
822
+ 63 ms. Two calls per block, one block per poll tick, priority 3, nothing at all while the
823
+ node is in initial download. The 11 MB verbosity-2 path (MEASUREMENTS 6) is never used.
824
+
825
+ Three decoding traps, each found on a real block the same afternoon:
826
+
827
+ | block | what the coinbase did | what was wrong with the obvious parser |
828
+ |---|---|---|
829
+ | 966257 | `/ViaBTC/Mined by ecgbtc/` in its own push | none — the clean case, BIP34 `71be0e` = 966257 little-endian |
830
+ | 966253 | `Mined by AntPool971` with the extra nonce **inside the same frame** (`...971\x15\x00"\x00\xe16{m`) | "is the frame text?" is the wrong question; the answer is the **printable prefix**, so the tag is `Mined by AntPool971` and not `null` |
831
+ | 966258 | `/Foundry USA Pool #dropgold/` inside a 50-byte scriptSig that declares a **47-byte push it has no room for** | the strict push walk stops with no tag, though the name is plain ASCII in the block. Fallback: scan for printable runs. The row says `tagSource: 'scan'` so the weaker method is visible in the data |
832
+
833
+ OP_PUSHDATA1/2/3 (0x4c/0x4d/0x4e) are handled: tags longer than 75 bytes use them, and
834
+ stopping at them silently files the block as an unknown pool. The witness commitment
835
+ (`fabe6d6d…`) is recovered from the unparsed tail — and when a scriptSig genuinely has no
836
+ commitment, as Foundry's does here, the answer is `null` rather than something recovered.
837
+ The test asserts both.
838
+
839
+ ### Labels from mempool.space, with provenance
840
+
841
+ `node scripts/pool-map.js` writes `data/pool-map.json` from
842
+ `raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json` — 35,733 bytes,
843
+ 171 pools listed, **151 with usable tags, 200 matchers**, sha256 `0491a15f88db…`. Matching
844
+ is a case-insensitive **literal substring** test on the whole scriptSig as text, longest
845
+ tag first, tags under three normalised characters dropped so a bare "pool" cannot claim a
846
+ block. No regexes, no similarity, no guessing.
847
+
848
+ What that produced against the live node, 23 blocks (966237–966259):
849
+
850
+ ```
851
+ antpool 6 blk 26.1% raw tags seen: 'Mined by AntPool971', 'Mined by AntPool '
852
+ foundry usa 6 blk 26.1% raw tags seen: '/Foundry USA Pool #dropgold/', …#dropgold/O'
853
+ viabtc 3 blk 13.0% raw tags seen: '/ViaBTC/Mined by akamali2/', …
854
+ spiderpool 3 blk 13.0% raw tags seen: 'jSpiderPool/213/', 'jSpiderPool/561/'
855
+ f2pool 3 blk 13.0% raw tags seen: '/F2Pool/e', '/F2Pool/d'
856
+ ocean 1 blk 4.3% raw tag: '< OCEAN.XYZ >'
857
+ mara pool 1 blk 4.3% raw tag: 'j| MARA Made in USA '
858
+ ```
859
+
860
+ Every grouped row keeps the distinct raw tags folded into it, and every labelled row
861
+ keeps the tag that matched (`matchedTag`) plus how the tag was read (`tagSource`). The
862
+ map's provenance travels with the payload: `labelSource = { source, sha256, fetchedAt,
863
+ attribution }`, so "who says this is AntPool?" is answerable from the screen. Unmatched
864
+ blocks stay `unknown:<fingerprint>` with their raw text shown. Refresh is manual by
865
+ design: the app must not need the network at runtime, and a fetch nobody asked for should
866
+ not be able to change which organisation the dashboard blames for a block.
867
+
868
+ Fetch trivia, because it cost a debugging turn: node's `fetch` tried every A and AAAA
869
+ address, all IPv6 attempts were `ENETUNREACH` (this box has no IPv6 route — the node's own
870
+ log says so), and the call died inside a 20 s budget while an IPv4 socket to the same host
871
+ connected in **367 ms** and `curl` returned 200 in 300 ms. The tool pins `family: 4` and
872
+ falls back to `curl` rather than depending on Happy Eyeballs behaving.
873
+
874
+ ## 26. The block being built, and the ancestor graph the Mining page was wrong about (2026-09-09)
875
+
876
+ The Mining page used to state that Goggles-style cluster analysis was impossible on this
877
+ node, because `getrawmempool verbose` returns `vsize` and `fees.base` with no `depends` and
878
+ no `ancestorcount`, and there is no `zmqpubsequence`. That observation was measured; the
879
+ conclusion drawn from it was not. **The node does publish the ancestor graph -- from
880
+ `getblocktemplate`, where every selected transaction carries `depends`.**
881
+
882
+ Measured on height 966265, same day:
883
+
884
+ ```
885
+ getblocktemplate(rules:[segwit]) 1,790,010 bytes, 1.286 s first call, 1.482 s second
886
+ selected transactions 1,496
887
+ transaction entries { data, depends, fee, hash, sigops, txid, weight }
888
+ weight selected 1,630,838 WU of 4,000,000 = 40.8%
889
+ fees selected 790,578 sat (0.00791 BTC); coinbasevalue 313,322,914 sat
890
+ per-tx feerate sat/vB min 0.10 median 1.60 p90 4.00 max 74.30
891
+ ancestor packages 1,475 total, 19 multi-transaction, 40 txs (2.7%) inside one
892
+ example package 3 tx: rates 38.1 / 7.6 / 0.5 sat/vB -> package feerate 10.93
893
+ ```
894
+
895
+ That last line is the whole point of the Goggles view: the 0.5 sat/vB transaction is not
896
+ going to be mined for its own sake, and no per-transaction feerate says so. Folded with its
897
+ child, the package pays 10.93. `depends` entries are **indices into the same
898
+ transactions array** (measured: `'depends': [6]`), not txids -- reading them as txids
899
+ finds zero packages in a template that has 19, which is how a false limitation ends up
900
+ written into documentation and repeated by everyone who reads it.
901
+
902
+ `getmininginfo` still reports `pooledtx: 0` on this build while `getrawmempool` returns
903
+ thousands of entries, so the "transactions waiting to be mined" figure is taken from
904
+ `getrawmempool`/`getmempoolinfo`, never from `pooledtx` -- another counter that reads zero
905
+ and means "not published".
906
+
907
+ Cost, and the decision it forced: one `getblocktemplate` costs this node **1.3-1.5 s of its
908
+ single RPC thread**, on a server that serves one connection at a time. A background poll
909
+ would spend the node's minute on a page most viewers do not have open, so the Mining page
910
+ asks for the template only while it is visible, at most every 20 s, and the server shares
911
+ one in-flight call between concurrent viewers and serves the cached answer for 15 s after.
912
+ The `data` field -- the full hex of every selected transaction, and the entire reason the
913
+ reply is 1.79 MB -- is dropped before the summary is built and never reaches a snapshot
914
+ frame.
915
+
916
+ **Superseded 2026-09-13 (§26b): Bitcoin Core publishes the graph in the mempool, so the call
917
+ is no longer made.** The paragraph above is kept because it was measured and it was true of
918
+ the node it was measured on. Against Bitcoin Core on an Umbrel, `getrawmempool(true)` returns
919
+ per entry:
920
+
921
+ ```
922
+ depends[] present -- 24,796 of 29,742 entries were in a package
923
+ ancestorcount present, with ancestorsize and fees.ancestor
924
+ descendantcount present, with descendantsize and fees.descendant
925
+ chunkweight present, with fees.chunk <- Core's own cluster-mempool linearization
926
+ ```
927
+
928
+ So the dependency graph does not have to be bought with a 1.79 MB template call: it is in the
929
+ reply the monitor already reads every 20 s for the mempool view. `server/collect/gbt.js`
930
+ assembles the block from it -- greedy over `fees.chunk`/`chunkweight`, each transaction taken
931
+ with its unselected ancestors -- and returns it in the shape a `getblocktemplate` reply has,
932
+ so `summarizeTemplate`, `templateCells`, `packagesFromTemplate` and `blockEconomy` read it
933
+ unchanged.
934
+
935
+ Measured the same day, a back-to-back template and mempool pair at height 966821 (so the two
936
+ describe the same pool):
937
+
938
+ ```
939
+ transactions weight fees assembly
940
+ ours 6,546 3,995,859 643,076 sat 52 ms
941
+ the node's template 6,535 3,991,951 642,860 sat ~500 ms of the NODE's thread
942
+ difference +11 +3,908 +216 sat (0.03%)
943
+ set difference 178 in theirs not ours, 189 in ours not theirs -- all at the
944
+ 0.30 sat/vB margin, where ties are arbitrary
945
+ ```
946
+
947
+ It is a reconstruction, not the node's answer: sigop limits and policy the mempool does not
948
+ publish are not modelled, so it can differ at the margin. What it costs the operator's node is
949
+ nothing.
950
+
951
+ One number in the paragraph above is also stale on a warm node: on 2026-09-13 the same
952
+ `getblocktemplate` answered in **0.5 s**, not 1.3-1.5 s, after the `dbcache`/`rpcthreads`
953
+ tuning in INSTALL.md. The 4.0-4.5 s figure in §TROUBLESHOOTING was an untuned Umbrel.
954
+
955
+ ## 27. The node's RPC surface, re-read (2026-09-11)
956
+
957
+ 29 calls against production (`127.0.0.1:8331`, cookie auth), strictly one at a time,
958
+ ~1.5 s apart, 2026-09-11 **09:18:42–09:23:42Z**. Each was a `curl` whose `time_total` is
959
+ the latency column below. The running monitor kept polling throughout (see *Contention*).
960
+ The raw responses stayed in the session scratch and are **not** committed, because
961
+ `getpeerinfo` rows carry real peer addresses. The method list is committed as
962
+ `docs/rpc-methods-2026-09-11.txt`, the baseline §16 never kept.
963
+
964
+ ### Which build answered, and RPC can now say so
965
+
966
+ ```
967
+ 09:20:05Z getnetworkinfo build commit "860b8fdd" build dirty false
968
+ subversion <the build's own vendor string> version 1 connections 9 (in 0 / out 9)
969
+ 09:20:32Z uptime 10823 -> RPC up since 06:20:09Z
970
+ 09:23:06Z getblockchaininfo blocks 966,485 == headers 966,485, initialblockdownload false, vp 1
971
+ ```
972
+
973
+ Cross-checked off the wire. The daemon pid (started 06:18:24Z per `ps lstart`, box in
974
+ UTC) has `/proc/<pid>/exe` → the deployed build `deploy-20260910ag`, and its log banner
975
+ reads `LOG START: 2026-09-11 06:18:25 UTC … v0.0.1 built Sep 10 2026 20:04:29`. The
976
+ build's `live` symlink points at the same file, but its mtime was 09:24 (touched
977
+ *during* the survey), so it was not taken as evidence. `uptime` started 105 s after the
978
+ process did, the same shape as §18's ~100 s bind delay.
979
+
980
+ **This supersedes a claim three sections made.** §15 ("RPC cannot tell you which regime you
981
+ are in"), §18 ("the log banner remains the only way") and §23 (subversion identical across
982
+ builds) were true of every build before this one. On `deploy-20260910ag`, RPC attests the
983
+ commit and whether the tree was dirty. Two limits:
984
+
985
+ - a build that predates the field omits it, so absence means "a pre-attestation build",
986
+ not "commit unknown";
987
+ - a commit says which code, not what it publishes *now*. §23 measured a counter going
988
+ from 0 to real inside one process, so the commit keys a cache of capability answers
989
+ but does not replace re-asking.
990
+
991
+ ### `help`: 165 methods, a flat list, and no usage text
992
+
993
+ | call | time | bytes | result |
994
+ |---|---|---|---|
995
+ | `help` | 1.503 s (contended, below) | 3,028 | `== Methods served by this node (165) ==`, one alphabetical list, **no category headings**; the header says it "is generated from the dispatch tables, so it is exactly what will be answered" |
996
+ | `help <m>`, 11 methods, 09:21:05–09:21:24Z | 0.17–0.25 ms (one outlier: 4.42 s) | 283–298 | the same template for every method: "This node serves `<m>`, but does not carry Bitcoin Core's per-method usage text. Consult Core's own `help <m>` …" |
997
+
998
+ §16 counted **171** on the 09-08 builds and did not save the list, so the six methods
999
+ missing now **cannot be named**. That is why the list is committed this time. The next
1000
+ count should be a diff of that file.
1001
+
1002
+ The per-method text is also **cut at 255 characters**. 9 of the 11 sampled replies end
1003
+ mid-word (`…recorded in docs/RP`, `…docs/RPC_L`). Only the two shortest names
1004
+ (`addhdkey` 240 chars, `getorphantxs` 252) arrive whole. Either way `help` says nothing
1005
+ about arguments or side effects. **Every read-only judgement in the table below came from
1006
+ the node's source** (`asm/rpc_node.c`, `asm/rpc_chain.c`), not from `help`.
1007
+
1008
+ ### New and non-standard methods
1009
+
1010
+ | method | read-only? (from source) | params | latency | shape |
1011
+ |---|---|---|---|---|
1012
+ | the build's own download-info method | yes: reads the shared-memory `node_status_t` the download publishes; no Core counterpart | none | **0.175 ms** (09:22:08Z) | measured idle: `{"active":false,"bytes_total":0}`, 71 B. **The download-time shape is read from source and was NOT measured on the wire** (production was synced): `workers, pool_idle_pct, pool, banned, free_peers, window, first_hole, claim, applied, end_height, staged, stall_timeout_s, stall_evictions, median_bps, bytes_total`, plus `peers[]` of `{worker, addr, subver, services, startingheight, conntime, bytes_recv, bps_recv, idle_pct, inflight_lo, inflight_hi}` |
1013
+ | `getorphantxs` | yes: a compact snapshot the worker shares | verbosity `0` or `1`; `2` (hex) is refused `-8` by source | v0 **0.253 ms**, 375 B; v1 **0.200 ms**, 622 B | v0: 5 txids. v1: `{txid, bytes, parents, age_ms}`; 5 orphans, `bytes` 3,071 ×4 and 195, `parents` 1 each, `age_ms` 97,934–217,047. Core fields absent by source: `wtxid`, `vsize`, `weight`, `expiration`, `from`. `parents` (inputs still missing) is this node's own |
1014
+ | `getprivatebroadcastinfo` | yes (its sibling `abortprivatebroadcast` is a control op, not invoked) | none | 0.187 ms | HTTP 404, `-32601` "Private broadcast is not enabled. Ensure you're running Bitcoin Core with -privatebroadcast=1": the option is off on production |
1015
+ | `getaddressbalance` / `getaddresstxids` | read, from the address-index journal; Core has no such methods | address, array, or `{"addresses":[…]}` | **not invoked** | by source: answers only with `addrindex=1`; `getaddresstxids` returns up to 100,000 txids. A monitor has no address to ask about |
1016
+ | `getdescriptoractivity` | read but **heavy**: walks blocks directly; `DENY_EXACT` in `server/rpc/allowlist.js` | blockhashes, scanobjects | **not invoked** | — |
1017
+ | `getmempoolcluster` | refuses by source: "this node's mempool has no cluster linearization…" | — | not invoked (help only); already in §14 | — |
1018
+
1019
+ `simulaterawtransaction`, `addhdkey` and `exportwatchonlywallet` were only
1020
+ `help`-sampled. Nothing is recorded about them beyond the boilerplate.
1021
+
1022
+ ### Gap statuses on this build
1023
+
1024
+ | gap | before | deploy-20260910ag, 2026-09-11 |
1025
+ |---|---|---|
1026
+ | per-peer **bytes** | `[]` on deploy-20260907a (§3) and on the 2026-09-09 process (§23); 4 rows for 6 connections on deploy-20260908a (§18) | **closed.** 09:22:05Z `getpeerinfo` 9 rows; `getconnectioncount` 9 at 09:22:14Z. Row sums **109,027,561** recv / **34,804,574** sent against `getnettotals` **109,036,122** / **34,807,044** at 09:22:07Z = **99.992%** / 99.993%, 1.5 s apart. 09:23:36Z: 8 rows, 90,137,131 vs 90,139,443 (99.997%) |
1027
+ | per-peer **relay counts** | absent (DEFECTS) | still absent. Row keys: `id, addr, services, servicesnames, relaytxes, lastsend, lastrecv, bytessent, bytesrecv, conntime, timeoffset, version, subver, inbound, permissions, startingheight, synced_headers, synced_blocks, network`. `relaytxes` is a boolean (true on all 9), not a count |
1028
+ | `getnettotals` as a lifetime counter | assumed | **no: it falls when a peer leaves** (next subsection) |
1029
+ | ancestor fields in `getrawmempool true` | absent (§5) | still absent: 09:23:08Z, **19,014** entries, 2,641,932 B, **0.144 s**, keys `vsize, weight, time, fees{base}` only |
1030
+ | … in `getmempoolentry` | not recorded | **present**, 0.69 ms, 522 B: `vsize, weight, time, height, descendantcount, descendantsize, ancestorcount, ancestorsize, wtxid, fees{base, modified, ancestor, descendant}, depends, spentby, unbroadcast` (sampled tx: `ancestorcount` 18). One call per txid, so a full sweep is ≥19,014 × 0.69 ms ≈ 13 s of the single RPC thread: a drill-down, not a poll. §26's `getblocktemplate` was the cheap ancestor graph on that node; **on Core the graph is in `getrawmempool(true)` itself and costs nothing extra — see §26b** |
1031
+ | ZMQ | `zmqpubsequence` refused (DEFECTS) | `getzmqnotifications` → `[]`, 0.180 ms. No mempool sequence feed. **2026-09-13, Bitcoin Core/Umbrel: all five publishers present** — `pubsequence` tcp://0.0.0.0:28335, `pubrawblock` 28332, `pubrawtx` 28333, `pubhashblock` 28334, `pubhashtx` 28336, hwm 1000 each. Not reachable from this host, though: all five refuse while 8332 is open, because bitcoind binds them inside its container and Umbrel publishes only the RPC port |
1032
+ | log over RPC | no `getlogevents` (§16) | still none: the only log-named method in the 165 is `logging`, which `allowlist.js` denies |
1033
+
1034
+ ### `getnettotals` is a sum over live peers, not a lifetime counter
1035
+
1036
+ | time (Z) | call | recv | sent |
1037
+ |---|---|---|---|
1038
+ | 09:22:05.667 | `getpeerinfo`, 9 rows, summed | 109,027,561 | 34,804,574 |
1039
+ | 09:22:07.189 | `getnettotals` | **109,036,122** | 34,807,044 |
1040
+ | 09:22:08.709 | the download-info method | `bytes_total` 0 | — |
1041
+ | 09:23:03.804 | `getnettotals` | **89,979,098** | 30,012,780 |
1042
+ | 09:23:36.938 | `getpeerinfo`, **8** rows, summed | 90,137,131 | 30,063,796 |
1043
+ | 09:23:38.456 (served 09:23:41.372) | `getnettotals` | 90,139,443 | 30,066,175 |
1044
+
1045
+ The counter fell **19,057,024** bytes received (4,794,264 sent) in 56.6 s. The row missing
1046
+ at 09:23:36 (`id` 15) held **19,303,469** / 4,868,658 at 09:22:05. The difference,
1047
+ 246,445 / 74,394, is what the surviving peers moved in between. The download term was 0
1048
+ (the download-info method reported inactive), so the drop is exactly one departed peer.
1049
+
1050
+ The node's source says so in so many words. `cmd_getnettotals` sums the `used` slots of
1051
+ the live peer table plus `dl_bytes_total`, and comments: *"Core counts bytes for the
1052
+ process lifetime including closed peers; we sum the LIVE peer table plus everything the
1053
+ download received this run."* So it is a documented divergence from Core, not a bug in
1054
+ the node. Two consequences:
1055
+
1056
+ - **The 99.99% above is not coverage.** Rows and total agree because both forget closed
1057
+ peers. §15's `peerinfo-partial` (70.29% on the bench build) passes on this build for
1058
+ that reason, not because nothing is lost.
1059
+ - **A delta across a disconnect is negative**: −336,613 B/s over 09:22:07→09:23:03. The
1060
+ monitor's `CounterRate` reads any decrease as a node restart and publishes `0`. That
1061
+ is an open defect, recorded in DEFECTS under Security / correctness. The next clean
1062
+ interval, 09:23:03→09:23:41, read 4,268 B/s received.
1063
+
1064
+ ### Contention: three sub-millisecond calls took seconds
1065
+
1066
+ | call | time | same method elsewhere in the run |
1067
+ |---|---|---|
1068
+ | `help` (09:18:42Z) | **1.503 s** | — (first call of the run) |
1069
+ | `help getprivatebroadcastinfo` (09:21:09Z) | **4.421 s** | the other 10 `help <m>`: 0.167–0.251 ms (two at 5 and 11 ms, one at 87 ms) |
1070
+ | `getnettotals` (09:23:38Z) | **2.914 s** | 0.174 and 0.259 ms |
1071
+
1072
+ The third call carries its own evidence. Its `timemillis` is **09:23:41.372Z**, 2.916 s
1073
+ after curl started, and the reply arrived at 09:23:41.370Z by curl's clock. The node built
1074
+ the answer at the end of the 2.9 s, not the start, so the time was spent **waiting to be
1075
+ served**, not computing. The likely cause is queueing behind the running monitor's own
1076
+ tiers on the single RPC thread (§1): the fast tier batches five methods every 4 s, and
1077
+ §26's `getblocktemplate` holds the thread 1.3–1.5 s (no longer called — §26b). **Unconfirmed**: the monitor's lane
1078
+ telemetry for those seconds was not read. The general point stands. A latency measured
1079
+ from this box while the monitor runs is an upper bound, and a sub-ms method's outlier is
1080
+ somebody else's call.
1081
+
1082
+ ### Not wired, as of this survey
1083
+
1084
+ `grep` over `server/`, 2026-09-11: nothing calls the download-info method or
1085
+ `getorphantxs`, and nothing reads the build's commit/dirty attestation fields. The only
1086
+ `server/` mention is the allowlist comment that admits the download-info method to the
1087
+ read-only console via its vendor method-name prefix. What each could supply is listed in
1088
+ DEFECTS (Functional gaps) as an
1089
+ opportunity, not a feature.
1090
+
1091
+ ## 28. What an address index over the chain costs, read from the node's own files (2026-09-14)
1092
+
1093
+ `scripts/blockfile-measure.js --sample 16 --verify` on the local Core datadir: 16 of 5,756
1094
+ `blk`/`rev` file pairs, evenly spaced from file 0 to 5754, each decoded end to end with
1095
+ `server/chain/tx.js` and `server/chain/blockfile.js`, one block per file checked against
1096
+ `getblock <hash> 3` (every prevout: value, script, height, coinbase flag) — **0 mismatches**
1097
+ in 91,813 spent coins. Totals are interpolated between the samples, because a file's
1098
+ contents change enormously over the chain's history (file 0 holds 119,960 blocks; file 5754
1099
+ holds 84).
1100
+
1101
+ **Calibrated against the node, not only extrapolated.** Interpolated transactions:
1102
+ 1,448,817,382; `getchaintxstats` at height 966,921: **1,438,794,396** — 0.7% high (the blk
1103
+ files also carry stale blocks). The unspent-output cross-check is looser and says so:
1104
+ funding rows minus spending rows is 121.3 M against `gettxoutsetinfo` txouts **165.2 M**.
1105
+ That is a small difference of two ~3.5 B figures, each within a couple of percent, so treat
1106
+ row totals as ±2% and the difference between them as not measured by this method.
1107
+
1108
+ | | Whole chain (interpolated) |
1109
+ |---|---|
1110
+ | block files / undo files | 768.3 GB / 108.8 GB |
1111
+ | transactions | 1.449 B |
1112
+ | outputs / funding rows (spendable) | 3.823 B / 3.627 B |
1113
+ | inputs = spending rows | 3.506 B |
1114
+
1115
+ Single-core time, by stage:
1116
+
1117
+ | stage | hours | note |
1118
+ |---|---|---|
1119
+ | read | 0.62 | **warm cache for some files** (file 0 and 5754 read in ~40 ms); cold device read is the 0.7 h of the earlier spike (DEFECTS) |
1120
+ | XOR | 0.23 | |
1121
+ | decode blocks, addresses included | 5.87 | the dominant cost |
1122
+ | pair blocks with undo | 0.34 | after the fix below |
1123
+ | decode undo, addresses included | 3.06 | early files cost most: uncompressed-key P2PK coins are rebuilt with BigInt modular arithmetic |
1124
+ | **CPU total** | **9.5** | per core; files are independent, and this box has 32 cores |
1125
+
1126
+ Raw index rows, before any storage engine's overhead:
1127
+ **85.6 GB** history only (12 B per row: an 8-byte script-hash or outpoint prefix and a 4-byte
1128
+ height) and **142.7 GB** with an 8-byte amount on every row, so a balance needs no node call.
1129
+ Free space on `/storage` at the time: 653 GB.
1130
+
1131
+ **The pairing bug this run found.** Pairing each block with its undo record by trying every
1132
+ candidate of the same transaction count is quadratic where blocks are tiny: file 0 ran 35
1133
+ minutes without finishing, and a second attempt (one hash per distinct record) still took
1134
+ 230 s. Core appends undo records in connection order, so `pairBlocksWithUndo` puts each
1135
+ file's blocks in chain order by their previous-block links and walks the records in step,
1136
+ checksum-verified: file 0 pairs in 5.9 s, a recent file in ~15 ms, and every block but
1137
+ genesis (which has no undo) pairs.
1138
+
1139
+ ## 29. Storing the address index: SQLite against sorted flat files (2026-09-14)
1140
+
1141
+ `node --no-warnings scripts/index-bench.js --files 384,2685,5370` builds real index rows from three
1142
+ file pairs (2015, 2021 and 2026 — 436 blocks, 695,639 transactions) and stores them two ways.
1143
+
1144
+ **The row.** One per (address script, transaction that touched it): an output paying the script, or
1145
+ an input spending an output that paid it, the spent script taken from the undo file. Key: the first
1146
+ 8 bytes of sha256(script), block height, position in the block. Value: the net satoshis that
1147
+ transaction moved for the script. The transaction itself stays in the node (`txindex`). A script
1148
+ paid and spent in the same transaction is one row, which removes **18.3%** of raw rows here
1149
+ (21.6% on the 2026 file alone): 3,827,812 raw rows became **3,128,638**, 4.50 per transaction.
1150
+
1151
+ | | SQLite (`node:sqlite`, WITHOUT ROWID) | sorted flat file |
1152
+ |---|---|---|
1153
+ | bytes per row | 26.5 as inserted; 24.1 after VACUUM; 27.4 bulk-loaded in key order | **21.0** with amounts; **13.0** history only (+ a sparse index of one key per 4,096 rows) |
1154
+ | build rate | 418 k rows/s in arrival order; **1.40 M rows/s** in key order | sort **5.53 M rows/s**, write **2.99 M rows/s** |
1155
+ | lookup (warm, 20,000 real scripts) | **0.0027 ms** | 0.057 ms (one 86 KB read per lookup) |
1156
+
1157
+ **What these numbers do not show.** The whole sample fits in memory, so every figure is the
1158
+ in-memory regime. At full size the arrival-order SQLite rate will not hold: a B-tree fed random
1159
+ keys past RAM pays a disk seek per insert. That collapse was not measured and nothing here should
1160
+ be read as its rate. Key-order loading avoids it, but requires sorting every row first -- the same
1161
+ sort the flat file needs.
1162
+
1163
+ **Extrapolated to the chain** (MEASUREMENTS 28: 7.13 B raw rows, less the 18.3% merged here ≈
1164
+ **5.8 B rows**; the merge share varies by era, so ±10%):
1165
+
1166
+ | design | size |
1167
+ |---|---|
1168
+ | flat, history only (13 B) | ≈ 76 GB |
1169
+ | flat, with amounts (21 B) | ≈ 122 GB |
1170
+ | SQLite, with amounts (24–27 B) | ≈ 140–160 GB, before any second index for reorg deletes by height |
1171
+
1172
+ A key-order SQLite load of 5.8 B rows at 1.40 M rows/s is ≈ 69 minutes on one thread; the flat
1173
+ file's sort is ≈ 18 minutes of single-thread CPU if bucketed by hash prefix so each bucket sorts in
1174
+ memory. Both sit on top of the 9.5 single-core hours of decoding (§28), which splits across cores
1175
+ because files are independent.
1176
+
1177
+ ## 30. The address index, built and compared (2026-09-14)
1178
+
1179
+ `node scripts/index-build.js --out ~/blockyard-index --workers 16` read every blk/rev pair from the
1180
+ local node's `/storage` NVMe and wrote to a separate NVMe (`/`). `node scripts/index-benchmark.js`
1181
+ then measured it against the node.
1182
+
1183
+ **Build.** 29 min 45 s wall, 16 workers (1,633% CPU; 7.8 CPU-hours; peak RSS 30 GB):
1184
+
1185
+ | phase | time |
1186
+ |---|---|
1187
+ | block hashes 0..966,930 (`getblockhash`, batches of 5,000) | 49 s |
1188
+ | scan 5,756 file pairs (lean rows, not the full decoder) | 25 min 45 s |
1189
+ | sort 256 buckets | 3 min 10 s |
1190
+
1191
+ 5,890,519,289 rows, **123.7 GB**, every height 0..966,930 present exactly once, 2 stale blocks
1192
+ skipped, 0 missing undo records, 0 duplicate rows. §29 projected ≈5.8 B rows and ≈122 GB: both
1193
+ within 1.5%. The first attempt failed on its last file, the one the node is still writing: Core
1194
+ preallocates it as raw zeros, which read as the XOR key once de-obfuscated (`records` now takes the key).
1195
+
1196
+ **Correct.** 40 of 40 addresses (the heavy ones below except the genesis address, and a sample from
1197
+ across the chain) have an index balance equal, to the satoshi, to `scantxoutset` at the same height.
1198
+ Before that, every (script, transaction) pair of four whole blocks from 2009 to the tip was found
1199
+ at its height and position with its net amount (21,351 pairs, a 5-file test build). The genesis
1200
+ address is excluded from the balance check on purpose: its first 50 BTC is the genesis coinbase,
1201
+ which Core never added to the UTXO set; the index counts it as received, as explorers do.
1202
+
1203
+ **Lookups** (344 distinct addresses sampled from 24 blocks spread over the chain; median history 14
1204
+ transactions, largest 3,195,827):
1205
+
1206
+ | | p50 | p90 | p99 | max |
1207
+ |---|---|---|---|---|
1208
+ | first lookup | 0.248 ms | 1.108 ms | 28.3 ms | 119 ms |
1209
+ | repeated (warm) | 0.028 ms | — | 25.0 ms | — |
1210
+
1211
+ Heavy addresses, whole history summed for the balance: 2,326,967 transactions in 82.9 ms; 65,786 in
1212
+ 2.5 ms; 5,583 in 0.5 ms. Opening the index (the sparse keys into memory) takes 84 ms.
1213
+
1214
+ **Against the alternatives.**
1215
+
1216
+ | method | what it answers | cost |
1217
+ |---|---|---|
1218
+ | this index | full history, balance, per-transaction amounts | 0.25 ms p50 lookup; 124 GB; 30 min build on 16 cores |
1219
+ | `scantxoutset` (Core, no index) | current balance / UTXOs only, no history | **26.5 s** for one scan of 40 addresses, holding the node's RPC thread |
1220
+ | `getaddresstxids` (insight-style) | — | refused by Core at every setting (DEFECTS) |
1221
+ | SQLite, same rows (§29, in memory only) | same | 24–27 B/row ≈ 140–160 GB; 1.4 M rows/s sorted load |
1222
+ | romanz/electrs (published) | history, no amounts | ≈2 h on 6 cores; 56 GB |
1223
+ | mempool/electrs, what mempool.space runs (published) | history, amounts, full tx store | "a few hours"; 1.3 TB |
1224
+
1225
+ The published figures are the projects' own READMEs, on other hardware; they are context, not a
1226
+ race run on this box.
1227
+
1228
+ ## 31. The first fresh install, and a sync false alarm (2026-09-14)
1229
+
1230
+ Figures from the first install on a machine that is not this one -- a Mac, Core 29.1, block files on
1231
+ a platter array -- as recorded in the day's commits; not re-run here, since the machine is the
1232
+ operator's. The descriptor figures were taken on this box.
1233
+
1234
+ **A node without `coinstatsindex`, sent `gettxoutsetinfo` every minute** (`6bf0ea5`): RPC answers of
1235
+ 18 s on the monitor's lane, 90 s timeouts, the verbose mempool read dropped, the block-space board
1236
+ empty; `getindexinfo` reported no coinstats index and the UTXO set held 165 M outputs. The same node
1237
+ measured alone by `npm run check` (`ab2c5dc`): `getblockchaininfo` 10 ms, `getblock <tip> 3` 921 ms,
1238
+ `getrawmempool true` 1.0 s. The index build running beside it was throttled four ways first
1239
+ (`4a2bb49`, `89c738e`, `02b35e5`, `26a9433`) and was not the cause; the pacer's first threshold of
1240
+ 1 s then ran a healthy build at about a sixth of its speed (`0b87d2e`), so it holds at
1241
+ `rpc.slowLatencyMs` (5 s) and eases above 40% of that now (RULES 27).
1242
+
1243
+ **Descriptors** (`a05f1c6`): the index store held a descriptor per segment and layer (256 and more)
1244
+ and the build all 256 bucket files, on a platform whose soft limit is 256. After opening per read, on
1245
+ the full index on this box: 0.02 ms median warm lookup, 0.28 ms p90, 21 descriptors held by the
1246
+ process; the build keeps at most 64 buckets open.
1247
+
1248
+ **The installer at 80 columns** (`ce54ae8`): the whole run piped at 80 columns, widest line 79.
1249
+
1250
+ **Sync** (`72908f0`): two independent nodes at the same height, no block for 42 minutes, both
1251
+ showing STALLED. Block intervals are close to exponential with a 10-minute mean, so a gap of 40
1252
+ minutes or more has probability e^-4, about 1.8% -- once in fifty blocks, a few times a day. Stalled
1253
+ now means a connected peer reports a higher tip (`getpeerinfo` `synced_headers`); peers agreeing on
1254
+ the tip is a long gap and synced; no peer height at all waits two hours.
1255
+
1256
+ ## 32. The DOOM Diversion's emulated PC (2026-09-15)
1257
+
1258
+ Not the node: the i386 and the PC in `public/js/x86.js` and `dospc.js` running the shareware
1259
+ `DOOM.EXE` v1.9 (the Diversion). Taken on this box -- AMD Ryzen 9 9950X3D, Node v22.23.2, Chromium
1260
+ 152 (snap, headless). Reproduce the headless figures with `node scripts/dos-bench.js doom`.
1261
+
1262
+ **Speed of the interpreter**, 400 M instructions of DOOM's title and demos on a clock of 30 M
1263
+ instructions to the virtual second:
1264
+
1265
+ | build | instructions a second |
1266
+ |---|---|
1267
+ | first cut: unsigned values (`>>> 0`), flag operands in closure variables, a try/catch per instruction | 66-68 M |
1268
+ | every value an int32, lazy flags in an `Int32Array`, one try/catch around the loop, 32-bit fast paths | 95 M |
1269
+ | the same with the Sound Blaster and OPL3 attached, synthesising at 44.1 kHz per call of `tick` | 73 M |
1270
+ | the OPL's per-register work hoisted out of the sample loop | 82 M |
1271
+ | `tick` working in batches of at least 128 frames (it had been called every 2,000 instructions: a sample or two each) | **92 M** |
1272
+
1273
+ The try/catch alone was 14% of the profile, and the collector 1.5% from doubles boxed in closure
1274
+ variables; the rewrite was checked instruction by instruction against the first cut for 40 M
1275
+ instructions (identical except the start-up's environment read, which the first cut got wrong).
1276
+
1277
+ **In the browser**, the machine in a worker, sound on: **94-110 M instructions a second, 34-37
1278
+ frames a second** -- DOOM's own cap is 35. The game needs about a million instructions a frame of
1279
+ real work; the rest of each tic it spends in its own busy wait for the timer. The page's CSP forbids
1280
+ eval, so a JIT was never an option; a slower machine than this one has roughly a factor of two in
1281
+ hand before DOOM drops below 35.
1282
+
1283
+ **Start-up**: graphics mode after 8.0 M instructions without a sound card, 17.0 M with one (the
1284
+ DMX driver probes the DSP and the OPL); 20 pages flipped by 31 M.
1285
+
1286
+ **Correctness of the CPU, measured against the host's own**: a differential fuzzer ran random
1287
+ instructions natively (a C harness in 64-bit mode, 32-bit operands, register forms) and in `x86.js`,
1288
+ comparing all registers and every flag the instruction defines -- ALU rows, immediates, shifts and
1289
+ rotates with counts past the width, MUL/IMUL/DIV/IDIV including divide faults, BT/BTS/BTR/BTC,
1290
+ BSF/BSR, SHLD/SHRD, MOVZX/MOVSX, SETcc, BSWAP, XADD, CMPXCHG, SAHF/LAHF. Two seeds, 78,565
1291
+ instructions, 2,338 divide faults agreed, **zero mismatches**. The harness needs a C compiler, so it
1292
+ is not in `npm test`; `test/x86.test.js` keeps a case from each class it covered.
1293
+
1294
+ **The music is in tune**: over 10 s of the title music, 178 notes keyed on, 96 of them within
1295
+ 2.5 cents of equal temperament; the rest spread to +-50 cents, which is DMX's pitch bends.
1296
+
1297
+ ## 33. Quake on the same PC (2026-09-15)
1298
+
1299
+ `QUAKE.EXE` v1.06 (DJGPP, go32 stub) on the emulated PC, this box, Node v22.23.2. Reproduce with
1300
+ `node scripts/dos-bench.js quake`.
1301
+
1302
+ **Speed**: 77 M instructions a second headless with the Sound Blaster attached, 72-79 in a Chromium
1303
+ worker -- lower than DOOM's 90-105 because Quake's code is FPU-heavy (the x87 was 15% of the profile)
1304
+ and every memory operand adds a segment base (DJGPP's DS is at its memory block; `ea` was 13%).
1305
+ Moving the FPU stack to a Float64Array with typed-array operand conversion and one base addition
1306
+ when DS and SS share it took 74 to 76: the interpreter's dispatch is the rest.
1307
+
1308
+ **Frame rate**: `+timedemo demo1` on a clock of 74 M instructions to the virtual second -- the
1309
+ emulator's own speed -- reported **969 frames in 33.5 seconds, 28.9 fps**, about 2.6 M instructions a
1310
+ frame. In the browser, a new game on the start map drew 26 frames a second. Period hardware for
1311
+ comparison: a Pentium 90 ran the same demo at 320x200 at roughly that rate.
1312
+
1313
+ **Start-up**: graphics mode after 244 M instructions on a 30 M clock (Quake pages its 27 MB heap in
1314
+ and times its hardware), twenty screens drawn by 285 M; 2.8 s of wall time headless.
1315
+
1316
+ ## 34. The decoded-instruction cache, and Quake's view size (2026-09-15)
1317
+
1318
+ This box, Node v22.23.2, `node scripts/dos-bench.js doom 400` / `quake 1500` (M instructions a
1319
+ second, headless, Sound Blaster attached):
1320
+
1321
+ | build | DOOM | Quake |
1322
+ |---|---|---|
1323
+ | before (the interpreter, §32-33) | 90 | 77 |
1324
+ | a closure per decoded instruction | 68 | 69 |
1325
+ | int32 decodings in a typed array per page, one switch; a write drops the whole page | 83 | 90 |
1326
+ | ...a write clears only the instructions it overlaps (DOOM patches its span drawer each call) | 100 | 90 |
1327
+ | ...the FPU decoded too, and the loop keeps the current page between instructions | 107 | 104 |
1328
+ | ...no page check after a cached handler | **111** | **108** |
1329
+
1330
+ The page-drop row is the one to remember: 217,660 whole-page drops in 300 M instructions of DOOM,
1331
+ every one from `mov [eax],ebx` into the constants of its own span routine at 0x12bdaf.
1332
+
1333
+ **Quake's view size**, `+viewsize N +timedemo demo1` (969 frames), on a clock set to the emulator's
1334
+ speed:
1335
+
1336
+ | viewsize | before the cache (76 M clock) | after (105 M clock) |
1337
+ |---|---|---|
1338
+ | 100 | 29.7 fps | 40.9 fps |
1339
+ | 80 (the new default) | 32.5 | 44.7 |
1340
+ | 60 | 40.5 | -- |
1341
+
1342
+ In a Chromium worker: Quake 99-106 MIPS and 40-42 frames a second in a new game at `viewsize 80`
1343
+ (26 before), DOOM 133 MIPS at its 35 fps cap.
1344
+
1345
+ **Checked, not assumed**: both games lock-stepped against the uncached interpreter (DOOM 400 M, Quake
1346
+ 1.5 G instructions; registers and flags compared every 10,000; memory identical at the end), and the
1347
+ native fuzzer re-run through `run(1)` over 118k instructions with no mismatch.
1348
+
1349
+ ## 35. A second pass on the CPU: what paid and what did not (2026-09-15)
1350
+
1351
+ Asked for all five of: split the ALU routine, specialise the hot x87 forms, cheaper dispatch, dead
1352
+ flags, and a block copy of the frame into video memory. **Method, after the first readings misled:**
1353
+ each build against a copy of the previous one (`oracle4`), pinned to one core with `taskset`, best of
1354
+ three; unpinned runs on this shared box moved +-5% between identical runs. Quake throughput as M
1355
+ instructions a second over its timedemo, and as **frames a second of wall time** over 600 timedemo
1356
+ frames -- the second catches work a MIPS figure cannot, such as a `rep movsd` that is one instruction
1357
+ however many bytes it moves. DOOM on `-timedemo demo1` only: its normal MIPS depends on how much time
1358
+ lands in its cheap wait loop, which a change to the machine's slice size alone moved by 5%.
1359
+
1360
+ | build | Quake MIPS | Quake fps (wall) | DOOM timedemo MIPS |
1361
+ |---|---|---|---|
1362
+ | before (the cache, §34) | 101 | 37.7 | 103 |
1363
+ | ALU split per operation + hot x87 forms decoded to their own handlers | 133 | -- | -- |
1364
+ | + fused cmp/test+Jcc dispatch and "no flags" forms by flag liveness | 131 | -- | 110 |
1365
+ | the same without the look-ahead (fusion and no-flags off) | 133 | -- | 114.5 |
1366
+ | + the address formed inline in the loop instead of a call per handler | 141.5 | -- | 112 |
1367
+ | + aligned reads/writes inline in the hottest moves | 141 | 52.5 | 115 |
1368
+ | the same without the VGA block copy | 141 | 51 (noise) | -- |
1369
+
1370
+ **Kept**: the ALU split, the x87 handlers, the inline address and moves -- Quake's frames a second of
1371
+ wall time 37.7 -> 52.5 (+40%). **Removed**: the fused branches and the no-flags forms (nothing gained;
1372
+ the look-ahead re-ran on every re-decode of DOOM's self-patching drawer), and the block copy (within
1373
+ noise: the frame copy is 64,000 bytes against 140 M instructions a second).
1374
+
1375
+ The reason inlining mattered: `run()` is one function with 170-odd cases, V8's cumulative inlining
1376
+ budget runs out long before the helpers it calls, and every uninlined `eaOf` was a real call.
1377
+
1378
+ **In a Chromium worker** (new game, `viewsize 80`): Quake 115-118 MIPS and 39-49 frames a second on
1379
+ screen, once the worker looked for a finished frame every 50,000 instructions instead of once per
1380
+ 10 ms slice (two frame copies inside one slice had been showing as one). DOOM 139 MIPS.
1381
+
1382
+ Checked: DOOM 400 M and Quake 1.5 G instructions lock-stepped identical to `oracle4` (memory equal),
1383
+ and the native fuzzer through `run(1)`, 78k instructions, no mismatch.
1384
+
1385
+ ## 36. Wolfenstein 3D in real mode (2026-09-15)
1386
+
1387
+ `WOLF3D.EXE` v1.4 (LZEXE-packed, Borland C, real mode) on the emulated PC, this box, Node v22.23.2,
1388
+ pinned to one core. It unpacks itself and reaches the sign-on screen in 1.0 s of wall time (60 M
1389
+ instructions on a 20 M clock).
1390
+
1391
+ **Speed**: in a game, turning on the first map, **65 M instructions a second** headless (twice the same
1392
+ reading) and 68-69 in a Chromium worker. Real-mode code runs through the uncached `step()`, so this is
1393
+ the plain interpreter's speed; the game needs about **285 k instructions a frame**, so its 70 frames a
1394
+ second (the VGA's refresh, which it waits for) take 20 M a second -- a third of what is there, and the
1395
+ browser shows 70 frames a second.
1396
+
1397
+ **DOOM and Quake unchanged**: best of three against a copy of the previous build (`oracle5`), DOOM
1398
+ timedemo 93.3 vs 93.0 M a second, Quake 126.3 vs 127.7. Quake lock-stepped identical for 600 M
1399
+ instructions, memory equal. DOOM lock-steps identical to 175 M and then differs, by design: at 173 M it
1400
+ copies between VGA pages in write mode 1, which the previous build wrote as plain data, and reads the
1401
+ planes back.