@wasm-gaming/engine-specs 0.2.2 → 0.2.3
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 +9 -1
- package/demo/components/drop-area.html +124 -0
- package/demo/components/launcher.html +118 -83
- package/demo/demo.js +71 -22
- package/demo/demo.sdk.js +21 -8
- package/demo/storage.js +88 -0
- package/package.json +1 -1
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,124 @@
|
|
|
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
|
+
<label class="picker">
|
|
8
|
+
{{ selectedName ? 'Change File' : buttonText }}
|
|
9
|
+
<input type="file" :accept="accept" @change="onPick" />
|
|
10
|
+
</label>
|
|
11
|
+
</section>
|
|
12
|
+
|
|
13
|
+
<script :setup="{
|
|
14
|
+
prompt,
|
|
15
|
+
assetHint = 'or choose one from your computer',
|
|
16
|
+
accept,
|
|
17
|
+
buttonText = 'Pick file',
|
|
18
|
+
fileName = '',
|
|
19
|
+
}">
|
|
20
|
+
let dragging = false;
|
|
21
|
+
let selectedName = fileName || "";
|
|
22
|
+
|
|
23
|
+
const extractFile = (arg) => {
|
|
24
|
+
if (!arg) return null;
|
|
25
|
+
if (arg instanceof File) return arg;
|
|
26
|
+
if (arg.detail instanceof File) return arg.detail;
|
|
27
|
+
if (arg.target?.files?.item(0)) return arg.target.files.item(0);
|
|
28
|
+
if (arg.dataTransfer?.files?.item(0)) return arg.dataTransfer.files.item(0);
|
|
29
|
+
return null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const emitFile = (file) => {
|
|
33
|
+
if (!file) return;
|
|
34
|
+
selectedName = file.name;
|
|
35
|
+
$emit("select", file);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const onDrop = (event) => {
|
|
39
|
+
dragging = false;
|
|
40
|
+
const file = extractFile(event);
|
|
41
|
+
if (file) emitFile(file);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const onPick = (event) => {
|
|
45
|
+
const file = extractFile(event);
|
|
46
|
+
if (file) emitFile(file);
|
|
47
|
+
};
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<style>
|
|
51
|
+
.drop {
|
|
52
|
+
border: 2px dashed var(--demo-drop-border, rgba(255, 255, 255, 0.6));
|
|
53
|
+
border-radius: var(--demo-subpanel-radius, 8px);
|
|
54
|
+
padding: 2rem 1.25rem;
|
|
55
|
+
text-align: center;
|
|
56
|
+
transition: all 250ms ease;
|
|
57
|
+
background: var(--demo-drop-bg, rgba(255, 255, 255, 0.15));
|
|
58
|
+
backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
|
|
59
|
+
-webkit-backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.drop.active {
|
|
63
|
+
border-color: var(--demo-drop-active-border, var(--demo-accent2, #0284c7));
|
|
64
|
+
background: var(--demo-drop-active-bg, rgba(255, 255, 255, 0.4));
|
|
65
|
+
box-shadow: var(--demo-drop-active-shadow, inset 0 0 0 1px rgba(255, 255, 255, 0.8));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.drop.selected {
|
|
69
|
+
border-style: solid;
|
|
70
|
+
border-color: var(--demo-accent2, #0284c7);
|
|
71
|
+
background: var(--demo-subpanel-bg, rgba(255, 255, 255, 0.35));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.drop strong {
|
|
75
|
+
display: block;
|
|
76
|
+
margin-bottom: 0.5rem;
|
|
77
|
+
font-size: 1.1rem;
|
|
78
|
+
font-weight: 600;
|
|
79
|
+
text-transform: uppercase;
|
|
80
|
+
letter-spacing: 0.05em;
|
|
81
|
+
color: var(--demo-text, #0f172a);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.drop p {
|
|
85
|
+
margin: 0.2rem 0;
|
|
86
|
+
color: var(--demo-muted, #475569);
|
|
87
|
+
font-family: var(--demo-font-mono, monospace);
|
|
88
|
+
font-size: 0.85rem;
|
|
89
|
+
word-break: break-all;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.drop p.file-selected {
|
|
93
|
+
color: var(--demo-text, #0f172a);
|
|
94
|
+
font-weight: 600;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.picker {
|
|
98
|
+
display: inline-flex;
|
|
99
|
+
margin-top: 1rem;
|
|
100
|
+
background: var(--demo-picker-bg, rgba(255, 255, 255, 0.5));
|
|
101
|
+
color: var(--demo-picker-text, var(--demo-text, #0f172a));
|
|
102
|
+
border: 1px solid var(--demo-picker-border, rgba(255, 255, 255, 0.6));
|
|
103
|
+
border-radius: var(--demo-subpanel-radius, 8px);
|
|
104
|
+
padding: 0.5rem 1rem;
|
|
105
|
+
cursor: pointer;
|
|
106
|
+
text-transform: uppercase;
|
|
107
|
+
font-weight: 600;
|
|
108
|
+
font-size: 0.85rem;
|
|
109
|
+
letter-spacing: 0.05em;
|
|
110
|
+
transition: all 0.2s;
|
|
111
|
+
backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
|
|
112
|
+
-webkit-backdrop-filter: blur(var(--demo-subpanel-blur, 12px));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.picker:hover {
|
|
116
|
+
background: var(--demo-picker-hover-bg, rgba(255, 255, 255, 0.8));
|
|
117
|
+
border-color: var(--demo-picker-hover-border, rgba(255, 255, 255, 0.9));
|
|
118
|
+
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.picker input {
|
|
122
|
+
display: none;
|
|
123
|
+
}
|
|
124
|
+
</style>
|
|
@@ -1,53 +1,107 @@
|
|
|
1
|
-
<form class="launcher">
|
|
1
|
+
<form class="launcher" @submit.prevent="onLaunch">
|
|
2
2
|
<section class="sdk">
|
|
3
3
|
<CSdkInfo ...sdkInfo />
|
|
4
4
|
</section>
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
/>
|
|
16
|
+
<CDropArea
|
|
17
|
+
:prompt="'Drop ROM File'"
|
|
18
|
+
:asset-hint="romAssetHint"
|
|
19
|
+
:accept="romAccept"
|
|
20
|
+
:button-text="'Pick ROM'"
|
|
21
|
+
:file-name="romFile?.name"
|
|
22
|
+
@select="onRomSelect"
|
|
23
|
+
/>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<button type="submit" class="launch-btn" :disabled="!canLaunch">
|
|
27
|
+
Launch Engine
|
|
28
|
+
</button>
|
|
29
|
+
|
|
14
30
|
<div class="status">{{ status }}</div>
|
|
15
31
|
</form>
|
|
16
32
|
|
|
17
33
|
<script :setup="{
|
|
18
34
|
CSdkInfo,
|
|
35
|
+
CDropArea,
|
|
19
36
|
sdkInfo,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
37
|
+
onLaunchFiles,
|
|
38
|
+
onSaveBios,
|
|
39
|
+
onSaveRom,
|
|
40
|
+
initialRomFile = null,
|
|
41
|
+
initialBiosFile = null,
|
|
42
|
+
romAccept,
|
|
43
|
+
romAssetHint = 'or choose ROM file from computer',
|
|
44
|
+
biosAccept,
|
|
45
|
+
biosAssetHint = 'optional BIOS firmware file',
|
|
46
|
+
requiresBIOS = false,
|
|
47
|
+
showBIOS = true,
|
|
23
48
|
}">
|
|
24
|
-
let
|
|
25
|
-
let
|
|
49
|
+
let romFile = initialRomFile;
|
|
50
|
+
let biosFile = initialBiosFile;
|
|
26
51
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
status =
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
status =
|
|
52
|
+
let canLaunch = Boolean(romFile && (!requiresBIOS || biosFile));
|
|
53
|
+
let status = "";
|
|
54
|
+
|
|
55
|
+
const updateStatus = () => {
|
|
56
|
+
canLaunch = Boolean(romFile && (!requiresBIOS || biosFile));
|
|
57
|
+
if (requiresBIOS && !biosFile && !romFile) {
|
|
58
|
+
status = "Ready - Please select ROM and BIOS files to launch.";
|
|
59
|
+
} else if (requiresBIOS && !biosFile) {
|
|
60
|
+
status = "BIOS file is required to launch.";
|
|
61
|
+
} else if (!romFile) {
|
|
62
|
+
status = "Ready - Please select a ROM file to launch.";
|
|
63
|
+
} else {
|
|
64
|
+
status = `Ready to launch! (${romFile.name}${biosFile ? ' + BIOS' : ''})`;
|
|
38
65
|
}
|
|
39
66
|
};
|
|
40
67
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
68
|
+
updateStatus();
|
|
69
|
+
|
|
70
|
+
const extractFile = (arg) => {
|
|
71
|
+
if (!arg) return null;
|
|
72
|
+
if (arg instanceof File) return arg;
|
|
73
|
+
if (arg.detail instanceof File) return arg.detail;
|
|
74
|
+
if (arg.detail) return extractFile(arg.detail);
|
|
75
|
+
return null;
|
|
46
76
|
};
|
|
47
77
|
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
78
|
+
const onRomSelect = (arg) => {
|
|
79
|
+
const file = extractFile(arg);
|
|
80
|
+
if (!file) return;
|
|
81
|
+
romFile = file;
|
|
82
|
+
updateStatus();
|
|
83
|
+
onSaveRom?.(file);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const onBiosSelect = (arg) => {
|
|
87
|
+
const file = extractFile(arg);
|
|
88
|
+
if (!file) return;
|
|
89
|
+
biosFile = file;
|
|
90
|
+
updateStatus();
|
|
91
|
+
onSaveBios?.(file);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const onLaunch = async () => {
|
|
95
|
+
if (!canLaunch) {
|
|
96
|
+
updateStatus();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
status = `Launching with ${romFile.name}...`;
|
|
101
|
+
await onLaunchFiles({ rom: romFile, bios: biosFile });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
104
|
+
status = `Failed to load engine: ${message}`;
|
|
51
105
|
}
|
|
52
106
|
};
|
|
53
107
|
</script>
|
|
@@ -89,66 +143,47 @@
|
|
|
89
143
|
box-shadow: var(--demo-subpanel-shadow, inset 0 0 0 1px rgba(255, 255, 255, 0.6));
|
|
90
144
|
}
|
|
91
145
|
|
|
92
|
-
.drop {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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));
|
|
146
|
+
.drop-grid {
|
|
147
|
+
display: grid;
|
|
148
|
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
149
|
+
gap: 1rem;
|
|
101
150
|
}
|
|
102
151
|
|
|
103
|
-
.drop.
|
|
104
|
-
|
|
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));
|
|
152
|
+
.drop-grid.single {
|
|
153
|
+
grid-template-columns: 1fr;
|
|
107
154
|
}
|
|
108
155
|
|
|
109
|
-
.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
font-size: 1.
|
|
113
|
-
font-weight:
|
|
156
|
+
.launch-btn {
|
|
157
|
+
width: 100%;
|
|
158
|
+
padding: 0.9rem 1.5rem;
|
|
159
|
+
font-size: 1.1rem;
|
|
160
|
+
font-weight: 700;
|
|
161
|
+
font-family: var(--demo-font-sans, sans-serif);
|
|
114
162
|
text-transform: uppercase;
|
|
115
|
-
letter-spacing: 0.
|
|
116
|
-
color: var(--demo-text, #
|
|
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));
|
|
163
|
+
letter-spacing: 0.08em;
|
|
164
|
+
color: var(--demo-launch-text, #ffffff);
|
|
165
|
+
background: var(--demo-launch-bg, var(--demo-accent, #ff007f));
|
|
166
|
+
border: none;
|
|
132
167
|
border-radius: var(--demo-subpanel-radius, 8px);
|
|
133
|
-
padding: 0.6rem 1.2rem;
|
|
134
168
|
cursor: pointer;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
169
|
+
transition: all 0.2s ease;
|
|
170
|
+
box-shadow: var(--demo-accent-glow, 0 0 15px rgba(255, 0, 127, 0.3));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.launch-btn:hover:not(:disabled) {
|
|
174
|
+
filter: brightness(1.1);
|
|
175
|
+
transform: translateY(-1px);
|
|
142
176
|
}
|
|
143
177
|
|
|
144
|
-
.
|
|
145
|
-
|
|
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);
|
|
178
|
+
.launch-btn:active:not(:disabled) {
|
|
179
|
+
transform: translateY(0);
|
|
148
180
|
}
|
|
149
181
|
|
|
150
|
-
.
|
|
151
|
-
|
|
182
|
+
.launch-btn:disabled {
|
|
183
|
+
opacity: 0.5;
|
|
184
|
+
cursor: not-allowed;
|
|
185
|
+
box-shadow: none;
|
|
186
|
+
background: var(--demo-muted, #475569);
|
|
152
187
|
}
|
|
153
188
|
|
|
154
189
|
.status {
|
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 } 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,39 @@ if (sdk.manifest?.name) {
|
|
|
23
30
|
document.title = `${sdk.manifest.name} - Demo`;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
|
-
// Compute asset accept
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
38
|
+
const hasBiosSpec = Boolean(biosSpec);
|
|
39
|
+
const showBIOS = biosParam !== "false" && (hasBiosSpec || biosParam === "required" || biosParam === "true" || biosParam === "optional");
|
|
40
|
+
const requiresBIOS = biosParam === "required" || (biosParam !== "optional" && Boolean(biosSpec?.required));
|
|
41
|
+
|
|
42
|
+
// Load previously stored ROM & BIOS from OPFS if available
|
|
43
|
+
const [storedRom, storedBios] = await Promise.all([
|
|
44
|
+
loadStoredAsset("rom"),
|
|
45
|
+
showBIOS ? loadStoredAsset("bios") : null,
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const romAccept = romSpec?.accept?.join(",");
|
|
49
|
+
const romAssetHint = romSpec?.accept ? `Supported: ${romSpec.accept.join(", ")}` : "or choose ROM file from computer";
|
|
32
50
|
|
|
33
|
-
const
|
|
34
|
-
|
|
51
|
+
const biosAccept = biosSpec?.accept?.join(",");
|
|
52
|
+
const biosAssetHint = biosSpec?.description ?? (biosSpec?.accept ? `Supported: ${biosSpec.accept.join(", ")}` : (requiresBIOS ? "required BIOS firmware file" : "optional BIOS firmware file"));
|
|
53
|
+
|
|
54
|
+
async function toArrayBuffer(input) {
|
|
55
|
+
if (!input) return null;
|
|
56
|
+
if (input instanceof ArrayBuffer) return input;
|
|
57
|
+
if (ArrayBuffer.isView(input)) return input.buffer;
|
|
58
|
+
if (typeof input.arrayBuffer === "function") return await input.arrayBuffer();
|
|
59
|
+
if (input.detail) return await toArrayBuffer(input.detail);
|
|
60
|
+
throw new TypeError("Provided input is not a File, Blob, or ArrayBuffer.");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const bootWithFiles = async ({ rom, bios }) => {
|
|
64
|
+
const romBytes = await toArrayBuffer(rom);
|
|
65
|
+
const biosBytes = await toArrayBuffer(bios);
|
|
35
66
|
|
|
36
67
|
mainEl.classList.add("running");
|
|
37
68
|
|
|
@@ -43,9 +74,10 @@ const bootWithFile = async (file) => {
|
|
|
43
74
|
|
|
44
75
|
mainEl.replaceChildren(runtimeEl);
|
|
45
76
|
|
|
77
|
+
const primaryFile = rom || bios;
|
|
46
78
|
const fileInfoProps = {
|
|
47
|
-
fileName:
|
|
48
|
-
size: `${
|
|
79
|
+
fileName: primaryFile.name,
|
|
80
|
+
size: `${primaryFile.size.toLocaleString()} bytes`,
|
|
49
81
|
};
|
|
50
82
|
const fileInfoPlaceholder = CFileInfo
|
|
51
83
|
.renderShadow({
|
|
@@ -54,15 +86,23 @@ const bootWithFile = async (file) => {
|
|
|
54
86
|
})
|
|
55
87
|
.mount(fileInfoEl);
|
|
56
88
|
|
|
57
|
-
const
|
|
89
|
+
const assets = {};
|
|
90
|
+
if (romBytes && romSpec) {
|
|
91
|
+
assets[romSpec.key] = romBytes;
|
|
92
|
+
}
|
|
93
|
+
if (biosBytes && biosSpec) {
|
|
94
|
+
assets[biosSpec.key] = biosBytes;
|
|
95
|
+
}
|
|
96
|
+
if (Object.keys(assets).length === 0 && romBytes) {
|
|
97
|
+
assets.rom = romBytes;
|
|
98
|
+
}
|
|
99
|
+
|
|
58
100
|
const instance = await sdk.load({
|
|
59
101
|
attachTo: runtimeEl,
|
|
60
|
-
assets
|
|
61
|
-
[assetKey]: bytes,
|
|
62
|
-
},
|
|
102
|
+
assets,
|
|
63
103
|
options: {
|
|
64
|
-
fileName:
|
|
65
|
-
byteLength:
|
|
104
|
+
fileName: primaryFile.name,
|
|
105
|
+
byteLength: primaryFile.size,
|
|
66
106
|
},
|
|
67
107
|
onEvent(event) {
|
|
68
108
|
if (event.type === "error") {
|
|
@@ -73,7 +113,7 @@ const bootWithFile = async (file) => {
|
|
|
73
113
|
|
|
74
114
|
instance.start();
|
|
75
115
|
|
|
76
|
-
const checksums = await computeChecksums(
|
|
116
|
+
const checksums = await computeChecksums(romBytes || biosBytes);
|
|
77
117
|
fileInfoPlaceholder.destroy();
|
|
78
118
|
|
|
79
119
|
CFileInfo
|
|
@@ -84,6 +124,7 @@ const bootWithFile = async (file) => {
|
|
|
84
124
|
CLauncher
|
|
85
125
|
.render({
|
|
86
126
|
CSdkInfo,
|
|
127
|
+
CDropArea,
|
|
87
128
|
sdkInfo: {
|
|
88
129
|
name: sdk.manifest.name,
|
|
89
130
|
id: sdk.manifest.id,
|
|
@@ -91,10 +132,18 @@ CLauncher
|
|
|
91
132
|
version: sdk.manifest.version,
|
|
92
133
|
baseWidth: sdk.manifest.video?.baseWidth ?? 320,
|
|
93
134
|
baseHeight: sdk.manifest.video?.baseHeight ?? 240,
|
|
94
|
-
assetKey:
|
|
135
|
+
assetKey: romSpec?.key ?? "rom",
|
|
95
136
|
},
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
137
|
+
initialRomFile: storedRom,
|
|
138
|
+
initialBiosFile: storedBios,
|
|
139
|
+
onSaveRom: (file) => saveStoredAsset("rom", file),
|
|
140
|
+
onSaveBios: (file) => saveStoredAsset("bios", file),
|
|
141
|
+
onLaunchFiles: bootWithFiles,
|
|
142
|
+
romAccept,
|
|
143
|
+
romAssetHint,
|
|
144
|
+
biosAccept,
|
|
145
|
+
biosAssetHint,
|
|
146
|
+
requiresBIOS,
|
|
147
|
+
showBIOS,
|
|
99
148
|
})
|
|
100
149
|
.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
|
|
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: "Optional 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;
|
package/demo/storage.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.2.3",
|
|
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",
|