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,428 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // blockpack.js -- lays a block's transactions out as SQUARES on an integer
4
+ // grid, the look of mempool.space's block visualisation, implemented here
5
+ // from scratch.
6
+ //
7
+ // THE SCALE. One 1x1 grid unit stands for vbytesPerUnit() vbytes, chosen so a
8
+ // full block needs a little less than resolution x resolution units: the rest
9
+ // is headroom for rounding sides to whole units. A transaction's square has
10
+ // the side whose area is closest to its vsize in units, rounded a little
11
+ // generously (see sideFor), and never less than one unit.
12
+ //
13
+ // THE PACKING is first fit: each square goes to the lowest row where it fits,
14
+ // and within that row as far left as it fits. Transactions arrive richest
15
+ // first, so the richest settle on the lowest rows, and every small one drops
16
+ // into the lowest gap it fits -- including the gaps beside big squares -- so
17
+ // a real block packs densely.
18
+ //
19
+ // THE STABLE RE-PACK (packStable) lays a refresh out so that transactions
20
+ // already on the board keep their squares, and only what changed moves.
21
+
22
+ import { feeShade } from './feepalette.js';
23
+
24
+ // A full block needs this share of resolution^2 raw units.
25
+ const BLOCK_SHARE = 0.98;
26
+ // A square claims this much more area than its raw units. Most of a real
27
+ // block is transactions of 1.2-1.4 units that can only be drawn as ONE unit,
28
+ // so a block packed at exactly its raw units comes out well short of the
29
+ // grid (89% on a live block); scaling every square's area up by the same
30
+ // small factor gives that area back to the larger squares, keeping their
31
+ // sizes relative to each other exact. Measured on a live 3,051-transaction
32
+ // block (2026-09-11): at 96 units it packs 90 rows tall and 93.5% full, a
33
+ // 140 vB transaction is one unit at 96 and at 112 units, and a block of
34
+ // mid-sized transactions (200-6,200 vB) at 90% of the limit still packs
35
+ // inside a 40-unit grid.
36
+ const AREA_GAIN = 1.06;
37
+
38
+ export function vbytesPerUnit(blockLimit, resolution) {
39
+ const units = Math.max(1, Number(resolution) || 1) ** 2 * BLOCK_SHARE;
40
+ return Math.max(1e-9, Number(blockLimit) || 0) / units;
41
+ }
42
+
43
+ // the side whose square is nearest the transaction's (gained) area, in whole units
44
+ export function sideFor(vsize, vpu, gridWidth = Infinity) {
45
+ const units = Math.max(0, Number(vsize) || 0) / Math.max(1e-12, Number(vpu) || 0);
46
+ const side = Math.round(Math.sqrt(AREA_GAIN * units));
47
+ return Math.max(1, Math.min(Math.floor(gridWidth) || 1, side));
48
+ }
49
+
50
+ // the vsize whose square is exactly `side` units: its root lands on the whole
51
+ // number itself, the middle of the band of sizes that round to it, so no
52
+ // float error can tip it into a neighbour
53
+ export function vsizeForSide(side, vpu) {
54
+ const k = Math.max(1, Math.floor(Number(side)) || 1);
55
+ return (Number(vpu) * k * k) / AREA_GAIN;
56
+ }
57
+
58
+ // AREA-TRUE SIDES FOR THE DETAILED VIEW (operator, 2026-09-11: "Our packer sucks compared to
59
+ // mempool space. How do we improve?"). Measured on the live block that day: 6,699 transactions,
60
+ // 80% of them 139-140 vB, at 96 units (110.7 vB a unit). A 140 vB transaction is 1.27 units and
61
+ // sideFor draws it as ONE, so the block as drawn was 82% of its true area -- a full block stopped
62
+ // 77 rows up a 96-row board, the rest bare grid. Whole-unit squares can only be area-true on
63
+ // average if some of them round up: a transaction of u units is drawn at floor(sqrt(u)) or one
64
+ // more, the larger with the probability that makes its expected area exactly u. The coin is a
65
+ // hash of the txid, so a transaction keeps its side on every refresh (a running error carry is
66
+ // area-exact too, but one newcomer early in the order would resize everything after it). No
67
+ // AREA_GAIN here: the areas are already true.
68
+ function unitHash(key) {
69
+ let h = 0x9747b28c; // not feepalette's seed: size and shade stay independent
70
+ for (let i = 0; i < key.length; i++) { h ^= key.charCodeAt(i); h = Math.imul(h, 0x01000193); }
71
+ h ^= h >>> 15; h = Math.imul(h, 0x2c1b3c6d); h ^= h >>> 12; // mix, so similar ids spread
72
+ return (h >>> 0) / 4294967296;
73
+ }
74
+
75
+ export function ditheredSide(vsize, vpu, gridWidth = Infinity, key = '') {
76
+ const units = Math.max(0, Number(vsize) || 0) / Math.max(1e-12, Number(vpu) || 0);
77
+ const lo = Math.max(1, Math.floor(Math.sqrt(units)));
78
+ const hi = lo + 1;
79
+ const p = Math.min(1, Math.max(0, (units - lo * lo) / (hi * hi - lo * lo)));
80
+ const s = key == null || key === '' ? Math.max(1, Math.round(Math.sqrt(units))) : (unitHash(String(key)) < p ? hi : lo);
81
+ return Math.max(1, Math.min(Math.floor(gridWidth) || 1, s));
82
+ }
83
+
84
+ const idOf = (tx) => (tx && typeof tx === 'object' ? tx.txid : tx);
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // The occupancy grid. Rows are unbounded upward: a square that does not fit
88
+ // below `height` is placed above it, so insert() always succeeds.
89
+ //
90
+ // For every cell the grid keeps `run`: how many free cells start there and
91
+ // continue to the right in the same row (0 for an occupied cell). A square of
92
+ // side s fits at (x, y) when run >= s at column x in each of its s rows, and
93
+ // a scan along a row can jump over a whole free run too short to use.
94
+ export class BlockLayout {
95
+ constructor({ width, height } = {}) {
96
+ this.width = Math.max(1, Math.floor(Number(width) || 1));
97
+ this.height = Math.max(1, Math.floor(Number(height) || this.width));
98
+ this.rows = 0;
99
+ this.cap = 0;
100
+ this.run = new Uint16Array(0);
101
+ this.free = new Uint32Array(0); // free cells per row
102
+ this.floor = 0; // no free cell below this row
103
+ this.top = 0; // one past the highest occupied row
104
+ this.at = new Map(); // txid -> { x, y, s }
105
+ this.grow(this.height);
106
+ }
107
+
108
+ grow(rows) {
109
+ if (rows <= this.rows) return;
110
+ const W = this.width;
111
+ if (rows > this.cap) {
112
+ const cap = Math.max(rows, this.cap * 2, 16);
113
+ const run = new Uint16Array(cap * W);
114
+ run.set(this.run.subarray(0, this.rows * W));
115
+ const free = new Uint32Array(cap);
116
+ free.set(this.free.subarray(0, this.rows));
117
+ this.run = run; this.free = free; this.cap = cap;
118
+ }
119
+ for (let y = this.rows; y < rows; y++) {
120
+ const o = y * W;
121
+ for (let x = 0; x < W; x++) this.run[o + x] = W - x;
122
+ this.free[y] = W;
123
+ }
124
+ this.rows = rows;
125
+ }
126
+
127
+ fits(x, y, s) {
128
+ if (x < 0 || y < 0 || x + s > this.width) return false;
129
+ const W = this.width;
130
+ for (let yy = y; yy < y + s; yy++) {
131
+ if (yy >= this.rows) return true; // rows not yet made are empty
132
+ if (this.run[yy * W + x] < s) return false;
133
+ }
134
+ return true;
135
+ }
136
+
137
+ fill(x, y, s, on) {
138
+ const W = this.width;
139
+ this.grow(y + s);
140
+ for (let yy = y; yy < y + s; yy++) {
141
+ const o = yy * W;
142
+ if (on) {
143
+ for (let xx = x; xx < x + s; xx++) this.run[o + xx] = 0;
144
+ this.free[yy] -= s;
145
+ // the free run ending at x is now shorter
146
+ let n = 0;
147
+ for (let xx = x - 1; xx >= 0 && this.run[o + xx] !== 0; xx--) this.run[o + xx] = ++n;
148
+ } else {
149
+ let n = x + s < W ? this.run[o + x + s] : 0;
150
+ for (let xx = x + s - 1; xx >= 0; xx--) {
151
+ if (xx < x && this.run[o + xx] === 0) break;
152
+ this.run[o + xx] = ++n;
153
+ }
154
+ this.free[yy] += s;
155
+ }
156
+ }
157
+ if (on) {
158
+ this.top = Math.max(this.top, y + s);
159
+ while (this.floor < this.rows && this.free[this.floor] === 0) this.floor++;
160
+ } else {
161
+ this.floor = Math.min(this.floor, y);
162
+ }
163
+ }
164
+
165
+ // first fit: the lowest row, then the leftmost column, where s x s is free.
166
+ //
167
+ // TRIED AND REVERTED (2026-09-12): alternating the scan direction by row, to stop every row's
168
+ // leftover space landing on the same side. It reads well in Detailed, where a tile is one unit
169
+ // and the leftover is a cell or two -- and badly in Simple, where a row holds a few EQUAL pieces
170
+ // and the wide leftover then sat at opposite ends on alternate rows: a staircase with stranded
171
+ // columns ("why is packing messed up again"). The board's rightward lean is dealt with in the
172
+ // FLIGHT instead (blockscene3d flightDir), which is where it came from.
173
+ find(s) {
174
+ const W = this.width;
175
+ for (let y = this.floor; ; y++) {
176
+ if (y >= this.rows) return { x: 0, y };
177
+ if (this.free[y] < s) continue;
178
+ const o = y * W;
179
+ for (let x = 0; x + s <= W;) {
180
+ const r = this.run[o + x];
181
+ if (r < s) { x += r + 1; continue; } // this free run is too short: skip it and the cell ending it
182
+ let ok = true;
183
+ for (let yy = y + 1; yy < y + s && yy < this.rows; yy++) {
184
+ if (this.run[yy * W + x] < s) { ok = false; break; }
185
+ }
186
+ if (ok) return { x, y };
187
+ x++;
188
+ }
189
+ }
190
+ }
191
+
192
+ insert(tx, side) {
193
+ const s = Math.max(1, Math.min(this.width, Math.floor(Number(side)) || 1));
194
+ const id = idOf(tx);
195
+ if (this.at.has(id)) this.remove(tx);
196
+ const { x, y } = this.find(s);
197
+ this.fill(x, y, s, true);
198
+ const pos = { x, y, s };
199
+ this.at.set(id, pos);
200
+ return { ...pos };
201
+ }
202
+
203
+ // occupy an explicit position; null (and nothing changes) when any of it is taken
204
+ place(tx, x, y, side) {
205
+ const s = Math.max(1, Math.floor(Number(side)) || 1);
206
+ const id = idOf(tx);
207
+ if (this.at.has(id)) this.remove(tx);
208
+ if (!Number.isInteger(x) || !Number.isInteger(y) || !this.fits(x, y, s)) return null;
209
+ this.fill(x, y, s, true);
210
+ const pos = { x, y, s };
211
+ this.at.set(id, pos);
212
+ return { ...pos };
213
+ }
214
+
215
+ remove(tx) {
216
+ const id = idOf(tx);
217
+ const pos = this.at.get(id);
218
+ if (!pos) return false;
219
+ this.at.delete(id);
220
+ this.fill(pos.x, pos.y, pos.s, false);
221
+ if (pos.y + pos.s >= this.top) {
222
+ let t = 0;
223
+ for (const p of this.at.values()) t = Math.max(t, p.y + p.s);
224
+ this.top = t;
225
+ }
226
+ return true;
227
+ }
228
+
229
+ positionOf(tx) {
230
+ const p = this.at.get(idOf(tx));
231
+ return p ? { ...p } : null;
232
+ }
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+
237
+ function prepare(txs, vpu, width, dither = false) {
238
+ return (txs || []).map((tx) => {
239
+ const vsize = Math.max(1, Number(tx?.vsize) || 0);
240
+ const fee = Number(tx?.fee);
241
+ const rate = Number.isFinite(fee) && fee > 0 ? fee / vsize : 0;
242
+ return { txid: tx?.txid, vsize, rate, s: dither ? ditheredSide(vsize, vpu, width, tx?.txid ?? '') : sideFor(vsize, vpu, width) };
243
+ });
244
+ }
245
+
246
+ const tileOf = (it, pos) => ({ txid: it.txid, x: pos.x, y: pos.y, s: pos.s, vsize: it.vsize, rate: it.rate, color: feeShade(it.rate, it.txid) });
247
+
248
+ export function packBlock(txs, { resolution = 80, blockLimit = 1000000, dither = false } = {}) {
249
+ const width = Math.max(1, Math.floor(Number(resolution) || 80));
250
+ const vpu = vbytesPerUnit(blockLimit, width);
251
+ const layout = new BlockLayout({ width, height: width });
252
+ const items = prepare(txs, vpu, width, dither);
253
+ const tiles = new Array(items.length);
254
+ let gridHeight = 0;
255
+ for (let i = 0; i < items.length; i++) {
256
+ const it = items[i];
257
+ const pos = layout.insert(it.txid, it.s);
258
+ tiles[i] = tileOf(it, pos, feeShade);
259
+ if (pos.y + pos.s > gridHeight) gridHeight = pos.y + pos.s;
260
+ }
261
+ return { tiles, gridWidth: width, gridHeight, vbytesPerUnit: vpu };
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // THE STABLE RE-PACK. `prev` is the board as drawn (tiles with txid, x, y, s);
266
+ // `txs` the next layout's transactions, richest first.
267
+ //
268
+ // * A transaction on the previous board with the same side keeps its square.
269
+ // * The tail's equal pieces are interchangeable (their ids name a slot, not a
270
+ // transaction), so a piece takes any previous piece's slot of its size.
271
+ // * Newcomers, and anything whose side changed, fill the free space first
272
+ // fit, in the order given.
273
+ // * When that leaves more off the board than a fresh pack would, the board
274
+ // is re-packed above a line, `keptBelow`: survivors wholly below it stay
275
+ // put and everything else is laid out again first fit. The line starts at
276
+ // the top of the board and comes down only as far as it must, so what
277
+ // moves is the cheap top of the board.
278
+ //
279
+ // Returns null when there is nothing to be stable against (an empty board)
280
+ // or when most of the block is new (a block was mined): the caller packs
281
+ // fresh. Otherwise { tiles, waiting, keptBelow }: the tiles that are on the
282
+ // board, in input order, and the vbytes that did not fit on it.
283
+
284
+ const SURVIVOR_SHARE = 0.5; // below this share of the block's vbytes kept, pack fresh
285
+ const WAIT_ALLOWANCE = 0.01; // may leave this share more waiting than a fresh pack
286
+
287
+ const isPiece = (id) => typeof id === 'string' && id.startsWith('aggregate');
288
+
289
+ export function packStable(prev, txs, opts = {}) {
290
+ if (!Array.isArray(prev) || !prev.length || !Array.isArray(txs) || !txs.length) return null;
291
+ const width = Math.max(1, Math.floor(Number(opts.resolution) || 80));
292
+ const height = width;
293
+ const vpu = vbytesPerUnit(opts.blockLimit ?? 1000000, width);
294
+ const items = prepare(txs, vpu, width, !!opts.dither);
295
+
296
+ // where each survivor was
297
+ const was = new Map();
298
+ const slots = new Map(); // side -> previous piece slots, lowest first
299
+ for (const t of prev) {
300
+ if (!t || !(t.s >= 1) || t.x < 0 || t.y < 0 || t.x + t.s > width || t.y + t.s > height) continue;
301
+ if (isPiece(t.txid)) {
302
+ if (!slots.has(t.s)) slots.set(t.s, []);
303
+ slots.get(t.s).push(t);
304
+ } else {
305
+ was.set(t.txid, t);
306
+ }
307
+ }
308
+ for (const list of slots.values()) list.sort((a, b) => a.y - b.y || a.x - b.x);
309
+ let kept = 0, total = 0;
310
+ const seen = new Set();
311
+ for (const it of items) {
312
+ total += it.vsize;
313
+ if (seen.has(it.txid)) continue; // a duplicated id keeps one square
314
+ seen.add(it.txid);
315
+ let w = isPiece(it.txid) ? slots.get(it.s)?.shift() : was.get(it.txid);
316
+ if (w && w.s === it.s) { it.keep = w; kept += it.vsize; }
317
+ }
318
+ if (kept < total * SURVIVOR_SHARE) return null;
319
+
320
+ const attempt = (line) => {
321
+ const layout = new BlockLayout({ width, height });
322
+ const pos = new Array(items.length);
323
+ for (let i = 0; i < items.length; i++) {
324
+ const k = items[i].keep;
325
+ if (k && k.y + k.s <= line) pos[i] = layout.place(i, k.x, k.y, k.s);
326
+ }
327
+ let waiting = 0;
328
+ for (let i = 0; i < items.length; i++) {
329
+ if (pos[i]) continue;
330
+ const p = layout.insert(i, items[i].s);
331
+ if (p.y + p.s > height) { layout.remove(i); waiting += items[i].vsize; pos[i] = null; } else pos[i] = p;
332
+ }
333
+ return { pos, waiting, keptBelow: line };
334
+ };
335
+
336
+ // what a fresh pack would leave off the board: the line never needs to go lower than that
337
+ const fresh = attempt(0);
338
+ const allowed = fresh.waiting + total * WAIT_ALLOWANCE + 1e-9;
339
+ const step = Math.max(1, Math.round(height / 24));
340
+ let best = fresh;
341
+ for (let line = height; line > 0; line -= step) {
342
+ const r = attempt(line);
343
+ if (r.waiting <= allowed) { best = r; break; }
344
+ }
345
+ const tiles = [];
346
+ for (let i = 0; i < items.length; i++) if (best.pos[i]) tiles.push(tileOf(items[i], best.pos[i], feeShade));
347
+ return { tiles, waiting: best.waiting, keptBelow: best.keptBelow };
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // THE EXACT FILL (operator, 2026-09-12: "Simple viewer mode is what I use as
352
+ // default. It needs to be fucking perfect", then "packer is still fucked up").
353
+ //
354
+ // The Simple board is one block's worth: a few hundred real transactions,
355
+ // richest first, and ONE summed tail standing for the thousands behind them.
356
+ // The old path cut the tail into equal squares of a side chosen in advance,
357
+ // packed everything, and when it overflowed shrank the scale 5% at a time until
358
+ // it happened to fit -- so the last row was partial BY CONSTRUCTION. Measured
359
+ // on the live pool: the top row 55% full, 92 cells stranded, a torn edge.
360
+ //
361
+ // This packs the other way round. The scale is SOLVED: one unit is chosen so
362
+ // that the real transactions' squares plus the tail's area come to exactly
363
+ // resolution x resolution. The real transactions go down first-fit as before.
364
+ // Then every cell still empty is tiled with the largest square that fits, no
365
+ // bigger than `cap` -- so the tail stays a field of small equal-looking pieces
366
+ // (a 14x14 tail piece beside a 1x1 transaction would claim to be 196x bigger,
367
+ // which it is not; it was tried and rejected for exactly that) and the board
368
+ // fills to the last cell. Measured on the live pool and three synthetic ones:
369
+ // 1936 of 1936 cells, a flush top row, every time.
370
+ //
371
+ // `plain`: [{ txid, vsize, rate }] richest first. `tail`: { vbytes, rateAt(frac) }
372
+ // where rateAt maps a share of the tail (0 = richest) to a feerate, or null.
373
+ export function packExact(plain, tail, { resolution = 44, cap = 3 } = {}) {
374
+ const RES = Math.max(1, Math.floor(Number(resolution) || 44));
375
+ const total = RES * RES;
376
+ const items = (plain || []).map((c, i) => ({ txid: c.txid ?? `cell-${i}`, vsize: Math.max(1, Number(c.vsize) || 0), rate: Math.max(0, Number(c.rate) || 0) }));
377
+ const tailVb = tail ? Math.max(0, Number(tail.vbytes) || 0) : 0;
378
+ const realArea = (vpu) => items.reduce((a, c) => { const s = sideFor(c.vsize, vpu, RES); return a + s * s; }, 0);
379
+ // both terms fall as vpu rises, so bisect for the vpu that fills the grid exactly
380
+ let lo = 1, hi = 1e6;
381
+ for (let i = 0; i < 64; i++) { const m = (lo + hi) / 2; if (realArea(m) + tailVb / m > total) lo = m; else hi = m; }
382
+ let vpu = (lo + hi) / 2;
383
+
384
+ // the real transactions, first-fit; if first fit cannot honour the solved area (it is a
385
+ // heuristic, not a guarantee) the scale steps up a little and they are laid again
386
+ let layout, tiles;
387
+ for (let attempt = 0; attempt < 8; attempt++) {
388
+ layout = new BlockLayout({ width: RES, height: RES });
389
+ tiles = [];
390
+ let overflow = false;
391
+ for (const c of items) {
392
+ const s = sideFor(c.vsize, vpu, RES);
393
+ const pos = layout.insert(c.txid, s);
394
+ if (pos.y + pos.s > RES) { overflow = true; break; }
395
+ tiles.push({ txid: c.txid, x: pos.x, y: pos.y, s: pos.s, vsize: c.vsize, rate: c.rate, color: feeShade(c.rate, c.txid) });
396
+ }
397
+ if (!overflow) break;
398
+ vpu *= 1.04;
399
+ }
400
+
401
+ // the remainder, tiled to the last cell: low-left first, the largest square up to `cap`
402
+ const pieces = [];
403
+ let k = 0;
404
+ // BlockLayout.fits treats rows beyond the grid as empty (the layout grows), so the top edge
405
+ // is this loop's to guard: a piece may not reach past row RES
406
+ const room = (x, y, s) => y + s <= RES && layout.fits(x, y, s);
407
+ for (let y = 0; y < RES; y++) {
408
+ for (let x = 0; x < RES; x++) {
409
+ if (!room(x, y, 1)) continue;
410
+ let s = 1;
411
+ while (s + 1 <= cap && room(x, y, s + 1)) s++;
412
+ const id = `aggregate-${k++}`;
413
+ const pos = layout.place(id, x, y, s);
414
+ if (pos) pieces.push({ ...pos, txid: id });
415
+ }
416
+ }
417
+ // each piece stands for its share of the tail's vbytes, coloured richest-first by that share
418
+ const pieceArea = pieces.reduce((a, p) => a + p.s * p.s, 0) || 1;
419
+ let acc = 0;
420
+ for (const p of pieces) {
421
+ const vsize = tailVb * (p.s * p.s) / pieceArea;
422
+ const frac = tailVb > 0 ? (acc + vsize / 2) / tailVb : 0;
423
+ acc += vsize;
424
+ const rate = tail && typeof tail.rateAt === 'function' ? Math.max(0, Number(tail.rateAt(frac)) || 0) : Math.max(0, Number(tail?.rate) || 0);
425
+ tiles.push({ txid: p.txid, x: p.x, y: p.y, s: p.s, vsize, rate, color: feeShade(rate, p.txid) });
426
+ }
427
+ return { tiles, gridWidth: RES, gridHeight: RES, vbytesPerUnit: vpu, pieces: pieces.length };
428
+ }