@datamoc/mw_games 0.43.0 → 0.44.2
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 +67 -0
- package/README.md +22 -0
- package/REFERENCE.md +82 -6
- package/api-surface.json +1028 -2
- package/dist/actors/Resource.d.ts.map +1 -1
- package/dist/actors/Resource.js +2 -3
- package/dist/actors/Resource.js.map +1 -1
- package/dist/audio/AdaptiveMusic.d.ts +85 -0
- package/dist/audio/AdaptiveMusic.d.ts.map +1 -0
- package/dist/audio/AdaptiveMusic.js +230 -0
- package/dist/audio/AdaptiveMusic.js.map +1 -0
- package/dist/audio/AudioLoader.d.ts +7 -2
- package/dist/audio/AudioLoader.d.ts.map +1 -1
- package/dist/audio/AudioLoader.js +23 -18
- package/dist/audio/AudioLoader.js.map +1 -1
- package/dist/audio/AudioViews.d.ts +70 -0
- package/dist/audio/AudioViews.d.ts.map +1 -0
- package/dist/audio/AudioViews.js +119 -0
- package/dist/audio/AudioViews.js.map +1 -0
- package/dist/audio/BusBudget.d.ts +89 -0
- package/dist/audio/BusBudget.d.ts.map +1 -0
- package/dist/audio/BusBudget.js +120 -0
- package/dist/audio/BusBudget.js.map +1 -0
- package/dist/audio/Channels.d.ts +65 -1
- package/dist/audio/Channels.d.ts.map +1 -1
- package/dist/audio/Channels.js +117 -30
- package/dist/audio/Channels.js.map +1 -1
- package/dist/audio/CoreHost.d.ts +114 -0
- package/dist/audio/CoreHost.d.ts.map +1 -0
- package/dist/audio/CoreHost.js +335 -0
- package/dist/audio/CoreHost.js.map +1 -0
- package/dist/audio/DecoderGuard.d.ts +139 -0
- package/dist/audio/DecoderGuard.d.ts.map +1 -0
- package/dist/audio/DecoderGuard.js +208 -0
- package/dist/audio/DecoderGuard.js.map +1 -0
- package/dist/audio/Fft.d.ts +3 -0
- package/dist/audio/Fft.d.ts.map +1 -0
- package/dist/audio/Fft.js +45 -0
- package/dist/audio/Fft.js.map +1 -0
- package/dist/audio/LoopTags.d.ts +18 -0
- package/dist/audio/LoopTags.d.ts.map +1 -1
- package/dist/audio/LoopTags.js +20 -0
- package/dist/audio/LoopTags.js.map +1 -1
- package/dist/audio/Midi.d.ts +35 -0
- package/dist/audio/Midi.d.ts.map +1 -1
- package/dist/audio/Midi.js +71 -1
- package/dist/audio/Midi.js.map +1 -1
- package/dist/audio/MidiRender.d.ts +31 -0
- package/dist/audio/MidiRender.d.ts.map +1 -1
- package/dist/audio/MidiRender.js +79 -2
- package/dist/audio/MidiRender.js.map +1 -1
- package/dist/audio/MidiSynth.d.ts +147 -0
- package/dist/audio/MidiSynth.d.ts.map +1 -0
- package/dist/audio/MidiSynth.js +205 -0
- package/dist/audio/MidiSynth.js.map +1 -0
- package/dist/audio/Playable.d.ts +7 -0
- package/dist/audio/Playable.d.ts.map +1 -1
- package/dist/audio/Playable.js.map +1 -1
- package/dist/audio/Positional.d.ts +12 -4
- package/dist/audio/Positional.d.ts.map +1 -1
- package/dist/audio/Positional.js +5 -2
- package/dist/audio/Positional.js.map +1 -1
- package/dist/audio/Resample.d.ts +61 -0
- package/dist/audio/Resample.d.ts.map +1 -0
- package/dist/audio/Resample.js +97 -0
- package/dist/audio/Resample.js.map +1 -0
- package/dist/audio/Settings.d.ts +99 -0
- package/dist/audio/Settings.d.ts.map +1 -0
- package/dist/audio/Settings.js +134 -0
- package/dist/audio/Settings.js.map +1 -0
- package/dist/audio/Sfx.d.ts +148 -0
- package/dist/audio/Sfx.d.ts.map +1 -0
- package/dist/audio/Sfx.js +213 -0
- package/dist/audio/Sfx.js.map +1 -0
- package/dist/audio/SpectralDistance.d.ts +34 -0
- package/dist/audio/SpectralDistance.d.ts.map +1 -0
- package/dist/audio/SpectralDistance.js +64 -0
- package/dist/audio/SpectralDistance.js.map +1 -0
- package/dist/audio/StreamChannel.d.ts +150 -0
- package/dist/audio/StreamChannel.d.ts.map +1 -0
- package/dist/audio/StreamChannel.js +390 -0
- package/dist/audio/StreamChannel.js.map +1 -0
- package/dist/audio/StreamPlayable.d.ts +73 -0
- package/dist/audio/StreamPlayable.d.ts.map +1 -0
- package/dist/audio/StreamPlayable.js +130 -0
- package/dist/audio/StreamPlayable.js.map +1 -0
- package/dist/audio/SyxBank.d.ts +73 -0
- package/dist/audio/SyxBank.d.ts.map +1 -0
- package/dist/audio/SyxBank.js +130 -0
- package/dist/audio/SyxBank.js.map +1 -0
- package/dist/audio/Underrun.d.ts +25 -0
- package/dist/audio/Underrun.d.ts.map +1 -0
- package/dist/audio/Underrun.js +18 -0
- package/dist/audio/Underrun.js.map +1 -0
- package/dist/audio/Wav.d.ts +56 -0
- package/dist/audio/Wav.d.ts.map +1 -0
- package/dist/audio/Wav.js +164 -0
- package/dist/audio/Wav.js.map +1 -0
- package/dist/audio/WebMidi.d.ts +58 -0
- package/dist/audio/WebMidi.d.ts.map +1 -0
- package/dist/audio/WebMidi.js +79 -0
- package/dist/audio/WebMidi.js.map +1 -0
- package/dist/audio/humanize/Onsets.d.ts.map +1 -1
- package/dist/audio/humanize/Onsets.js +1 -44
- package/dist/audio/humanize/Onsets.js.map +1 -1
- package/dist/audio/index.d.ts +38 -6
- package/dist/audio/index.d.ts.map +1 -1
- package/dist/audio/index.js +19 -3
- package/dist/audio/index.js.map +1 -1
- package/dist/battle/AttackPreview.d.ts.map +1 -1
- package/dist/battle/AttackPreview.js +8 -8
- package/dist/battle/AttackPreview.js.map +1 -1
- package/dist/battle/StatStages.d.ts.map +1 -1
- package/dist/battle/StatStages.js +2 -1
- package/dist/battle/StatStages.js.map +1 -1
- package/dist/board/Engine.d.ts +2 -0
- package/dist/board/Engine.d.ts.map +1 -1
- package/dist/board/Engine.js +1 -1
- package/dist/board/Engine.js.map +1 -1
- package/dist/board/HexSkirmish.d.ts.map +1 -1
- package/dist/board/HexSkirmish.js +11 -72
- package/dist/board/HexSkirmish.js.map +1 -1
- package/dist/board/Tactics.d.ts.map +1 -1
- package/dist/board/Tactics.js +3 -5
- package/dist/board/Tactics.js.map +1 -1
- package/dist/board/tourney.d.ts.map +1 -1
- package/dist/board/tourney.js +2 -19
- package/dist/board/tourney.js.map +1 -1
- package/dist/core/Handles.js +7 -7
- package/dist/core/Handles.js.map +1 -1
- package/dist/mw_games.global.js +25 -22
- package/dist/mwl/conditions.d.ts +12 -0
- package/dist/mwl/conditions.d.ts.map +1 -1
- package/dist/mwl/conditions.js +49 -8
- package/dist/mwl/conditions.js.map +1 -1
- package/dist/mwl/index.d.ts +11 -0
- package/dist/mwl/index.d.ts.map +1 -1
- package/dist/mwl/index.js +6 -0
- package/dist/mwl/index.js.map +1 -1
- package/dist/mwl/package-container.d.ts +185 -0
- package/dist/mwl/package-container.d.ts.map +1 -0
- package/dist/mwl/package-container.js +446 -0
- package/dist/mwl/package-container.js.map +1 -0
- package/dist/mwl/package-document.d.ts +217 -0
- package/dist/mwl/package-document.d.ts.map +1 -0
- package/dist/mwl/package-document.js +639 -0
- package/dist/mwl/package-document.js.map +1 -0
- package/dist/mwl/package-rom.d.ts +37 -0
- package/dist/mwl/package-rom.d.ts.map +1 -0
- package/dist/mwl/package-rom.js +96 -0
- package/dist/mwl/package-rom.js.map +1 -0
- package/dist/mwl/package-verify.d.ts +34 -0
- package/dist/mwl/package-verify.d.ts.map +1 -0
- package/dist/mwl/package-verify.js +111 -0
- package/dist/mwl/package-verify.js.map +1 -0
- package/dist/mwl/package.d.ts +184 -0
- package/dist/mwl/package.d.ts.map +1 -0
- package/dist/mwl/package.js +382 -0
- package/dist/mwl/package.js.map +1 -0
- package/dist/physics/PhysicsWorld.d.ts.map +1 -1
- package/dist/physics/PhysicsWorld.js +4 -3
- package/dist/physics/PhysicsWorld.js.map +1 -1
- package/dist/physics/XpbdWorld.d.ts.map +1 -1
- package/dist/physics/XpbdWorld.js +2 -1
- package/dist/physics/XpbdWorld.js.map +1 -1
- package/dist/testing/FakeAudio.d.ts +149 -0
- package/dist/testing/FakeAudio.d.ts.map +1 -0
- package/dist/testing/FakeAudio.js +219 -0
- package/dist/testing/FakeAudio.js.map +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +1 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/threads/index.d.ts +2 -2
- package/dist/threads/index.d.ts.map +1 -1
- package/dist/threads/index.js +1 -1
- package/dist/threads/index.js.map +1 -1
- package/dist/threads/spawn.d.ts +32 -0
- package/dist/threads/spawn.d.ts.map +1 -1
- package/dist/threads/spawn.js +35 -11
- package/dist/threads/spawn.js.map +1 -1
- package/dist/two-d/Game.d.ts +13 -2
- package/dist/two-d/Game.d.ts.map +1 -1
- package/dist/two-d/Game.js +42 -18
- package/dist/two-d/Game.js.map +1 -1
- package/dist/two-d/render/HazeLayer.d.ts.map +1 -1
- package/dist/two-d/render/HazeLayer.js +2 -1
- package/dist/two-d/render/HazeLayer.js.map +1 -1
- package/dist/two-d/render/ImageModifiers.d.ts +4 -2
- package/dist/two-d/render/ImageModifiers.d.ts.map +1 -1
- package/dist/two-d/render/ImageModifiers.js +13 -8
- package/dist/two-d/render/ImageModifiers.js.map +1 -1
- package/dist/two-d/render/NeonGlow.d.ts.map +1 -1
- package/dist/two-d/render/NeonGlow.js +2 -1
- package/dist/two-d/render/NeonGlow.js.map +1 -1
- package/dist/two-d/render/Particles.d.ts.map +1 -1
- package/dist/two-d/render/Particles.js +2 -1
- package/dist/two-d/render/Particles.js.map +1 -1
- package/dist/two-d/render/SceneTransition.d.ts +29 -0
- package/dist/two-d/render/SceneTransition.d.ts.map +1 -1
- package/dist/two-d/render/SceneTransition.js +27 -0
- package/dist/two-d/render/SceneTransition.js.map +1 -1
- package/dist/two-d/render/ScreenEffects.d.ts.map +1 -1
- package/dist/two-d/render/ScreenEffects.js +2 -1
- package/dist/two-d/render/ScreenEffects.js.map +1 -1
- package/dist/two-d/render/SkeletalSprite.d.ts.map +1 -1
- package/dist/two-d/render/SkeletalSprite.js +2 -1
- package/dist/two-d/render/SkeletalSprite.js.map +1 -1
- package/dist/two-d/render/Types2D.d.ts +2 -2
- package/dist/two-d/render/Types2D.d.ts.map +1 -1
- package/dist/two-d/render/Types2D.js +2 -2
- package/dist/two-d/render/Types2D.js.map +1 -1
- package/dist/two-d/render/index.d.ts +2 -2
- package/dist/two-d/render/index.d.ts.map +1 -1
- package/dist/two-d/render/index.js +1 -1
- package/dist/two-d/render/index.js.map +1 -1
- package/dist/two-d/ui/Bar.d.ts.map +1 -1
- package/dist/two-d/ui/Bar.js +3 -5
- package/dist/two-d/ui/Bar.js.map +1 -1
- package/dist/two-d/ui/Dropdown.d.ts +5 -1
- package/dist/two-d/ui/Dropdown.d.ts.map +1 -1
- package/dist/two-d/ui/Dropdown.js +8 -1
- package/dist/two-d/ui/Dropdown.js.map +1 -1
- package/dist/two-d/ui/DropdownView.d.ts +15 -1
- package/dist/two-d/ui/DropdownView.d.ts.map +1 -1
- package/dist/two-d/ui/DropdownView.js +21 -3
- package/dist/two-d/ui/DropdownView.js.map +1 -1
- package/dist/two-d/ui/FloatingText.d.ts.map +1 -1
- package/dist/two-d/ui/FloatingText.js +2 -1
- package/dist/two-d/ui/FloatingText.js.map +1 -1
- package/dist/two-d/ui/IconGrid.d.ts.map +1 -1
- package/dist/two-d/ui/IconGrid.js +2 -1
- package/dist/two-d/ui/IconGrid.js.map +1 -1
- package/dist/two-d/ui/ListView.d.ts.map +1 -1
- package/dist/two-d/ui/ListView.js +2 -1
- package/dist/two-d/ui/ListView.js.map +1 -1
- package/dist/two-d/ui/ScrollBox.d.ts +6 -0
- package/dist/two-d/ui/ScrollBox.d.ts.map +1 -1
- package/dist/two-d/ui/ScrollBox.js +12 -1
- package/dist/two-d/ui/ScrollBox.js.map +1 -1
- package/dist/two-d/ui/ScrollText.d.ts +11 -2
- package/dist/two-d/ui/ScrollText.d.ts.map +1 -1
- package/dist/two-d/ui/ScrollText.js +18 -2
- package/dist/two-d/ui/ScrollText.js.map +1 -1
- package/dist/two-d/ui/Slider.d.ts.map +1 -1
- package/dist/two-d/ui/Slider.js +4 -4
- package/dist/two-d/ui/Slider.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +8 -1
- package/sbom.cdx.json +6 -6
- package/tools/audio.d.mts +75 -0
- package/tools/audio.mjs +437 -0
- package/tools/mwl.mjs +144 -2
- package/tools.md +1 -1
package/tools/audio.mjs
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, extname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `mwg-audio`: the developer-side tool for a game's audio files. It tells what a file is
|
|
8
|
+
* (`inspect`), renders a MIDI file to a WAV through any registered engine (`render`), converts
|
|
9
|
+
* between the formats the library reads and writes (`convert`), measures what the audio costs a
|
|
10
|
+
* page and the memory a bus holds once tracks are rendered (`budget`), and says how far apart
|
|
11
|
+
* two renders sound (`compare`). Nothing here runs in a browser or reaches a player.
|
|
12
|
+
*
|
|
13
|
+
* The chip formats (MWA, YM, VGM, NSF, SID) belong to a separate package: `inspect` names them
|
|
14
|
+
* by their signature, and `convert` hands them to `--converter <module>`, a module whose default
|
|
15
|
+
* export is `(bytes, name) => Uint8Array`. Engines for `render --synth` come from `--module
|
|
16
|
+
* <file>`, a module that calls `registerMidiSynth` when imported.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
|
|
21
|
+
/** the library: the TypeScript source in this repository, the built `dist/` in an installed package */
|
|
22
|
+
async function loadLibrary() {
|
|
23
|
+
try {
|
|
24
|
+
const [midi, render, soundFont, wav, resample, distance, synth] = await Promise.all([
|
|
25
|
+
import('../src/audio/Midi.ts'),
|
|
26
|
+
import('../src/audio/MidiRender.ts'),
|
|
27
|
+
import('../src/audio/SoundFont.ts'),
|
|
28
|
+
import('../src/audio/Wav.ts'),
|
|
29
|
+
import('../src/audio/Resample.ts'),
|
|
30
|
+
import('../src/audio/SpectralDistance.ts'),
|
|
31
|
+
import('../src/audio/MidiSynth.ts'),
|
|
32
|
+
]);
|
|
33
|
+
return { ...midi, ...render, ...soundFont, ...wav, ...resample, ...distance, ...synth };
|
|
34
|
+
} catch {
|
|
35
|
+
return import(pathToFileURL(join(HERE, '..', 'dist', 'audio', 'index.js')).href);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ascii = (bytes, from, to) => String.fromCharCode(...bytes.subarray(from, to));
|
|
40
|
+
|
|
41
|
+
const SYSEX_MAKERS = new Map([
|
|
42
|
+
[0x41, 'Roland'],
|
|
43
|
+
[0x42, 'Korg'],
|
|
44
|
+
[0x43, 'Yamaha'],
|
|
45
|
+
[0x44, 'Casio'],
|
|
46
|
+
[0x47, 'Akai'],
|
|
47
|
+
[0x7e, 'universal non-real-time'],
|
|
48
|
+
[0x7f, 'universal real-time'],
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Names a file by its content, falling back on the extension for formats with no signature.
|
|
53
|
+
* Returns `{ format, kind }` where kind is `midi`, `wav`, `soundfont`, `compressed`, `chip`,
|
|
54
|
+
* `tracker`, `sysex` or `unknown`.
|
|
55
|
+
*/
|
|
56
|
+
export function detectAudioFormat(bytes, name = '') {
|
|
57
|
+
const head4 = ascii(bytes, 0, 4);
|
|
58
|
+
const extension = extname(name).toLowerCase();
|
|
59
|
+
if (head4 === 'MThd') return { format: 'MIDI', kind: 'midi' };
|
|
60
|
+
if (head4 === 'RIFF') {
|
|
61
|
+
const form = ascii(bytes, 8, 12);
|
|
62
|
+
if (form === 'WAVE') return { format: 'WAV', kind: 'wav' };
|
|
63
|
+
if (form === 'sfbk') return { format: 'SoundFont 2', kind: 'soundfont' };
|
|
64
|
+
if (form === 'RMID') return { format: 'RIFF MIDI', kind: 'midi' };
|
|
65
|
+
}
|
|
66
|
+
if (head4 === 'OggS')
|
|
67
|
+
return { format: ascii(bytes, 28, 35) === '\x01vorbis' ? 'Ogg Vorbis' : 'Ogg', kind: 'compressed' };
|
|
68
|
+
if (head4 === 'fLaC') return { format: 'FLAC', kind: 'compressed' };
|
|
69
|
+
if (head4 === 'MWA1') return { format: 'MWA', kind: 'chip' };
|
|
70
|
+
if (head4 === 'NESM') return { format: 'NSF', kind: 'chip' };
|
|
71
|
+
if (head4 === 'PSID' || head4 === 'RSID') return { format: 'SID', kind: 'chip' };
|
|
72
|
+
if (head4 === 'Vgm ') return { format: 'VGM', kind: 'chip' };
|
|
73
|
+
if (head4 === 'IMPM') return { format: 'Impulse Tracker', kind: 'tracker' };
|
|
74
|
+
if (ascii(bytes, 0, 17) === 'Extended Module: ') return { format: 'XM', kind: 'tracker' };
|
|
75
|
+
if (head4.startsWith('YM') && /^YM[3456]/.test(head4)) return { format: 'YM', kind: 'chip' };
|
|
76
|
+
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
|
|
77
|
+
return {
|
|
78
|
+
format: extension === '.vgz' ? 'VGZ (gzip VGM)' : 'gzip',
|
|
79
|
+
kind: extension === '.vgz' ? 'chip' : 'unknown',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (bytes[0] === 0xf0 && bytes.includes(0xf7)) return { format: 'MIDI SysEx', kind: 'sysex' };
|
|
83
|
+
if (head4.startsWith('ID3') || (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0))
|
|
84
|
+
return { format: 'MP3', kind: 'compressed' };
|
|
85
|
+
if (ascii(bytes, 4, 8) === 'ftyp') return { format: 'MP4 audio', kind: 'compressed' };
|
|
86
|
+
if (extension === '.mod' && bytes.length > 1084) return { format: 'MOD', kind: 'tracker' };
|
|
87
|
+
return { format: 'unknown', kind: 'unknown' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** the SysEx messages of a file: how many, who made the instrument, how large */
|
|
91
|
+
function sysexSummary(bytes) {
|
|
92
|
+
const messages = [];
|
|
93
|
+
let start = -1;
|
|
94
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
95
|
+
if (bytes[i] === 0xf0) start = i;
|
|
96
|
+
else if (bytes[i] === 0xf7 && start !== -1) {
|
|
97
|
+
messages.push({ maker: bytes[start + 1], size: i - start + 1 });
|
|
98
|
+
start = -1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
messages: messages.length,
|
|
103
|
+
makers: [...new Set(messages.map((m) => SYSEX_MAKERS.get(m.maker) ?? `0x${m.maker.toString(16)}`))],
|
|
104
|
+
largestMessage: Math.max(0, ...messages.map((m) => m.size)),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** the facts about a MIDI file a developer wants before rendering it */
|
|
109
|
+
function midiSummary(lib, bytes) {
|
|
110
|
+
const file = lib.parseMidi(bytes);
|
|
111
|
+
const notes = lib.scheduleMidi(file);
|
|
112
|
+
const channels = {};
|
|
113
|
+
for (const note of notes) {
|
|
114
|
+
const entry = (channels[note.channel] ??= { notes: 0, programs: new Set() });
|
|
115
|
+
entry.notes++;
|
|
116
|
+
entry.programs.add(note.channel === 9 ? 'drums' : note.program);
|
|
117
|
+
}
|
|
118
|
+
const end = notes.reduce((latest, note) => Math.max(latest, note.time + note.duration), 0);
|
|
119
|
+
return {
|
|
120
|
+
ticksPerQuarter: file.ticksPerQuarter,
|
|
121
|
+
events: file.events.length,
|
|
122
|
+
notes: notes.length,
|
|
123
|
+
seconds: Number(end.toFixed(3)),
|
|
124
|
+
loopStartSeconds: lib.midiLoopStart(file),
|
|
125
|
+
channels: Object.fromEntries(
|
|
126
|
+
Object.entries(channels).map(([channel, entry]) => [
|
|
127
|
+
channel,
|
|
128
|
+
{ notes: entry.notes, programs: [...entry.programs] },
|
|
129
|
+
]),
|
|
130
|
+
),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** describes one file: its format and the numbers that matter for that format */
|
|
135
|
+
export async function inspectAudio(bytes, name = '') {
|
|
136
|
+
const lib = await loadLibrary();
|
|
137
|
+
const detected = detectAudioFormat(bytes, name);
|
|
138
|
+
const report = { name, bytes: bytes.length, ...detected };
|
|
139
|
+
if (detected.kind === 'wav') Object.assign(report, lib.wavInfo(bytes));
|
|
140
|
+
else if (detected.kind === 'midi' && detected.format === 'MIDI') Object.assign(report, midiSummary(lib, bytes));
|
|
141
|
+
else if (detected.kind === 'sysex') Object.assign(report, sysexSummary(bytes));
|
|
142
|
+
else if (detected.kind === 'soundfont') {
|
|
143
|
+
const font = lib.parseSoundFont(bytes);
|
|
144
|
+
report.presets = font.presets ? font.presets.length : undefined;
|
|
145
|
+
}
|
|
146
|
+
return report;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** `"0,9"` as `[0, 9]`, each a MIDI channel */
|
|
150
|
+
export function parseChannelList(text) {
|
|
151
|
+
return text.split(',').map((item) => {
|
|
152
|
+
const channel = Number(item.trim());
|
|
153
|
+
if (!Number.isInteger(channel) || channel < 0 || channel > 15)
|
|
154
|
+
throw new Error(`invalid channel "${item}": expected 0 to 15`);
|
|
155
|
+
return channel;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function importModule(path) {
|
|
160
|
+
return import(pathToFileURL(resolve(path)).href);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Renders MIDI bytes to WAV bytes. `synth` is an engine name or a comma list tried in order
|
|
165
|
+
* (the engines come from `modules`, imported first), `soundfont` the bytes of a SoundFont.
|
|
166
|
+
*/
|
|
167
|
+
export async function renderToWav(midiBytes, options = {}) {
|
|
168
|
+
const lib = await loadLibrary();
|
|
169
|
+
for (const module of options.modules ?? []) await importModule(module);
|
|
170
|
+
const rendered = lib.renderMidiToBuffer(lib.parseMidi(midiBytes), {
|
|
171
|
+
synth: options.synth ? options.synth.split(',').map((name) => name.trim()) : undefined,
|
|
172
|
+
soundfont: options.soundfont ? lib.parseSoundFont(options.soundfont) : null,
|
|
173
|
+
sampleRate: options.rate,
|
|
174
|
+
gain: options.gain,
|
|
175
|
+
channels: options.channels,
|
|
176
|
+
});
|
|
177
|
+
return lib.encodeWav(rendered, options.float ? 'float32' : 'pcm16');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** resamples WAV bytes to another rate */
|
|
181
|
+
export async function resampleWav(wavBytes, rate, float = false) {
|
|
182
|
+
const lib = await loadLibrary();
|
|
183
|
+
return lib.encodeWav(lib.resampleStereo(lib.decodeWav(wavBytes), rate), float ? 'float32' : 'pcm16');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** how far apart two WAV files sound, in dB of log-spectrum difference */
|
|
187
|
+
export async function compareWavs(a, b, options = {}) {
|
|
188
|
+
const lib = await loadLibrary();
|
|
189
|
+
return lib.spectralDistance(lib.decodeWav(a), lib.decodeWav(b), options);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const AUDIO_EXTENSIONS = new Set([
|
|
193
|
+
'.mid',
|
|
194
|
+
'.midi',
|
|
195
|
+
'.wav',
|
|
196
|
+
'.ogg',
|
|
197
|
+
'.mp3',
|
|
198
|
+
'.flac',
|
|
199
|
+
'.m4a',
|
|
200
|
+
'.mwa',
|
|
201
|
+
'.sf2',
|
|
202
|
+
'.sf3',
|
|
203
|
+
'.syx',
|
|
204
|
+
'.nsf',
|
|
205
|
+
'.sid',
|
|
206
|
+
'.vgm',
|
|
207
|
+
'.ym',
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
/** every audio file under the given files and folders */
|
|
211
|
+
function collectFiles(paths) {
|
|
212
|
+
const found = [];
|
|
213
|
+
const visit = (path) => {
|
|
214
|
+
const stats = statSync(path);
|
|
215
|
+
if (stats.isDirectory()) {
|
|
216
|
+
for (const entry of readdirSync(path).sort()) visit(join(path, entry));
|
|
217
|
+
} else if (AUDIO_EXTENSIONS.has(extname(path).toLowerCase())) {
|
|
218
|
+
found.push(path);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
for (const path of paths) visit(path);
|
|
222
|
+
return found;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const MEGABYTE = 1024 * 1024;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* What the audio of a game costs: for each file the bytes on disk, the bytes it adds to a page
|
|
229
|
+
* (base64 makes it a third larger) and the memory it holds once decoded into a bus. A MIDI file
|
|
230
|
+
* is rendered to PCM before it plays, so its memory is its length times the render rate; a
|
|
231
|
+
* compressed file's decoded size cannot be known without decoding it, and is left out of the
|
|
232
|
+
* total rather than guessed.
|
|
233
|
+
*/
|
|
234
|
+
export async function audioBudget(paths, options = {}) {
|
|
235
|
+
const lib = await loadLibrary();
|
|
236
|
+
const rate = options.rate ?? 22050;
|
|
237
|
+
const maxSeconds = options.maxSeconds ?? 480;
|
|
238
|
+
const files = [];
|
|
239
|
+
for (const path of collectFiles(paths)) {
|
|
240
|
+
const bytes = readFileSync(path);
|
|
241
|
+
const detected = detectAudioFormat(bytes, path);
|
|
242
|
+
let seconds = null;
|
|
243
|
+
let decodedBytes = null;
|
|
244
|
+
let renderRate = rate;
|
|
245
|
+
let sizingSeconds = null;
|
|
246
|
+
if (detected.kind === 'midi' && detected.format === 'MIDI') {
|
|
247
|
+
const summary = midiSummary(lib, bytes);
|
|
248
|
+
seconds = summary.seconds;
|
|
249
|
+
//the memory a render holds includes its decay tail; the track's own length does not
|
|
250
|
+
sizingSeconds = Math.min(maxSeconds, seconds + 1.5);
|
|
251
|
+
} else if (detected.kind === 'wav') {
|
|
252
|
+
const info = lib.wavInfo(bytes);
|
|
253
|
+
seconds = info.seconds;
|
|
254
|
+
sizingSeconds = seconds;
|
|
255
|
+
renderRate = info.sampleRate;
|
|
256
|
+
}
|
|
257
|
+
if (sizingSeconds !== null) decodedBytes = Math.ceil(sizingSeconds * renderRate) * 2 * 4;
|
|
258
|
+
files.push({
|
|
259
|
+
path,
|
|
260
|
+
format: detected.format,
|
|
261
|
+
diskBytes: bytes.length,
|
|
262
|
+
pageBytes: Math.ceil((bytes.length * 4) / 3),
|
|
263
|
+
seconds,
|
|
264
|
+
decodedBytes,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
const total = (key) => files.reduce((sum, file) => sum + (file[key] ?? 0), 0);
|
|
268
|
+
const problems = [];
|
|
269
|
+
if (options.maxMegabytes !== undefined && total('decodedBytes') > options.maxMegabytes * MEGABYTE) {
|
|
270
|
+
problems.push(
|
|
271
|
+
`decoded audio is ${(total('decodedBytes') / MEGABYTE).toFixed(1)} MB, over the ${options.maxMegabytes} MB budget`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
for (const file of files) {
|
|
275
|
+
if (file.seconds !== null && file.seconds > (options.maxTrackSeconds ?? Infinity)) {
|
|
276
|
+
problems.push(`${file.path} is ${file.seconds.toFixed(0)} s, over ${options.maxTrackSeconds} s`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
files,
|
|
281
|
+
totals: { diskBytes: total('diskBytes'), pageBytes: total('pageBytes'), decodedBytes: total('decodedBytes') },
|
|
282
|
+
problems,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const USAGE = `usage: mwg-audio <command>
|
|
287
|
+
inspect <file>... the format and its numbers: MIDI notes and channels, WAV layout, SysEx messages
|
|
288
|
+
render <in.mid> -o <out.wav> [--synth <names>] [--module <file>...] [--soundfont <file>] [--rate <hz>]
|
|
289
|
+
[--channels 0,9] [--gain <0-1>] [--float]
|
|
290
|
+
a MIDI file to WAV through a registered engine (--module imports the code that registers it)
|
|
291
|
+
convert <in> <out> [--rate <hz>] [--converter <module>]
|
|
292
|
+
.mid to .wav, .wav to .wav at another rate, or any file through a converter module
|
|
293
|
+
(MWA and the other chip formats come from the retro-audio package's converter)
|
|
294
|
+
budget <file|folder>... [--rate <hz>] [--max-mb <n>] [--max-track-seconds <n>] [--json]
|
|
295
|
+
disk, page and decoded memory cost of the audio; exits 1 over a budget
|
|
296
|
+
compare <a.wav> <b.wav> [--max <db>] spectral distance between two renders; exits 1 over --max`;
|
|
297
|
+
|
|
298
|
+
async function main(argv) {
|
|
299
|
+
const [command, ...rest] = argv;
|
|
300
|
+
if (!command || command === '--help' || command === '-h') {
|
|
301
|
+
console.log(USAGE);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const flag = (name) => {
|
|
305
|
+
const at = rest.indexOf(name);
|
|
306
|
+
return at === -1 ? undefined : rest[at + 1];
|
|
307
|
+
};
|
|
308
|
+
const valuesAfter = (name) => {
|
|
309
|
+
const out = [];
|
|
310
|
+
for (let at = rest.indexOf(name); at !== -1 && at < rest.length; at = rest.indexOf(name, at + 1)) {
|
|
311
|
+
for (let i = at + 1; i < rest.length && !rest[i].startsWith('--') && rest[i] !== '-o'; i++)
|
|
312
|
+
out.push(rest[i]);
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
};
|
|
316
|
+
const number = (name) => (flag(name) === undefined ? undefined : Number(flag(name)));
|
|
317
|
+
const positional = () => {
|
|
318
|
+
const out = [];
|
|
319
|
+
const valued = new Set([
|
|
320
|
+
'-o',
|
|
321
|
+
'--synth',
|
|
322
|
+
'--module',
|
|
323
|
+
'--soundfont',
|
|
324
|
+
'--rate',
|
|
325
|
+
'--channels',
|
|
326
|
+
'--gain',
|
|
327
|
+
'--converter',
|
|
328
|
+
'--max',
|
|
329
|
+
'--max-mb',
|
|
330
|
+
'--max-track-seconds',
|
|
331
|
+
]);
|
|
332
|
+
for (let i = 0; i < rest.length; i++) {
|
|
333
|
+
if (valued.has(rest[i])) i++;
|
|
334
|
+
else if (!rest[i].startsWith('-')) out.push(rest[i]);
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
if (command === 'inspect') {
|
|
340
|
+
const files = positional();
|
|
341
|
+
if (files.length === 0) throw new Error('usage: mwg-audio inspect <file>...');
|
|
342
|
+
for (const file of files) console.log(JSON.stringify(await inspectAudio(readFileSync(file), file), null, 2));
|
|
343
|
+
} else if (command === 'render') {
|
|
344
|
+
const [input] = positional();
|
|
345
|
+
const out = flag('-o');
|
|
346
|
+
if (!input || !out) throw new Error('usage: mwg-audio render <in.mid> -o <out.wav>');
|
|
347
|
+
const wav = await renderToWav(readFileSync(input), {
|
|
348
|
+
synth: flag('--synth'),
|
|
349
|
+
modules: valuesAfter('--module'),
|
|
350
|
+
soundfont: flag('--soundfont') ? readFileSync(flag('--soundfont')) : undefined,
|
|
351
|
+
rate: number('--rate'),
|
|
352
|
+
gain: number('--gain'),
|
|
353
|
+
channels: flag('--channels') ? parseChannelList(flag('--channels')) : undefined,
|
|
354
|
+
float: rest.includes('--float'),
|
|
355
|
+
});
|
|
356
|
+
mkdirSync(dirname(resolve(out)), { recursive: true });
|
|
357
|
+
writeFileSync(out, wav);
|
|
358
|
+
console.log(`wrote ${out}: ${(wav.length / 1024).toFixed(0)} KB`);
|
|
359
|
+
} else if (command === 'convert') {
|
|
360
|
+
const [input, output] = positional();
|
|
361
|
+
if (!input || !output) throw new Error('usage: mwg-audio convert <in> <out>');
|
|
362
|
+
const bytes = readFileSync(input);
|
|
363
|
+
const target = extname(output).toLowerCase();
|
|
364
|
+
let result;
|
|
365
|
+
if (flag('--converter')) {
|
|
366
|
+
const converter = (await importModule(flag('--converter'))).default;
|
|
367
|
+
if (typeof converter !== 'function')
|
|
368
|
+
throw new Error('a converter module must export a default function (bytes, name) => Uint8Array');
|
|
369
|
+
result = await converter(bytes, input);
|
|
370
|
+
} else if (target === '.mwa') {
|
|
371
|
+
throw new Error(
|
|
372
|
+
"converting to MWA is the retro-audio package's job: pass its converter with --converter <module>",
|
|
373
|
+
);
|
|
374
|
+
} else if (target === '.wav') {
|
|
375
|
+
const detected = detectAudioFormat(bytes, input);
|
|
376
|
+
if (detected.kind === 'midi') result = await renderToWav(bytes, { rate: number('--rate') });
|
|
377
|
+
else if (detected.kind === 'wav')
|
|
378
|
+
result = number('--rate') ? await resampleWav(bytes, number('--rate')) : bytes;
|
|
379
|
+
else throw new Error(`cannot convert ${detected.format} to .wav without a converter`);
|
|
380
|
+
} else {
|
|
381
|
+
throw new Error(`no conversion to "${target}" is built in: pass --converter <module>`);
|
|
382
|
+
}
|
|
383
|
+
mkdirSync(dirname(resolve(output)), { recursive: true });
|
|
384
|
+
writeFileSync(output, result);
|
|
385
|
+
console.log(`wrote ${output}: ${(result.length / 1024).toFixed(0)} KB`);
|
|
386
|
+
} else if (command === 'budget') {
|
|
387
|
+
const paths = positional();
|
|
388
|
+
if (paths.length === 0) throw new Error('usage: mwg-audio budget <file|folder>...');
|
|
389
|
+
const report = await audioBudget(paths, {
|
|
390
|
+
rate: number('--rate'),
|
|
391
|
+
maxMegabytes: number('--max-mb'),
|
|
392
|
+
maxTrackSeconds: number('--max-track-seconds'),
|
|
393
|
+
});
|
|
394
|
+
if (rest.includes('--json')) {
|
|
395
|
+
console.log(JSON.stringify(report, null, 2));
|
|
396
|
+
} else {
|
|
397
|
+
for (const file of report.files) {
|
|
398
|
+
const memory =
|
|
399
|
+
file.decodedBytes === null
|
|
400
|
+
? 'decoded size unknown'
|
|
401
|
+
: `${(file.decodedBytes / MEGABYTE).toFixed(1)} MB decoded`;
|
|
402
|
+
console.log(
|
|
403
|
+
`${file.path} ${file.format} ${(file.pageBytes / 1024).toFixed(0)} KB in the page ${memory}`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
const { totals } = report;
|
|
407
|
+
console.log(
|
|
408
|
+
`total: ${(totals.pageBytes / 1024).toFixed(0)} KB in the page, ${(totals.decodedBytes / MEGABYTE).toFixed(1)} MB decoded (files of unknown decoded size not counted)`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
for (const problem of report.problems) console.error(problem);
|
|
412
|
+
if (report.problems.length > 0) process.exitCode = 1;
|
|
413
|
+
} else if (command === 'compare') {
|
|
414
|
+
const [a, b] = positional();
|
|
415
|
+
if (!a || !b) throw new Error('usage: mwg-audio compare <a.wav> <b.wav>');
|
|
416
|
+
const result = await compareWavs(readFileSync(a), readFileSync(b));
|
|
417
|
+
console.log(`${result.decibels.toFixed(2)} dB over ${result.windows} windows`);
|
|
418
|
+
if (number('--max') !== undefined && result.decibels > number('--max')) {
|
|
419
|
+
console.error(`over the ${number('--max')} dB limit`);
|
|
420
|
+
process.exitCode = 1;
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
throw new Error(`unknown command "${command}"\n${USAGE}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
//realpath, since an npm `bin` shim reaches this file through a symlink
|
|
428
|
+
if (
|
|
429
|
+
process.argv[1] &&
|
|
430
|
+
existsSync(process.argv[1]) &&
|
|
431
|
+
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
432
|
+
) {
|
|
433
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
434
|
+
console.error(error instanceof Error ? error.message : error);
|
|
435
|
+
process.exitCode = 1;
|
|
436
|
+
});
|
|
437
|
+
}
|
package/tools/mwl.mjs
CHANGED
|
@@ -8,15 +8,24 @@ import {
|
|
|
8
8
|
collectHookReferences,
|
|
9
9
|
compile,
|
|
10
10
|
compileAndEmitSources,
|
|
11
|
+
compilePackages,
|
|
11
12
|
compileSources,
|
|
12
13
|
contentReport,
|
|
14
|
+
decodePackage,
|
|
15
|
+
emitArtifacts,
|
|
13
16
|
emitHooksDeclaration,
|
|
14
17
|
emitModule,
|
|
18
|
+
encodePackage,
|
|
15
19
|
extractCatalog,
|
|
20
|
+
generateCredits,
|
|
21
|
+
loadPackage,
|
|
22
|
+
mergePackage,
|
|
23
|
+
packageContent,
|
|
16
24
|
parse,
|
|
17
25
|
preprocess,
|
|
18
26
|
validate,
|
|
19
27
|
validateHookReferences,
|
|
28
|
+
verifyPackage,
|
|
20
29
|
} from '../dist/mwl/index.js';
|
|
21
30
|
|
|
22
31
|
/**
|
|
@@ -54,6 +63,9 @@ const commands = [
|
|
|
54
63
|
'ci',
|
|
55
64
|
'hooks',
|
|
56
65
|
'build',
|
|
66
|
+
'pack',
|
|
67
|
+
'unpack',
|
|
68
|
+
'verify',
|
|
57
69
|
];
|
|
58
70
|
const [command, input] = args;
|
|
59
71
|
const outputFlag = args.indexOf('-o');
|
|
@@ -67,16 +79,18 @@ const target = targetFlag === -1 ? undefined : args[targetFlag + 1];
|
|
|
67
79
|
|
|
68
80
|
if (args.includes('--help') || args.includes('-h')) {
|
|
69
81
|
console.log(
|
|
70
|
-
'usage: mwg-mwl <validate|compile|format|migrate|extract-i18n|assets|report|diagnostics|ci|hooks|build> input.mwl [-o output] [--to 1.0] [--manifest hooks.json] [--asset-root directory]',
|
|
82
|
+
'usage: mwg-mwl <validate|compile|format|migrate|extract-i18n|assets|report|diagnostics|ci|hooks|build|pack|unpack|verify> input.mwl|input.mwp|directory [-o output] [--to 1.0] [--manifest hooks.json] [--asset-root directory] [--audio-converter module] [--allow capabilities] [--json]',
|
|
71
83
|
);
|
|
72
84
|
process.exit(0);
|
|
73
85
|
}
|
|
74
86
|
|
|
75
87
|
if (!command || !input || !commands.includes(command)) {
|
|
76
88
|
console.error(
|
|
77
|
-
'Usage: mwl <validate|compile|format|migrate|extract-i18n|assets|report|diagnostics|ci|hooks|build> input.mwl [-o output] [--to 1.0] [--manifest hooks.json] [--asset-root directory]',
|
|
89
|
+
'Usage: mwl <validate|compile|format|migrate|extract-i18n|assets|report|diagnostics|ci|hooks|build|pack|unpack|verify> input.mwl|input.mwp|directory [-o output] [--to 1.0] [--manifest hooks.json] [--asset-root directory] [--audio-converter module] [--allow capabilities] [--json]',
|
|
78
90
|
);
|
|
79
91
|
process.exitCode = 2;
|
|
92
|
+
} else if (command === 'pack' || command === 'unpack' || command === 'verify' || /.mwp$/i.test(input)) {
|
|
93
|
+
process.exitCode = await packageCommand();
|
|
80
94
|
} else {
|
|
81
95
|
const files = fs.statSync(input).isDirectory()
|
|
82
96
|
? collectSources(input)
|
|
@@ -322,3 +336,131 @@ async function hooks(files) {
|
|
|
322
336
|
fs.rmSync(entryFile, { force: true });
|
|
323
337
|
}
|
|
324
338
|
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* `pack <dir> -o x.mwp` writes a folder (`content.mwl` plus its media) as one package, refusing to
|
|
342
|
+
* write one that `verify` would reject. `unpack x.mwp -o dir` writes the files back. `verify x.mwp`
|
|
343
|
+
* checks digests, limits, schema, references, licences, ROM images and the capabilities requested
|
|
344
|
+
* (`--allow audio,script` applies a game policy, `--json` prints the report). Any other command
|
|
345
|
+
* given a `.mwp` runs on the content the package's characters expand to.
|
|
346
|
+
*
|
|
347
|
+
* Audio conversion to MWA belongs to the retro-audio package, not to mwg: `--audio-converter module`
|
|
348
|
+
* names an ES module whose default export is `(path, bytes) => { path, data } | undefined`, called
|
|
349
|
+
* for chip-music files (.ym .vgm .nsf .sid .dro .imf .cmf). Without it those files are packed as
|
|
350
|
+
* they are, with a warning.
|
|
351
|
+
*/
|
|
352
|
+
async function packageCommand() {
|
|
353
|
+
const flag = (name) => {
|
|
354
|
+
const at = args.indexOf(name);
|
|
355
|
+
return at === -1 ? undefined : args[at + 1];
|
|
356
|
+
};
|
|
357
|
+
if (command === 'pack') {
|
|
358
|
+
const root = path.resolve(input);
|
|
359
|
+
const converter = flag('--audio-converter');
|
|
360
|
+
const convert = converter ? (await import(pathToFileURL(path.resolve(converter)).href)).default : undefined;
|
|
361
|
+
if (converter && typeof convert !== 'function') throw new Error(`${converter} must default-export a function`);
|
|
362
|
+
const files = [];
|
|
363
|
+
const visit = (directory) => {
|
|
364
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
365
|
+
if (entry.name.startsWith('.')) continue;
|
|
366
|
+
const full = path.join(directory, entry.name);
|
|
367
|
+
if (entry.isDirectory()) visit(full);
|
|
368
|
+
else
|
|
369
|
+
files.push({
|
|
370
|
+
path: path.relative(root, full).split(path.sep).join('/'),
|
|
371
|
+
data: new Uint8Array(fs.readFileSync(full)),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
visit(root);
|
|
376
|
+
const chip = /.(?:ym|vgm|nsf|sid|dro|imf|cmf)$/i;
|
|
377
|
+
const packed = [];
|
|
378
|
+
for (const file of files) {
|
|
379
|
+
if (!chip.test(file.path)) {
|
|
380
|
+
packed.push(file);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (!convert)
|
|
384
|
+
console.error(`warning: ${file.path} is packed as it is; pass --audio-converter to store it as MWA`);
|
|
385
|
+
packed.push((convert && (await convert(file.path, file.data))) || file);
|
|
386
|
+
}
|
|
387
|
+
const bytes = await encodePackage(packed, { main: flag('--main') });
|
|
388
|
+
const report = await verifyPackage(bytes);
|
|
389
|
+
for (const warning of report.warnings) console.error(`warning: ${warning}`);
|
|
390
|
+
if (!report.ok) {
|
|
391
|
+
for (const error of report.errors) console.error(`error: ${error}`);
|
|
392
|
+
console.error('nothing was written');
|
|
393
|
+
return 1;
|
|
394
|
+
}
|
|
395
|
+
const target = path.resolve(output ?? `${root}.mwp`);
|
|
396
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
397
|
+
fs.writeFileSync(target, bytes);
|
|
398
|
+
console.log(`packed ${packed.length} file(s) into ${target} (${report.ref})`);
|
|
399
|
+
return 0;
|
|
400
|
+
}
|
|
401
|
+
const bytes = new Uint8Array(fs.readFileSync(input));
|
|
402
|
+
if (command === 'verify') {
|
|
403
|
+
const allow = flag('--allow');
|
|
404
|
+
const report = await verifyPackage(
|
|
405
|
+
bytes,
|
|
406
|
+
allow === undefined ? {} : { policy: { allow: allow.split(',').filter(Boolean) } },
|
|
407
|
+
);
|
|
408
|
+
if (args.includes('--json')) console.log(JSON.stringify(report, null, ' '));
|
|
409
|
+
else {
|
|
410
|
+
for (const warning of report.warnings) console.error(`warning: ${warning}`);
|
|
411
|
+
for (const error of report.errors) console.error(`error: ${error}`);
|
|
412
|
+
if (report.ok)
|
|
413
|
+
console.log(
|
|
414
|
+
`${input}: valid package ${report.ref} (capabilities: ${report.capabilities.join(', ') || 'none'})`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
return report.ok ? 0 : 1;
|
|
418
|
+
}
|
|
419
|
+
if (command === 'unpack') {
|
|
420
|
+
const decoded = await decodePackage(bytes);
|
|
421
|
+
const target = path.resolve(output ?? input.replace(/.mwp$/i, ''));
|
|
422
|
+
for (const file of decoded.files) {
|
|
423
|
+
const destination = path.resolve(target, file.path);
|
|
424
|
+
if (!destination.startsWith(target + path.sep))
|
|
425
|
+
throw new Error(`${file.path} escapes the output directory`);
|
|
426
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
427
|
+
fs.writeFileSync(destination, file.data);
|
|
428
|
+
}
|
|
429
|
+
console.log(`unpacked ${decoded.files.length} file(s) into ${target}`);
|
|
430
|
+
return 0;
|
|
431
|
+
}
|
|
432
|
+
if (!['validate', 'compile', 'extract-i18n', 'assets', 'report', 'build'].includes(command)) {
|
|
433
|
+
console.error(`mwl ${command} does not take a package; unpack it first`);
|
|
434
|
+
return 2;
|
|
435
|
+
}
|
|
436
|
+
const loaded = await loadPackage(bytes);
|
|
437
|
+
const set = mergePackage(loaded);
|
|
438
|
+
const game = compilePackages(set);
|
|
439
|
+
if (command === 'validate') {
|
|
440
|
+
console.log(`${input}: valid package`);
|
|
441
|
+
return 0;
|
|
442
|
+
}
|
|
443
|
+
if (command === 'build') {
|
|
444
|
+
const directory = path.resolve(output ?? 'generated');
|
|
445
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
446
|
+
const content = JSON.stringify(packageContent(set), null, ' ');
|
|
447
|
+
for (const artifact of emitArtifacts(game, {
|
|
448
|
+
artifacts: {
|
|
449
|
+
'package-content.json': `${content}
|
|
450
|
+
`,
|
|
451
|
+
},
|
|
452
|
+
}))
|
|
453
|
+
fs.writeFileSync(path.join(directory, artifact.name), artifact.content);
|
|
454
|
+
const credits = generateCredits([loaded.document]);
|
|
455
|
+
if (credits) fs.writeFileSync(path.join(directory, 'CREDITS.txt'), credits);
|
|
456
|
+
console.log(`built package ${loaded.document.meta.id} into ${directory}`);
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
const values =
|
|
460
|
+
command === 'extract-i18n' ? extractCatalog(game) : command === 'assets' ? game.assets : contentReport(game);
|
|
461
|
+
const text = `${JSON.stringify(values, null, ' ')}
|
|
462
|
+
`;
|
|
463
|
+
if (output) fs.writeFileSync(output, text);
|
|
464
|
+
else process.stdout.write(text);
|
|
465
|
+
return 0;
|
|
466
|
+
}
|
package/tools.md
CHANGED
|
@@ -13,7 +13,7 @@ by double-clicking. Run from the repo root.
|
|
|
13
13
|
| `soundfonts` | Picks a SoundFont other than the bundled `microgm-opt` (WTFPL, 2.6 MB, the default). `list` prints the catalogue (`data/soundfont-catalog.json`, a licence-classified snapshot of the marmooo free-soundfonts collection: public domain, MIT and CC-BY fonts fetch freely, share-alike, copyleft and unclear ones need `--accept-licence`); `fetch <font>` downloads only the instruments the game's MIDI files use (`--midi`) or a list (`--programs 0,24,128:0`), and merges them into one SoundFont 3 (the compressed samples untouched, no decoding, no `ffmpeg`) beside a `.credits.txt` that keeps the licence with the file; the game decodes it where it runs with `loadSoundFont`. `fetch --sf2` and `convert <in.sf3> <out.sf2>` decode to a plain SoundFont 2 instead, about eight times larger, which needs `ffmpeg` on PATH (developer machines only). The result goes in the asset folder, where the build subsets it to the programs the game plays | `npx mwg-soundfonts list`, `npx mwg-soundfonts fetch GeneralUser_GS_v2.0.3 --midi assets/*.mid --accept-licence`, or `fetchSoundFont` from `@datamoc/mw_games/tools/soundfonts` |
|
|
14
14
|
| `extract-rgssad` | Decrypts an RPG Maker XP/VX archive to plain files (vendored MIT decoder, developer-side only); an entry whose name would land outside `outDir` is refused (item 389) | `npm run extract:rgssad -- <archive.rgssad> <outDir>` here; `npx mwg-extract-rgssad <archive> <outDir>` or `extractRgssad` from `@datamoc/mw_games/tools/extract-rgssad` in a game |
|
|
15
15
|
| `multiplayer-server` | The reference lockstep server for `core.LockstepClient`: rooms, one shared tick, `null` for a missed input, messages capped at `maxMessageBytes` (64 KB) and read through `parseInbound`, room names bounded, `maxClientsPerRoom` (64). Speaks `ws:`, so a deployment puts it behind a TLS proxy (items 129, 385) | `npx mwg-lockstep-server [port] [--host=]` (127.0.0.1 unless `--host`), or `createLockstepServer(options)` from `@datamoc/mw_games/tools/multiplayer-server`; needs the optional peer `ws` |
|
|
16
|
-
| `mwl` | Compiles MWL content (`[game]`/`[scenario]`/`[side]`/`[unit]`/`[event]`/`[campaign]`) into a game-data module, an i18n catalog and an asset manifest | `npm run mwl -- build <contentDir> -o <outDir>`, or `node node_modules/@datamoc/mw_games/tools/mwl.mjs build <contentDir> -o <outDir>` from an install. It is a command line, not an importable module (it dispatches when loaded), so it has no package subpath; script against the typed library instead: `@datamoc/mw_games/mwl` |
|
|
16
|
+
| `mwl` | Compiles MWL content (`[game]`/`[scenario]`/`[side]`/`[unit]`/`[event]`/`[campaign]`) into a game-data module, an i18n catalog and an asset manifest. Also packs a folder (`content.mwl` plus media) into one `.mwp` content package (`pack`), unpacks it, and verifies it (`verify`: digests, limits, schema, licences, instrument ROM refusal, requested capabilities); `build`, `validate`, `extract-i18n` and `assets` accept a `.mwp` in place of a folder. `pack --audio-converter <module>` is the hook for converting chip music to MWA, which lives in the retro-audio package | `npm run mwl -- build <contentDir> -o <outDir>`, or `node node_modules/@datamoc/mw_games/tools/mwl.mjs build <contentDir> -o <outDir>` from an install. It is a command line, not an importable module (it dispatches when loaded), so it has no package subpath; script against the typed library instead: `@datamoc/mw_games/mwl` |
|
|
17
17
|
| `extract-html` | Splits the resources embedded in an HTML file (scripts, CSS, images) back out into files | `npm run extract:html -- <page.html> -o <outDir>`, or `extractHtml` from `@datamoc/mw_games/tools/extract-html` |
|
|
18
18
|
| `compile-openings` | Compiles opening tables (`eco`, `name`, `pgn` TSV rows) into a position-keyed book JSON a game checks in beside its own code: SAN resolved against the real move generator, every position keeping its continuations, the longest line ending there naming it. Rows are vendored CC0 data (`data/openings/`), never fetched | `node tools/compile-openings.mjs <tsv folder> <book.json>`, or `npx mwg-openings <tsv folder> <book.json>` in a game |
|
|
19
19
|
| `build-tablebases` | Solves pawnless white-extras-against-lone-king endings by retrograde analysis and ships them compiled as Huffman-coded JSON under `data/tablebases` (3-piece: `KQK KRK KBK KNK`; 4-piece like `KBNK KBBK` generate the same way but stay a tool run away, full 4-piece tables are tens of megabytes each). One `threads.spawn` task per ending over capped lanes (4-piece lanes stay narrow: hundreds of megabytes of arrays each); captures convert into the already-solved smaller tables, so 3-piece endings solve first. Golden counts: KQK 0/200896/99448/223944, KRK 0/201700/98644/223944, KBK/KNK all draws, KBBK 0/5628080/21071792/6854560, KBNK 0/11188168/8816020/13550244 (unknown/loss/draw/win) | `node tools/build-tablebases.mjs <outDir> [ending ...] [--jobs=N]`, or `npx mwg-tablebases <outDir> [ending ...]` in a game |
|