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
@@ -0,0 +1,459 @@
1
+ // SOUNDCARD: the Sound Blaster Pro 2 in the DOOM Diversion's PC -- a DSP that plays digitised sound
2
+ // through DMA, and an OPL3 FM synthesiser for the music (operator, 2026-09-15: "Get DOOM working as
3
+ // a diversion inside blockyard with zero dependancies").
4
+ //
5
+ // Our own implementation, at the level a driver sees the card: the DSP's reset handshake, command
6
+ // bytes and interrupts; the 8237 DMA controller's address and count registers, which is where the
7
+ // samples come from (the driver leaves them in conventional memory and the card pulls them); and the
8
+ // OPL's register map, timers and status byte, which is how a driver decides the chip exists at all.
9
+ //
10
+ // THE SYNTHESIS IS A MODEL, NOT A CHIP. Each OPL operator is a phase accumulator, one of eight
11
+ // waveforms, and an envelope in decibels (attack, decay to the sustain level, release) with the
12
+ // chip's documented rate scaling: 2826 ms for a full attack and 39281 ms for a full 96 dB decay at
13
+ // rate 4, each rate step a quarter of an octave faster. Modulation is phase modulation in cycles --
14
+ // a modulator at full level moves its carrier by four cycles, the chip's own scaling. It sounds like
15
+ // an OPL; it is not sample-exact to one, and nothing here pretends it is.
16
+ //
17
+ // Time is the machine's: `tick(t)` is called with the PC's clock, and every millisecond of it
18
+ // produces `rate` samples a second of stereo output, mixed from the DSP's DAC and the FM voices, into
19
+ // a ring the caller drains. The DSP's end-of-block interrupt is raised from inside that same loop,
20
+ // so a driver's next block is requested exactly when the last one ran out, in machine time.
21
+
22
+ const OPL_RATE = 49716;
23
+
24
+ // ------------------------------------------------------------------ OPL tables
25
+ const WAVE_BITS = 10, WAVE_LEN = 1 << WAVE_BITS;
26
+ const WAVES = [];
27
+ {
28
+ const sin = (i) => Math.sin((2 * Math.PI * (i + 0.5)) / WAVE_LEN);
29
+ const mk = (f) => { const t = new Float32Array(WAVE_LEN); for (let i = 0; i < WAVE_LEN; i++) t[i] = f(i); return t; };
30
+ const half = WAVE_LEN / 2, quarter = WAVE_LEN / 4;
31
+ WAVES.push(mk((i) => sin(i))); // 0 sine
32
+ WAVES.push(mk((i) => (i < half ? sin(i) : 0))); // 1 half-sine
33
+ WAVES.push(mk((i) => Math.abs(sin(i)))); // 2 abs-sine
34
+ WAVES.push(mk((i) => ((i % half) < quarter ? Math.abs(sin(i)) : 0))); // 3 pulse-sine
35
+ WAVES.push(mk((i) => (i < half ? sin(i * 2) : 0))); // 4 even-periods sine
36
+ WAVES.push(mk((i) => (i < half ? Math.abs(sin(i * 2)) : 0))); // 5 even-periods abs-sine
37
+ WAVES.push(mk((i) => (i < half ? 1 : -1))); // 6 square
38
+ WAVES.push(mk((i) => (i < half ? Math.pow(10, -(i / half) * 4.8) : -Math.pow(10, -((WAVE_LEN - 1 - i) / half) * 4.8)))); // 7 derived square
39
+ }
40
+ // the tremolo LFO: a raised cosine, 0 to 1, over 256 steps
41
+ const LFO_TREM = new Float32Array(256);
42
+ for (let i = 0; i < 256; i++) LFO_TREM[i] = (1 - Math.cos((2 * Math.PI * i) / 256)) * 0.5;
43
+ const MULT = [0.5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 12, 12, 15, 15];
44
+ const KSL_TABLE = [0, 18, 24, 27, 30, 32.25, 33.75, 35.25, 36, 37.5, 38.25, 39, 39.75, 40.5, 41.25, 42];
45
+ const KSL_SCALE = [0, 0.5, 0.25, 1]; // 0, 3, 1.5 and 6 dB an octave, as fractions of the table's 6
46
+ const MAX_DB = 96;
47
+ // dB -> linear, in tenths of a dB: the envelope is summed in dB and looked up once a sample
48
+ const DB_STEPS = 10;
49
+ const DB_TO_AMP = new Float32Array(MAX_DB * DB_STEPS * 2 + 2);
50
+ for (let i = 0; i < DB_TO_AMP.length; i++) DB_TO_AMP[i] = i >= MAX_DB * DB_STEPS ? 0 : Math.pow(10, -i / DB_STEPS / 20);
51
+
52
+ /** Seconds for a full attack and a full 96 dB decay at an effective rate (0..63). */
53
+ export function oplRateTimes(rate) {
54
+ if (rate < 4) return { attack: Infinity, decay: Infinity };
55
+ if (rate >= 60) return { attack: 0, decay: 0.3 / 1000 * Math.pow(2, -(Math.min(rate, 63) - 60) / 4) * 8 };
56
+ const k = Math.pow(2, (rate - 4) / 4);
57
+ return { attack: 2.82624 / k, decay: 39.28064 / k };
58
+ }
59
+
60
+ const EG_OFF = 0, EG_ATTACK = 1, EG_DECAY = 2, EG_SUSTAIN = 3, EG_RELEASE = 4;
61
+
62
+ class Operator {
63
+ constructor() {
64
+ this.am = 0; this.vib = 0; this.egt = 0; this.ksr = 0; this.mult = 1;
65
+ this.ksl = 0; this.tl = 0; this.ar = 0; this.dr = 0; this.sl = 0; this.rr = 0; this.wave = 0;
66
+ this.phase = 0; this.env = MAX_DB; this.stage = EG_OFF;
67
+ this.out = 0; this.prev = 0;
68
+ // worked out per render call from the registers (Opl3.prepare)
69
+ this.attackK = 0; this.decayInc = 0; this.releaseInc = 0; this.slLevel = 0; this.levelDb = 0; this.inc = 0; this.table = null;
70
+ }
71
+ }
72
+
73
+ class Channel {
74
+ constructor() {
75
+ this.fnum = 0; this.block = 0; this.key = false; this.fb = 0; this.cnt = 0;
76
+ this.left = true; this.right = true;
77
+ this.ops = [new Operator(), new Operator()];
78
+ }
79
+ }
80
+
81
+ export class Opl3 {
82
+ constructor(rate) {
83
+ this.rate = rate;
84
+ this.reg = new Uint8Array(0x200);
85
+ this.chans = Array.from({ length: 18 }, () => new Channel());
86
+ this.newMode = false; // register 0x105 bit 0: OPL3 features on
87
+ this.wse = false; // register 0x01 bit 5: waveform select (OPL2)
88
+ this.nts = 0;
89
+ this.amDepth = 1; this.vibDepth = 7;
90
+ this.lfoAm = 0; this.lfoVib = 0;
91
+ this.timer1 = 0; this.timer2 = 0; this.status = 0; this.timerCtl = 0;
92
+ this.index = [0, 0];
93
+ this.trem = new Float32Array(0); this.vib = new Float32Array(0);
94
+ }
95
+
96
+ /** The status byte: timer flags in bits 5-7, and OPL3's low bits read 0 (an OPL2 reads 6). */
97
+ readStatus() { return this.status; }
98
+
99
+ write(array, reg, v) {
100
+ const r = (array << 8) | reg;
101
+ this.reg[r] = v;
102
+ if (array === 1 && reg === 0x05) { this.newMode = (v & 1) === 1; return; }
103
+ if (array === 0) {
104
+ switch (reg) {
105
+ case 0x01: this.wse = (v & 0x20) !== 0; return;
106
+ case 0x02: this.timer1 = v; return;
107
+ case 0x03: this.timer2 = v; return;
108
+ case 0x04:
109
+ if (v & 0x80) { this.status = 0; return; } // reset the flags
110
+ this.timerCtl = v;
111
+ // a started timer has overflowed by the time a driver reads the status: detection waits
112
+ // longer than either period, so it is honest to raise the flag at once
113
+ if ((v & 1) && !(v & 0x40)) this.status |= 0xc0;
114
+ if ((v & 2) && !(v & 0x20)) this.status |= 0xa0;
115
+ return;
116
+ case 0x08: this.nts = (v >> 6) & 1; return;
117
+ case 0xbd: this.amDepth = v & 0x80 ? 4.8 : 1; this.vibDepth = v & 0x40 ? 14 : 7; return;
118
+ }
119
+ }
120
+ const base = array === 0 ? 0 : 9;
121
+ if (reg >= 0x20 && reg <= 0xf5 && reg < 0xa0 || reg >= 0xe0) {
122
+ const opi = OP_SLOT[reg & 0x1f];
123
+ if (opi < 0) return;
124
+ const ch = this.chans[base + Math.floor(opi / 2)], op = ch.ops[opi % 2];
125
+ switch (reg & 0xe0) {
126
+ case 0x20: op.am = (v >> 7) & 1; op.vib = (v >> 6) & 1; op.egt = (v >> 5) & 1; op.ksr = (v >> 4) & 1; op.mult = MULT[v & 15]; return;
127
+ case 0x40: op.ksl = (v >> 6) & 3; op.tl = v & 0x3f; return;
128
+ case 0x60: op.ar = (v >> 4) & 15; op.dr = v & 15; return;
129
+ case 0x80: op.sl = (v >> 4) & 15; op.rr = v & 15; if (op.stage === EG_SUSTAIN && op.env < this.slDb(op)) op.stage = EG_DECAY; return;
130
+ case 0xe0: op.wave = v & 7; return;
131
+ }
132
+ return;
133
+ }
134
+ if (reg >= 0xa0 && reg <= 0xa8) { const ch = this.chans[base + reg - 0xa0]; ch.fnum = (ch.fnum & 0x300) | v; return; }
135
+ if (reg >= 0xb0 && reg <= 0xb8) {
136
+ const ch = this.chans[base + reg - 0xb0];
137
+ ch.fnum = (ch.fnum & 0xff) | ((v & 3) << 8);
138
+ ch.block = (v >> 2) & 7;
139
+ const key = (v & 0x20) !== 0;
140
+ if (key && !ch.key) for (const op of ch.ops) { op.stage = EG_ATTACK; op.phase = 0; }
141
+ if (!key && ch.key) for (const op of ch.ops) if (op.stage !== EG_OFF) op.stage = EG_RELEASE;
142
+ ch.key = key;
143
+ return;
144
+ }
145
+ if (reg >= 0xc0 && reg <= 0xc8) {
146
+ const ch = this.chans[base + reg - 0xc0];
147
+ ch.fb = (v >> 1) & 7; ch.cnt = v & 1;
148
+ ch.left = (v & 0x10) !== 0; ch.right = (v & 0x20) !== 0;
149
+ }
150
+ }
151
+
152
+ slDb(op) { return op.sl === 15 ? 93 : op.sl * 3; }
153
+
154
+ effRate(ch, op, r) {
155
+ if (r === 0) return 0;
156
+ const rof = (ch.block << 1) | ((ch.fnum >> (this.nts ? 8 : 9)) & 1);
157
+ return Math.min(63, r * 4 + (op.ksr ? rof : rof >> 2));
158
+ }
159
+
160
+ /**
161
+ * Mix `n` stereo frames into out[off..] (added, not replaced). Registers do not change inside a
162
+ * call -- the machine writes them between calls -- so everything that depends only on them (a
163
+ * channel's frequency, an operator's level and envelope rates) is worked out once per call, and
164
+ * the per-sample loop is phase, envelope and a table read.
165
+ */
166
+ render(out, off, n) {
167
+ const dt = 1 / this.rate;
168
+ const waveMask = this.newMode ? 7 : this.wse ? 3 : 0;
169
+ if (this.trem.length < n) { this.trem = new Float32Array(n); this.vib = new Float32Array(n); }
170
+ const trem = this.trem, vib = this.vib;
171
+ const vibScale = this.vibDepth * Math.LN2 / 1200; // cents to a frequency ratio, to first order
172
+ for (let i = 0; i < n; i++) {
173
+ this.lfoAm += 3.7 * dt; if (this.lfoAm >= 1) this.lfoAm -= 1;
174
+ this.lfoVib += 6.1 * dt; if (this.lfoVib >= 1) this.lfoVib -= 1;
175
+ trem[i] = LFO_TREM[(this.lfoAm * 256) | 0] * this.amDepth;
176
+ vib[i] = 1 + WAVES[0][(this.lfoVib * WAVE_LEN) | 0] * vibScale;
177
+ }
178
+ for (let c = 0; c < 18; c++) {
179
+ const ch = this.chans[c];
180
+ const m = ch.ops[0], k = ch.ops[1];
181
+ if (m.stage === EG_OFF && k.stage === EG_OFF) continue;
182
+ if (c >= 9 && !this.newMode) continue;
183
+ const cycles = ch.fnum * (1 << ch.block) * OPL_RATE / 1048576 * dt; // one cycle a sample = 1
184
+ const kslDb = Math.max(0, KSL_TABLE[ch.fnum >> 6] - 6 * (7 - ch.block));
185
+ this.prepare(ch, m, cycles, kslDb, dt, waveMask);
186
+ this.prepare(ch, k, cycles, kslDb, dt, waveMask);
187
+ const fb = ch.fb ? Math.pow(2, ch.fb - 6) * 0.5 : 0;
188
+ const gl = (!this.newMode || ch.left) ? 0.11 : 0, gr = (!this.newMode || ch.right) ? 0.11 : 0;
189
+ for (let i = 0; i < n; i++) {
190
+ // modulator, with feedback from its own last two outputs
191
+ const mo = this.sample(ch, m, fb ? (m.out + m.prev) * fb : 0, trem[i], vib[i]);
192
+ m.prev = m.out; m.out = mo;
193
+ const s = ch.cnt === 0 ? this.sample(ch, k, mo * 4, trem[i], vib[i]) : mo + this.sample(ch, k, 0, trem[i], vib[i]);
194
+ out[off + i * 2] += s * gl;
195
+ out[off + i * 2 + 1] += s * gr;
196
+ }
197
+ }
198
+ }
199
+
200
+ // the per-call constants of one operator, stored on it
201
+ prepare(ch, op, cycles, kslDb, dt, waveMask) {
202
+ const at = oplRateTimes(this.effRate(ch, op, op.ar)).attack;
203
+ op.attackK = at === 0 ? -1 : at === Infinity ? 0 : (dt / at) * 3.22;
204
+ const dtime = oplRateTimes(this.effRate(ch, op, op.dr)).decay;
205
+ op.decayInc = dtime === Infinity ? 0 : MAX_DB * dt / dtime;
206
+ const rtime = oplRateTimes(this.effRate(ch, op, op.rr)).decay;
207
+ op.releaseInc = rtime === Infinity ? 0 : MAX_DB * dt / rtime;
208
+ op.slLevel = this.slDb(op);
209
+ op.levelDb = op.tl * 0.75 + kslDb * KSL_SCALE[op.ksl];
210
+ op.inc = cycles * op.mult;
211
+ op.table = WAVES[op.wave & waveMask];
212
+ }
213
+
214
+ sample(ch, op, pm, trem, vib) {
215
+ // envelope, in dB of attenuation
216
+ switch (op.stage) {
217
+ case EG_ATTACK:
218
+ // the chip's attack is exponential in dB: fast at first, easing into full level
219
+ if (op.attackK < 0) op.env = 0; else op.env -= (op.env + 4) * op.attackK;
220
+ if (op.env <= 0) { op.env = 0; op.stage = EG_DECAY; }
221
+ break;
222
+ case EG_DECAY:
223
+ op.env += op.decayInc;
224
+ if (op.env >= op.slLevel) { op.env = op.slLevel; op.stage = EG_SUSTAIN; }
225
+ break;
226
+ case EG_SUSTAIN:
227
+ if (!op.egt) op.env += op.releaseInc; // a percussive sound keeps decaying
228
+ break;
229
+ case EG_RELEASE:
230
+ op.env += op.releaseInc;
231
+ break;
232
+ default:
233
+ return 0;
234
+ }
235
+ if (op.env >= MAX_DB) {
236
+ op.env = MAX_DB;
237
+ if (op.stage === EG_RELEASE || !ch.key) op.stage = EG_OFF;
238
+ }
239
+ op.phase += op.vib ? op.inc * vib : op.inc;
240
+ if (op.phase >= 1) op.phase -= Math.floor(op.phase);
241
+ const db = op.env + op.levelDb + (op.am ? trem : 0);
242
+ if (db >= MAX_DB) return 0;
243
+ let p = op.phase + pm;
244
+ p -= Math.floor(p);
245
+ return op.table[(p * WAVE_LEN) | 0] * DB_TO_AMP[(db * DB_STEPS) | 0];
246
+ }
247
+ }
248
+ // register offset (low 5 bits of 0x20..0x95, 0xE0..0xF5) -> operator slot: channel * 2 + (0 mod, 1 car)
249
+ const OP_SLOT = [0, 2, 4, 1, 3, 5, -1, -1, 6, 8, 10, 7, 9, 11, -1, -1, 12, 14, 16, 13, 15, 17, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1];
250
+
251
+ // ------------------------------------------------------------------ the card
252
+ /**
253
+ * A Sound Blaster Pro 2 at `base` (0x220), IRQ `irq`, 8-bit DMA `dma`, with its OPL3 at 0x388 too.
254
+ * `mem` is the machine's memory (DMA reads it). Output is stereo at `rate`.
255
+ */
256
+ export function createSoundCard({ mem, base = 0x220, irq = 7, dma = 1, rate = 48000, ring = 1 << 16 } = {}) {
257
+ const opl = new Opl3(rate);
258
+ const dsp = {
259
+ resetting: false, queue: [], cmd: -1, args: [], need: 0,
260
+ speaker: true, rate: 11025, stereo: false, blockSize: 0x7ff,
261
+ playing: false, auto: false, paused: false, left: 0, exitAuto: false,
262
+ irqPending: false, test: 0, level: 0, lastSample: 0x80, pos: 0,
263
+ irqAt: -1,
264
+ };
265
+ const dmac = {
266
+ addr: new Uint16Array(4), count: new Uint16Array(4), baseAddr: new Uint16Array(4), baseCount: new Uint16Array(4),
267
+ page: new Uint8Array(4), mode: new Uint8Array(4), mask: 0x0f, flip: false, status: 0,
268
+ };
269
+ const mixer = { index: 0, reg: new Uint8Array(256) };
270
+ mixer.reg[0x22] = 0xff; mixer.reg[0x04] = 0xff; mixer.reg[0x26] = 0xff;
271
+ const PAGE_PORT = [0x87, 0x83, 0x81, 0x82];
272
+
273
+ // output ring: interleaved stereo float frames
274
+ const buf = new Float32Array(ring * 2);
275
+ let head = 0, count = 0;
276
+ const scratch = new Float32Array(8192);
277
+ let lastT = -1, outFrac = 0, dspFrac = 0;
278
+ let pendingIrq = null;
279
+
280
+ function resetDsp() {
281
+ dsp.playing = false; dsp.auto = false; dsp.paused = false; dsp.cmd = -1; dsp.need = 0; dsp.args = [];
282
+ dsp.queue = [0xaa]; dsp.stereo = false; dsp.exitAuto = false;
283
+ }
284
+ function command(c) {
285
+ dsp.cmd = c; dsp.args = [];
286
+ const needs = { 0x10: 1, 0x14: 2, 0x16: 2, 0x17: 2, 0x24: 2, 0x40: 1, 0x41: 2, 0x42: 2, 0x48: 2, 0x74: 2, 0x75: 2, 0x76: 2, 0x77: 2, 0x80: 2, 0xe0: 1, 0xe2: 1, 0xe4: 1 };
287
+ if ((c & 0xf0) === 0xb0 || (c & 0xf0) === 0xc0) dsp.need = 3;
288
+ else dsp.need = needs[c] ?? 0;
289
+ if (dsp.need === 0) execute();
290
+ }
291
+ function startBlock(len, auto) {
292
+ dsp.left = len; dsp.auto = auto; dsp.playing = true; dsp.paused = false; dsp.exitAuto = false;
293
+ }
294
+ function execute() {
295
+ const c = dsp.cmd, a = dsp.args;
296
+ dsp.cmd = -1;
297
+ switch (c) {
298
+ case 0x10: dsp.level = a[0]; dsp.lastSample = a[0]; return;
299
+ case 0x14: case 0x16: case 0x17: case 0x91: startBlock(c === 0x91 ? dsp.blockSize + 1 : (a[0] | (a[1] << 8)) + 1, false); return;
300
+ case 0x1c: case 0x90: startBlock(dsp.blockSize + 1, true); return;
301
+ case 0x20: dsp.queue.push(0x80); return;
302
+ case 0x40: dsp.rate = Math.round(1000000 / (256 - a[0])); return;
303
+ case 0x41: case 0x42: dsp.rate = (a[0] << 8) | a[1]; return;
304
+ case 0x48: dsp.blockSize = a[0] | (a[1] << 8); return;
305
+ case 0x80: dsp.silence = (a[0] | (a[1] << 8)) + 1; startBlock(dsp.silence, false); dsp.silent = true; return;
306
+ case 0xa0: dsp.stereo = false; return;
307
+ case 0xa8: dsp.stereo = true; return;
308
+ case 0xd0: dsp.paused = true; return;
309
+ case 0xd1: dsp.speaker = true; return;
310
+ case 0xd3: dsp.speaker = false; return;
311
+ case 0xd4: dsp.paused = false; return;
312
+ case 0xd8: dsp.queue.push(dsp.speaker ? 0xff : 0x00); return;
313
+ case 0xda: dsp.exitAuto = true; return;
314
+ case 0xe0: dsp.queue.push(~a[0] & 0xff); return;
315
+ case 0xe1: dsp.queue.push(3, 2); return;
316
+ case 0xe3: for (const ch of 'COPYRIGHT (C) CREATIVE TECHNOLOGY LTD, 1992.\0') dsp.queue.push(ch.charCodeAt(0)); return;
317
+ case 0xe4: dsp.test = a[0]; return;
318
+ case 0xe8: dsp.queue.push(dsp.test); return;
319
+ case 0xf2: case 0xf3: pendingIrq = 'soon'; return;
320
+ case 0xf8: dsp.queue.push(0); return;
321
+ }
322
+ if ((c & 0xf0) === 0xb0 || (c & 0xf0) === 0xc0) {
323
+ // SB16 transfers: mode byte then a length; the 8-bit ones are the ones this card honours
324
+ startBlock((a[1] | (a[2] << 8)) + 1, (c & 0x04) !== 0);
325
+ dsp.stereo = (a[0] & 0x20) !== 0;
326
+ }
327
+ }
328
+
329
+ // one sample off DMA: returns 0..255, or -1 when the channel has nothing
330
+ function dmaByte() {
331
+ const ch = dma & 3;
332
+ if (dmac.mask & (1 << ch)) return -1;
333
+ const addr = (dmac.page[ch] << 16) | dmac.addr[ch];
334
+ const v = mem[addr];
335
+ dmac.addr[ch] = (dmac.addr[ch] + ((dmac.mode[ch] & 0x20) ? -1 : 1)) & 0xffff;
336
+ dmac.count[ch] = (dmac.count[ch] - 1) & 0xffff;
337
+ if (dmac.count[ch] === 0xffff) {
338
+ dmac.status |= 1 << ch;
339
+ if (dmac.mode[ch] & 0x10) { dmac.addr[ch] = dmac.baseAddr[ch]; dmac.count[ch] = dmac.baseCount[ch]; }
340
+ else dmac.mask |= 1 << ch;
341
+ }
342
+ return v;
343
+ }
344
+
345
+ function portIn(port) {
346
+ if (port === 0x388 || port === 0x38a || port === base + 8 || port === base) return opl.readStatus();
347
+ if (port === base + 0x0a) { if (dsp.queue.length) dsp.last = dsp.queue.shift(); return dsp.last ?? 0xaa; }
348
+ if (port === base + 0x0c) return 0x7f; // ready for a command
349
+ if (port === base + 0x0e) { dsp.irqPending = false; return dsp.queue.length ? 0xff : 0x7f; }
350
+ if (port === base + 0x04) return mixer.index;
351
+ if (port === base + 0x05) return mixer.reg[mixer.index];
352
+ if (port === base + 0x06) return 0xff;
353
+ if (port <= 0x07) {
354
+ const ch = port >> 1, isCount = port & 1;
355
+ const v = isCount ? dmac.count[ch] : dmac.addr[ch];
356
+ const r = dmac.flip ? v >> 8 : v & 0xff;
357
+ dmac.flip = !dmac.flip;
358
+ return r;
359
+ }
360
+ if (port === 0x08) { const s = dmac.status; dmac.status &= 0xf0; return s; }
361
+ return undefined;
362
+ }
363
+
364
+ function portOut(port, v) {
365
+ if (port === 0x388 || port === base + 8 || port === base) { opl.index[0] = v; return true; }
366
+ if (port === 0x389 || port === base + 9 || port === base + 1) { opl.write(0, opl.index[0], v); return true; }
367
+ if (port === 0x38a || port === base + 2) { opl.index[1] = v; return true; }
368
+ if (port === 0x38b || port === base + 3) { opl.write(1, opl.index[1], v); return true; }
369
+ if (port === base + 0x06) {
370
+ if (v & 1) { dsp.resetting = true; return true; }
371
+ if (dsp.resetting) { dsp.resetting = false; resetDsp(); }
372
+ return true;
373
+ }
374
+ if (port === base + 0x0c) {
375
+ if (dsp.cmd >= 0) { dsp.args.push(v); if (dsp.args.length >= dsp.need) execute(); }
376
+ else command(v);
377
+ return true;
378
+ }
379
+ if (port === base + 0x04) { mixer.index = v; return true; }
380
+ if (port === base + 0x05) { mixer.reg[mixer.index] = v; if (mixer.index === 0x00) mixer.reg.fill(0); return true; }
381
+ if (port <= 0x07) {
382
+ const ch = port >> 1, isCount = port & 1;
383
+ const arr = isCount ? dmac.count : dmac.addr, baseArr = isCount ? dmac.baseCount : dmac.baseAddr;
384
+ arr[ch] = dmac.flip ? (arr[ch] & 0xff) | (v << 8) : (arr[ch] & 0xff00) | v;
385
+ baseArr[ch] = arr[ch];
386
+ dmac.flip = !dmac.flip;
387
+ return true;
388
+ }
389
+ if (port === 0x0a) { if (v & 4) dmac.mask |= 1 << (v & 3); else dmac.mask &= ~(1 << (v & 3)); return true; }
390
+ if (port === 0x0b) { dmac.mode[v & 3] = v; return true; }
391
+ if (port === 0x0c) { dmac.flip = false; return true; }
392
+ if (port === 0x0f) { dmac.mask = v & 0x0f; return true; }
393
+ const pi = PAGE_PORT.indexOf(port);
394
+ if (pi >= 0) { dmac.page[pi] = v; return true; }
395
+ return false;
396
+ }
397
+
398
+ function push(l, r) {
399
+ if (count === ring) { head = (head + 1) % ring; count--; } // overrun: drop the oldest
400
+ const at = (head + count) % ring;
401
+ buf[at * 2] = l; buf[at * 2 + 1] = r;
402
+ count++;
403
+ }
404
+
405
+ /** Advance the card to machine time `t` (ms), raising its interrupt through `raiseIrq`. */
406
+ function tick(t, raiseIrq) {
407
+ if (pendingIrq === 'soon') { pendingIrq = null; dsp.irqPending = true; raiseIrq(irq); }
408
+ if (lastT < 0) { lastT = t; return; }
409
+ // the machine calls this every few thousand instructions -- a sample or two each time. Work in
410
+ // batches of at least 128 frames (under 3 ms): a DSP block is thousands of samples, so its
411
+ // interrupt is no later for it, and the synthesiser's per-call set-up is paid 300 times a
412
+ // second instead of 40,000
413
+ if ((t - lastT) * rate / 1000 + outFrac < 128) return;
414
+ let ms = t - lastT;
415
+ lastT = t;
416
+ if (ms <= 0) return;
417
+ if (ms > 200) ms = 200; // a stall is not replayed as a burst
418
+ outFrac += ms * rate / 1000;
419
+ let n = Math.floor(outFrac);
420
+ outFrac -= n;
421
+ const step = dsp.rate / rate / (dsp.stereo ? 2 : 1);
422
+ let off = 0;
423
+ while (n > 0) {
424
+ const k = Math.min(n, scratch.length >> 1);
425
+ scratch.fill(0, 0, k * 2);
426
+ opl.render(scratch, 0, k);
427
+ for (let i = 0; i < k; i++) {
428
+ let s = 0;
429
+ if (dsp.playing && !dsp.paused) {
430
+ dspFrac += step;
431
+ while (dspFrac >= 1 && dsp.playing) {
432
+ dspFrac -= 1;
433
+ if (dsp.silent) dsp.lastSample = 0x80;
434
+ else { const b = dmaByte(); if (b >= 0) dsp.lastSample = b; }
435
+ if (--dsp.left <= 0) {
436
+ dsp.irqPending = true; raiseIrq(irq);
437
+ if (dsp.auto && !dsp.exitAuto) dsp.left = dsp.blockSize + 1;
438
+ else { dsp.playing = false; dsp.silent = false; dsp.lastSample = 0x80; }
439
+ }
440
+ }
441
+ }
442
+ if (dsp.speaker) s = (dsp.lastSample - 128) / 128 * 0.9;
443
+ push(scratch[i * 2] + s, scratch[i * 2 + 1] + s);
444
+ }
445
+ n -= k; off += k;
446
+ }
447
+ }
448
+
449
+ /** Take up to `max` frames of output (interleaved stereo), or all there is. */
450
+ function drain(max = count) {
451
+ const n = Math.min(max, count);
452
+ const out = new Float32Array(n * 2);
453
+ for (let i = 0; i < n; i++) { const at = (head + i) % ring; out[i * 2] = buf[at * 2]; out[i * 2 + 1] = buf[at * 2 + 1]; }
454
+ head = (head + n) % ring; count -= n;
455
+ return out;
456
+ }
457
+
458
+ return { opl, dsp, dmac, mixer, portIn, portOut, tick, drain, get buffered() { return count; }, rate };
459
+ }
@@ -9,7 +9,7 @@
9
9
  // the same sky (drawSky). The game inherits the framework rather than imitating it.
10
10
  import { board3d } from './details3d.js';
11
11
  import { newGame, tick, move, rotate, softDrop, hardDrop, tiles, previewTiles, peekNext, gravityMs, PIECES, COLS, ROWS } from './tetris.js';
12
- import { loadSettings, setSetting, tetrustOptions } from './settings.js';
12
+ import { loadSettings, setSetting, tetrustOptions, nextSky, SKY_LABELS } from './settings.js';
13
13
  import * as sound from './tetsound.js';
14
14
 
15
15
  const SCORES_KEY = 'blockyard.tetrust.scores';
@@ -173,9 +173,20 @@ function drawScores(highlightAt = null) {
173
173
 
174
174
  // THE GAME'S OWN SWITCHES (operator: "Toggle for each in the game display"): three buttons on the
175
175
  // HUD, the same settings the Display panel shows, so a flip in either place is a flip in both
176
- const SWITCHES = [['tetStars', 'stars'], ['tetGalaxy', 'galaxy'], ['tetMusic', 'music'], ['tetSfx', 'sfx']];
176
+ const SWITCHES = [['tetMusic', 'music'], ['tetSfx', 'sfx']];
177
+ /** The sky button says which sky it is on, and lights up while there is one. */
178
+ function skyButton(id, sky) {
179
+ const b = el(id);
180
+ if (!b) return;
181
+ b.textContent = `\u2600 ${SKY_LABELS[sky] ?? sky}`;
182
+ b.classList.toggle('on', sky !== 'none');
183
+ b.setAttribute('aria-pressed', sky !== 'none' ? 'true' : 'false');
184
+ }
177
185
  function drawSwitches() {
178
186
  const t = tetrustOptions(loadSettings());
187
+ // ONE SKY BUTTON (docs/PLAN-SKIES.md): Galaxy, Earth or none, round the ring, in place of the old
188
+ // star field and galaxy pair -- the same setting the Sky tab's table shows for this board
189
+ skyButton('tetSkySw', t.sky.sky);
179
190
  for (const [id, key] of SWITCHES) {
180
191
  const b = el(id);
181
192
  if (!b) continue;
@@ -194,7 +205,6 @@ function flip(key) {
194
205
  setSetting(loadSettings(), `tetrust.${key}`, !cur);
195
206
  sound.unlock();
196
207
  drawSwitches();
197
- if (key === 'stars' || key === 'galaxy') drawSky();
198
208
  G.dirty = true;
199
209
  if (!G.running || G.paused) draw();
200
210
  }
@@ -205,9 +215,7 @@ function drawSky() {
205
215
  const t = tetrustOptions(loadSettings());
206
216
  board3d(sky, [], {
207
217
  ...SKY,
208
- stars: t.stars, galaxy: t.stars && t.galaxy, galaxyAt: t.galaxyAt,
209
- starDensity: t.starDensity, starBrightness: t.starBrightness,
210
- nebulae: t.nebulae, galaxies: t.galaxies, dust: t.dust, clusters: t.clusters, starColours: t.starColours, starGlints: t.starGlints,
218
+ ...t.sky, // which sky this board chose, and what it is made of (settings.js skyFor)
211
219
  });
212
220
  }
213
221
 
@@ -341,6 +349,7 @@ function bind() {
341
349
  document.addEventListener('visibilitychange', () => { if (document.hidden && G.running && !G.paused) pause('paused — you looked away'); });
342
350
  el('tetResume')?.addEventListener('click', () => resume());
343
351
  for (const [id, key] of SWITCHES) el(id)?.addEventListener('click', () => flip(key));
352
+ el('tetSkySw')?.addEventListener('click', () => { const s = loadSettings(); setSetting(s, 'tetrust.sky', nextSky(tetrustOptions(s).sky.sky)); sound.unlock(); drawSwitches(); drawSky(); draw(); });
344
353
  }
345
354
 
346
355
  export function renderTetrust(s, state, h) {
@@ -8,7 +8,7 @@
8
8
  // the first play or key press, and nothing here throws where there is no AudioContext at all
9
9
  // (tests, or a browser with audio blocked): every call is a no-op then.
10
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 };
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, D4: 293.66, F4: 349.23, G4: 392, BB4: 466.16, R: 0 };
12
12
 
13
13
  // Korobeiniki, A minor, one verse: [note, beats]
14
14
  export const THEME = Object.freeze([
@@ -23,6 +23,24 @@ export const THEME = Object.freeze([
23
23
  ]);
24
24
  export const BPM = 148;
25
25
 
26
+ // SCORCHED YARD's tune (2026-09-16): a march in D minor, sixteen bars, on the same square wave --
27
+ // a drum-less thing that keeps time between the turns and does not fight the blasts
28
+ export const THEME_SCORCHED = Object.freeze([
29
+ ['D4', 1], ['D4', 0.5], ['D4', 0.5], ['F4', 1], ['A4', 1],
30
+ ['G4', 1], ['F4', 0.5], ['E4', 0.5], ['D4', 2],
31
+ ['F4', 1], ['F4', 0.5], ['F4', 0.5], ['A4', 1], ['C5', 1],
32
+ ['BB4', 1], ['A4', 0.5], ['G4', 0.5], ['A4', 2],
33
+ ['D5', 1], ['D5', 0.5], ['D5', 0.5], ['C5', 1], ['BB4', 1],
34
+ ['A4', 1], ['G4', 0.5], ['F4', 0.5], ['G4', 2],
35
+ ['A4', 1], ['BB4', 0.5], ['A4', 0.5], ['G4', 1], ['F4', 1],
36
+ ['E4', 1], ['E4', 0.5], ['E4', 0.5], ['D4', 2],
37
+ ['D4', 0.5], ['R', 0.5], ['D4', 0.5], ['R', 0.5], ['A4', 1], ['R', 1],
38
+ ['F4', 0.5], ['R', 0.5], ['F4', 0.5], ['R', 0.5], ['D5', 1], ['R', 1],
39
+ ['C5', 1], ['BB4', 1], ['A4', 1], ['G4', 1],
40
+ ['F4', 1], ['E4', 1], ['D4', 2],
41
+ ]);
42
+ export const THEMES = Object.freeze({ tetrust: { notes: THEME, bpm: BPM }, scorched: { notes: THEME_SCORCHED, bpm: 112 } });
43
+
26
44
  // the effects: [frequency from, frequency to, seconds, wave, gain]
27
45
  export const SFX = Object.freeze({
28
46
  move: [520, 520, 0.045, 'square', 0.05],
@@ -56,6 +74,15 @@ export const SFX = Object.freeze({
56
74
  // BLOCKANOID (arkanoid.js): a silver brick RINGS rather than breaks and gold rings harder and
57
75
  // lower, so the ear learns which bricks are worth hitting before the eye does. A capsule is a
58
76
  // rising chime (it is always good news), the laser a short zap, a minion a dull pop.
77
+ // SCORCHED YARD (scorchedyard.js): a thump for the shot, a crack for a small blast and a rumble
78
+ // for a nuke, a knock for a hit, a low sweep for a death, a tick for the turn, a rustle for dirt
79
+ syFire: [140, 40, 0.16, 'sawtooth', 0.1],
80
+ syBlast: [180, 30, 0.26, 'sawtooth', 0.11],
81
+ syBig: [90, 18, 0.7, 'sawtooth', 0.12],
82
+ syHit: [520, 180, 0.09, 'square', 0.06],
83
+ syDeath: [400, 40, 0.9, 'sawtooth', 0.1],
84
+ syTurn: [880, 880, 0.04, 'square', 0.035],
85
+ syDirt: [260, 120, 0.12, 'triangle', 0.05],
59
86
  silver: [560, 620, 0.06, 'triangle', 0.055],
60
87
  gold: [240, 210, 0.07, 'square', 0.05],
61
88
  capsule: [620, 1560, 0.22, 'triangle', 0.075],
@@ -135,12 +162,13 @@ const TIMER_MS = 500;
135
162
  function schedule() {
136
163
  const c = S.ctx;
137
164
  if (!c || !S.music) return;
138
- const beat = 60 / BPM;
165
+ const tune = THEMES[S.theme] ?? THEMES.tetrust;
166
+ const beat = 60 / tune.bpm;
139
167
  // fallen behind (the tab was hidden, the thread was held): pick the tune up from now rather
140
168
  // than cramming the missed notes into an instant
141
169
  if (S.at < c.currentTime - 0.05) S.at = c.currentTime + 0.02;
142
170
  while (S.at < c.currentTime + LOOKAHEAD_S) {
143
- const [name, beats] = THEME[S.i % THEME.length];
171
+ const [name, beats] = tune.notes[S.i % tune.notes.length];
144
172
  const dur = beats * beat;
145
173
  const hz = HZ[name] ?? 0;
146
174
  if (hz > 0) {
@@ -153,9 +181,11 @@ function schedule() {
153
181
  S.timer = setTimeout(schedule, TIMER_MS);
154
182
  }
155
183
 
156
- export function setMusic(on) {
184
+ export function setMusic(on, theme = 'tetrust') {
157
185
  const want = !!on;
158
- if (want === S.music && (!want || S.timer)) return; // already so: never restart a running tune
186
+ const tune = THEMES[theme] ? theme : 'tetrust';
187
+ if (want === S.music && (!want || S.timer) && S.theme === tune) return; // already so: never restart a running tune
188
+ if (S.theme !== tune) { S.theme = tune; S.i = 0; }
159
189
  S.music = want;
160
190
  if (S.timer) { clearTimeout(S.timer); S.timer = null; }
161
191
  if (!S.music) { hush(); return; }