@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.
- package/CHANGELOG.md +69 -0
- package/REFERENCE.md +2115 -0
- package/api-surface.json +48 -0
- package/data/microgm-opt.PROVENANCE.md +3 -0
- package/data/soundfont-catalog.json +183 -0
- package/dist/ai/index.d.ts +10 -0
- package/dist/ai/index.d.ts.map +1 -1
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/lua.d.ts +6 -0
- package/dist/ai/lua.d.ts.map +1 -1
- package/dist/ai/lua.js.map +1 -1
- package/dist/audio/SampleDecoder.d.ts +41 -0
- package/dist/audio/SampleDecoder.d.ts.map +1 -0
- package/dist/audio/SampleDecoder.js +70 -0
- package/dist/audio/SampleDecoder.js.map +1 -0
- package/dist/audio/SoundFont.d.ts +59 -0
- package/dist/audio/SoundFont.d.ts.map +1 -1
- package/dist/audio/SoundFont.js +248 -2
- package/dist/audio/SoundFont.js.map +1 -1
- package/dist/audio/index.d.ts +3 -1
- package/dist/audio/index.d.ts.map +1 -1
- package/dist/audio/index.js +2 -1
- package/dist/audio/index.js.map +1 -1
- package/dist/mw_games.global.js +11 -11
- package/dist/mwl/presentation.d.ts +16 -0
- package/dist/mwl/presentation.d.ts.map +1 -1
- package/dist/mwl/presentation.js.map +1 -1
- package/dist/mwl/pyroeis-runtime.d.ts +7 -0
- package/dist/mwl/pyroeis-runtime.d.ts.map +1 -1
- package/dist/mwl/pyroeis-runtime.js.map +1 -1
- package/dist/mwl/pyroeis.d.ts +3 -0
- package/dist/mwl/pyroeis.d.ts.map +1 -1
- package/dist/mwl/pyroeis.js.map +1 -1
- package/dist/physics/PhysicsWorld.d.ts +3 -0
- package/dist/physics/PhysicsWorld.d.ts.map +1 -1
- package/dist/physics/PhysicsWorld.js.map +1 -1
- package/dist/physics/XpbdWorld.d.ts +11 -0
- package/dist/physics/XpbdWorld.d.ts.map +1 -1
- package/dist/physics/XpbdWorld.js.map +1 -1
- package/dist/physics/cloth/index.d.ts +20 -19
- package/dist/physics/cloth/index.d.ts.map +1 -1
- package/dist/physics/cloth/index.js +19 -0
- package/dist/physics/cloth/index.js.map +1 -1
- package/dist/physics/spring/index.d.ts +19 -18
- package/dist/physics/spring/index.d.ts.map +1 -1
- package/dist/physics/spring/index.js +23 -0
- package/dist/physics/spring/index.js.map +1 -1
- package/dist/three-d/Character3D.d.ts +8 -7
- package/dist/three-d/Character3D.d.ts.map +1 -1
- package/dist/three-d/Character3D.js +7 -0
- package/dist/three-d/Character3D.js.map +1 -1
- package/dist/three-d/Collision3D.d.ts +4 -0
- package/dist/three-d/Collision3D.d.ts.map +1 -1
- package/dist/three-d/Collision3D.js.map +1 -1
- package/dist/three-d/Engine3D.d.ts +2 -0
- package/dist/three-d/Engine3D.d.ts.map +1 -1
- package/dist/three-d/Engine3D.js.map +1 -1
- package/dist/three-d/Grid.d.ts +5 -0
- package/dist/three-d/Grid.d.ts.map +1 -1
- package/dist/three-d/Grid.js.map +1 -1
- package/dist/three-d/Heightmap.d.ts +3 -0
- package/dist/three-d/Heightmap.d.ts.map +1 -1
- package/dist/three-d/Heightmap.js.map +1 -1
- package/dist/three-d/TileGrid3D.d.ts +6 -0
- package/dist/three-d/TileGrid3D.d.ts.map +1 -1
- package/dist/three-d/TileGrid3D.js.map +1 -1
- package/dist/three-d/Vox.d.ts +7 -0
- package/dist/three-d/Vox.d.ts.map +1 -1
- package/dist/three-d/Vox.js.map +1 -1
- package/dist/two-d/presentation/MwlPresentation.d.ts +7 -0
- package/dist/two-d/presentation/MwlPresentation.d.ts.map +1 -1
- package/dist/two-d/presentation/MwlPresentation.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +16 -1
- package/sbom.cdx.json +139 -139
- package/tools/benchmark-browser.mjs +6 -0
- package/tools/bundle-size.mjs +4 -0
- package/tools/compile-resources.mjs +3 -2
- package/tools/init.mjs +3 -1
- package/tools/make-example-assets.mjs +4 -0
- package/tools/multiplayer-server.mjs +4 -0
- package/tools/mwl.mjs +7 -0
- package/tools/play-uci.mjs +4 -0
- package/tools/sbom.mjs +4 -0
- package/tools/soundfonts.d.mts +79 -0
- package/tools/soundfonts.mjs +301 -0
- package/tools/verify-scenarios.mjs +6 -2
- package/tools/vite.mjs +1 -1
- 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';
|
package/tools/bundle-size.mjs
CHANGED
|
@@ -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) =>
|
|
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 (
|
|
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]',
|
package/tools/play-uci.mjs
CHANGED
|
@@ -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
|