diff --git a/app/App.js b/app/App.js new file mode 100644 index 0000000..bcad90c --- /dev/null +++ b/app/App.js @@ -0,0 +1,5 @@ +"use strict"; + +globalThis.App = class App { + +}; diff --git a/app/Bundle.java b/app/Bundle.java new file mode 100644 index 0000000..abef5dc --- /dev/null +++ b/app/Bundle.java @@ -0,0 +1,214 @@ +import java.awt.image.*; +import java.io.*; +import java.nio.charset.*; +import java.util.*; +import javax.imageio.*; + +public class Bundle { + + // File loaded from disk + static class File2 implements Comparable { + + // Instance fields + byte[] data; // Contents + String filename; // Full path relative to root + + // Comparator + public int compareTo(File2 o) { + if (filename.equals("app/_boot.js")) + return -1; + if (o.filename.equals("app/_boot.js")) + return 1; + return filename.compareTo(o.filename); + } + + } + + // Load all files in directory tree into memory + static HashMap readFiles(String bundleTitle) { + var dirs = new ArrayDeque(); + var root = new File("."); + var subs = new ArrayDeque(); + var files = new HashMap(); + + // Process all subdirectories + dirs.add(root); + while (!dirs.isEmpty()) { + var dir = dirs.remove(); + + // Add all subdirectories + for (var sub : dir.listFiles(f->f.isDirectory())) { + + // Exclusions + if (dir == root && sub.getName().equals(".git")) + continue; + + // Add the directory for bundling + dirs.add(sub); + } + + // Add all files + for (var file : dir.listFiles(f->f.isFile())) { + var file2 = new File2(); + + // Read the file into memory + try { + var stream = new FileInputStream(file); + file2.data = stream.readAllBytes(); + stream.close(); + } catch (Exception e) { + throw new RuntimeException(e.getMessage()); + } + + // Determine the file's full pathname + subs.clear(); + subs.addFirst(file.getName()); + for (;;) { + file = file.getParentFile(); + if (file.equals(root)) + break; + subs.addFirst(file.getName()); + } + file2.filename = String.join("/", subs); + + // Exclusions + if ( + file2.filename.startsWith(".git" ) || + file2.filename.startsWith(bundleTitle + "_") && + file2.filename.endsWith (".html" ) + ) continue; + + // Add the file to the output + files.put(file2.filename, file2); + } + + } + + return files; + } + + // Prepend manifest object to _boot.js + static void manifest(HashMap files, String bundleName) { + + // Produce a sorted list of files + var values = files.values().toArray(new File2[files.size()]); + Arrays.sort(values); + + // Build a file manifest + var manifest = new StringBuilder(); + manifest.append("\"use strict\";\nlet manifest=["); + for (var file : values) { + manifest.append("{" + + "name:\"" + file.filename + "\"," + + "size:" + file.data.length + + "},"); + } + manifest.append("];\nlet bundleName=\"" + bundleName + "\";\n"); + + // Prepend the manifest to _boot.js + var boot = files.get("app/_boot.js"); + boot.data = ( + manifest.toString() + + new String(boot.data, StandardCharsets.UTF_8) + + "\u0000" + ).getBytes(StandardCharsets.UTF_8); + } + + // Construct bundled blob + static byte[] blob(HashMap files) { + + // Produce a sorted list of files + var values = files.values().toArray(new File2[files.size()]); + Arrays.sort(values); + + // Build the blob + var blob = new ByteArrayOutputStream(); + for (var file : values) try { + blob.write(file.data); + } catch (Exception e) { } + + return blob.toByteArray(); + } + + // Encode bundled blob as a .png + static byte[] png(byte[] blob) { + + // Calculate the dimensions of the image + int width = (int) Math.ceil(Math.sqrt(blob.length)); + int height = (int) Math.ceil((double) blob.length / width); + + // Prepare the pixel data + var pixels = new int[width * height]; + for (int x = 0; x < blob.length; x++) { + int l = blob[x] & 0xFF; + pixels[x] = 0xFF000000 | l << 16 | l << 8 | l; + } + + // Produce a BufferedImage containing the pixels + var img = new BufferedImage(width, height, + BufferedImage.TYPE_BYTE_GRAY); + img.getRaster().setPixels(0, 0, width, height, pixels); + + // Encode the image as a PNG byte array + var png = new ByteArrayOutputStream(); + try { ImageIO.write(img, "png", png); } + catch (Exception e) { } + return png.toByteArray(); + } + + // Embed bundle .png into template.html as a data URL + static void template(byte[] png, String bundleName) { + + // Encode the PNG as a data URL + String url = "data:image/png;base64," + + Base64.getMimeEncoder().encodeToString(png) + .replaceAll("\\r\\n", ""); + + try { + + // Read template.html into memory + var inStream = new FileInputStream("app/template.html"); + String template = + new String(inStream.readAllBytes(), StandardCharsets.UTF_8) + .replace("src=\"\"", "src=\"" + url + "\"") + ; + inStream.close(); + + // Write the output HTML file + var outStream = new FileOutputStream(bundleName + ".html"); + outStream.write(template.getBytes(StandardCharsets.UTF_8)); + outStream.close(); + } catch (Exception e) { throw new RuntimeException(e.getMessage()); } + + } + + // Determine the filename of the bundle + static String bundleName(String name) { + var calendar = Calendar.getInstance(); + return String.format("%s_%04d%02d%02d", + name, + calendar.get(Calendar.YEAR), + calendar.get(Calendar.MONTH) + 1, + calendar.get(Calendar.DAY_OF_MONTH) + ); + } + + // Program entry point + public static void main(String[] args) { + + // Error checking + if (args.length != 1) { + System.err.println("Usage: Bundle "); + return; + } + + // Program tasks + String bundleName = bundleName(args[0]); + var files = readFiles(args[0]); + manifest(files, bundleName); + var blob = blob(files); + var png = png(blob); + template(png, bundleName); + } + +} diff --git a/app/_boot.js b/app/_boot.js new file mode 100644 index 0000000..36810ae --- /dev/null +++ b/app/_boot.js @@ -0,0 +1,274 @@ +// Produce an async function from a source string +if (!globalThis.AsyncFunction) + globalThis.AsyncFunction = + Object.getPrototypeOf(async function(){}).constructor; + +// Read scripts from files on disk to aid with debugging +let debug = location.hash == "#debug"; + + + +/////////////////////////////////////////////////////////////////////////////// +// Bundle // +/////////////////////////////////////////////////////////////////////////////// + +// Resource asset manager +globalThis.Bundle = class BundledFile { + + + + ///////////////////////////// Static Methods ////////////////////////////// + + // Adds a bundled file from loaded file data + static add(name, data) { + return Bundle.files[name] = new Bundle(name, data); + } + + // Retrieve the file given its filename + static get(name) { + return Bundle.files[name]; + } + + // Run a file as a JavaScript source file + static async run(name) { + await Bundle.files[name].run(); + } + + // Resolve a URL for a script source file + static script(name) { + return debug ? name : Bundle.files[name].toDataURL(); + } + + + + ///////////////////////// Initialization Methods ////////////////////////// + + // Object constructor + constructor(name, data) { + + // Configure instance fields + this.data = data; + this.name = name; + + // Detect the MIME type + this.mime = + name.endsWith(".css" ) ? "text/css;charset=UTF-8" : + name.endsWith(".frag") ? "text/plain;charset=UTF-8" : + name.endsWith(".js" ) ? "text/javascript;charset=UTF-8" : + name.endsWith(".png" ) ? "image/png" : + name.endsWith(".svg" ) ? "image/svg+xml;charset=UTF-8" : + name.endsWith(".txt" ) ? "text/plain;charset=UTF-8" : + name.endsWith(".vert") ? "text/plain;charset=UTF-8" : + name.endsWith(".wasm") ? "application/wasm" : + "application/octet-stream" + ; + + } + + + + ///////////////////////////// Public Methods ////////////////////////////// + + // Execute the file as a JavaScript source file + async run() { + + // Not running in debug mode + if (!debug) { + await new AsyncFunction(this.toString())(); + return; + } + + // Running in debug mode + return new Promise((resolve,reject)=>{ + let script = document.createElement("script"); + document.head.appendChild(script); + script.addEventListener("load", ()=>resolve()); + script.src = this.name; + }); + + } + + // Produce a blob from the file data + toBlob() { + return new Blob(this.data, { type: this.mime }); + } + + // Produce a blob URL for the file data + toBlobURL() { + return URL.createObjectURL(this.toBlob()); + } + + // Encode the file data as a data URL + toDataURL() { + return "data:" + this.mime + ";base64," + btoa(this.toString()); + } + + // Decode the file data as a UTF-8 string + toString() { + return new TextDecoder().decode(this.data); + } + +}; +Bundle.files = []; + + + +/////////////////////////////////////////////////////////////////////////////// +// ZIP Bundler // +/////////////////////////////////////////////////////////////////////////////// + +// Data buffer utility processor +class Bin { + + // Object constructor + constructor() { + this.data = []; + this.offset = 0; + } + + ///////////////////////////// Public Methods ////////////////////////////// + + // Convert the data contents to a byte array + toByteArray() { + return Uint8Array.from(this.data); + } + + // Encode a byte array + writeBytes(data) { + this.data = this.data.concat(Array.from(data)); + } + + // Encode a sized integer + writeInt(length, value) { + for (value &= 0xFFFFFFFF; length > 0; length--, value >>>= 8) + this.data.push(value & 0xFF); + } + + // Encode a string as UTF-8 with prepended length + writeString(value) { + this.writeBytes(new TextEncoder().encode(value)); + } + +} + +// Generate the CRC32 lookup table +let crcLookup = new Uint32Array(256); +for (let x = 0; x <= 255; x++) { + let l = x; + for (let j = 7; j >= 0; j--) + l = ((l >>> 1) ^ (0xEDB88320 & -(l & 1))); + crcLookup[x] = l; +} + +// Calculate the CRC32 checksum for a byte array +function crc32(data) { + let c = 0xFFFFFFFF; + for (let x = 0; x < data.length; x++) + c = ((c >>> 8) ^ crcLookup[(c ^ data[x]) & 0xFF]); + return ~c & 0xFFFFFFFF; +} + +// Produce a .ZIP header from a bundled file +function toZipHeader(file, crc32, offset) { + let central = offset || offset === 0; + let ret = new Bin(); + if (central) { + ret.writeInt (4, 0x02014B50); // Signature + ret.writeInt (2, 20); // Version created by + } else + ret.writeInt (4, 0x04034B50); // Signature + ret.writeInt (2, 20); // Version required + ret.writeInt (2, 0); // Bit flags + ret.writeInt (2, 0); // Compression method + ret.writeInt (2, 0); // Modified time + ret.writeInt (2, 0); // Modified date + ret.writeInt (4, crc32); // Checksum + ret.writeInt (4, file.data.length); // Compressed size + ret.writeInt (4, file.data.length); // Uncompressed size + ret.writeInt (2, file.name.length); // Filename length + ret.writeInt (2, 0); // Extra field length + if (central) { + ret.writeInt (2, 0); // File comment length + ret.writeInt (2, 0); // Disk number start + ret.writeInt (2, 0); // Internal attributes + ret.writeInt (4, 0); // External attributes + ret.writeInt (4, offset); // Relative offset + } + ret.writeString (file.name, true); // Filename + if (!central) + ret.writeBytes(file.data); // File data + return ret.toByteArray(); +} + +// Package all bundled files into a .zip file for download +Bundle.save = function() { + let centrals = new Array(manifest.length); + let locals = new Array(manifest.length); + let offset = 0; + let size = 0; + + // Encode file and directory entries + let keys = Object.keys(Bundle.files); + for (let x = 0; x < keys.length; x++) { + let file = Bundle.get(keys[x]); + let sum = crc32(file.data); + locals [x] = toZipHeader(file, sum); + centrals[x] = toZipHeader(file, sum, offset); + offset += locals [x].length; + size += centrals[x].length; + } + + // Encode end of central directory + let end = new Bin(); + end.writeInt(4, 0x06054B50); // Signature + end.writeInt(2, 0); // Disk number + end.writeInt(2, 0); // Central dir start disk + end.writeInt(2, centrals.length); // # central dir this disk + end.writeInt(2, centrals.length); // # central dir total + end.writeInt(4, size); // Size of central dir + end.writeInt(4, offset); // Offset of central dir + end.writeInt(2, 0); // .ZIP comment length + + // Prompt the user to save the resulting file + let a = document.createElement("a"); + a.download = bundleName + ".zip"; + a.href = URL.createObjectURL(new Blob( + locals.concat(centrals).concat([end.toByteArray()]), + { type: "application/zip" } + )); + a.click(); +} + + + + +/////////////////////////////////////////////////////////////////////////////// +// Boot Loader // +/////////////////////////////////////////////////////////////////////////////// + +/* + The Bundle.java utility prepends a manifest object to _boot.js that is an + array of file infos, each of which has a name and size property. +*/ + +// Remove the bundle image element from the document +arguments[0].remove(); + +// Process all files from the bundle blob +let blob = arguments[1]; +let offset = arguments[2] - manifest[0].size * 4; +for (let file of manifest) { + let data = new Uint8Array(file.size); + for (let x = 0; x < file.size; x++, offset += 4) + data[x] = blob[offset]; + if (file == manifest[0]) + offset += 4; + Bundle.add(file.name, data); +} + +// Program startup +let run = async function() { + Bundle.run("app/App.js"); + new App(); +}; +run(); diff --git a/app/template.html b/app/template.html new file mode 100644 index 0000000..285484d --- /dev/null +++ b/app/template.html @@ -0,0 +1,10 @@ + + + + + Virtual Boy Emulator + + + + + diff --git a/makefile b/makefile new file mode 100644 index 0000000..c685c9c --- /dev/null +++ b/makefile @@ -0,0 +1,37 @@ +.PHONY: help +help: + @echo + @echo "Virtual Boy Emulator - August 22, 2021" + @echo + @echo "Target build environment is any Debian with the following packages:" + @echo " emscripten" + @echo " gcc" + @echo " openjdk-17-jdk" + @echo + @echo "Available make targets:" + @echo " bundle Package the repository for HTML distribution" + @echo " clean Remove output files" + @echo " core Check the C core source for compiler warnings" + @echo " wasm Build the WebAssembly core module" + @echo + +.PHONY: bundle +bundle: + @java app/Bundle.java vbemu +# @gcc -c core/libvb.c -std=c90 -Wall -Wextra + +.PHONY: clean +clean: + @rm -f vbemu_*.html core.wasm + +.PHONY: core +core: + @echo "Check C core for style warnings" + +.PHONY: wasm +wasm: + @echo "Build WASM core module" +# @emcc -o core.wasm wasm/*.c core/libvb.c -Icore \ +# --no-entry -O2 -flto -s WASM=1 -s EXPORTED_RUNTIME_METHODS=[] \ +# -s ALLOW_MEMORY_GROWTH -s MAXIMUM_MEMORY=4GB -fno-strict-aliasing + @rm -f *.wasm.tmp*