@datamoc/mw_games 0.40.0 → 0.41.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 (90) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/REFERENCE.md +2115 -0
  3. package/api-surface.json +48 -0
  4. package/data/microgm-opt.PROVENANCE.md +3 -0
  5. package/data/soundfont-catalog.json +183 -0
  6. package/dist/ai/index.d.ts +10 -0
  7. package/dist/ai/index.d.ts.map +1 -1
  8. package/dist/ai/index.js.map +1 -1
  9. package/dist/ai/lua.d.ts +6 -0
  10. package/dist/ai/lua.d.ts.map +1 -1
  11. package/dist/ai/lua.js.map +1 -1
  12. package/dist/audio/SampleDecoder.d.ts +41 -0
  13. package/dist/audio/SampleDecoder.d.ts.map +1 -0
  14. package/dist/audio/SampleDecoder.js +70 -0
  15. package/dist/audio/SampleDecoder.js.map +1 -0
  16. package/dist/audio/SoundFont.d.ts +59 -0
  17. package/dist/audio/SoundFont.d.ts.map +1 -1
  18. package/dist/audio/SoundFont.js +248 -2
  19. package/dist/audio/SoundFont.js.map +1 -1
  20. package/dist/audio/index.d.ts +3 -1
  21. package/dist/audio/index.d.ts.map +1 -1
  22. package/dist/audio/index.js +2 -1
  23. package/dist/audio/index.js.map +1 -1
  24. package/dist/mw_games.global.js +11 -11
  25. package/dist/mwl/presentation.d.ts +16 -0
  26. package/dist/mwl/presentation.d.ts.map +1 -1
  27. package/dist/mwl/presentation.js.map +1 -1
  28. package/dist/mwl/pyroeis-runtime.d.ts +7 -0
  29. package/dist/mwl/pyroeis-runtime.d.ts.map +1 -1
  30. package/dist/mwl/pyroeis-runtime.js.map +1 -1
  31. package/dist/mwl/pyroeis.d.ts +3 -0
  32. package/dist/mwl/pyroeis.d.ts.map +1 -1
  33. package/dist/mwl/pyroeis.js.map +1 -1
  34. package/dist/physics/PhysicsWorld.d.ts +3 -0
  35. package/dist/physics/PhysicsWorld.d.ts.map +1 -1
  36. package/dist/physics/PhysicsWorld.js.map +1 -1
  37. package/dist/physics/XpbdWorld.d.ts +11 -0
  38. package/dist/physics/XpbdWorld.d.ts.map +1 -1
  39. package/dist/physics/XpbdWorld.js.map +1 -1
  40. package/dist/physics/cloth/index.d.ts +20 -19
  41. package/dist/physics/cloth/index.d.ts.map +1 -1
  42. package/dist/physics/cloth/index.js +19 -0
  43. package/dist/physics/cloth/index.js.map +1 -1
  44. package/dist/physics/spring/index.d.ts +19 -18
  45. package/dist/physics/spring/index.d.ts.map +1 -1
  46. package/dist/physics/spring/index.js +23 -0
  47. package/dist/physics/spring/index.js.map +1 -1
  48. package/dist/three-d/Character3D.d.ts +8 -7
  49. package/dist/three-d/Character3D.d.ts.map +1 -1
  50. package/dist/three-d/Character3D.js +7 -0
  51. package/dist/three-d/Character3D.js.map +1 -1
  52. package/dist/three-d/Collision3D.d.ts +4 -0
  53. package/dist/three-d/Collision3D.d.ts.map +1 -1
  54. package/dist/three-d/Collision3D.js.map +1 -1
  55. package/dist/three-d/Engine3D.d.ts +2 -0
  56. package/dist/three-d/Engine3D.d.ts.map +1 -1
  57. package/dist/three-d/Engine3D.js.map +1 -1
  58. package/dist/three-d/Grid.d.ts +5 -0
  59. package/dist/three-d/Grid.d.ts.map +1 -1
  60. package/dist/three-d/Grid.js.map +1 -1
  61. package/dist/three-d/Heightmap.d.ts +3 -0
  62. package/dist/three-d/Heightmap.d.ts.map +1 -1
  63. package/dist/three-d/Heightmap.js.map +1 -1
  64. package/dist/three-d/TileGrid3D.d.ts +6 -0
  65. package/dist/three-d/TileGrid3D.d.ts.map +1 -1
  66. package/dist/three-d/TileGrid3D.js.map +1 -1
  67. package/dist/three-d/Vox.d.ts +7 -0
  68. package/dist/three-d/Vox.d.ts.map +1 -1
  69. package/dist/three-d/Vox.js.map +1 -1
  70. package/dist/two-d/presentation/MwlPresentation.d.ts +7 -0
  71. package/dist/two-d/presentation/MwlPresentation.d.ts.map +1 -1
  72. package/dist/two-d/presentation/MwlPresentation.js.map +1 -1
  73. package/dist/version.d.ts +1 -1
  74. package/dist/version.js +1 -1
  75. package/package.json +16 -1
  76. package/sbom.cdx.json +139 -139
  77. package/tools/benchmark-browser.mjs +6 -0
  78. package/tools/bundle-size.mjs +4 -0
  79. package/tools/compile-resources.mjs +3 -2
  80. package/tools/init.mjs +3 -1
  81. package/tools/make-example-assets.mjs +4 -0
  82. package/tools/multiplayer-server.mjs +4 -0
  83. package/tools/mwl.mjs +7 -0
  84. package/tools/play-uci.mjs +4 -0
  85. package/tools/sbom.mjs +4 -0
  86. package/tools/soundfonts.d.mts +79 -0
  87. package/tools/soundfonts.mjs +301 -0
  88. package/tools/verify-scenarios.mjs +6 -2
  89. package/tools/vite.mjs +1 -1
  90. package/tools.md +216 -0
@@ -136,6 +136,12 @@ export async function measurePage({
136
136
  * `benchmark-results/<page>.json`, named by the page so two pages keep separate histories.
137
137
  */
138
138
  async function main(argv) {
139
+ if (argv.includes('--help') || argv.includes('-h')) {
140
+ console.log(
141
+ 'usage: mwg-bench <page.html | dist folder> [--frames=180] [--min-fps=45] [--max-p95=40] [--history=<file>]',
142
+ );
143
+ return;
144
+ }
139
145
  const value = (name, env, fallback) =>
140
146
  argv.find((a) => a.startsWith(`--${name}=`))?.split('=')[1] ?? process.env[env] ?? fallback;
141
147
  const target = argv.find((arg) => !arg.startsWith('--')) ?? 'examples/dungeon/dist/index.html';
@@ -99,6 +99,10 @@ function measureFramework(root) {
99
99
  }
100
100
 
101
101
  function main(argv) {
102
+ if (argv.includes('--help') || argv.includes('-h')) {
103
+ console.log('usage: mwg-size <dist folder> [--baseline=<file>] [--tolerance=0.02] [--update]');
104
+ return;
105
+ }
102
106
  const tolerance = Number(
103
107
  argv.find((a) => a.startsWith('--tolerance='))?.slice(12) ?? process.env.MWG_SIZE_TOLERANCE ?? 0.02,
104
108
  );
@@ -41,9 +41,10 @@ export const DEFAULT_MIME = {
41
41
  '.mid': 'audio/midi',
42
42
  '.midi': 'audio/midi',
43
43
  '.sf2': 'application/octet-stream',
44
+ '.sf3': 'application/octet-stream',
44
45
  };
45
46
 
46
- const isSoundFontKey = (key) => key.toLowerCase().endsWith('.sf2');
47
+ const isSoundFontKey = (key) => /\.sf[23]$/i.test(key);
47
48
  const isMidiKey = (key) => /\.(mid|midi)$/i.test(key);
48
49
 
49
50
  /**
@@ -120,7 +121,7 @@ export async function* walk(dir) {
120
121
  * pixel-art tileset's exact colours survive; set false only with `webpQuality` to opt into
121
122
  * lossy re-encoding for photographic source art)
122
123
  * @param options.webpQuality 0-100, used only when `webpLossless` is false (default 90)
123
- * @param options.soundFont subset an embedded `.sf2` to the programs the embedded
124
+ * @param options.soundFont subset an embedded `.sf2` or `.sf3` to the programs the embedded
124
125
  * `.mid`/`.midi` files use (default true); `false` embeds every SoundFont whole
125
126
  * @param options.soundFontUsage explicit `{ bank, program }` list to subset to, for a
126
127
  * game that voices notes no shipped MIDI file requests; overrides the MIDI-derived list
package/tools/init.mjs CHANGED
@@ -130,7 +130,9 @@ export function initProject(folder) {
130
130
  //no top-level await: this module is also imported, and the real path covers an npm bin symlink
131
131
  if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
132
132
  const folder = process.argv[2];
133
- if (!folder) {
133
+ if (folder === '--help' || folder === '-h') {
134
+ console.log('usage: mwg-init <new folder>');
135
+ } else if (!folder) {
134
136
  console.error('usage: mwg-init <new folder>');
135
137
  process.exitCode = 2;
136
138
  } else {
@@ -717,6 +717,10 @@ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.me
717
717
  const args = process.argv.slice(2);
718
718
  const folder = args.find((arg) => !arg.startsWith('--'));
719
719
  const inRepository = existsSync(join(here, '..', 'examples'));
720
+ if (args.includes('--help') || args.includes('-h')) {
721
+ console.log('usage: mwg-placeholder-assets <folder> [--force]');
722
+ process.exit(0);
723
+ }
720
724
  if (!folder && !inRepository) {
721
725
  console.error('usage: mwg-placeholder-assets <folder> [--force]');
722
726
  process.exit(1);
@@ -187,6 +187,10 @@ export function createLockstepServer(options = {}) {
187
187
  //realpath, since an npm `bin` shim reaches this file through a symlink
188
188
  if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
189
189
  const args = process.argv.slice(2);
190
+ if (args.includes('--help') || args.includes('-h')) {
191
+ console.log('usage: mwg-lockstep-server [port] [--host=<address>]');
192
+ process.exit(0);
193
+ }
190
194
  const port = Number(args.find((arg) => !arg.startsWith('--')) ?? 8787);
191
195
  const host = args.find((arg) => arg.startsWith('--host='))?.slice(7) ?? '127.0.0.1';
192
196
  const server = createLockstepServer({ port, host });
package/tools/mwl.mjs CHANGED
@@ -65,6 +65,13 @@ const assetRoot = assetRootFlag === -1 ? undefined : args[assetRootFlag + 1];
65
65
  const targetFlag = args.indexOf('--to');
66
66
  const target = targetFlag === -1 ? undefined : args[targetFlag + 1];
67
67
 
68
+ if (args.includes('--help') || args.includes('-h')) {
69
+ 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]',
71
+ );
72
+ process.exit(0);
73
+ }
74
+
68
75
  if (!command || !input || !commands.includes(command)) {
69
76
  console.error(
70
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]',
@@ -171,6 +171,10 @@ const isMain = process.argv[1] && realpathSync(process.argv[1]) === fileURLToPat
171
171
 
172
172
  if (isMain) {
173
173
  const args = process.argv.slice(2);
174
+ if (args.includes('--help') || args.includes('-h')) {
175
+ console.log('usage: mwg-uci [--book <book.json>] [--tables <dir>] [--depth N]');
176
+ process.exit(0);
177
+ }
174
178
  const flag = (name) => {
175
179
  const at = args.indexOf(name);
176
180
  return at === -1 ? undefined : args[at + 1];
package/tools/sbom.mjs CHANGED
@@ -301,6 +301,10 @@ async function readJson(path) {
301
301
  * `application`, anything else as a `library`. `--check` compares instead of writing, for CI.
302
302
  */
303
303
  async function main(argv) {
304
+ if (argv.includes('--help') || argv.includes('-h')) {
305
+ console.log('usage: mwg-sbom [project folder] [--out=<file>] [--check]');
306
+ return;
307
+ }
304
308
  const root = resolve(argv.find((arg) => !arg.startsWith('--')) ?? '.');
305
309
  const out = resolve(root, argv.find((arg) => arg.startsWith('--out='))?.slice(6) ?? OUTPUT);
306
310
  const packageJson = await readJson(join(root, 'package.json'));
@@ -0,0 +1,79 @@
1
+ /** how freely a catalogue font may be shipped inside a game */
2
+ export type SoundFontLicenceClass =
3
+ 'public-domain' | 'permissive' | 'attribution' | 'share-alike' | 'copyleft' | 'check';
4
+
5
+ export interface SoundFontCatalogEntry {
6
+ name: string;
7
+ licence: string;
8
+ licenceClass: SoundFontLicenceClass;
9
+ web?: string;
10
+ /** instrument files the collection carries for this font (programs for a bundled bank) */
11
+ programs: number;
12
+ /** megabytes of a full General MIDI bank, SoundFont 3 compressed; absent for the bundled bank */
13
+ gmMB?: number;
14
+ /** megabytes of every instrument file, SoundFont 3 compressed (the bundled bank's own size) */
15
+ fullMB: number;
16
+ /** the file of a bundled bank, relative to the package root */
17
+ file?: string;
18
+ note?: string;
19
+ }
20
+
21
+ export interface SoundFontCatalog {
22
+ description: string;
23
+ /** where the per-instrument files are fetched from by default */
24
+ source: string;
25
+ /** the name of the bank the package ships and games use unless they choose another */
26
+ default: string;
27
+ bundled: SoundFontCatalogEntry[];
28
+ fonts: SoundFontCatalogEntry[];
29
+ }
30
+
31
+ export interface SoundFontProgramRef {
32
+ bank: number;
33
+ program: number;
34
+ }
35
+
36
+ export interface FetchSoundFontOptions {
37
+ /** a catalogue font name, from `mwg-soundfonts list` */
38
+ name: string;
39
+ programs: SoundFontProgramRef[];
40
+ catalog?: SoundFontCatalog;
41
+ /** a folder or `http(s)` URL laid out as `<font>/<bank>/<program>.sf3`; the catalogue's source by default */
42
+ source?: string;
43
+ /** required for a licence class that is not public-domain, permissive or attribution */
44
+ acceptLicence?: boolean;
45
+ /** the decoded font is a SoundFont 2 (`ffmpeg` needed) instead of a SoundFont 3; `sf3` by default */
46
+ format?: 'sf2' | 'sf3';
47
+ /** Ogg Vorbis bytes to mono 16-bit frames, for `format: 'sf2'`; `ffmpeg` by default */
48
+ decode?: (ogg: Uint8Array, rate: number) => Int16Array | Promise<Int16Array>;
49
+ /** replaces the file reader, mainly for tests */
50
+ read?: (path: string) => Promise<Uint8Array | null>;
51
+ }
52
+
53
+ export interface FetchedSoundFont {
54
+ /** one font holding the found programs, in `format` */
55
+ bytes: Uint8Array;
56
+ format: 'sf2' | 'sf3';
57
+ found: SoundFontProgramRef[];
58
+ /** requested programs the collection does not carry */
59
+ missing: SoundFontProgramRef[];
60
+ font: SoundFontCatalogEntry;
61
+ }
62
+
63
+ /** the licence-classified catalogue of fetchable fonts */
64
+ export declare const CATALOG_PATH: string;
65
+ export declare function readCatalog(path?: string): SoundFontCatalog;
66
+ export declare function findFont(catalog: SoundFontCatalog, name: string): SoundFontCatalogEntry | undefined;
67
+ /** `"0,24,128:0"`: a bare number is a program of bank 0, `bank:program` names both */
68
+ export declare function parseProgramList(text: string): SoundFontProgramRef[];
69
+ /** mono 16-bit frames from Ogg Vorbis bytes, through `ffmpeg` */
70
+ export declare function ffmpegDecode(ogg: Uint8Array): Int16Array;
71
+ /** a SoundFont 3 decoded into SoundFont 2 bytes (`ffmpeg` unless `decode` is given) */
72
+ export declare function sf3ToSf2(
73
+ bytes: Uint8Array,
74
+ decode?: (ogg: Uint8Array, rate: number) => Int16Array | Promise<Int16Array>,
75
+ ): Promise<Uint8Array>;
76
+ export declare function readCollectionFile(source: string, path: string): Promise<Uint8Array | null>;
77
+ export declare function fetchSoundFont(options: FetchSoundFontOptions): Promise<FetchedSoundFont>;
78
+ /** the credits note that travels beside a fetched font */
79
+ export declare function creditsFor(font: SoundFontCatalogEntry, programs: SoundFontProgramRef[]): string;
@@ -0,0 +1,301 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ /**
8
+ * `mwg-soundfonts`: the developer-side half of choosing a SoundFont. The package ships one
9
+ * small bank (`data/microgm-opt.sf2`); a game that wants another picks it from the catalogue
10
+ * (`data/soundfont-catalog.json`, a licence-classified snapshot of the marmooo free-soundfonts
11
+ * collection), and this tool fetches only the instruments the game's MIDI files use and merges
12
+ * them into one font for the asset folder, where the build subsets it again like any other.
13
+ *
14
+ * The merged font stays a SoundFont 3 (Ogg Vorbis samples, a fraction of the size) by default:
15
+ * nothing is decoded here, and the game decodes it where it runs with `loadSoundFont`.
16
+ * `--sf2` decodes it into a plain SoundFont 2 instead, which is `ffmpeg`'s job: needed on a
17
+ * developer machine only, never by the player, and named when missing. Nothing here runs in a
18
+ * browser.
19
+ */
20
+
21
+ const HERE = dirname(fileURLToPath(import.meta.url));
22
+ export const CATALOG_PATH = join(HERE, '..', 'data', 'soundfont-catalog.json');
23
+
24
+ /** licence classes a font may be fetched under without saying so */
25
+ const FREE_TO_SHIP = new Set(['public-domain', 'permissive', 'attribution']);
26
+
27
+ /**
28
+ * The SoundFont implementation lives in the library, not in this tool: in this repository it is
29
+ * the TypeScript source, in an installed package the built `dist/` it compiles to (the same
30
+ * lookup `compile-resources` makes).
31
+ */
32
+ async function loadLibrary() {
33
+ try {
34
+ const [soundFont, midi] = await Promise.all([
35
+ import('../src/audio/SoundFont.ts'),
36
+ import('../src/audio/Midi.ts'),
37
+ ]);
38
+ return { ...soundFont, ...midi };
39
+ } catch {
40
+ return import('../dist/audio/index.js');
41
+ }
42
+ }
43
+
44
+ /** the catalogue as parsed JSON: `{ source, default, bundled, fonts }` */
45
+ export function readCatalog(path = CATALOG_PATH) {
46
+ return JSON.parse(readFileSync(path, 'utf8'));
47
+ }
48
+
49
+ /** a catalogue entry (bundled bank included) by name, or undefined */
50
+ export function findFont(catalog, name) {
51
+ return [...catalog.bundled, ...catalog.fonts].find((font) => font.name === name);
52
+ }
53
+
54
+ /** `"0,24,128:0"` as `[{ bank, program }]`: a bare number is a program of bank 0, `bank:program` names both */
55
+ export function parseProgramList(text) {
56
+ return text
57
+ .split(',')
58
+ .map((item) => item.trim())
59
+ .filter(Boolean)
60
+ .map((item) => {
61
+ const [first, second] = item.split(':');
62
+ const bank = second === undefined ? 0 : Number(first);
63
+ const program = Number(second === undefined ? first : second);
64
+ if (
65
+ !Number.isInteger(bank) ||
66
+ !Number.isInteger(program) ||
67
+ bank < 0 ||
68
+ bank > 128 ||
69
+ program < 0 ||
70
+ program > 127
71
+ ) {
72
+ throw new Error(`invalid program "${item}": expected a program 0-127 or bank:program`);
73
+ }
74
+ return { bank, program };
75
+ });
76
+ }
77
+
78
+ /**
79
+ * mono 16-bit PCM from Ogg Vorbis bytes, through `ffmpeg`, at the rate the stream carries (the
80
+ * `rate` a font states for the sample is the same one). The bytes come from a font file that may
81
+ * have been downloaded, so they are not trusted: anything that does not start with the Ogg magic
82
+ * is refused before a process is spawned, the demuxer is forced to Ogg rather than probed (a
83
+ * probed "sample" could be read as a playlist naming local files), and only the `pipe` protocol
84
+ * is allowed.
85
+ */
86
+ export function ffmpegDecode(ogg) {
87
+ if (Buffer.from(ogg.subarray(0, 4)).toString('latin1') !== 'OggS') {
88
+ throw new Error('a compressed sample is not Ogg Vorbis data: the font is damaged or not a SoundFont 3');
89
+ }
90
+ const run = spawnSync(
91
+ 'ffmpeg',
92
+ [
93
+ '-v',
94
+ 'error',
95
+ '-protocol_whitelist',
96
+ 'pipe',
97
+ '-f',
98
+ 'ogg',
99
+ '-i',
100
+ 'pipe:0',
101
+ '-f',
102
+ 's16le',
103
+ '-ac',
104
+ '1',
105
+ 'pipe:1',
106
+ ],
107
+ { input: ogg, maxBuffer: 256 * 1024 * 1024 },
108
+ );
109
+ if (run.error?.code === 'ENOENT') {
110
+ throw new Error('ffmpeg was not found on PATH: install it to decode SoundFont 3 (developer machines only)');
111
+ }
112
+ if (run.status !== 0) throw new Error(`ffmpeg could not decode a sample: ${run.stderr}`);
113
+ const out = run.stdout;
114
+ return new Int16Array(out.buffer.slice(out.byteOffset, out.byteOffset + out.length - (out.length & 1)));
115
+ }
116
+
117
+ /** a SoundFont 3 decoded into SoundFont 2 bytes (`decode` is `ffmpeg` unless given) */
118
+ export async function sf3ToSf2(bytes, decode = ffmpegDecode) {
119
+ const { decodeSoundFont } = await loadLibrary();
120
+ return new Uint8Array(await decodeSoundFont(bytes, decode));
121
+ }
122
+
123
+ const pad = (value, width) => String(value).padStart(width, '0');
124
+
125
+ /** reads one file of a collection laid out as `<source>/<font>/<bank>/<program>.sf3`, from a folder or a URL; null when absent */
126
+ export async function readCollectionFile(source, path) {
127
+ if (/^https?:\/\//.test(source)) {
128
+ const response = await fetch(
129
+ `${source.replace(/\/$/, '')}/${path.split('/').map(encodeURIComponent).join('/')}`,
130
+ );
131
+ if (response.status === 404) return null;
132
+ if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
133
+ return new Uint8Array(await response.arrayBuffer());
134
+ }
135
+ const file = resolve(source, path);
136
+ return existsSync(file) ? new Uint8Array(readFileSync(file)) : null;
137
+ }
138
+
139
+ /**
140
+ * Fetches `programs` (`[{ bank, program }]`) of the catalogue font `name` and merges them into
141
+ * one font: a SoundFont 3 with the compressed samples untouched, or with `format: 'sf2'` a
142
+ * SoundFont 2 decoded through `decode` (`ffmpeg` by default). Returns
143
+ * `{ bytes, format, found, missing, font }`: `missing` lists the programs the collection does
144
+ * not carry (the renderer falls back for them as it does for any font). A font whose licence
145
+ * class is not free to ship is refused unless `acceptLicence` is set, and a font that is not in
146
+ * the catalogue is refused outright.
147
+ */
148
+ export async function fetchSoundFont({
149
+ name,
150
+ programs,
151
+ catalog = readCatalog(),
152
+ source,
153
+ acceptLicence = false,
154
+ format = 'sf3',
155
+ decode,
156
+ read,
157
+ }) {
158
+ const font = catalog.fonts.find((entry) => entry.name === name);
159
+ if (!font) {
160
+ const bundled = catalog.bundled.find((entry) => entry.name === name);
161
+ throw new Error(
162
+ bundled
163
+ ? `${name} ships in the package as ${bundled.file}: copy that file instead of fetching it`
164
+ : `${name} is not in the catalogue: run "mwg-soundfonts list"`,
165
+ );
166
+ }
167
+ if (!FREE_TO_SHIP.has(font.licenceClass) && !acceptLicence) {
168
+ throw new Error(
169
+ `${name} is ${font.licence} (${font.licenceClass})${font.note ? `: ${font.note}` : ''}. ` +
170
+ 'Pass --accept-licence once you have read its terms and are sure the game may ship it.',
171
+ );
172
+ }
173
+ if (format !== 'sf3' && format !== 'sf2') throw new Error(`unknown format "${format}": expected sf3 or sf2`);
174
+ if (programs.length === 0) throw new Error('no programs requested: pass --midi <files> or --programs <list>');
175
+ const origin = source ?? catalog.source;
176
+ const reader = read ?? ((path) => readCollectionFile(origin, path));
177
+ const { mergeSoundFonts, decodeSoundFont } = await loadLibrary();
178
+
179
+ const found = [];
180
+ const missing = [];
181
+ const files = [];
182
+ const seen = new Set();
183
+ for (const { bank, program } of programs) {
184
+ const key = `${bank}:${program}`;
185
+ if (seen.has(key)) continue;
186
+ seen.add(key);
187
+ const bytes = await reader(`${name}/${pad(bank, 3)}/${pad(program, 3)}.sf3`);
188
+ if (!bytes) {
189
+ missing.push({ bank, program });
190
+ continue;
191
+ }
192
+ files.push(bytes);
193
+ found.push({ bank, program });
194
+ }
195
+ if (files.length === 0) throw new Error(`${name} carries none of the requested programs`);
196
+ const merged = mergeSoundFonts(files);
197
+ const bytes = new Uint8Array(format === 'sf2' ? await decodeSoundFont(merged, decode ?? ffmpegDecode) : merged);
198
+ return { bytes, format, found, missing, font };
199
+ }
200
+
201
+ /** the credits note that travels beside a fetched font, so the licence is not lost */
202
+ export function creditsFor(font, programs) {
203
+ return [
204
+ `${font.name}`,
205
+ `Licence: ${font.licence} (${font.licenceClass})`,
206
+ ...(font.web ? [`Source: ${font.web}`] : []),
207
+ `Programs: ${programs.map(({ bank, program }) => `${bank}:${program}`).join(', ')}`,
208
+ ...(font.licenceClass === 'attribution' ? ['Credit the author of this font in your game.'] : []),
209
+ ...(font.licenceClass === 'share-alike' || font.licenceClass === 'copyleft'
210
+ ? ['This licence carries sharing obligations: read it before shipping a game that embeds this font.']
211
+ : []),
212
+ '',
213
+ ].join('\n');
214
+ }
215
+
216
+ const USAGE = `usage: mwg-soundfonts <command>
217
+ list the bundled bank and every catalogue font, with licence and size
218
+ fetch <font> (--midi <file>... | --programs <list>) [--out <file>] [--source <url|folder>] [--accept-licence] [--sf2]
219
+ one font holding just those instruments (programs: 0,24,128:0),
220
+ a SoundFont 3 the game decodes with loadSoundFont, or with --sf2
221
+ a decoded SoundFont 2 (needs ffmpeg on PATH)
222
+ convert <in.sf3> <out.sf2> SoundFont 3 to SoundFont 2 (needs ffmpeg on PATH)`;
223
+
224
+ function formatList(catalog) {
225
+ const rows = [
226
+ ...catalog.bundled.map((font) => ({ ...font, shipped: 'bundled in the package: ' + font.file })),
227
+ ...catalog.fonts.map((font) => ({
228
+ ...font,
229
+ shipped: `${font.gmMB} MB for a full GM bank, ${font.fullMB} MB in all (sf3)`,
230
+ })),
231
+ ];
232
+ return rows
233
+ .map((font) => {
234
+ const free = FREE_TO_SHIP.has(font.licenceClass) ? '' : ' [read the licence first]';
235
+ return `${font.name}${font.name === catalog.default ? ' (default)' : ''}\n ${font.licence} (${font.licenceClass})${free}\n ${font.shipped}`;
236
+ })
237
+ .join('\n');
238
+ }
239
+
240
+ async function main(argv) {
241
+ const [command, ...rest] = argv;
242
+ if (!command || command === '--help' || command === '-h') {
243
+ console.log(USAGE);
244
+ return;
245
+ }
246
+ const flag = (name) => {
247
+ const at = rest.indexOf(name);
248
+ return at === -1 ? undefined : rest[at + 1];
249
+ };
250
+ const valuesAfter = (name) => {
251
+ const at = rest.indexOf(name);
252
+ if (at === -1) return [];
253
+ const out = [];
254
+ for (let index = at + 1; index < rest.length && !rest[index].startsWith('--'); index++) out.push(rest[index]);
255
+ return out;
256
+ };
257
+ if (command === 'list') {
258
+ console.log(formatList(readCatalog()));
259
+ } else if (command === 'convert') {
260
+ const [input, output] = rest;
261
+ if (!input || !output) throw new Error('usage: mwg-soundfonts convert <in.sf3> <out.sf2>');
262
+ writeFileSync(output, await sf3ToSf2(readFileSync(input)));
263
+ console.log(`wrote ${output}`);
264
+ } else if (command === 'fetch') {
265
+ const name = rest.find((arg) => !arg.startsWith('--'));
266
+ if (!name) throw new Error('usage: mwg-soundfonts fetch <font> (--midi <file>... | --programs <list>)');
267
+ const programs = flag('--programs') ? parseProgramList(flag('--programs')) : [];
268
+ const midiFiles = valuesAfter('--midi');
269
+ if (midiFiles.length > 0) {
270
+ const { collectSoundFontUsage, parseMidi } = await loadLibrary();
271
+ programs.push(...collectSoundFontUsage(midiFiles.map((file) => parseMidi(readFileSync(file)))));
272
+ }
273
+ const result = await fetchSoundFont({
274
+ name,
275
+ programs,
276
+ source: flag('--source'),
277
+ acceptLicence: rest.includes('--accept-licence'),
278
+ format: rest.includes('--sf2') ? 'sf2' : 'sf3',
279
+ });
280
+ const out = resolve(flag('--out') ?? `${name}.${result.format}`);
281
+ mkdirSync(dirname(out), { recursive: true });
282
+ writeFileSync(out, result.bytes);
283
+ writeFileSync(out.replace(/\.sf[23]$/, '') + '.credits.txt', creditsFor(result.font, result.found));
284
+ console.log(`wrote ${out}: ${result.found.length} programs, ${(result.bytes.length / 1024).toFixed(0)} KB`);
285
+ if (result.missing.length > 0) {
286
+ console.log(
287
+ `not in ${name}: ${result.missing.map(({ bank, program }) => `${bank}:${program}`).join(', ')}`,
288
+ );
289
+ }
290
+ } else {
291
+ throw new Error(`unknown command "${command}"\n${USAGE}`);
292
+ }
293
+ }
294
+
295
+ //realpath, since an npm `bin` shim reaches this file through a symlink
296
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
297
+ main(process.argv.slice(2)).catch((error) => {
298
+ console.error(error instanceof Error ? error.message : error);
299
+ process.exitCode = 1;
300
+ });
301
+ }
@@ -105,9 +105,13 @@ export async function verifyScenarios({ file, turns = 3, timeoutMs = 120000, onl
105
105
  if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
106
106
  const [file, ...args] = process.argv.slice(2);
107
107
  const options = { file };
108
+ const usage = 'usage: mwg-scenarios <built index.html> [--turns N] [--timeout-ms N] [--only prefix]';
109
+ if (file === '--help' || file === '-h') {
110
+ console.log(usage);
111
+ process.exit(0);
112
+ }
108
113
  try {
109
- if (!file)
110
- throw new Error('usage: mwg-scenarios <built index.html> [--turns N] [--timeout-ms N] [--only prefix]');
114
+ if (!file) throw new Error(usage);
111
115
  for (let i = 0; i < args.length; i += 2) {
112
116
  const name = { '--turns': 'turns', '--timeout-ms': 'timeoutMs', '--only': 'only' }[args[i]];
113
117
  if (!name || args[i + 1] === undefined) throw new Error(`invalid option: ${args[i]}`);
package/tools/vite.mjs CHANGED
@@ -14,7 +14,7 @@ import { buildArtifactSbom, serialize, shippedFiles } from './sbom.mjs';
14
14
  * build. `assetFiles: ['characters/hero.svg', 'music/theme.ogg']` embeds only those paths;
15
15
  * omit it for all assets, or pass [] for none. An unknown entry fails by name. Filtering
16
16
  * affects the build only, so the dev server still serves the asset folder normally.
17
- * An embedded `.sf2` is subset at build time to the programs the embedded `.mid` files
17
+ * An embedded `.sf2` or `.sf3` is subset at build time to the programs the embedded `.mid` files
18
18
  * use (`soundFont: false` embeds it whole, `soundFontUsage` names the programs instead).
19
19
  * At the end of the build it runs `emitPage` over the output folder with the same
20
20
  * options. A setting the game's own config gives explicitly still wins, since vite merges a