Skip to content

Commit 2992609

Browse files
committed
Switch to loading the Macintosh HD disk image on-demand in chunks
Unlike our lazy files (createLazyFile from emulator-worker-lazy-file), this loads separate parts of the file in chunks, instead of the entire file. Unlike the Emscripten FS.createLazyFile API, this supports compression (because we split the file into individually Brotli-compressed chunks, instead of using HTTP range requests). It also supports prefetching, so that we can preload the sets of chunks that are needed for the Mac startup. This helps to speed things up, since normal chunk loading is synchronous, thus we end up blocking the emulator frequently if we have to do things on-demand. To get prefetching working and to get more control, the actual chunk fetching is done in a service worker (and cached using the worker cache API). The emulator worker still thinks it's doing synchronous XMLHttpRequests, but they're actually intercepted by the service worker. This helps a little bit with the uncached time-to-first-idlewait (from ~3.3 seconds to ~2.5 seconds) since we're no longer loading parts of the disk image that are not needed. However, the bigger benefit is that this unblocks having a much larger disk image, instead of using ExtFS/the Emscripten file system for the software library. ExtFS and/or the File System Manager interface that it builds on don't appear to provide the level of compatibility that old software expects (e.g. Indiana Jones and the Last Crusade and Battle Chess refuse to launch because they can't find certain files), so the goal is to instead have that software directly installed in the disk image, while still downloading it on-demand.
1 parent bf8b18c commit 2992609

14 files changed

Lines changed: 302 additions & 21 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,7 @@ npm-debug.log*
1919

2020
# generated source map with local paths
2121
public/BasiliskII.wasm.map
22+
23+
# generated disk image chunks and manifest
24+
src/Data/*.dsk.json
25+
public/Disk

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,19 @@ Common development tasks, all done via `npm run`:
88

99
- `start`: Run local dev server (will be running at http://localhost:3127)
1010
- `import-basilisk-ii`: Copy generated WebAssembly from a https://github.com/mihaip/macemu checkout (assumed to be in a sibling directory to this repo).
11-
- `import-basilisk-ii-data`: Variant of the above for the data files. The `broli` CLI tool is assumed to be installed (e.g. via `brew install brotli`).
11+
- `import-basilisk-ii-data`: Variant of the above for the data files.
1212
- `build-library-manifest`: Rebuild the combined manifest file used for the software library (should be done when updating the contents of `public/Library`)
1313

1414
Common deployment tasks (also done via `npm run`)
1515

1616
- `build`: Rebuild for either local use (in the `build/` directory) or for Cloudflare Worker use
1717
- `worker-preview`: Preview built assets in a Cloudflare Worker (requires a separate `build` invocation)
1818
- `worker-deploy`: Deploy built assets to the live version of the Cloudflare Worker (requires a separate `build` invocation)
19+
20+
Dependencies can be installed with:
21+
22+
```
23+
npm install
24+
pip3 install -r requirements.txt
25+
brew install brotli
26+
```

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
git+https://github.com/mihaip/machfs.git@5992e174ed569f7929985cf35d4467b2b1e093f1#egg=machfs
2+
brotli==1.0.9

scripts/import-basilisk-ii-data.sh

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
ROOT_DIR="`dirname "${BASH_SOURCE[0]}"`/.."
77
BASILISK_II_DIR="${ROOT_DIR}/../macemu/BasiliskII/src/Unix"
88
DATA_DIR="${ROOT_DIR}/src/Data"
9+
PUBLIC_DIR="${ROOT_DIR}/public"
910

10-
# ROM and disk images
11-
brotli -q 11 -k "${BASILISK_II_DIR}/Quadra-650.rom" "${BASILISK_II_DIR}/Macintosh HD.dsk"
12-
mv "${BASILISK_II_DIR}/Quadra-650.rom.br" "${BASILISK_II_DIR}/Macintosh HD.dsk.br" "${DATA_DIR}/"
11+
# ROM
12+
#brotli -q 11 -k "${BASILISK_II_DIR}/Quadra-650.rom"
13+
#mv "${BASILISK_II_DIR}/Quadra-650.rom.br" "${DATA_DIR}/"
14+
15+
scripts/import-disk-image.py "${BASILISK_II_DIR}/Macintosh HD.dsk" "${PUBLIC_DIR}/Disk" "${DATA_DIR}/"

scripts/import-disk-image.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#!/usr/local/bin/python3
2+
3+
import brotli
4+
import hashlib
5+
import json
6+
import os
7+
import sys
8+
9+
input_path = sys.argv[1]
10+
output_dir = sys.argv[2]
11+
manifest_dir = sys.argv[3]
12+
13+
CHUNK_SIZE = 256 * 1024
14+
chunk_count = 0
15+
total_size = 0
16+
17+
input_file_name = os.path.basename(input_path)
18+
19+
hash = hashlib.sha256()
20+
21+
sys.stderr.write("Chunking and compressing %s" % input_file_name)
22+
sys.stderr.flush()
23+
24+
with open(input_path, "rb") as input_file:
25+
while True:
26+
chunk = input_file.read(CHUNK_SIZE)
27+
if not chunk:
28+
break
29+
total_size += len(chunk)
30+
chunk_compressed = brotli.compress(chunk, quality=11)
31+
chunk_path = os.path.join(output_dir,
32+
f"{input_file_name}.{chunk_count}.br")
33+
# Use compressed version for the version hash so that if we change the
34+
# compression quality we can trigger a re-download.
35+
hash.update(chunk_compressed)
36+
with open(chunk_path, "wb+") as chunk_file:
37+
chunk_file.write(chunk_compressed)
38+
chunk_count += 1
39+
sys.stderr.write(".")
40+
sys.stderr.flush()
41+
42+
sys.stderr.write("\n")
43+
44+
manifest_path = os.path.join(manifest_dir, f"{input_file_name}.json")
45+
with open(manifest_path, "w+") as manifest_file:
46+
json.dump(
47+
{
48+
"totalSize": total_size,
49+
"chunkCount": chunk_count,
50+
"chunkSize": CHUNK_SIZE,
51+
"version": hash.hexdigest()
52+
},
53+
manifest_file,
54+
indent=4)

src/BasiliskII/emulator-common.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,26 @@ export enum LockStates {
3131
EMUL_THREAD_LOCK,
3232
}
3333

34+
export type EmulatorChunkedFileSpec = {
35+
baseUrl: string;
36+
totalSize: number;
37+
chunkCount: number;
38+
chunkSize: number;
39+
version: string;
40+
prefetchChunks: number[];
41+
};
42+
43+
export function generateChunkUrl(
44+
spec: EmulatorChunkedFileSpec,
45+
chunk: number
46+
): string {
47+
return `${spec.baseUrl}.${chunk}.br?v=${spec.version}`;
48+
}
49+
3450
export type EmulatorWorkerConfig = {
3551
jsUrl: string;
3652
wasmUrl: string;
53+
disk: EmulatorChunkedFileSpec;
3754
autoloadFiles: {[name: string]: ArrayBuffer};
3855
arguments: string[];
3956
video: EmulatorWorkerVideoConfig;

src/BasiliskII/emulator-service-worker.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import JSZip from "jszip";
2-
import type {EmulatorFallbackCommand} from "./emulator-common";
2+
import type {
3+
EmulatorChunkedFileSpec,
4+
EmulatorFallbackCommand,
5+
} from "./emulator-common";
6+
import {generateChunkUrl} from "./emulator-common";
37
import {
48
FInfoFields,
59
FinderFlags,
@@ -21,10 +25,34 @@ function constructJsResponse(js: string) {
2125
});
2226
}
2327

28+
const DISK_CACHE_NAME = "disk-cache";
29+
const diskCacheSpecs: EmulatorChunkedFileSpec[] = [];
30+
2431
self.addEventListener("message", event => {
2532
const {data} = event;
2633
if (data.type === "worker-command") {
2734
workerCommands.push(data.command);
35+
} else if (data.type === "init-disk-cache") {
36+
const diskFileSpec = data.spec as EmulatorChunkedFileSpec;
37+
diskCacheSpecs.push(diskFileSpec);
38+
event.waitUntil(
39+
(async function () {
40+
const cache = await caches.open(DISK_CACHE_NAME);
41+
const prefetchChunkUrls = [];
42+
for (const chunk of diskFileSpec.prefetchChunks) {
43+
const chunkUrl = generateChunkUrl(diskFileSpec, chunk);
44+
const cachedResponse = await cache.match(
45+
new Request(chunkUrl)
46+
);
47+
if (!cachedResponse) {
48+
prefetchChunkUrls.push(chunkUrl);
49+
}
50+
}
51+
if (prefetchChunkUrls.length) {
52+
return cache.addAll(prefetchChunkUrls);
53+
}
54+
})()
55+
);
2856
}
2957
});
3058

@@ -40,6 +68,12 @@ self.addEventListener("fetch", (event: FetchEvent) => {
4068
requestUrl.searchParams.has("item")
4169
) {
4270
event.respondWith(handleLibraryFile(event));
71+
} else if (
72+
diskCacheSpecs.some(spec =>
73+
decodeURIComponent(requestUrl.pathname).startsWith(spec.baseUrl)
74+
)
75+
) {
76+
event.respondWith(handleDiskCacheRequest(event.request));
4377
}
4478
});
4579

@@ -200,6 +234,17 @@ async function fetchZip(path: string, version: string): Promise<JSZip> {
200234
return zip;
201235
}
202236

237+
async function handleDiskCacheRequest(request: Request): Promise<Response> {
238+
const cache = await caches.open(DISK_CACHE_NAME);
239+
const match = await cache.match(request);
240+
if (match) {
241+
return match;
242+
}
243+
const response = await fetch(request);
244+
cache.put(request, response.clone());
245+
return response;
246+
}
247+
203248
// Boilerplate to make sure we're running as quickly as possible.
204249
self.addEventListener("install", event => {
205250
self.skipWaiting();

src/BasiliskII/emulator-ui.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {
2+
EmulatorChunkedFileSpec,
23
EmulatorFallbackCommand,
34
EmulatorWorkerConfig,
45
EmulatorWorkerVideoBlit,
@@ -41,7 +42,7 @@ export type EmulatorConfig = {
4142
screenCanvas: HTMLCanvasElement;
4243
basiliskPrefsPath: string;
4344
romPath: string;
44-
diskPath: string;
45+
disk: EmulatorChunkedFileSpec;
4546
};
4647

4748
export interface EmulatorDelegate {
@@ -144,7 +145,6 @@ export class Emulator {
144145
async start() {
145146
const {
146147
useTouchEvents,
147-
useSharedMemory,
148148
screenCanvas: canvas,
149149
enableExtractor,
150150
} = this.#config;
@@ -171,13 +171,9 @@ export class Emulator {
171171
// Fetch all of the dependent files ourselves, to avoid a waterfall
172172
// if we let Emscripten handle it (it would first load the JS, and
173173
// then that would load the WASM and data files).
174-
const [[jsBlobUrl, wasmBlobUrl], [disk, rom, prefs]] = await load(
174+
const [[jsBlobUrl, wasmBlobUrl], [rom, prefs]] = await load(
175175
[BasiliskIIPath, BasiliskIIWasmPath],
176-
[
177-
this.#config.diskPath,
178-
this.#config.romPath,
179-
this.#config.basiliskPrefsPath,
180-
],
176+
[this.#config.romPath, this.#config.basiliskPrefsPath],
181177
(total, left) => {
182178
this.#delegate?.emulatorDidMakeLoadingProgress?.(
183179
this,
@@ -190,8 +186,8 @@ export class Emulator {
190186
const config: EmulatorWorkerConfig = {
191187
jsUrl: jsBlobUrl,
192188
wasmUrl: wasmBlobUrl,
189+
disk: this.#config.disk,
193190
autoloadFiles: {
194-
"Macintosh HD": disk,
195191
"Quadra-650.rom": rom,
196192
"prefs": prefs,
197193
},
@@ -205,10 +201,12 @@ export class Emulator {
205201
enableExtractor,
206202
};
207203

208-
if (!useSharedMemory) {
209-
await this.#serviceWorkerReady;
210-
}
211-
this.#worker.postMessage({type: "start", config}, [disk, rom, prefs]);
204+
await this.#serviceWorkerReady;
205+
this.#serviceWorker!.postMessage({
206+
type: "init-disk-cache",
207+
spec: config.disk,
208+
});
209+
this.#worker.postMessage({type: "start", config}, [rom, prefs]);
212210
}
213211

214212
stop() {
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type {EmulatorChunkedFileSpec} from "./emulator-common";
2+
import {generateChunkUrl} from "./emulator-common";
3+
4+
export function createChunkedFile(
5+
parent: string | FS.FSNode,
6+
name: string,
7+
spec: EmulatorChunkedFileSpec,
8+
canRead: boolean,
9+
canWrite: boolean
10+
): FS.FSNode {
11+
const contents = new Uint8Array(spec.totalSize);
12+
const file = FS.createDataFile(
13+
parent,
14+
name,
15+
contents,
16+
canRead,
17+
canWrite,
18+
// Set canOwn so that MEMFS uses `contents` directly, and we can mutate
19+
// it later when loading the contents on demand.
20+
true
21+
);
22+
23+
const {chunkSize} = spec;
24+
const loadedChunks = new Set<number>();
25+
loadedChunksBySpec.set(spec, loadedChunks);
26+
27+
function loadChunk(chunkIndex: number) {
28+
const chunkUrl = generateChunkUrl(spec, chunkIndex);
29+
const xhr = new XMLHttpRequest();
30+
xhr.responseType = "arraybuffer";
31+
xhr.open("GET", chunkUrl, false);
32+
xhr.send();
33+
const data = new Uint8Array(xhr.response as ArrayBuffer);
34+
contents.set(data, chunkIndex * chunkSize);
35+
}
36+
37+
const defaultStreamOps = file.stream_ops;
38+
file.stream_ops = {
39+
...defaultStreamOps,
40+
read(
41+
stream: FS.FSStream,
42+
buffer: Uint8Array,
43+
offset: number,
44+
length: number,
45+
position: number
46+
) {
47+
if (position >= contents.length) {
48+
return 0;
49+
}
50+
if (stream.node.contents.buffer !== contents.buffer) {
51+
// If Emscripten somehow changes the contents out from under us,
52+
// we should go back to normal reading.
53+
return defaultStreamOps.read.call(
54+
this,
55+
stream,
56+
buffer,
57+
offset,
58+
length,
59+
position
60+
);
61+
}
62+
63+
const readSize = Math.min(contents.length - position, length);
64+
65+
const startChunk = Math.floor(position / chunkSize);
66+
const endChunk = Math.floor((position + readSize - 1) / chunkSize);
67+
for (
68+
let chunkIndex = startChunk;
69+
chunkIndex <= endChunk;
70+
chunkIndex++
71+
) {
72+
if (!loadedChunks.has(chunkIndex)) {
73+
loadChunk(chunkIndex);
74+
loadedChunks.add(chunkIndex);
75+
}
76+
}
77+
78+
buffer.set(contents.slice(position, position + readSize), offset);
79+
return readSize;
80+
},
81+
};
82+
83+
return file;
84+
}
85+
86+
export function validateSpecPrefetchChunks(spec: EmulatorChunkedFileSpec) {
87+
const loadedChunks = loadedChunksBySpec.get(spec);
88+
if (!loadedChunks) {
89+
console.warn(
90+
`Chunked file ${spec.baseUrl} never had any chunks loaded`
91+
);
92+
return;
93+
}
94+
const prefetchedChunks = new Set(spec.prefetchChunks);
95+
const needsPrefetch = [];
96+
const extraPrefetch = [];
97+
for (const chunk of loadedChunks) {
98+
if (!prefetchedChunks.has(chunk)) {
99+
needsPrefetch.push(chunk);
100+
}
101+
}
102+
for (const chunk of prefetchedChunks) {
103+
if (!loadedChunks.has(chunk)) {
104+
extraPrefetch.push(chunk);
105+
}
106+
}
107+
if (extraPrefetch.length) {
108+
console.warn(
109+
`Chunked file ${spec.baseUrl} had unncessary chunks prefetched:`,
110+
extraPrefetch
111+
);
112+
}
113+
if (needsPrefetch.length) {
114+
console.warn(
115+
`Chunked file ${spec.baseUrl} had needs more chunks prefetched:`,
116+
needsPrefetch
117+
);
118+
}
119+
}
120+
121+
const loadedChunksBySpec = new Map<EmulatorChunkedFileSpec, Set<number>>();

0 commit comments

Comments
 (0)