blockyard 0.0.1 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +679 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +172 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +40 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1575 -0
- package/docs/ARCHITECTURE.md +1307 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +840 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +202 -0
- package/docs/INSTALL.md +490 -0
- package/docs/MEASUREMENTS.md +1254 -0
- package/docs/PRIVATE-LEADERBOARD.md +230 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +195 -0
- package/docs/STATE-2026-09-09.md +200 -0
- package/docs/TROUBLESHOOTING.md +298 -0
- package/docs/USER-GUIDE.md +1022 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +1785 -0
- package/public/index.html +893 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +964 -0
- package/public/js/app.js +1312 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2678 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +311 -0
- package/public/js/details3d.js +2957 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +83 -0
- package/public/js/markets.js +357 -0
- package/public/js/mining.js +1138 -0
- package/public/js/panels.js +966 -0
- package/public/js/pricechart.js +188 -0
- package/public/js/settings.js +1014 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +20 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +410 -0
- package/scripts/shots.mjs +272 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +193 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2516 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +620 -0
- package/server/http/api.js +1275 -0
- package/server/http/explorer.js +418 -0
- package/server/http/server.js +412 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +628 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +100 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// TETRUST SOUND (operator, 2026-09-12: "We need to add Tetris music and sound effects to teh
|
|
2
|
+
// gameplay. Toggle for each in the game display").
|
|
3
|
+
//
|
|
4
|
+
// No files and no dependencies, so everything here is SYNTHESISED with the Web Audio API: the
|
|
5
|
+
// music is a note table played on oscillators, the effects are short shaped tones. The tune is
|
|
6
|
+
// Korobeiniki, the Russian folk song everyone knows as the Tetris theme -- public domain, older
|
|
7
|
+
// than any game. A browser lets audio start only on a gesture, so the context is made lazily on
|
|
8
|
+
// the first play or key press, and nothing here throws where there is no AudioContext at all
|
|
9
|
+
// (tests, or a browser with audio blocked): every call is a no-op then.
|
|
10
|
+
|
|
11
|
+
const HZ = { A4: 440, B4: 493.88, C5: 523.25, D5: 587.33, E5: 659.25, F5: 698.46, G5: 783.99, A5: 880, GS4: 415.3, E4: 329.63, R: 0 };
|
|
12
|
+
|
|
13
|
+
// Korobeiniki, A minor, one verse: [note, beats]
|
|
14
|
+
export const THEME = Object.freeze([
|
|
15
|
+
['E5', 1], ['B4', 0.5], ['C5', 0.5], ['D5', 1], ['C5', 0.5], ['B4', 0.5],
|
|
16
|
+
['A4', 1], ['A4', 0.5], ['C5', 0.5], ['E5', 1], ['D5', 0.5], ['C5', 0.5],
|
|
17
|
+
['B4', 1.5], ['C5', 0.5], ['D5', 1], ['E5', 1],
|
|
18
|
+
['C5', 1], ['A4', 1], ['A4', 1], ['R', 1],
|
|
19
|
+
['D5', 1.5], ['F5', 0.5], ['A5', 1], ['G5', 0.5], ['F5', 0.5],
|
|
20
|
+
['E5', 1.5], ['C5', 0.5], ['E5', 1], ['D5', 0.5], ['C5', 0.5],
|
|
21
|
+
['B4', 1], ['B4', 0.5], ['C5', 0.5], ['D5', 1], ['E5', 1],
|
|
22
|
+
['C5', 1], ['A4', 1], ['A4', 1], ['R', 1],
|
|
23
|
+
]);
|
|
24
|
+
export const BPM = 148;
|
|
25
|
+
|
|
26
|
+
// the effects: [frequency from, frequency to, seconds, wave, gain]
|
|
27
|
+
export const SFX = Object.freeze({
|
|
28
|
+
move: [520, 520, 0.045, 'square', 0.05],
|
|
29
|
+
rotate: [780, 980, 0.06, 'square', 0.05],
|
|
30
|
+
soft: [300, 300, 0.03, 'triangle', 0.04],
|
|
31
|
+
drop: [160, 70, 0.11, 'sawtooth', 0.09],
|
|
32
|
+
lock: [220, 180, 0.07, 'triangle', 0.06],
|
|
33
|
+
clear: [420, 1320, 0.28, 'square', 0.08],
|
|
34
|
+
tetris: [330, 1760, 0.45, 'square', 0.1],
|
|
35
|
+
over: [440, 90, 0.7, 'sawtooth', 0.09],
|
|
36
|
+
level: [660, 1320, 0.22, 'triangle', 0.07],
|
|
37
|
+
// BLOCKOUT (breakout.js): the bat is a low knock, a brick a short blip -- higher for the dear
|
|
38
|
+
// rows -- a wall a duller tap, and a lost ball a drop. The module is the games' sound, not
|
|
39
|
+
// Tetrust's alone; both set `sfx` from their own switch as they render, and only one plays at a
|
|
40
|
+
// time because only one tab is on screen.
|
|
41
|
+
// PING AND PONG (operator, 2026-09-12: "make sure to have distinct 'Ping' and 'Pong' sounds like
|
|
42
|
+
// Arkanoid does for the paddle and block impacts"). They were 300 Hz and 680 Hz, both falling
|
|
43
|
+
// square blips -- close enough in timbre that with the ball moving you could not tell which had
|
|
44
|
+
// happened without looking. Now they are opposite in every dimension a listener actually uses:
|
|
45
|
+
//
|
|
46
|
+
// PONG (bat) low, and it FALLS -- a soft wooden knock under you
|
|
47
|
+
// PING (brick) high, and it RISES -- a bright strike up at the wall
|
|
48
|
+
//
|
|
49
|
+
// Pitch alone is a weak cue on small speakers; the direction of the sweep survives them.
|
|
50
|
+
paddle: [200, 150, 0.075, 'triangle', 0.075],
|
|
51
|
+
brick: [1046, 1245, 0.042, 'square', 0.05],
|
|
52
|
+
brickhard: [1396, 1760, 0.05, 'square', 0.055],
|
|
53
|
+
wall: [420, 400, 0.04, 'triangle', 0.04],
|
|
54
|
+
life: [320, 90, 0.42, 'sawtooth', 0.08],
|
|
55
|
+
levelup: [520, 1180, 0.3, 'triangle', 0.07],
|
|
56
|
+
// BLOCKANOID (arkanoid.js): a silver brick RINGS rather than breaks and gold rings harder and
|
|
57
|
+
// lower, so the ear learns which bricks are worth hitting before the eye does. A capsule is a
|
|
58
|
+
// rising chime (it is always good news), the laser a short zap, a minion a dull pop.
|
|
59
|
+
silver: [560, 620, 0.06, 'triangle', 0.055],
|
|
60
|
+
gold: [240, 210, 0.07, 'square', 0.05],
|
|
61
|
+
capsule: [620, 1560, 0.22, 'triangle', 0.075],
|
|
62
|
+
laser: [1200, 420, 0.05, 'sawtooth', 0.045],
|
|
63
|
+
enemy: [380, 140, 0.12, 'sawtooth', 0.06],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const S = { ctx: null, music: false, sfx: false, timer: null, at: 0, i: 0, gain: null, live: new Set() };
|
|
67
|
+
|
|
68
|
+
function context() {
|
|
69
|
+
if (S.ctx) return S.ctx;
|
|
70
|
+
const AC = globalThis.AudioContext || globalThis.webkitAudioContext;
|
|
71
|
+
if (!AC) return null;
|
|
72
|
+
try {
|
|
73
|
+
S.ctx = new AC();
|
|
74
|
+
S.gain = S.ctx.createGain();
|
|
75
|
+
S.gain.gain.value = 0.9;
|
|
76
|
+
S.gain.connect(S.ctx.destination);
|
|
77
|
+
} catch { S.ctx = null; }
|
|
78
|
+
return S.ctx;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Call on a user gesture: a suspended context (autoplay policy) is resumed here. */
|
|
82
|
+
export function unlock() {
|
|
83
|
+
const c = context();
|
|
84
|
+
if (c && c.state === 'suspended') c.resume().catch(() => {});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function tone(freqFrom, freqTo, secs, wave, gain, at = null) {
|
|
88
|
+
const c = context();
|
|
89
|
+
if (!c) return;
|
|
90
|
+
const t0 = at ?? c.currentTime;
|
|
91
|
+
const o = c.createOscillator(), g = c.createGain();
|
|
92
|
+
o.type = wave;
|
|
93
|
+
o.frequency.setValueAtTime(freqFrom, t0);
|
|
94
|
+
if (freqTo !== freqFrom) o.frequency.exponentialRampToValueAtTime(Math.max(1, freqTo), t0 + secs);
|
|
95
|
+
g.gain.setValueAtTime(0.0001, t0);
|
|
96
|
+
g.gain.exponentialRampToValueAtTime(gain, t0 + 0.008);
|
|
97
|
+
g.gain.exponentialRampToValueAtTime(0.0001, t0 + secs);
|
|
98
|
+
o.connect(g); g.connect(S.gain);
|
|
99
|
+
o.start(t0); o.stop(t0 + secs + 0.02);
|
|
100
|
+
// remembered until it has played, so a stop (pause, switch off) can cut what was scheduled
|
|
101
|
+
// ahead rather than letting a second and a half of tune run on
|
|
102
|
+
S.live.add(o);
|
|
103
|
+
o.onended = () => S.live.delete(o);
|
|
104
|
+
}
|
|
105
|
+
function hush() {
|
|
106
|
+
for (const o of S.live) { try { o.stop(); } catch { /* already stopped */ } }
|
|
107
|
+
S.live.clear();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One sound effect, if effects are on. */
|
|
111
|
+
export function play(name) {
|
|
112
|
+
if (!S.sfx) return;
|
|
113
|
+
const d = SFX[name];
|
|
114
|
+
if (!d) return;
|
|
115
|
+
tone(d[0], d[1], d[2], d[3], d[4]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function setSfx(on) { S.sfx = !!on; }
|
|
119
|
+
|
|
120
|
+
// THE MUSIC: a lookahead scheduler. Every 200 ms it schedules whatever notes fall in the next
|
|
121
|
+
// 1.5 s, on the AUDIO clock (absolute times), so the tune keeps its tempo however late the page's
|
|
122
|
+
// timers run -- and they run late: a galaxy repaint or a well repaint can hold the main thread
|
|
123
|
+
// well past a timer. The first cut looked 250 ms ahead on a 90 ms timer and stuttered whenever a
|
|
124
|
+
// frame took longer than that ("the music is glitching out and not playing at a steady state").
|
|
125
|
+
// A stop takes at most the lookahead to fall silent, which a pause can afford. Melody on a square
|
|
126
|
+
// wave, the same note an octave down on a soft triangle underneath.
|
|
127
|
+
// (second cut, same day: "The song wobbles and is not consistent ... lock that shit down". The
|
|
128
|
+
// wobble was not the lookahead: the HUD redraw called setMusic(true) on every app render, and
|
|
129
|
+
// each call restarted the scheduler from "now" over the notes already queued -- so the tune
|
|
130
|
+
// stuttered on itself every few seconds. setMusic is idempotent now, and the lookahead is a
|
|
131
|
+
// generous four seconds: the notes are on the audio thread's clock the moment they are queued,
|
|
132
|
+
// and nothing the page does after that can move them.)
|
|
133
|
+
const LOOKAHEAD_S = 4;
|
|
134
|
+
const TIMER_MS = 500;
|
|
135
|
+
function schedule() {
|
|
136
|
+
const c = S.ctx;
|
|
137
|
+
if (!c || !S.music) return;
|
|
138
|
+
const beat = 60 / BPM;
|
|
139
|
+
// fallen behind (the tab was hidden, the thread was held): pick the tune up from now rather
|
|
140
|
+
// than cramming the missed notes into an instant
|
|
141
|
+
if (S.at < c.currentTime - 0.05) S.at = c.currentTime + 0.02;
|
|
142
|
+
while (S.at < c.currentTime + LOOKAHEAD_S) {
|
|
143
|
+
const [name, beats] = THEME[S.i % THEME.length];
|
|
144
|
+
const dur = beats * beat;
|
|
145
|
+
const hz = HZ[name] ?? 0;
|
|
146
|
+
if (hz > 0) {
|
|
147
|
+
tone(hz, hz, dur * 0.88, 'square', 0.045, S.at);
|
|
148
|
+
tone(hz / 2, hz / 2, dur * 0.88, 'triangle', 0.03, S.at);
|
|
149
|
+
}
|
|
150
|
+
S.at += dur;
|
|
151
|
+
S.i++;
|
|
152
|
+
}
|
|
153
|
+
S.timer = setTimeout(schedule, TIMER_MS);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function setMusic(on) {
|
|
157
|
+
const want = !!on;
|
|
158
|
+
if (want === S.music && (!want || S.timer)) return; // already so: never restart a running tune
|
|
159
|
+
S.music = want;
|
|
160
|
+
if (S.timer) { clearTimeout(S.timer); S.timer = null; }
|
|
161
|
+
if (!S.music) { hush(); return; }
|
|
162
|
+
const c = context();
|
|
163
|
+
if (!c) return;
|
|
164
|
+
if (c.state === 'suspended') c.resume().catch(() => {});
|
|
165
|
+
S.at = c.currentTime + 0.05;
|
|
166
|
+
schedule();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Hold the tune (a pause): the scheduler stops, the place in the tune is kept. */
|
|
170
|
+
export function holdMusic(hold) {
|
|
171
|
+
if (hold) { if (S.timer) { clearTimeout(S.timer); S.timer = null; } hush(); return; }
|
|
172
|
+
if (S.music && !S.timer && S.ctx) { S.at = S.ctx.currentTime + 0.05; schedule(); }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function state() { return { music: S.music, sfx: S.sfx, live: !!S.ctx }; }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-blockyard-build="%BLOCKYARD_BUILD%">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="referrer" content="no-referrer">
|
|
7
|
+
<title>sign in - BlockYard</title>
|
|
8
|
+
<link rel="stylesheet" href="/css/app.css">
|
|
9
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22g%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%23ffb85c%22%2F%3E%3Cstop%20offset%3D%22.55%22%20stop-color%3D%22%23f7931a%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23a8640d%22%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D%2232%22%20height%3D%2232%22%20rx%3D%227.5%22%20fill%3D%22url%28%23g%29%22%2F%3E%3Cg%20fill%3D%22%2316110a%22%20opacity%3D%22.14%22%3E%3Crect%20x%3D%224%22%20y%3D%2224.6%22%20width%3D%228%22%20height%3D%223.6%22%20rx%3D%221%22%2F%3E%3Crect%20x%3D%2213%22%20y%3D%2224.6%22%20width%3D%226%22%20height%3D%223.6%22%20rx%3D%221%22%2F%3E%3Crect%20x%3D%2220%22%20y%3D%2224.6%22%20width%3D%228%22%20height%3D%223.6%22%20rx%3D%221%22%2F%3E%3C%2Fg%3E%3Cpath%20fill%3D%22%231a1206%22%20fill-rule%3D%22evenodd%22%20d%3D%22M5%208h6.6a4.2%204.2%200%200%201%202.35%207.66A4.4%204.4%200%200%201%2012%2024H5Zm3.3%202.9v3.5h3.1a1.75%201.75%200%200%200%200-3.5Zm0%206.4v3.8h3.4a1.9%201.9%200%200%200%200-3.8Z%22%2F%3E%3Cpath%20fill%3D%22%231a1206%22%20d%3D%22M16.8%208h3.3l2.75%205.3L25.6%208h3.3l-4.85%208.8V24h-3.3v-7.2Z%22%2F%3E%3C%2Fsvg%3E">
|
|
10
|
+
</head>
|
|
11
|
+
<body class="login-body">
|
|
12
|
+
<div class="login-card">
|
|
13
|
+
<h1>Block<b>Yard</b></h1>
|
|
14
|
+
<p class="sub" id="sub">Bitcoin node monitor</p>
|
|
15
|
+
|
|
16
|
+
<div class="err hidden" id="err"></div>
|
|
17
|
+
|
|
18
|
+
<form id="f" autocomplete="on">
|
|
19
|
+
<div class="field">
|
|
20
|
+
<label for="u">username</label>
|
|
21
|
+
<input id="u" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="field">
|
|
24
|
+
<label for="p">password</label>
|
|
25
|
+
<input id="p" name="password" type="password" autocomplete="current-password" required>
|
|
26
|
+
</div>
|
|
27
|
+
<button class="btn primary block" id="go" type="submit">sign in</button>
|
|
28
|
+
</form>
|
|
29
|
+
<p class="note tiny faint mt-14" id="hint">Read-only by default. This tool cannot start, stop or reconfigure the node unless the operator has explicitly enabled specific actions.</p>
|
|
30
|
+
</div>
|
|
31
|
+
<script type="module" src="/js/login.js"></script>
|
|
32
|
+
</body>
|
|
33
|
+
</html>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// What would an address index over this node's chain cost? Measured on the node's own files, not
|
|
3
|
+
// estimated from blog posts.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/blockfile-measure.js [--dir <datadir>/blocks] [--files 0,500,...] [--sample N] [--verify]
|
|
6
|
+
//
|
|
7
|
+
// For each sampled blkNNNNN.dat and its revNNNNN.dat it times, separately: the disk read, the XOR,
|
|
8
|
+
// decoding every block (transactions, output scripts, addresses), pairing each block with its undo
|
|
9
|
+
// record (shape, then Core's checksum), and decoding the undo data (every spent output, with its
|
|
10
|
+
// address). It counts what an index would have to store -- a funding row per spendable output, a
|
|
11
|
+
// spending row per input -- and extrapolates across all files by interpolating between the samples,
|
|
12
|
+
// because file contents change a great deal over the chain's history.
|
|
13
|
+
//
|
|
14
|
+
// --verify also checks one block per sampled file against getblock <hash> 3 (every prevout), so the
|
|
15
|
+
// numbers are never measured on a decoder that is quietly wrong.
|
|
16
|
+
//
|
|
17
|
+
// Read-only: the node's files are opened for reading and nothing else.
|
|
18
|
+
import { openSync, readSync, closeSync, fstatSync, readdirSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { Reader, readHeader, readTx, classifyScript } from '../server/chain/tx.js';
|
|
22
|
+
import { xorKey, unxor, records, decodeBlockUndo, pairBlocksWithUndo, MAGIC } from '../server/chain/blockfile.js';
|
|
23
|
+
|
|
24
|
+
const arg = (name, def) => { const i = process.argv.indexOf(`--${name}`); return i > 0 ? process.argv[i + 1] : def; };
|
|
25
|
+
const dir = arg('dir', '/storage/core-oracle/blocks');
|
|
26
|
+
const all = readdirSync(dir).filter((f) => /^blk\d{5}\.dat$/.test(f)).sort();
|
|
27
|
+
const last = all.length - 1;
|
|
28
|
+
// the newest file is still being written, so the default sample stops one short of it
|
|
29
|
+
const sample = arg('files', null)
|
|
30
|
+
? arg('files').split(',').map(Number)
|
|
31
|
+
: Array.from({ length: Number(arg('sample', 16)) }, (_, i) => Math.round((i * (last - 1)) / (Number(arg('sample', 16)) - 1)));
|
|
32
|
+
const key = xorKey(dir);
|
|
33
|
+
const verify = process.argv.includes('--verify');
|
|
34
|
+
const ms = (t0) => performance.now() - t0;
|
|
35
|
+
|
|
36
|
+
let rpcCall = null;
|
|
37
|
+
if (verify) {
|
|
38
|
+
const { loadConfig } = await import('../server/config.js');
|
|
39
|
+
const { RpcClient } = await import('../server/rpc/client.js');
|
|
40
|
+
const cfg = loadConfig();
|
|
41
|
+
const node = cfg.nodes.find((n) => n.datadir && dir.startsWith(n.datadir)) ?? cfg.nodes[0];
|
|
42
|
+
const rpc = new RpcClient(node, { ...(cfg.rpc ?? {}), ...(node.rpc ?? {}) }, { log: { info() {}, warn() {}, error() {}, debug() {} } });
|
|
43
|
+
rpcCall = async (method, params) => {
|
|
44
|
+
const [r] = await rpc.batch([{ method, params }], { key: `measure:${method}:${params[0]}`, timeoutMs: 600_000, maxWaitMs: 600_000 });
|
|
45
|
+
if (!r.ok) throw new Error(`${method}: ${r.error?.message}`);
|
|
46
|
+
return r.result;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readRaw(file) {
|
|
51
|
+
const fd = openSync(file, 'r');
|
|
52
|
+
try {
|
|
53
|
+
const size = fstatSync(fd).size;
|
|
54
|
+
const buf = Buffer.allocUnsafe(size);
|
|
55
|
+
let got = 0;
|
|
56
|
+
while (got < size) { const n = readSync(fd, buf, got, size - got, got); if (n === 0) break; got += n; }
|
|
57
|
+
return buf.subarray(0, got);
|
|
58
|
+
} finally { closeSync(fd); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const rows = [];
|
|
62
|
+
for (const n of sample) {
|
|
63
|
+
const id = String(n).padStart(5, '0');
|
|
64
|
+
const r = { file: n };
|
|
65
|
+
let t = performance.now();
|
|
66
|
+
const blk = readRaw(path.join(dir, `blk${id}.dat`));
|
|
67
|
+
const rev = readRaw(path.join(dir, `rev${id}.dat`));
|
|
68
|
+
r.readMs = ms(t); r.blkMB = blk.length / 1e6; r.revMB = rev.length / 1e6;
|
|
69
|
+
t = performance.now(); unxor(blk, key); unxor(rev, key); r.xorMs = ms(t);
|
|
70
|
+
|
|
71
|
+
// blocks: full decode, addresses included (classifyScript runs per output inside readTx)
|
|
72
|
+
t = performance.now();
|
|
73
|
+
const blocks = [];
|
|
74
|
+
let txs = 0, outputs = 0, inputs = 0, funding = 0, height0 = null;
|
|
75
|
+
const scripts = new Set();
|
|
76
|
+
for (const rec of records(blk, MAGIC.main)) {
|
|
77
|
+
const rd = new Reader(rec.body);
|
|
78
|
+
const header = readHeader(rd);
|
|
79
|
+
const ntx = rd.varint();
|
|
80
|
+
const inCounts = [];
|
|
81
|
+
for (let i = 0; i < ntx; i++) {
|
|
82
|
+
const tx = readTx(rd);
|
|
83
|
+
txs++; outputs += tx.vout.length;
|
|
84
|
+
if (i > 0) { inputs += tx.vin.length; inCounts.push(tx.vin.length); }
|
|
85
|
+
for (const o of tx.vout) if (o.scriptPubKey.type !== 'nulldata') { funding++; scripts.add(createHash('sha256').update(o.scriptPubKey.hex).digest('base64').slice(0, 11)); }
|
|
86
|
+
}
|
|
87
|
+
blocks.push({ header, ntx, inCounts });
|
|
88
|
+
}
|
|
89
|
+
r.decodeMs = ms(t);
|
|
90
|
+
Object.assign(r, { blocks: blocks.length, txs, outputs, inputs, funding, distinctScripts: scripts.size });
|
|
91
|
+
|
|
92
|
+
// pair each block with its undo record: tx count first, then Core's checksum
|
|
93
|
+
t = performance.now();
|
|
94
|
+
const undos = [...records(rev, MAGIC.main, 32)];
|
|
95
|
+
const pairs = pairBlocksWithUndo(blocks.map((b) => ({ hash: b.header.hash, previousblockhash: b.header.previousblockhash, ntx: b.ntx })), undos);
|
|
96
|
+
const paired = [...pairs].map(([i, u]) => [blocks[i], u]);
|
|
97
|
+
r.pairMs = ms(t); r.paired = paired.length; r.undoRecords = undos.length;
|
|
98
|
+
|
|
99
|
+
// undo: every spent coin, with the address its script pays
|
|
100
|
+
t = performance.now();
|
|
101
|
+
let spent = 0, special = 0;
|
|
102
|
+
for (const [, u] of paired) {
|
|
103
|
+
for (const coins of decodeBlockUndo(u.body)) for (const c of coins) {
|
|
104
|
+
spent++;
|
|
105
|
+
classifyScript(c.script);
|
|
106
|
+
if (c.script.length === 67) special++; // an uncompressed-key P2PK, rebuilt from 33 bytes
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
r.undoMs = ms(t); r.spent = spent; r.p2pkUncompressedSpent = special;
|
|
110
|
+
|
|
111
|
+
if (verify && paired.length) {
|
|
112
|
+
const [b, u] = paired[Math.floor(paired.length / 2)];
|
|
113
|
+
const want = await rpcCall('getblock', [b.header.hash, 3]);
|
|
114
|
+
const undo = decodeBlockUndo(u.body);
|
|
115
|
+
let bad = 0, checked = 0;
|
|
116
|
+
want.tx.slice(1).forEach((tx, i) => tx.vin.forEach((vin, j) => {
|
|
117
|
+
const c = undo[i][j]; const p = vin.prevout; checked++;
|
|
118
|
+
if (c.value_sat !== Math.round(p.value * 1e8) || c.script.toString('hex') !== p.scriptPubKey.hex || c.height !== p.height || c.coinbase !== p.generated) bad++;
|
|
119
|
+
}));
|
|
120
|
+
r.verified = { height: want.height, coins: checked, mismatches: bad };
|
|
121
|
+
height0 = want.height;
|
|
122
|
+
}
|
|
123
|
+
rows.push(r);
|
|
124
|
+
const f = (v, d = 0) => v.toFixed(d);
|
|
125
|
+
console.log(`blk${id}: ${f(r.blkMB)}+${f(r.revMB)} MB read ${f(r.readMs)} ms, xor ${f(r.xorMs)}, decode ${f(r.decodeMs)}, pair ${f(r.pairMs)}, undo ${f(r.undoMs)} | ${r.blocks} blocks (${r.paired} paired), ${txs} tx, ${outputs} out, ${inputs} in${r.verified ? ` | verified h${r.verified.height}: ${r.verified.coins} coins, ${r.verified.mismatches} mismatches` : ''}${height0 == null ? '' : ''}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// extrapolate across every file by linear interpolation between sampled files
|
|
129
|
+
const at = (k, fileNo) => {
|
|
130
|
+
const s = rows;
|
|
131
|
+
if (fileNo <= s[0].file) return s[0][k];
|
|
132
|
+
for (let i = 1; i < s.length; i++) if (fileNo <= s[i].file) {
|
|
133
|
+
const a = s[i - 1], b = s[i]; const u = (fileNo - a.file) / Math.max(1, b.file - a.file);
|
|
134
|
+
return a[k] + (b[k] - a[k]) * u;
|
|
135
|
+
}
|
|
136
|
+
return s[s.length - 1][k];
|
|
137
|
+
};
|
|
138
|
+
const total = (k) => { let v = 0; for (let fno = 0; fno <= last; fno++) v += at(k, fno); return v; };
|
|
139
|
+
const T = Object.fromEntries(['blkMB', 'revMB', 'readMs', 'xorMs', 'decodeMs', 'pairMs', 'undoMs', 'txs', 'outputs', 'inputs', 'funding', 'spent'].map((k) => [k, total(k)]));
|
|
140
|
+
const h = (x) => (x / 3.6e6).toFixed(2);
|
|
141
|
+
const G = (x) => (x / 1e9).toFixed(2);
|
|
142
|
+
const summary = {
|
|
143
|
+
files: all.length, sampled: sample.length,
|
|
144
|
+
data: { blkGB: +(T.blkMB / 1e3).toFixed(1), revGB: +(T.revMB / 1e3).toFixed(1) },
|
|
145
|
+
counts: { txs: Math.round(T.txs), outputs: Math.round(T.outputs), inputs: Math.round(T.inputs), fundingRows: Math.round(T.funding), spendingRows: Math.round(T.spent) },
|
|
146
|
+
singleCoreHours: { read: +h(T.readMs), xor: +h(T.xorMs), decodeBlocks: +h(T.decodeMs), pairUndo: +h(T.pairMs), decodeUndo: +h(T.undoMs), cpuTotal: +h(T.xorMs + T.decodeMs + T.pairMs + T.undoMs) },
|
|
147
|
+
// raw row bytes for two designs, before any storage-engine overhead
|
|
148
|
+
indexGB: {
|
|
149
|
+
historyOnly: +G((T.funding + T.spent) * 12), // script-hash prefix 8 B + height 4 B, and outpoint prefix 8 B + height 4 B
|
|
150
|
+
withValues: +G((T.funding + T.spent) * 20), // the same plus an 8 B amount, so a balance needs no node call
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
console.log(JSON.stringify(summary, null, 1));
|
|
154
|
+
const out = arg('json', null);
|
|
155
|
+
if (out) writeFileSync(out, JSON.stringify({ measuredAt: new Date().toISOString(), dir, sample: rows, summary }, null, 1) + '\n');
|
|
156
|
+
process.exit(0);
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Look at the page with a real browser, and ask the vision model what it sees.
|
|
2
|
+
//
|
|
3
|
+
// Two kinds of evidence, deliberately kept apart:
|
|
4
|
+
// HARD -- pixels sampled in the page by Runtime.evaluate, and element rects. The
|
|
5
|
+
// canvas is same-origin, so getImageData answers "did anything actually get
|
|
6
|
+
// painted, and in which colours" without any judgement at all.
|
|
7
|
+
// SOFT -- a screenshot handed to the local vision model (qwen3.8 on the bench host).
|
|
8
|
+
// Good for "does this read as a block being filled", "is the legend legible",
|
|
9
|
+
// "do these overlap". Bad at precise numbers. Where the two disagree, the
|
|
10
|
+
// pixels win, and the report says so.
|
|
11
|
+
//
|
|
12
|
+
// Zero dependencies: Chromium speaks CDP over a plain WebSocket, and Node 22 ships one.
|
|
13
|
+
//
|
|
14
|
+
// BLOCKYARD_BASE=https://<lan>:8088 [BROWSER_CDP=http://127.0.0.1:9333] \
|
|
15
|
+
// [VISION_BASE=http://198.51.100.20:8888 VISION_MODEL=<model-id>] \
|
|
16
|
+
// node scripts/browser-check.mjs [overview|mining]
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
|
|
19
|
+
const BASE = process.env.BLOCKYARD_BASE;
|
|
20
|
+
const CDP = process.env.BROWSER_CDP ?? 'http://127.0.0.1:9333';
|
|
21
|
+
const VISION_BASE = process.env.VISION_BASE; // the local vision model, e.g. vLLM on the bench host
|
|
22
|
+
const VISION_MODEL = process.env.VISION_MODEL ?? 'qwen3.8-flash-next';
|
|
23
|
+
const PAGE = process.argv[2] ?? 'mining';
|
|
24
|
+
if (!BASE) {
|
|
25
|
+
console.log('usage: BLOCKYARD_BASE=https://<address>:8088 node scripts/browser-check.mjs [overview|mining]');
|
|
26
|
+
console.log(' needs a headless chromium with --remote-debugging-port and --ignore-certificate-errors');
|
|
27
|
+
process.exit(2);
|
|
28
|
+
}
|
|
29
|
+
const URL = `${BASE}/#${PAGE}`;
|
|
30
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
|
|
32
|
+
// ------------------------------------------------------------------ CDP client
|
|
33
|
+
const targets = JSON.parse(await (await fetch(`${CDP}/json/list`)).text());
|
|
34
|
+
const page = targets.find((t) => t.type === 'page');
|
|
35
|
+
if (!page) { console.log('no page target; is chromium running headless with --remote-debugging-port?'); process.exit(1); }
|
|
36
|
+
|
|
37
|
+
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
|
38
|
+
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
|
39
|
+
|
|
40
|
+
let nextId = 1;
|
|
41
|
+
const pending = new Map();
|
|
42
|
+
const events = [];
|
|
43
|
+
const consoleErrors = [];
|
|
44
|
+
const exceptions = [];
|
|
45
|
+
ws.onmessage = (ev) => {
|
|
46
|
+
const msg = JSON.parse(ev.data);
|
|
47
|
+
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
|
|
48
|
+
if (msg.method) {
|
|
49
|
+
events.push(msg.method);
|
|
50
|
+
if (msg.method === 'Runtime.exceptionThrown') {
|
|
51
|
+
exceptions.push(msg.params.exceptionDetails?.exception?.description ?? msg.params.exceptionDetails?.text);
|
|
52
|
+
}
|
|
53
|
+
if (msg.method === 'Runtime.consoleAPICalled' && ['error', 'warning'].includes(msg.params.type)) {
|
|
54
|
+
consoleErrors.push((msg.params.args ?? []).map((a) => a.value ?? a.description ?? a.type).join(' '));
|
|
55
|
+
}
|
|
56
|
+
if (msg.method === 'Log.entryAdded' && ['error', 'warning'].includes(msg.params.entry.level)) {
|
|
57
|
+
consoleErrors.push(`[${msg.params.entry.source}] ${msg.params.entry.text}`.slice(0, 200));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const send = (method, params = {}) => new Promise((res) => {
|
|
62
|
+
const id = nextId++;
|
|
63
|
+
pending.set(id, (msg) => res(msg.result ?? msg.error ?? {}));
|
|
64
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await send('Page.enable');
|
|
68
|
+
await send('Runtime.enable');
|
|
69
|
+
await send('Log.enable');
|
|
70
|
+
await send('Network.enable');
|
|
71
|
+
await send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 1900, deviceScaleFactor: 1, mobile: false });
|
|
72
|
+
|
|
73
|
+
const loadWait = new Promise((res) => {
|
|
74
|
+
const wait = (ev) => { if (ev === 'Page.loadEventFired') res(true); };
|
|
75
|
+
const orig = ws.onmessage;
|
|
76
|
+
ws.onmessage = (m) => { orig(m); wait(JSON.parse(m.data).method); };
|
|
77
|
+
setTimeout(() => res(false), 25_000);
|
|
78
|
+
});
|
|
79
|
+
// Cache-bust deliberately: Page.navigate to a URL that differs from the current one only
|
|
80
|
+
// in its #fragment is a NO-OP, so reusing a still-open browser silently re-measures the
|
|
81
|
+
// document that was loaded before the last deploy. Every "old code path" and "blank
|
|
82
|
+
// canvas" reading this harness ever produced against a warm browser was this.
|
|
83
|
+
//
|
|
84
|
+
// ...and the query-string bust is NOT enough on its own. It changes the
|
|
85
|
+
// DOCUMENT url only; the ES MODULES the document imports keep their own urls,
|
|
86
|
+
// so a warm browser serves them from cache and the harness measures the
|
|
87
|
+
// PREVIOUS build's JavaScript against the current build's HTML. Measured
|
|
88
|
+
// 2026-09-10: the viewer's projection was replaced outright, the server was
|
|
89
|
+
// confirmed to be serving the new file, every unit test agreed -- and the
|
|
90
|
+
// screenshot kept showing the old camera, from a headless shell that had been
|
|
91
|
+
// up 48 hours. Disabling the network cache is what makes this harness
|
|
92
|
+
// trustworthy for a JS change at all.
|
|
93
|
+
await send('Network.enable', {});
|
|
94
|
+
await send('Network.setCacheDisabled', { cacheDisabled: true });
|
|
95
|
+
const busted = URL + (URL.includes('?') ? '&' : '?') + 't=' + Date.now();
|
|
96
|
+
await send('Page.navigate', { url: busted });
|
|
97
|
+
const loaded = await loadWait;
|
|
98
|
+
// A document can load and still not be the app (a redirect to the login page loads
|
|
99
|
+
// perfectly well). Assert the shell is here before judging anything drawn inside it.
|
|
100
|
+
// NOTE what this does NOT check: document.body.firstChild. Leading whitespace is a text
|
|
101
|
+
// node in every ordinary document, and an earlier throwaway probe asserted on exactly
|
|
102
|
+
// that and "proved" the app rendered nothing when it had rendered fine all along.
|
|
103
|
+
const shell = await send('Runtime.evaluate', {
|
|
104
|
+
expression: `JSON.stringify({ bodyKids: document.body.children.length, nav: !!document.querySelector('#nav'), login: location.pathname.startsWith('/login') })`,
|
|
105
|
+
returnByValue: true,
|
|
106
|
+
}).then((r) => JSON.parse(r?.result?.value || '{}'));
|
|
107
|
+
if (!shell.bodyKids || !shell.nav || shell.login) {
|
|
108
|
+
console.error(`not the app: bodyKids=${shell.bodyKids} nav=${shell.nav} path=${shell.login ? '/login' : '?'}`);
|
|
109
|
+
await browser.close();
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
// The mining page asks the node for a block template on load (~1.3 s) and the map tweens
|
|
113
|
+
// for ~520 ms after that. Judge nothing before it has settled.
|
|
114
|
+
//
|
|
115
|
+
// AND switch the SPA to the requested page the way a user does — by clicking the nav
|
|
116
|
+
// button. Loading `/#mining` in this harness has repeatedly measured the OVERVIEW instead
|
|
117
|
+
// (every canvas zeroSized or from the wrong page), because the hash route is applied
|
|
118
|
+
// inside boot's async tail and a hash-only change on a warm document is no kind of
|
|
119
|
+
// navigation at all (rule 25). Clicking is deterministic; hoping for the hash is not.
|
|
120
|
+
await sleep(3000);
|
|
121
|
+
if (PAGE !== 'overview') {
|
|
122
|
+
await send('Runtime.evaluate', { expression: `document.querySelector('#nav button[data-page=${JSON.stringify(PAGE)}]')?.click()` });
|
|
123
|
+
}
|
|
124
|
+
await sleep(4000);
|
|
125
|
+
// Prove we are on the page we claim to be measuring, before judging a single pixel.
|
|
126
|
+
const onPage = JSON.parse(await send('Runtime.evaluate', {
|
|
127
|
+
expression: `(() => { const on = document.querySelector('.page.on'); return JSON.stringify({page: on?.dataset?.page ?? null, hash: location.hash}) })()`,
|
|
128
|
+
returnByValue: true,
|
|
129
|
+
}).then((r) => r?.result?.value ?? '{}'));
|
|
130
|
+
if (onPage.page && onPage.page !== PAGE) {
|
|
131
|
+
console.error(`not on #${PAGE}: the app is showing "${onPage.page}". The page switch failed; every measurement below would describe the wrong page.`);
|
|
132
|
+
ws.close();
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Refuse to review a page that did not load. Without this guard a broken invocation
|
|
137
|
+
// (an empty BLOCKYARD_BASE once sent it to https://:8088) produced a blank screenshot,
|
|
138
|
+
// the vision model confidently reported "the page is completely empty", and that read
|
|
139
|
+
// would have been believed. Check the document before asking anyone to judge it.
|
|
140
|
+
const sanity = await send('Runtime.evaluate', {
|
|
141
|
+
expression: `JSON.stringify({
|
|
142
|
+
href: location.href,
|
|
143
|
+
title: document.title,
|
|
144
|
+
nav: !!document.getElementById('nav'),
|
|
145
|
+
nodes: document.body ? document.body.childElementCount : 0,
|
|
146
|
+
text: document.body ? document.body.innerText.replace(/\\s+/g,' ').slice(0,120) : ''
|
|
147
|
+
})`,
|
|
148
|
+
returnByValue: true,
|
|
149
|
+
});
|
|
150
|
+
let sanityObj = {};
|
|
151
|
+
try { sanityObj = JSON.parse(sanity.result?.value ?? '{}'); } catch { /* reported below */ }
|
|
152
|
+
if (!sanityObj.nav || !sanityObj.nodes) {
|
|
153
|
+
console.log(`\n== ${URL} ==`);
|
|
154
|
+
console.log(`ABORT: nothing to look at. href=${sanityObj.href} title=${JSON.stringify(sanityObj.title)} bodyChildren=${sanityObj.nodes}`);
|
|
155
|
+
console.log('The browser reached a document with no app in it. Check BLOCKYARD_BASE (and that the service is up) before reading anything into a screenshot.');
|
|
156
|
+
if (consoleErrors.length) consoleErrors.slice(0, 5).forEach((e) => console.log(` ! ${e}`));
|
|
157
|
+
ws.close();
|
|
158
|
+
process.exit(3);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ------------------------------------------------------- HARD: measure the page
|
|
162
|
+
const PROBE = () => {
|
|
163
|
+
const out = { canvases: {}, rects: {}, cssom: {}, text: {}, scroll: {} };
|
|
164
|
+
const px = (id, label) => {
|
|
165
|
+
const c = document.getElementById(id);
|
|
166
|
+
if (!(c instanceof HTMLCanvasElement)) { out.canvases[id] = { missing: true }; return; }
|
|
167
|
+
const r = c.getBoundingClientRect();
|
|
168
|
+
out.rects[id] = { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
|
|
169
|
+
if (!r.width || !r.height) { out.canvases[id] = { zeroSized: true }; return; }
|
|
170
|
+
let ctx;
|
|
171
|
+
try { ctx = c.getContext('2d', { willReadFrequently: true }); } catch { out.canvases[id] = { noContext: true }; return; }
|
|
172
|
+
const dpr = c.width / r.width || 1;
|
|
173
|
+
const data = ctx.getImageData(0, 0, c.width, c.height).data;
|
|
174
|
+
const colours = new Map();
|
|
175
|
+
let lit = 0;
|
|
176
|
+
for (let i = 0; i < data.length; i += 4 * 17) { // sampled every 17 px, fast and enough
|
|
177
|
+
const a = data[i + 3];
|
|
178
|
+
if (a < 8) continue;
|
|
179
|
+
const key = `${data[i] >> 3},${data[i + 1] >> 3},${data[i + 2] >> 3}`;
|
|
180
|
+
colours.set(key, (colours.get(key) ?? 0) + 1);
|
|
181
|
+
if (data[i] + data[i + 1] + data[i + 2] > 200) lit++;
|
|
182
|
+
}
|
|
183
|
+
const top = [...colours.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
|
|
184
|
+
out.canvases[id] = {
|
|
185
|
+
sampled: label, dpr: +dpr.toFixed(2),
|
|
186
|
+
// If nothing painted, say what the drawing code left behind: __hasData is set by
|
|
187
|
+
// paint(), __rects by the treemap layout. hasData=true with 0 colours means it drew
|
|
188
|
+
// and something wiped it; false means the draw never ran at all.
|
|
189
|
+
hasData: !!c.__hasData, rects: c.__rects?.size ?? 0, cells: c.__cells ?? 0,
|
|
190
|
+
backing: `${c.width}x${c.height}`,
|
|
191
|
+
distinctColours: colours.size, litPixels: lit,
|
|
192
|
+
topColourShares: top.map(([k, v]) => `${k}:${(100 * v / [...colours.values()].reduce((a, b) => a + b, 0)).toFixed(0)}%`),
|
|
193
|
+
paintedPixelsPct: +((100 * [...colours.values()].reduce((a, b) => a + b, 0)) / (data.length / (4 * 17))).toFixed(1),
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
['gnMempoolTreemap', 'ovGnTreemap', 'ovTrain', 'ovMpChart', 'ovFeeChart', 'mnFeeLandscape']
|
|
197
|
+
.forEach((id) => px(id, document.getElementById(id) ? 'present' : 'absent'));
|
|
198
|
+
|
|
199
|
+
// CSSOM applied? If the CSP/CSSOM conversion is broken the marks have no background.
|
|
200
|
+
const dots = [...document.querySelectorAll('.bdot')].slice(0, 6);
|
|
201
|
+
out.cssom.bdotBackgrounds = dots.map((d) => getComputedStyle(d).backgroundColor);
|
|
202
|
+
const cards = [...document.querySelectorAll('.bcard')].slice(0, 8);
|
|
203
|
+
out.cssom.cardPoolVars = cards.map((c) => getComputedStyle(c).getPropertyValue('--pool').trim() || '(none)');
|
|
204
|
+
const rail = document.querySelector('.rail');
|
|
205
|
+
out.cssom.railDuration = rail ? getComputedStyle(rail).animationDuration : '(no rail)';
|
|
206
|
+
const bar = document.querySelector('.bfill span');
|
|
207
|
+
out.cssom.fillWidth = bar ? `${getComputedStyle(bar).width} (data-w=${bar.dataset?.w ?? '-'})` : '(no fill)';
|
|
208
|
+
// Distinguish "we never set it" from "the browser computed something else": inline CSSOM
|
|
209
|
+
// values are what applyMiningStyles writes, computed values are what actually renders.
|
|
210
|
+
out.cssom.cardInlinePool = [...document.querySelectorAll('.bcard')].slice(0, 4)
|
|
211
|
+
.map((c) => `${c.style.getPropertyValue('--pool') || '(unset)'}|computed:${getComputedStyle(c).getPropertyValue('--pool').trim() || '(none)'}`);
|
|
212
|
+
|
|
213
|
+
// Card geometry: overlapping cards is the classic flexbox overflow failure.
|
|
214
|
+
const flows = [...document.querySelectorAll('.flow, .flowwrap')];
|
|
215
|
+
out.rects.flowOverflow = flows.map((f) => ({ scrollW: f.scrollWidth, clientW: f.clientWidth, clipped: f.scrollWidth > f.clientWidth + 2 }));
|
|
216
|
+
|
|
217
|
+
const txt = (id) => { const el = document.getElementById(id); const s = (el?.textContent ?? '').trim(); return s.length > 160 ? `${s.slice(0, 160)}…` : s; };
|
|
218
|
+
['ovFeesCard', 'ovMpVsBlock', 'ovMpVsBlockKv', 'ovMempoolCard', 'ovGnTreemapNote', 'gnMempoolNote', 'mnPools', 'ovCaveats']
|
|
219
|
+
.forEach((id) => { out.text[id] = txt(id); });
|
|
220
|
+
out.scroll = { bodyH: document.body.scrollHeight, winH: innerHeight };
|
|
221
|
+
return out;
|
|
222
|
+
};
|
|
223
|
+
const probed = await send('Runtime.evaluate', { expression: `(${PROBE.toString()})()`, returnByValue: true });
|
|
224
|
+
const hard = probed.result?.value ?? { error: probed.error ?? 'no value' };
|
|
225
|
+
|
|
226
|
+
const shot = await send('Page.captureScreenshot', { format: 'png' });
|
|
227
|
+
const png = shot.data ?? null;
|
|
228
|
+
|
|
229
|
+
// ------------------------------------------------------- SOFT: ask the vision model
|
|
230
|
+
let soft = null;
|
|
231
|
+
if (png && VISION_BASE) {
|
|
232
|
+
const prompt = [
|
|
233
|
+
'You are reviewing a screenshot of a self-hosted Bitcoin node monitor (dark theme).',
|
|
234
|
+
`This is the "${PAGE}" page. Report only what you can see, and say "cannot tell" rather than guessing.`,
|
|
235
|
+
'Answer each numbered question in one short sentence:',
|
|
236
|
+
'1. Are any large areas blank, empty boxes, or placeholder dashes? Name them.',
|
|
237
|
+
'2. Is there a treemap of rectangles (a block-space map)? Do the rectangles vary in size and colour, and is any large unfilled band visible?',
|
|
238
|
+
'3. Is there a row of block cards? Read the height numbers you can see, left to right, and say whether one is marked as current.',
|
|
239
|
+
'4. Is any text unreadable because of low contrast, overlap, or clipping?',
|
|
240
|
+
'5. Does anything look obviously broken (misaligned, clipped, overlapping, wrong colours)?',
|
|
241
|
+
'6. What is the single most damaging visual problem on this page, if any?',
|
|
242
|
+
].join('\n');
|
|
243
|
+
const res = await fetch(`${VISION_BASE}/v1/chat/completions`, {
|
|
244
|
+
method: 'POST',
|
|
245
|
+
headers: { 'content-type': 'application/json' },
|
|
246
|
+
body: JSON.stringify({
|
|
247
|
+
model: VISION_MODEL,
|
|
248
|
+
max_tokens: 700,
|
|
249
|
+
temperature: 0.2,
|
|
250
|
+
messages: [{ role: 'user', content: [
|
|
251
|
+
{ type: 'text', text: prompt },
|
|
252
|
+
{ type: 'image_url', image_url: { url: `data:image/png;base64,${png}` } },
|
|
253
|
+
] }],
|
|
254
|
+
}),
|
|
255
|
+
});
|
|
256
|
+
const j = await res.json().catch(() => null);
|
|
257
|
+
const m = j?.choices?.[0]?.message ?? {};
|
|
258
|
+
// This deployment is a reasoning model: it can answer with content=null and the text in
|
|
259
|
+
// `reasoning`. Reading only `content` reports a confident-looking empty review, which is
|
|
260
|
+
// the worst possible failure for a check whose whole job is to notice problems.
|
|
261
|
+
soft = m.content ?? m.reasoning ?? JSON.stringify(j).slice(0, 400);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ------------------------------------------------------------------- report
|
|
265
|
+
console.log(`\n== ${URL} ${loaded ? '' : '(load event not seen)'} ==`);
|
|
266
|
+
console.log(`console errors/warnings: ${consoleErrors.length}`);
|
|
267
|
+
consoleErrors.slice(0, 8).forEach((e) => console.log(` ! ${e}`));
|
|
268
|
+
if (exceptions.length) { console.log(`uncaught exceptions: ${exceptions.length}`); exceptions.slice(0, 5).forEach((e) => console.log(` !! ${String(e).split('\n')[0]}`)); }
|
|
269
|
+
console.log('\n-- HARD: canvas pixels sampled in the page --');
|
|
270
|
+
for (const [id, v] of Object.entries(hard.canvases ?? {})) console.log(` ${id.padEnd(18)} ${JSON.stringify(v)}`);
|
|
271
|
+
console.log('-- HARD: CSSOM + geometry --');
|
|
272
|
+
console.log(' ', JSON.stringify(hard.cssom));
|
|
273
|
+
console.log(' flow:', JSON.stringify(hard.rects?.flowOverflow));
|
|
274
|
+
console.log('-- HARD: text on the page --');
|
|
275
|
+
for (const [id, v] of Object.entries(hard.text ?? {})) if (v) console.log(` ${id.padEnd(16)}: ${String(v).slice(0, 120)}`);
|
|
276
|
+
console.log(` page height ${hard.scroll?.bodyH} vs window ${hard.scroll?.winH}`);
|
|
277
|
+
if (png) {
|
|
278
|
+
const fs = await import('node:fs');
|
|
279
|
+
const path = `/tmp/browser-check-${PAGE}.png`;
|
|
280
|
+
fs.writeFileSync(path, Buffer.from(png, 'base64'));
|
|
281
|
+
console.log(`\nscreenshot: ${path} (${Math.round(png.length * 3 / 4 / 1024)} KB)`);
|
|
282
|
+
}
|
|
283
|
+
console.log(`\n-- SOFT: ${VISION_MODEL} on the screenshot --`);
|
|
284
|
+
console.log(soft ? soft.split('\n').map((l) => ` ${l.trim()}`).filter((l) => l.trim()).join('\n') : ' (no VISION_BASE set, so the screenshot was not reviewed)');
|
|
285
|
+
console.log('\nHARD evidence is measured in the page; SOFT is a model\'s reading. Where they disagree, the pixels win.');
|
|
286
|
+
ws.close();
|