@wasm-gaming/engine-specs 0.2.2 → 0.2.4

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/demo/README.md CHANGED
@@ -55,6 +55,10 @@ To apply your custom branding, override any of the `:root` variables in `demo.cs
55
55
  --demo-picker-bg: rgba(255, 255, 255, 0.1);
56
56
  --demo-picker-hover-bg: rgba(255, 255, 255, 0.2);
57
57
  --demo-picker-text: #ffffff;
58
+
59
+ /* Launch button */
60
+ --demo-launch-bg: var(--demo-accent);
61
+ --demo-launch-text: #ffffff;
58
62
  }
59
63
  ```
60
64
 
@@ -80,6 +84,8 @@ To apply your custom branding, override any of the `:root` variables in `demo.cs
80
84
  | `--demo-picker-bg` | Pick File button background | `rgba(255, 255, 255, 0.5)` |
81
85
  | `--demo-pill-bg` | Metadata badge background | `rgba(var(--demo-accent-rgb), 0.1)` |
82
86
  | `--demo-file-info-bg` | Canvas overlay panel background | `rgba(255, 255, 255, 0.7)` |
87
+ | `--demo-launch-bg` | Launch button background color | `var(--demo-accent)` |
88
+ | `--demo-launch-text` | Launch button text color | `#ffffff` |
83
89
  | `--demo-font-sans` | Primary sans-serif font family | `"Outfit", system-ui, sans-serif` |
84
90
  | `--demo-font-mono` | Monospace code font family | `"Space Mono", monospace` |
85
91
 
@@ -87,8 +93,10 @@ To apply your custom branding, override any of the `:root` variables in `demo.cs
87
93
 
88
94
  ## 🛠 Features
89
95
 
96
+ - **OPFS Asset Persistence**: Automatically stores selected ROM and BIOS files in the Origin Private File System (`navigator.storage.getDirectory()`), restoring them automatically across page reloads.
97
+ - **Split Asset Drop Zones**: Independent drop/picker areas for optional **BIOS** firmware and game **ROM** assets.
98
+ - **Dedicated Launch Trigger**: Prominent launch button that activates once required game assets are selected.
90
99
  - **Automated Manifest Integration**: Page title, SDK info, asset key mapping, and file type (`accept`) filters are automatically loaded from `sdk.manifest`.
91
- - **ROM Drag & Drop**: Interactive drop zone with file picker fallback.
92
100
  - **Checksum Calculation**: Computes MD5, SHA-1, and SHA-256 hashes of loaded files asynchronously in the background.
93
101
  - **Shadow DOM Isolation**: UI components are isolated using Shadow DOM to prevent style leakage.
94
102
  - **Subpath Hosting Ready**: Uses `import.meta.url` for component templates to work seamlessly on subpaths (e.g. GitHub Pages).
@@ -0,0 +1,170 @@
1
+ <section class="drop" :class.active="dragging" :class.selected="selectedName"
2
+ @dragover.prevent="dragging = true"
3
+ @dragleave="dragging = false"
4
+ @drop.prevent="onDrop">
5
+ <strong>{{ prompt }}</strong>
6
+ <p :class.file-selected="selectedName">{{ selectedName ? 'Selected: ' + selectedName : assetHint }}</p>
7
+ <div class="picker-actions">
8
+ <label class="picker">
9
+ {{ selectedName ? 'Change File' : buttonText }}
10
+ <input type="file" :accept="accept" @change="onPick" />
11
+ </label>
12
+ <button type="button" :if="selectedName" class="remove-btn" @click.stop="onRemove">
13
+ Remove
14
+ </button>
15
+ </div>
16
+ </section>
17
+
18
+ <script :setup="{
19
+ prompt,
20
+ assetHint = 'or choose one from your computer',
21
+ accept,
22
+ buttonText = 'Pick file',
23
+ fileName = '',
24
+ }">
25
+ let dragging = false;
26
+ let selectedName = fileName || "";
27
+
28
+ const extractFile = (arg) => {
29
+ if (!arg) return null;
30
+ if (arg instanceof File) return arg;
31
+ if (arg.detail instanceof File) return arg.detail;
32
+ if (arg.target?.files?.item(0)) return arg.target.files.item(0);
33
+ if (arg.dataTransfer?.files?.item(0)) return arg.dataTransfer.files.item(0);
34
+ return null;
35
+ };
36
+
37
+ const emitFile = (file) => {
38
+ if (!file) {
39
+ selectedName = "";
40
+ $emit("remove");
41
+ $emit("select", null);
42
+ return;
43
+ }
44
+ selectedName = file.name;
45
+ $emit("select", file);
46
+ };
47
+
48
+ const onDrop = (event) => {
49
+ dragging = false;
50
+ const file = extractFile(event);
51
+ if (file) emitFile(file);
52
+ };
53
+
54
+ const onPick = (event) => {
55
+ const file = extractFile(event);
56
+ if (file) emitFile(file);
57
+ };
58
+
59
+ const onRemove = () => {
60
+ selectedName = "";
61
+ $emit("remove");
62
+ $emit("select", null);
63
+ };
64
+ </script>
65
+
66
+ <style>
67
+ .drop {
68
+ border: 2px dashed var(--demo-drop-border, rgba(255, 255, 255, 0.6));
69
+ border-radius: var(--demo-subpanel-radius, 8px);
70
+ padding: 2rem 1.25rem;
71
+ text-align: center;
72
+ transition: all 250ms ease;
73
+ background: var(--demo-drop-bg, rgba(255, 255, 255, 0.15));
74
+ backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
75
+ -webkit-backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
76
+ }
77
+
78
+ .drop.active {
79
+ border-color: var(--demo-drop-active-border, var(--demo-accent2, #0284c7));
80
+ background: var(--demo-drop-active-bg, rgba(255, 255, 255, 0.4));
81
+ box-shadow: var(--demo-drop-active-shadow, inset 0 0 0 1px rgba(255, 255, 255, 0.8));
82
+ }
83
+
84
+ .drop.selected {
85
+ border-style: solid;
86
+ border-color: var(--demo-accent2, #0284c7);
87
+ background: var(--demo-subpanel-bg, rgba(255, 255, 255, 0.35));
88
+ }
89
+
90
+ .drop strong {
91
+ display: block;
92
+ margin-bottom: 0.5rem;
93
+ font-size: 1.1rem;
94
+ font-weight: 600;
95
+ text-transform: uppercase;
96
+ letter-spacing: 0.05em;
97
+ color: var(--demo-text, #0f172a);
98
+ }
99
+
100
+ .drop p {
101
+ margin: 0.2rem 0;
102
+ color: var(--demo-muted, #475569);
103
+ font-family: var(--demo-font-mono, monospace);
104
+ font-size: 0.85rem;
105
+ word-break: break-all;
106
+ }
107
+
108
+ .drop p.file-selected {
109
+ color: var(--demo-text, #0f172a);
110
+ font-weight: 600;
111
+ }
112
+
113
+ .picker-actions {
114
+ display: inline-flex;
115
+ align-items: center;
116
+ gap: 0.5rem;
117
+ margin-top: 1rem;
118
+ }
119
+
120
+ .picker {
121
+ display: inline-flex;
122
+ background: var(--demo-picker-bg, rgba(255, 255, 255, 0.5));
123
+ color: var(--demo-picker-text, var(--demo-text, #0f172a));
124
+ border: 1px solid var(--demo-picker-border, rgba(255, 255, 255, 0.6));
125
+ border-radius: var(--demo-subpanel-radius, 8px);
126
+ padding: 0.5rem 1rem;
127
+ cursor: pointer;
128
+ text-transform: uppercase;
129
+ font-weight: 600;
130
+ font-size: 0.85rem;
131
+ letter-spacing: 0.05em;
132
+ transition: all 0.2s;
133
+ backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
134
+ -webkit-backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
135
+ }
136
+
137
+ .picker:hover {
138
+ background: var(--demo-picker-hover-bg, rgba(255, 255, 255, 0.8));
139
+ border-color: var(--demo-picker-hover-border, rgba(255, 255, 255, 0.9));
140
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
141
+ }
142
+
143
+ .picker input {
144
+ display: none;
145
+ }
146
+
147
+ .remove-btn {
148
+ display: inline-flex;
149
+ align-items: center;
150
+ background: rgba(239, 68, 68, 0.15);
151
+ color: #ef4444;
152
+ border: 1px solid rgba(239, 68, 68, 0.4);
153
+ border-radius: var(--demo-subpanel-radius, 8px);
154
+ padding: 0.5rem 0.8rem;
155
+ cursor: pointer;
156
+ text-transform: uppercase;
157
+ font-weight: 600;
158
+ font-size: 0.85rem;
159
+ letter-spacing: 0.05em;
160
+ transition: all 0.2s;
161
+ backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
162
+ -webkit-backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
163
+ }
164
+
165
+ .remove-btn:hover {
166
+ background: rgba(239, 68, 68, 0.3);
167
+ border-color: rgba(239, 68, 68, 0.8);
168
+ color: #dc2626;
169
+ }
170
+ </style>
@@ -1,53 +1,129 @@
1
- <form class="launcher">
1
+ <form class="launcher" @submit.prevent="onLaunch">
2
2
  <section class="sdk">
3
3
  <CSdkInfo ...sdkInfo />
4
4
  </section>
5
- <section class="drop" :class.active="dragging" @dragover.prevent="dragging = true" @dragleave="dragging = false"
6
- @drop.prevent="onDrop">
7
- <strong>Drop your ROM/asset file here</strong>
8
- <p>{{ assetHint }}</p>
9
- <label class="picker">
10
- Pick file
11
- <input type="file" :accept="accept" @change="onPick" />
12
- </label>
13
- </section>
5
+
6
+ <div class="drop-grid" :class.single="!showBIOS">
7
+ <CDropArea
8
+ :if="showBIOS"
9
+ :prompt="requiresBIOS ? 'Drop BIOS File (Required)' : 'Drop BIOS File (Optional)'"
10
+ :asset-hint="biosAssetHint"
11
+ :accept="biosAccept"
12
+ :button-text="'Pick BIOS'"
13
+ :file-name="biosFile?.name"
14
+ @select="onBiosSelect"
15
+ @remove="onBiosRemove"
16
+ />
17
+ <CDropArea
18
+ :prompt="'Drop ROM File'"
19
+ :asset-hint="romAssetHint"
20
+ :accept="romAccept"
21
+ :button-text="'Pick ROM'"
22
+ :file-name="romFile?.name"
23
+ @select="onRomSelect"
24
+ @remove="onRomRemove"
25
+ />
26
+ </div>
27
+
28
+ <button type="submit" class="launch-btn" :disabled="!canLaunch">
29
+ Launch Engine
30
+ </button>
31
+
14
32
  <div class="status">{{ status }}</div>
15
33
  </form>
16
34
 
17
35
  <script :setup="{
18
36
  CSdkInfo,
37
+ CDropArea,
19
38
  sdkInfo,
20
- onFile,
21
- accept,
22
- assetHint = 'or choose one from your computer',
39
+ onLaunchFiles,
40
+ onSaveBios,
41
+ onSaveRom,
42
+ onRemoveBios,
43
+ onRemoveRom,
44
+ initialRomFile = null,
45
+ initialBiosFile = null,
46
+ romAccept,
47
+ romAssetHint = 'or choose ROM file from computer',
48
+ biosAccept,
49
+ biosAssetHint = 'optional BIOS firmware file',
50
+ requiresBIOS = false,
51
+ showBIOS = false,
23
52
  }">
24
- let status = "Ready";
25
- let dragging = false;
53
+ let romFile = initialRomFile;
54
+ let biosFile = initialBiosFile;
55
+
56
+ let canLaunch = Boolean(romFile && (!requiresBIOS || biosFile));
57
+ let status = "";
58
+
59
+ const updateStatus = () => {
60
+ canLaunch = Boolean(romFile && (!requiresBIOS || biosFile));
61
+ if (requiresBIOS && !biosFile && !romFile) {
62
+ status = "Ready - Please select ROM and BIOS files to launch.";
63
+ } else if (requiresBIOS && !biosFile) {
64
+ status = "BIOS file is required to launch.";
65
+ } else if (!romFile) {
66
+ status = "Ready - Please select a ROM file to launch.";
67
+ } else {
68
+ status = `Ready to launch! (${romFile.name}${biosFile ? ' + BIOS' : ''})`;
69
+ }
70
+ };
71
+
72
+ updateStatus();
73
+
74
+ const extractFile = (arg) => {
75
+ if (!arg) return null;
76
+ if (arg instanceof File) return arg;
77
+ if (arg.detail instanceof File) return arg.detail;
78
+ if (arg.detail) return extractFile(arg.detail);
79
+ return null;
80
+ };
26
81
 
27
- const onFileList = async (files) => {
28
- const file = files.item(0);
82
+ const onRomSelect = (arg) => {
83
+ const file = extractFile(arg);
29
84
  if (!file) {
85
+ onRomRemove();
30
86
  return;
31
87
  }
32
- try {
33
- status = `Loading ${file.name}...`;
34
- await onFile(file);
35
- } catch (error) {
36
- const message = error instanceof Error ? error.message : String(error);
37
- status = `Failed to load file: ${message}`;
38
- }
88
+ romFile = file;
89
+ updateStatus();
90
+ onSaveRom?.(file);
39
91
  };
40
92
 
41
- const onDrop = async (event) => {
42
- dragging = false;
43
- if (event.dataTransfer?.files) {
44
- await onFileList(event.dataTransfer.files);
93
+ const onBiosSelect = (arg) => {
94
+ const file = extractFile(arg);
95
+ if (!file) {
96
+ onBiosRemove();
97
+ return;
45
98
  }
99
+ biosFile = file;
100
+ updateStatus();
101
+ onSaveBios?.(file);
46
102
  };
47
103
 
48
- const onPick = async (event) => {
49
- if (event.target.files) {
50
- await onFileList(event.target.files);
104
+ const onRomRemove = () => {
105
+ romFile = null;
106
+ updateStatus();
107
+ onRemoveRom?.();
108
+ };
109
+
110
+ const onBiosRemove = () => {
111
+ biosFile = null;
112
+ updateStatus();
113
+ onRemoveBios?.();
114
+ };
115
+
116
+ const onLaunch = async () => {
117
+ if (!canLaunch) {
118
+ updateStatus();
119
+ return;
120
+ }
121
+ try {
122
+ status = `Launching with ${romFile.name}...`;
123
+ await onLaunchFiles({ rom: romFile, bios: biosFile });
124
+ } catch (error) {
125
+ const message = error instanceof Error ? error.message : String(error);
126
+ status = `Failed to load engine: ${message}`;
51
127
  }
52
128
  };
53
129
  </script>
@@ -89,66 +165,47 @@
89
165
  box-shadow: var(--demo-subpanel-shadow, inset 0 0 0 1px rgba(255, 255, 255, 0.6));
90
166
  }
91
167
 
92
- .drop {
93
- border: 2px dashed var(--demo-drop-border, rgba(255, 255, 255, 0.6));
94
- border-radius: var(--demo-subpanel-radius, 8px);
95
- padding: 2.5rem 1.5rem;
96
- text-align: center;
97
- transition: all 250ms ease;
98
- background: var(--demo-drop-bg, rgba(255, 255, 255, 0.15));
99
- backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
100
- -webkit-backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
168
+ .drop-grid {
169
+ display: grid;
170
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
171
+ gap: 1rem;
101
172
  }
102
173
 
103
- .drop.active {
104
- border-color: var(--demo-drop-active-border, var(--demo-accent2, #0284c7));
105
- background: var(--demo-drop-active-bg, rgba(255, 255, 255, 0.4));
106
- box-shadow: var(--demo-drop-active-shadow, inset 0 0 0 1px rgba(255, 255, 255, 0.8));
174
+ .drop-grid.single {
175
+ grid-template-columns: 1fr;
107
176
  }
108
177
 
109
- .drop strong {
110
- display: block;
111
- margin-bottom: 0.5rem;
112
- font-size: 1.2rem;
113
- font-weight: 600;
178
+ .launch-btn {
179
+ width: 100%;
180
+ padding: 0.9rem 1.5rem;
181
+ font-size: 1.1rem;
182
+ font-weight: 700;
183
+ font-family: var(--demo-font-sans, sans-serif);
114
184
  text-transform: uppercase;
115
- letter-spacing: 0.05em;
116
- color: var(--demo-text, #0f172a);
117
- }
118
-
119
- .drop p {
120
- margin: 0.2rem 0;
121
- color: var(--demo-muted, #475569);
122
- font-family: var(--demo-font-mono, monospace);
123
- font-size: 0.9rem;
124
- }
125
-
126
- .picker {
127
- display: inline-flex;
128
- margin-top: 1.2rem;
129
- background: var(--demo-picker-bg, rgba(255, 255, 255, 0.5));
130
- color: var(--demo-picker-text, var(--demo-text, #0f172a));
131
- border: 1px solid var(--demo-picker-border, rgba(255, 255, 255, 0.6));
185
+ letter-spacing: 0.08em;
186
+ color: var(--demo-launch-text, #ffffff);
187
+ background: var(--demo-launch-bg, var(--demo-accent, #ff007f));
188
+ border: none;
132
189
  border-radius: var(--demo-subpanel-radius, 8px);
133
- padding: 0.6rem 1.2rem;
134
190
  cursor: pointer;
135
- text-transform: uppercase;
136
- font-weight: 600;
137
- font-size: 0.9rem;
138
- letter-spacing: 0.05em;
139
- transition: all 0.2s;
140
- backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
141
- -webkit-backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
191
+ transition: all 0.2s ease;
192
+ box-shadow: var(--demo-accent-glow, 0 0 15px rgba(255, 0, 127, 0.3));
193
+ }
194
+
195
+ .launch-btn:hover:not(:disabled) {
196
+ filter: brightness(1.1);
197
+ transform: translateY(-1px);
142
198
  }
143
199
 
144
- .picker:hover {
145
- background: var(--demo-picker-hover-bg, rgba(255, 255, 255, 0.8));
146
- border-color: var(--demo-picker-hover-border, rgba(255, 255, 255, 0.9));
147
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
200
+ .launch-btn:active:not(:disabled) {
201
+ transform: translateY(0);
148
202
  }
149
203
 
150
- .picker input {
151
- display: none;
204
+ .launch-btn:disabled {
205
+ opacity: 0.5;
206
+ cursor: not-allowed;
207
+ box-shadow: none;
208
+ background: var(--demo-muted, #475569);
152
209
  }
153
210
 
154
211
  .status {
package/demo/demo.css CHANGED
@@ -93,7 +93,7 @@ body {
93
93
  letter-spacing: 0.02em;
94
94
  }
95
95
 
96
- body::after {
96
+ main:not(.running)::after {
97
97
  content: "";
98
98
  position: fixed;
99
99
  inset: 0;
package/demo/demo.js CHANGED
@@ -1,13 +1,20 @@
1
1
  import { getSdk } from "./sdk.js";
2
2
  import { checksumAlgorithms, computeChecksums } from "./checksums.js";
3
+ import { loadStoredAsset, saveStoredAsset, clearStoredAsset } from "./storage.js";
3
4
  import { $, $create, Component79 } from "https://jgermade.github.io/jq79/jq79.js";
4
5
 
6
+ // Safe URL parameter resolution from script URL & page URL
7
+ const pageUrl = new URL(location.href);
8
+ const scriptUrl = new URL(import.meta.url, location.href);
9
+ const biosParam = scriptUrl.searchParams.get("bios") ?? pageUrl.searchParams.get("bios");
10
+
5
11
  // Fetch component HTML templates using import.meta.url for resilient subpath resolution
6
12
  const fetchComponent = (path) => Component79.fetch(new URL(path, import.meta.url).href);
7
13
 
8
14
  // Fetch all components at once to leverage http2 request multiplexing.
9
- const [CLauncher, CSdkInfo, CFileInfo] = await Promise.all([
15
+ const [CLauncher, CDropArea, CSdkInfo, CFileInfo] = await Promise.all([
10
16
  fetchComponent("./components/launcher.html"),
17
+ fetchComponent("./components/drop-area.html"),
11
18
  fetchComponent("./components/sdk-info.html"),
12
19
  fetchComponent("./components/file-info.html"),
13
20
  ]);
@@ -23,15 +30,38 @@ if (sdk.manifest?.name) {
23
30
  document.title = `${sdk.manifest.name} - Demo`;
24
31
  }
25
32
 
26
- // Compute asset accept filter and hint text from manifest
27
- const acceptedExts = sdk.manifest?.assets?.flatMap((a) => a.accept ?? []) ?? [];
28
- const acceptAttr = acceptedExts.length > 0 ? acceptedExts.join(",") : undefined;
29
- const assetHint = acceptedExts.length > 0
30
- ? `Supported files: ${acceptedExts.join(", ")}`
31
- : "or choose one from your computer";
33
+ // Compute asset accept filters and hints from manifest
34
+ const romSpec = sdk.manifest?.assets?.find((a) => a.key === "rom") ?? sdk.manifest?.assets?.[0];
35
+ const biosSpec = sdk.manifest?.assets?.find((a) => a.key === "bios") ?? sdk.manifest?.assets?.[1];
36
+
37
+ // Determine BIOS visibility and requirement rules (Default: showBIOS = false)
38
+ const showBIOS = biosParam === "true" || biosParam === "optional" || biosParam === "required";
39
+ const requiresBIOS = biosParam === "required";
40
+
41
+ // Load previously stored ROM & BIOS from OPFS if available
42
+ const [storedRom, storedBios] = await Promise.all([
43
+ loadStoredAsset("rom"),
44
+ showBIOS ? loadStoredAsset("bios") : null,
45
+ ]);
46
+
47
+ const romAccept = romSpec?.accept?.join(",");
48
+ const romAssetHint = romSpec?.accept ? `Supported: ${romSpec.accept.join(", ")}` : "or choose ROM file from computer";
32
49
 
33
- const bootWithFile = async (file) => {
34
- const bytes = await file.arrayBuffer();
50
+ const biosAccept = biosSpec?.accept?.join(",");
51
+ const biosAssetHint = biosSpec?.description ?? (biosSpec?.accept ? `Supported: ${biosSpec.accept.join(", ")}` : (requiresBIOS ? "required BIOS firmware file" : "optional BIOS firmware file"));
52
+
53
+ async function toArrayBuffer(input) {
54
+ if (!input) return null;
55
+ if (input instanceof ArrayBuffer) return input;
56
+ if (ArrayBuffer.isView(input)) return input.buffer;
57
+ if (typeof input.arrayBuffer === "function") return await input.arrayBuffer();
58
+ if (input.detail) return await toArrayBuffer(input.detail);
59
+ throw new TypeError("Provided input is not a File, Blob, or ArrayBuffer.");
60
+ }
61
+
62
+ const bootWithFiles = async ({ rom, bios }) => {
63
+ const romBytes = await toArrayBuffer(rom);
64
+ const biosBytes = await toArrayBuffer(bios);
35
65
 
36
66
  mainEl.classList.add("running");
37
67
 
@@ -43,9 +73,10 @@ const bootWithFile = async (file) => {
43
73
 
44
74
  mainEl.replaceChildren(runtimeEl);
45
75
 
76
+ const primaryFile = rom || bios;
46
77
  const fileInfoProps = {
47
- fileName: file.name,
48
- size: `${bytes.byteLength.toLocaleString()} bytes`,
78
+ fileName: primaryFile.name,
79
+ size: `${primaryFile.size.toLocaleString()} bytes`,
49
80
  };
50
81
  const fileInfoPlaceholder = CFileInfo
51
82
  .renderShadow({
@@ -54,15 +85,23 @@ const bootWithFile = async (file) => {
54
85
  })
55
86
  .mount(fileInfoEl);
56
87
 
57
- const assetKey = sdk.manifest?.assets?.[0]?.key ?? "rom";
88
+ const assets = {};
89
+ if (romBytes && romSpec) {
90
+ assets[romSpec.key] = romBytes;
91
+ }
92
+ if (biosBytes && biosSpec) {
93
+ assets[biosSpec.key] = biosBytes;
94
+ }
95
+ if (Object.keys(assets).length === 0 && romBytes) {
96
+ assets.rom = romBytes;
97
+ }
98
+
58
99
  const instance = await sdk.load({
59
100
  attachTo: runtimeEl,
60
- assets: {
61
- [assetKey]: bytes,
62
- },
101
+ assets,
63
102
  options: {
64
- fileName: file.name,
65
- byteLength: bytes.byteLength,
103
+ fileName: primaryFile.name,
104
+ byteLength: primaryFile.size,
66
105
  },
67
106
  onEvent(event) {
68
107
  if (event.type === "error") {
@@ -73,7 +112,7 @@ const bootWithFile = async (file) => {
73
112
 
74
113
  instance.start();
75
114
 
76
- const checksums = await computeChecksums(bytes);
115
+ const checksums = await computeChecksums(romBytes || biosBytes);
77
116
  fileInfoPlaceholder.destroy();
78
117
 
79
118
  CFileInfo
@@ -84,6 +123,7 @@ const bootWithFile = async (file) => {
84
123
  CLauncher
85
124
  .render({
86
125
  CSdkInfo,
126
+ CDropArea,
87
127
  sdkInfo: {
88
128
  name: sdk.manifest.name,
89
129
  id: sdk.manifest.id,
@@ -91,10 +131,20 @@ CLauncher
91
131
  version: sdk.manifest.version,
92
132
  baseWidth: sdk.manifest.video?.baseWidth ?? 320,
93
133
  baseHeight: sdk.manifest.video?.baseHeight ?? 240,
94
- assetKey: sdk.manifest.assets?.[0]?.key ?? "rom",
134
+ assetKey: romSpec?.key ?? "rom",
95
135
  },
96
- onFile: bootWithFile,
97
- accept: acceptAttr,
98
- assetHint,
136
+ initialRomFile: storedRom,
137
+ initialBiosFile: storedBios,
138
+ onSaveRom: (file) => saveStoredAsset("rom", file),
139
+ onSaveBios: (file) => saveStoredAsset("bios", file),
140
+ onRemoveRom: () => clearStoredAsset("rom"),
141
+ onRemoveBios: () => clearStoredAsset("bios"),
142
+ onLaunchFiles: bootWithFiles,
143
+ romAccept,
144
+ romAssetHint,
145
+ biosAccept,
146
+ biosAssetHint,
147
+ requiresBIOS,
148
+ showBIOS,
99
149
  })
100
150
  .mount(mainEl);
package/demo/demo.sdk.js CHANGED
@@ -14,7 +14,14 @@ export function createDummySdk() {
14
14
  mountPath: "/rom.bin",
15
15
  required: true,
16
16
  accept: [".rom", ".bin", ".zip", ".dat"],
17
- description: "Any file works in this demo; data is not executed.",
17
+ description: "Any game ROM file works in this demo.",
18
+ },
19
+ {
20
+ key: "bios",
21
+ mountPath: "/bios.bin",
22
+ required: false,
23
+ accept: [".bin", ".rom", ".sys"],
24
+ description: "System BIOS firmware file.",
18
25
  },
19
26
  ],
20
27
  input: "keyboard-default",
@@ -45,6 +52,19 @@ export function createDummySdk() {
45
52
  throw error;
46
53
  }
47
54
 
55
+ // Copy loaded ROM and BIOS assets to MEMFS (In-Memory Virtual File System)
56
+ const MEMFS = new Map();
57
+ if (config.assets) {
58
+ for (const assetSpec of manifest.assets) {
59
+ const data = config.assets[assetSpec.key];
60
+ if (data) {
61
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
62
+ MEMFS.set(assetSpec.mountPath, bytes);
63
+ console.log(`[MEMFS] Copied asset '${assetSpec.key}' (${bytes.byteLength} bytes) to ${assetSpec.mountPath}`);
64
+ }
65
+ }
66
+ }
67
+
48
68
  let raf = 0;
49
69
  let running = false;
50
70
  let t0 = performance.now();
@@ -90,7 +110,6 @@ export function createDummySdk() {
90
110
  }
91
111
 
92
112
  // Dark background inside the canvas to contrast with the light UI components
93
- // ctx.fillStyle = "#0f172a";
94
113
  ctx.fillStyle = "#060910ff";
95
114
  ctx.fillRect(0, 0, width, height);
96
115
 
@@ -100,18 +119,15 @@ export function createDummySdk() {
100
119
  for (let x = 0; x < drops.length; x++) {
101
120
  let drop = drops[x];
102
121
 
103
- // Change characters dynamically
104
122
  if (Math.random() > 0.4) {
105
123
  let randIdx = Math.floor(Math.random() * drop.chars.length);
106
124
  drop.chars[randIdx] = String.fromCharCode(0x30A0 + Math.random() * 96);
107
125
  }
108
126
 
109
- // Move the drop (slower)
110
127
  drop.y += drop.speed * (dt * 12);
111
128
 
112
129
  let startY = Math.floor(drop.y);
113
130
 
114
- // Draw the column
115
131
  for (let i = 0; i < drop.length; i++) {
116
132
  let charY = startY - i;
117
133
  if (charY < 0 || charY >= maxRows) continue;
@@ -120,10 +136,8 @@ export function createDummySdk() {
120
136
  let alpha = drop.opacity * (1 - (i / drop.length));
121
137
 
122
138
  if (i === 0) {
123
- // Head of the drop (bright cyan-white)
124
139
  ctx.fillStyle = `rgba(220, 255, 255, ${drop.opacity})`;
125
140
  } else {
126
- // Tail of the drop (bright colors for dark background)
127
141
  const hue = ((time / 15) + (x * 10)) % 360;
128
142
  ctx.fillStyle = `hsla(${hue}, 80%, 65%, ${alpha})`;
129
143
  }
@@ -131,7 +145,6 @@ export function createDummySdk() {
131
145
  ctx.fillText(text, x * fontSize + fontSize / 2, charY * fontSize);
132
146
  }
133
147
 
134
- // Reset drop when fully off-screen
135
148
  if ((drop.y - drop.length) * fontSize > height) {
136
149
  drop.y = 0;
137
150
  drop.speed = Math.random() * 1.5 + 0.5;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * OPFS Asset Storage Helper for Engine Demo
3
+ * Persists ROM and BIOS files in the Origin Private File System (OPFS) across page reloads.
4
+ */
5
+
6
+ const DIR_NAME = "wasm-gaming-demo";
7
+
8
+ async function getStorageDir() {
9
+ if (typeof navigator === "undefined" || !navigator.storage?.getDirectory) {
10
+ return null;
11
+ }
12
+ try {
13
+ const root = await navigator.storage.getDirectory();
14
+ return await root.getDirectoryHandle(DIR_NAME, { create: true });
15
+ } catch (err) {
16
+ console.warn("OPFS storage not accessible:", err);
17
+ return null;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Save an asset File into OPFS.
23
+ */
24
+ export async function saveStoredAsset(key, file) {
25
+ const dir = await getStorageDir();
26
+ if (!dir || !file) return;
27
+ try {
28
+ const fileHandle = await dir.getFileHandle(`${key}.bin`, { create: true });
29
+ const writable = await fileHandle.createWritable();
30
+ const bytes = await file.arrayBuffer();
31
+ await writable.write(bytes);
32
+ await writable.close();
33
+
34
+ // Store file metadata
35
+ const metaHandle = await dir.getFileHandle(`${key}.json`, { create: true });
36
+ const metaWritable = await metaHandle.createWritable();
37
+ await metaWritable.write(
38
+ JSON.stringify({
39
+ name: file.name,
40
+ size: file.size || bytes.byteLength,
41
+ type: file.type || "application/octet-stream",
42
+ lastModified: file.lastModified || Date.now(),
43
+ })
44
+ );
45
+ await metaWritable.close();
46
+ } catch (err) {
47
+ console.warn(`Failed to save ${key} in OPFS:`, err);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Retrieve a stored asset File from OPFS.
53
+ */
54
+ export async function loadStoredAsset(key) {
55
+ const dir = await getStorageDir();
56
+ if (!dir) return null;
57
+ try {
58
+ const fileHandle = await dir.getFileHandle(`${key}.bin`);
59
+ const metaHandle = await dir.getFileHandle(`${key}.json`);
60
+
61
+ const fileObj = await fileHandle.getFile();
62
+ const metaText = await (await metaHandle.getFile()).text();
63
+ const meta = JSON.parse(metaText);
64
+
65
+ const bytes = await fileObj.arrayBuffer();
66
+ return new File([bytes], meta.name, {
67
+ type: meta.type,
68
+ lastModified: meta.lastModified,
69
+ });
70
+ } catch {
71
+ // File or meta doesn't exist yet
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Clear a stored asset from OPFS.
78
+ */
79
+ export async function clearStoredAsset(key) {
80
+ const dir = await getStorageDir();
81
+ if (!dir) return;
82
+ try {
83
+ await dir.removeEntry(`${key}.bin`);
84
+ } catch {}
85
+ try {
86
+ await dir.removeEntry(`${key}.json`);
87
+ } catch {}
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wasm-gaming/engine-specs",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "The wasm-gaming engine contract: the single shared interface every WASM game/emulator engine and the host app conform to. TypeScript types + JSON Schema + a Zod runtime validator.",
5
5
  "type": "module",
6
6
  "types": "./dist/index.d.ts",