blockyard 0.0.9 → 0.1.1

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 (97) hide show
  1. package/CHANGELOG.md +359 -1
  2. package/README.md +48 -27
  3. package/SECURITY.md +2 -2
  4. package/bin/blockyard.js +2 -1
  5. package/docs/API.md +17 -15
  6. package/docs/ARCHITECTURE.md +128 -10
  7. package/docs/CONFIGURATION.md +39 -30
  8. package/docs/DEFECTS.md +4 -1
  9. package/docs/GETTING-STARTED.md +18 -8
  10. package/docs/INSTALL.md +97 -37
  11. package/docs/MEASUREMENTS.md +147 -0
  12. package/docs/PLAN-SCORCHED-YARD.md +456 -0
  13. package/docs/PLAN-SKIES.md +142 -0
  14. package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
  15. package/docs/SECURITY.md +58 -22
  16. package/docs/TROUBLESHOOTING.md +44 -5
  17. package/docs/USER-GUIDE.md +505 -35
  18. package/package.json +4 -2
  19. package/public/404.html +1 -1
  20. package/public/css/app.css +393 -82
  21. package/public/donate-qr.png +0 -0
  22. package/public/index.html +353 -109
  23. package/public/js/agents.js +228 -51
  24. package/public/js/app.js +131 -16
  25. package/public/js/blockanoid.js +15 -7
  26. package/public/js/blockout.js +15 -7
  27. package/public/js/blockscene3d.js +230 -38
  28. package/public/js/charts.js +21 -21
  29. package/public/js/depthchart.js +31 -27
  30. package/public/js/details3d.js +1481 -73
  31. package/public/js/doom.js +31 -0
  32. package/public/js/dosaudio.js +48 -0
  33. package/public/js/dosgame.js +389 -0
  34. package/public/js/dosio.js +186 -0
  35. package/public/js/dospc.js +1353 -0
  36. package/public/js/dosworker.js +196 -0
  37. package/public/js/explorer.js +7 -1
  38. package/public/js/livingsky.js +494 -0
  39. package/public/js/login.js +8 -2
  40. package/public/js/markets.js +46 -8
  41. package/public/js/mining.js +314 -36
  42. package/public/js/panels.js +41 -28
  43. package/public/js/pricechart.js +14 -13
  44. package/public/js/quake.js +20 -0
  45. package/public/js/safenext.js +14 -0
  46. package/public/js/scorched.js +1051 -0
  47. package/public/js/scorchedai.js +227 -0
  48. package/public/js/scorchedair.js +286 -0
  49. package/public/js/scorchedfx.js +376 -0
  50. package/public/js/scorchedshop.js +105 -0
  51. package/public/js/scorchedwind.js +69 -0
  52. package/public/js/scorchedyard.js +1338 -0
  53. package/public/js/settings.js +368 -100
  54. package/public/js/soundcard.js +459 -0
  55. package/public/js/tetrust.js +15 -6
  56. package/public/js/tetsound.js +35 -5
  57. package/public/js/theme.js +235 -0
  58. package/public/js/wolf3d.js +22 -0
  59. package/public/js/x86.js +1978 -0
  60. package/scripts/check.js +46 -0
  61. package/scripts/donate-qr.py +12 -9
  62. package/scripts/dos-bench.js +56 -0
  63. package/scripts/index-build.js +9 -2
  64. package/scripts/pool-map.js +152 -36
  65. package/scripts/setup.js +142 -22
  66. package/scripts/shots.mjs +27 -0
  67. package/scripts/smoke.sh +7 -6
  68. package/scripts/tls.js +31 -0
  69. package/scripts/ui.js +4 -2
  70. package/server/auth/sessions.js +33 -13
  71. package/server/chain/blockfile.js +64 -5
  72. package/server/chain/index/build.js +451 -58
  73. package/server/chain/index/heights.js +29 -3
  74. package/server/chain/index/live.js +13 -7
  75. package/server/chain/index/rows.js +6 -1
  76. package/server/chain/index/store.js +28 -5
  77. package/server/chain/index/worker.js +23 -11
  78. package/server/collect/logparse.js +65 -18
  79. package/server/collect/markets.js +76 -7
  80. package/server/collect/mining.js +32 -0
  81. package/server/collect/monitor.js +54 -12
  82. package/server/collect/network.js +305 -0
  83. package/server/config.js +53 -22
  84. package/server/http/api.js +119 -18
  85. package/server/http/games.js +77 -0
  86. package/server/http/server.js +30 -5
  87. package/server/http/sse.js +53 -7
  88. package/server/main.js +66 -11
  89. package/server/rpc/allowlist.js +26 -0
  90. package/server/rpc/client.js +30 -2
  91. package/server/store/audit.js +6 -1
  92. package/server/store/history.js +19 -3
  93. package/server/store/ledger.js +15 -4
  94. package/server/tls/selfsigned.js +160 -0
  95. package/systemd/blockyard.service +41 -8
  96. package/docs/PRIVATE-LEADERBOARD.md +0 -230
  97. package/docs/STATE-2026-09-09.md +0 -200
package/docs/INSTALL.md CHANGED
@@ -12,7 +12,7 @@ machines you choose. Every setting mentioned here is described in full in
12
12
  - [6. Run it as a service](#6-run-it-as-a-service)
13
13
  - [7. Decide who can reach it](#7-decide-who-can-reach-it)
14
14
  - [8. Accounts (optional)](#8-accounts-optional)
15
- - [9. HTTPS (optional)](#9-https-optional)
15
+ - [9. HTTPS (on by default)](#9-https-on-by-default)
16
16
  - [10. Behind a reverse proxy (optional)](#10-behind-a-reverse-proxy-optional)
17
17
  - [11. Updating](#11-updating)
18
18
  - [12. Uninstalling](#12-uninstalling)
@@ -22,7 +22,7 @@ machines you choose. Every setting mentioned here is described in full in
22
22
 
23
23
  | need | notes |
24
24
  |---|---|
25
- | **Node.js 22 or newer** | `node -v` must print `v22` or later. Older runtimes fail on syntax at start-up, which looks like a bug in the app. Install from [nodejs.org](https://nodejs.org), your distribution's backports, or a version manager such as `nvm`. |
25
+ | **Node.js 22.2 or newer** | `node -v` must print `v22.2` or later (the index build uses its built-in CRC-32). Older runtimes fail on syntax at start-up, which looks like a bug in the app. Install from [nodejs.org](https://nodejs.org), your distribution's backports, or a version manager such as `nvm`. |
26
26
  | **Bitcoin Core 25.0 or later** | [Bitcoin Core](https://github.com/bitcoin/bitcoin) with `server=1` and `txindex=1`, **on the same machine** as BlockYard, which reads the node's block files for the explorer's address index. A node on another machine is not supported. 25.0 is where `getblock` verbosity 3, which the index follower uses, arrived; 29.1 is what the macOS install was done against. `coinstatsindex=1` is optional (without it the UTXO figures are blank and the node is not asked for them). Not a pruned node: the index needs every block file. |
27
27
  | **RPC credentials** | Either read access to the node's cookie file (`<datadir>/<chain>/.cookie`, the usual case on the same machine) or an RPC user and password. |
28
28
  | **macOS or Linux** | There is nothing to compile, and the server calls no platform-specific API (no `child_process`, no `/proc`, no `systemctl`). Developed on Linux; a real install has been done on macOS (Core 29.1). The test suite runs in CI on Ubuntu, macOS and Windows (Node 22 and 24), but on Windows nothing more than the suite has been tried. Only the *service* instructions in section 6 are Linux-specific (they use systemd); on macOS run it in a terminal, or write a `launchd` plist. The index store opens its files per lookup, so macOS's default limit of 256 open files is enough. |
@@ -70,8 +70,10 @@ while every page keeps working: the Overview's "What this panel cannot tell you"
70
70
  progress (phase, files done, rows so far, an ETA that settles after the first few files), the
71
71
  address page says the same in place of a history, and an event — which the browser shows as a
72
72
  notification — marks the start, the finish and a failure. When it finishes the follower starts
73
- on the spot, so address pages work without a restart. Stopping BlockYard stops the build; there
74
- is no resume, so the next start begins it again. Three keys on the node entry control it:
73
+ on the spot, so address pages work without a restart. Stopping BlockYard stops the build, and the
74
+ next start resumes it: the files already scanned and the buckets already sorted are kept, and
75
+ only what was not finished is read again (at most about a minute of scanning). Three keys on the
76
+ node entry control it:
75
77
 
76
78
  | key | meaning |
77
79
  |---|---|
@@ -146,9 +148,10 @@ Balances are checked against the node: 40 of 40 sampled addresses equal `scantxo
146
148
  satoshi (`node scripts/index-benchmark.js` runs that check and the lookup timings against your
147
149
  own build).
148
150
 
149
- **Outbound network access** is needed only for the Markets and Kiosk tabs and for the
150
- explorer's dollar figures (HTTPS to five exchanges' public APIs). Everything else talks only
151
- to your node. See [SECURITY.md](SECURITY.md#outbound-connections).
151
+ **Outbound network access**: none, out of the box everything talks only to your node. The
152
+ Markets and Kiosk tabs and the explorer's dollar figures need the exchange feed (HTTPS to five
153
+ exchanges' public APIs), which is off until you tick **Display settings → Markets & Price → Enable market polling**
154
+ in the browser. See [SECURITY.md](SECURITY.md#outbound-connections).
152
155
 
153
156
  ## 2. Get the code
154
157
 
@@ -240,8 +243,8 @@ real-time explorer data over RPC was a failed idea.
240
243
 
241
244
  `rpcUser` / `rpcPassword` are still accepted, for a node that authenticates with `rpcauth` rather
242
245
  than the cookie file (see [Configuration](CONFIGURATION.md#nodes)). The web UI's node-connection
243
- form takes no password on purpose -- taking one over an endpoint that is open by default is not
244
- something to add quietly -- so credentials go in `config/local.json`.
246
+ form takes no password on purpose -- taking one over a web endpoint is not something to add
247
+ quietly -- so credentials go in `config/local.json`.
245
248
 
246
249
  ##### `bitcoin.conf` settings worth having
247
250
 
@@ -284,17 +287,20 @@ npm run check # every configured node: RPC, credentials, txindex, getblock
284
287
  npm start
285
288
  ```
286
289
 
287
- Watch the start-up lines. You should see the addresses it listens on, a line per node,
288
- and because accounts are off by default a warning that names who can read the monitor. If
289
- the node entry names an `addressIndex` directory with no index in it, `address index: building
290
- … with N workers -- the Overview shows the progress` follows, and the build runs on in the
291
- background (see [Building the address index](#building-the-address-index)). Then open
292
- <http://127.0.0.1:21000>.
290
+ Watch the start-up lines. You should see `listening on https://127.0.0.1:21000` (the first start
291
+ makes the monitor its own self-signed certificate under `data/tls/`; the browser warns once per
292
+ address and remembers it), a line per node, and because accounts are on by default — `created
293
+ the first admin account (admin)` with a generated password **shown once** (set
294
+ `BLOCKYARD_ADMIN_PASSWORD` before the first start to choose it; `blockyard user` changes it
295
+ later). If the node entry names an `addressIndex` directory with no index in it, `address index:
296
+ building … with N workers -- the Overview shows the progress` follows, and the build runs on in
297
+ the background (see [Building the address index](#building-the-address-index)). Then open
298
+ <https://127.0.0.1:21000> and sign in.
293
299
 
294
- Check it from the shell:
300
+ Check it from the shell (`-k`, because the certificate is self-signed):
295
301
 
296
302
  ```bash
297
- curl -s http://127.0.0.1:21000/api/health
303
+ curl -sk https://127.0.0.1:21000/api/health
298
304
  ```
299
305
 
300
306
  If a node shows as offline, see [TROUBLESHOOTING.md](TROUBLESHOOTING.md#a-node-shows-offline).
@@ -330,7 +336,8 @@ If a node shows as offline, see [TROUBLESHOOTING.md](TROUBLESHOOTING.md#a-node-s
330
336
  ```
331
337
 
332
338
  Set `User=` / `Group=` to the account from step 1, `WorkingDirectory=` to where the code
333
- lives, and replace the `Environment=` lines that name paths with your own (or delete
339
+ lives, the two `ReadWritePaths=` lines to that directory's `data` and `config` (and add one for
340
+ your address index directory; the unit makes everything else read-only to the service), and replace the `Environment=` lines that name paths with your own (or delete
334
341
  them and keep everything in `config/local.json`). Point `ExecStart` at an absolute Node
335
342
  22 binary — `/usr/bin/env node` can resolve to an older system Node under systemd:
336
343
 
@@ -356,10 +363,10 @@ Where the monitor listens is a security decision. Set `server.host` in `config/l
356
363
 
357
364
  | bind | who can connect | typical use |
358
365
  |---|---|---|
359
- | `"127.0.0.1"` | this machine only | reach it with an SSH tunnel: `ssh -L 21000:127.0.0.1:21000 you@host` |
366
+ | `"127.0.0.1"` (the default) | this machine only | reach it with an SSH tunnel: `ssh -L 21000:127.0.0.1:21000 you@host` |
360
367
  | `"192.0.2.10"` (a LAN address) | anything that can route to that address | a home or office LAN |
361
368
  | `["192.0.2.10", "198.51.100.7"]` | exactly those addresses | LAN plus a VPN such as Tailscale or WireGuard |
362
- | `"0.0.0.0"` (the default) | every interface | behind a firewall you control |
369
+ | `"0.0.0.0"` | every interface | behind a firewall you control |
363
370
 
364
371
  Notes:
365
372
 
@@ -377,15 +384,17 @@ Notes:
377
384
  - `server.allowCidrs` (or `BLOCKYARD_ALLOW_CIDRS=192.0.2.0/24,2001:db8::/32`) makes the
378
385
  monitor itself refuse clients outside those networks, as a second line of defence.
379
386
 
380
- ## 8. Accounts (optional)
387
+ ## 8. Accounts (on by default)
381
388
 
382
- By default anyone who can reach the port reads the monitor as a `viewer` charts, the
383
- explorer, the event stream, the read-only RPC console. User administration, the audit trail
384
- and every node write stay closed.
389
+ Sign-in is required out of the box. The first start with an empty data directory creates an
390
+ `admin` account and prints its password **once** in the log (under systemd: `journalctl -u
391
+ blockyard`). Set your own instead with `BLOCKYARD_ADMIN_PASSWORD` for that first start.
385
392
 
386
- To require sign-in, set `"auth": { "enabled": true }` or `BLOCKYARD_AUTH=1` and restart. The
387
- first start with an empty data directory creates an `admin` account and prints its password
388
- **once** in the log. Set your own instead with `BLOCKYARD_ADMIN_PASSWORD` for that first start.
393
+ To open the monitor to readers with no account, set `"auth": { "enabled": false }` or
394
+ `BLOCKYARD_AUTH=0` and restart: anyone who can reach the port then reads it as a `viewer` —
395
+ charts, the explorer, the event stream, the read-only RPC console while user
396
+ administration, the audit trail and every node write stay closed. The boot log names the
397
+ addresses that leaves readable.
389
398
 
390
399
  Manage accounts from the Admin page, or from the shell — for example, to reset the admin
391
400
  password:
@@ -398,17 +407,35 @@ Roles: `viewer` reads; `operator` may also run node actions that you have enable
398
407
  also manages users and reads the audit trail. Node actions are off unless you enable them
399
408
  explicitly — see [SECURITY.md](SECURITY.md#node-writes).
400
409
 
401
- ## 9. HTTPS (optional)
410
+ ## 9. HTTPS (on by default)
402
411
 
403
- Name a certificate and key and every listener serves HTTPS:
412
+ Every listener serves HTTPS out of the box. With no certificate of your own named, the first
413
+ start makes one: a self-signed certificate and key under `<data>/tls/` (`data/tls/cert.pem` and
414
+ `key.pem`, the key readable by the service account only), naming the addresses the monitor is
415
+ reached on — the bound hosts, this machine's addresses and hostname, `localhost`. It is remade
416
+ by itself when it nears expiry (825 days) or stops naming a bound address. Browsers warn once
417
+ per address about a self-signed certificate and then remember it; the start-up log prints its
418
+ fingerprint so you can compare.
419
+
420
+ To add a name or address (say, a DNS name you gave the machine) or start over:
421
+
422
+ ```bash
423
+ blockyard tls --san monitor.lan.example # or: node scripts/tls.js --san ...
424
+ blockyard tls --force # a fresh key and certificate
425
+ blockyard tls --print > blockyard.crt # the certificate, for another machine's trust store
426
+ ```
427
+
428
+ To use a certificate of your own instead, name it and every listener serves that:
404
429
 
405
430
  ```bash
406
431
  BLOCKYARD_TLS_CERT=/etc/blockyard/cert.pem BLOCKYARD_TLS_KEY=/etc/blockyard/key.pem npm start
407
432
  ```
408
433
 
409
- or in `config/local.json`: `"server": { "tls": { "cert": "...", "key": "..." } }`.
434
+ or in `config/local.json`: `"server": { "tls": { "cert": "...", "key": "..." } }`. To serve plain
435
+ HTTP — behind a reverse proxy that terminates TLS (section 10) — set `BLOCKYARD_TLS=0` or
436
+ `"server": { "tls": { "enabled": false } }`.
410
437
 
411
- A self-signed certificate is fine on a LAN (expect one browser warning per address):
438
+ If you would rather make the certificate with openssl yourself:
412
439
 
413
440
  ```bash
414
441
  sudo mkdir -p /etc/blockyard
@@ -424,8 +451,8 @@ rather than silently serving plain HTTP.
424
451
 
425
452
  ## 10. Behind a reverse proxy (optional)
426
453
 
427
- If you already run nginx, Caddy or similar, bind the monitor to `127.0.0.1` and let the proxy
428
- terminate TLS. The live stream (`/api/stream`) is Server-Sent Events, so the proxy must not
454
+ If you already run nginx, Caddy or similar, bind the monitor to `127.0.0.1`, set
455
+ `BLOCKYARD_TLS=0` so it speaks plain HTTP to the proxy, and let the proxy terminate TLS. The live stream (`/api/stream`) is Server-Sent Events, so the proxy must not
429
456
  buffer it. An nginx example:
430
457
 
431
458
  ```nginx
@@ -468,8 +495,41 @@ Your `config/local.json` and `data/` directory are untouched by updates. Read
468
495
  up new front-end files on the next page load; the header shows a notice when the page you
469
496
  have open is older than the server.
470
497
 
498
+ **Updating from 0.0.9.** The defaults hardened in 0.1.0, and a `config/local.json` written by
499
+ 0.0.9's installer does not name them, so the first start after the update behaves like a fresh
500
+ install in three ways:
501
+
502
+ - **HTTPS.** The monitor makes itself a self-signed certificate under `data/tls/` and serves
503
+ HTTPS on the same port; `http://…:21000` stops answering. Open `https://`, accept the
504
+ certificate once. Behind your own reverse proxy, set `BLOCKYARD_TLS=0` (§10).
505
+ - **Sign-in.** Accounts are on. The first start creates the `admin` account and prints its
506
+ password **once** in the log (`journalctl -u blockyard` under systemd); set
507
+ `BLOCKYARD_ADMIN_PASSWORD` before that start to choose it. To keep the monitor open as before,
508
+ put `"auth": { "enabled": false }` in `config/local.json` or start with `BLOCKYARD_AUTH=0`.
509
+ - **This machine only.** With no `server.host` in the config the bind is `127.0.0.1`. A
510
+ 0.0.9 config written by the installer names the host it chose, so a LAN bind stays; if yours
511
+ does not, add `BLOCKYARD_BIND` or `server.hosts` (§7).
512
+ - **Market polling is off** until someone ticks **Display settings → Markets & Price → Enable
513
+ market polling** — once, for every screen.
514
+
471
515
  ## 12. Uninstalling
472
516
 
517
+ BlockYard touches nothing outside its own directories: it reads `bitcoin.conf` and never writes
518
+ it, and it leaves the node as it found it. Removing it is deleting those directories.
519
+
520
+ **Installed from npm** (`npm install -g blockyard`): stop it, then
521
+
522
+ ```bash
523
+ npm uninstall -g blockyard
524
+ rm -rf ~/.blockyard # local.json, data/ (history, accounts, the audit trail) and data/index
525
+ ```
526
+
527
+ **A checkout** run with `npm start`: stop it and delete the checkout; `config/` and `data/`,
528
+ the index included, live inside it. If you pointed the index elsewhere at setup time
529
+ (`addressIndex` in `config/local.json`), delete that directory too.
530
+
531
+ **The systemd service** of section 6:
532
+
473
533
  ```bash
474
534
  sudo systemctl disable --now blockyard
475
535
  sudo rm /etc/systemd/system/blockyard.service && sudo systemctl daemon-reload
@@ -484,7 +544,7 @@ sudo userdel blockyard
484
544
  - [ ] `npm run check` passes: Core 25.0+, `txindex` synced, the block files readable, no pruning
485
545
  - [ ] the index directory has ~125 GB free and is writable by the service account
486
546
  - [ ] the start-up log shows the addresses you intended, and no node offline
487
- - [ ] you have decided who can reach the port (bind, firewall, `allowCidrs`)
488
- - [ ] accounts on if the port is reachable by people who should not see your node
489
- - [ ] HTTPS on, or a proxy / tunnel in front, if the network is not trusted
490
- - [ ] `BLOCKYARD_MARKETS=0` if the machine must make no outbound connections
547
+ - [ ] you have decided who can reach the port: it binds `127.0.0.1` until you say otherwise (bind, firewall, `allowCidrs`)
548
+ - [ ] accounts stay on (the default) if the port is reachable by people who should not see your node; you have the admin password from the first start
549
+ - [ ] HTTPS is on by default with the monitor's own certificate; a certificate of your own, or a proxy / tunnel in front, if you want no browser warning
550
+ - [ ] **Enable market polling** ticked in Display settings if you want the Markets and Kiosk tabs (off by default); `BLOCKYARD_MARKETS=0` on machines that must make no outbound connections at all
@@ -1252,3 +1252,150 @@ showing STALLED. Block intervals are close to exponential with a 10-minute mean,
1252
1252
  minutes or more has probability e^-4, about 1.8% -- once in fifty blocks, a few times a day. Stalled
1253
1253
  now means a connected peer reports a higher tip (`getpeerinfo` `synced_headers`); peers agreeing on
1254
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.