mirror of
https://codeberg.org/virtualxt/virtualxt.git
synced 2026-10-25 14:49:49 +00:00
Web UI: video button defaulted to EGA in HTML/JS while the runtime defaults to CGA, leaving the label out of sync at page load (first click was a silent no-op). Fixed by setting `videoState = 'CGA'`, the button label to `Video: CGA`, and reordering the cycle to `['CGA', 'HGC', 'EGA']`. Added a `Turbo` toggle (`Turbo: 4.77` default / `Turbo: 10.0`) live-applied via new `wasm_set_turbo` and the existing `?cpu=4.77` / `?cpu=10` URL parameter. Libretro: default CPU speed was 10 MHz (turbo enabled). Now defaults to 4.77 MHz (turbo disabled) to match the web build. Descriptor at `core.c:743` swapped to `disabled|enabled`; fall-through default at `core.c:1399` set to `CPU_FREQ_477`. First-time installs see the new default; existing users keep their saved option. The RPi bare-metal build inherits the new default because its env callback returns false for `GET_VARIABLE`. docs/STATUS.md: added entries and consolidated the Boot Diagnostics / Gotchas / EGA Notes / Known Limitations sections into a unified Notes section.
1004 lines
33 KiB
JavaScript
1004 lines
33 KiB
JavaScript
// VirtualXT Web Frontend -- JS Glue
|
|
//
|
|
// Handles WASM loading, canvas rendering, Web Audio, keyboard/mouse input,
|
|
// disk upload, HostFS mount, drag-and-drop, and the main frame loop.
|
|
|
|
const XT_BREAK = 0x80;
|
|
|
|
// DOM key → XT scan code set 1 (make code)
|
|
const keyToXt = {
|
|
"Escape": 0x01,
|
|
"1": 0x02, "!": 0x02,
|
|
"2": 0x03, "@": 0x03,
|
|
"3": 0x04, "#": 0x04,
|
|
"4": 0x05, "$": 0x05,
|
|
"5": 0x06, "%": 0x06,
|
|
"6": 0x07, "^": 0x07,
|
|
"7": 0x08, "&": 0x08,
|
|
"8": 0x09, "*": 0x09,
|
|
"9": 0x0A, "(": 0x0A,
|
|
"0": 0x0B, ")": 0x0B,
|
|
"-": 0x0C, "_": 0x0C,
|
|
"=": 0x0D, "+": 0x0D,
|
|
"Backspace": 0x0E,
|
|
"Tab": 0x0F,
|
|
"q": 0x10, "Q": 0x10,
|
|
"w": 0x11, "W": 0x11,
|
|
"e": 0x12, "E": 0x12,
|
|
"r": 0x13, "R": 0x13,
|
|
"t": 0x14, "T": 0x14,
|
|
"y": 0x15, "Y": 0x15,
|
|
"u": 0x16, "U": 0x16,
|
|
"i": 0x17, "I": 0x17,
|
|
"o": 0x18, "O": 0x18,
|
|
"p": 0x19, "P": 0x19,
|
|
"[": 0x1A, "{": 0x1A,
|
|
"]": 0x1B, "}": 0x1B,
|
|
"Enter": 0x1C,
|
|
"Control": 0x1D,
|
|
"a": 0x1E, "A": 0x1E,
|
|
"s": 0x1F, "S": 0x1F,
|
|
"d": 0x20, "D": 0x20,
|
|
"f": 0x21, "F": 0x21,
|
|
"g": 0x22, "G": 0x22,
|
|
"h": 0x23, "H": 0x23,
|
|
"j": 0x24, "J": 0x24,
|
|
"k": 0x25, "K": 0x25,
|
|
"l": 0x26, "L": 0x26,
|
|
";": 0x27, ":": 0x27,
|
|
"'": 0x28, '\"': 0x28,
|
|
"`": 0x29,
|
|
"Shift": 0x2A,
|
|
"\\": 0x2B, "|": 0x2B,
|
|
"z": 0x2C, "Z": 0x2C,
|
|
"x": 0x2D, "X": 0x2D,
|
|
"c": 0x2E, "C": 0x2E,
|
|
"v": 0x2F, "V": 0x2F,
|
|
"b": 0x30, "B": 0x30,
|
|
"n": 0x31, "N": 0x31,
|
|
"m": 0x32, "M": 0x32,
|
|
",": 0x33, "<": 0x33,
|
|
".": 0x34, ">": 0x34,
|
|
"/": 0x35, "?": 0x35,
|
|
"Alt": 0x38,
|
|
" ": 0x39,
|
|
"CapsLock": 0x3A,
|
|
"F1": 0x3B,
|
|
"F2": 0x3C,
|
|
"F3": 0x3D,
|
|
"F4": 0x3E,
|
|
"F5": 0x3F,
|
|
"F6": 0x40,
|
|
"F7": 0x41,
|
|
"F8": 0x42,
|
|
"F9": 0x43,
|
|
"F10": 0x44,
|
|
"NumLock": 0x45,
|
|
"ScrollLock": 0x46,
|
|
"Home": 0x47,
|
|
"ArrowUp": 0x48,
|
|
"PageUp": 0x49,
|
|
"ArrowLeft": 0x4B,
|
|
"ArrowRight": 0x4D,
|
|
"End": 0x4F,
|
|
"ArrowDown": 0x50,
|
|
"PageDown": 0x51,
|
|
"Insert": 0x52,
|
|
"Delete": 0x53,
|
|
};
|
|
|
|
// Track pressed keys to release on blur
|
|
const pressedKeys = new Set();
|
|
|
|
function xtScan(key, event) {
|
|
let scan = keyToXt[key];
|
|
if (!scan) return 0;
|
|
// Right shift
|
|
if (scan === 0x2A && event && event.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT)
|
|
scan = 0x36;
|
|
return scan;
|
|
}
|
|
|
|
// ── Persistent storage (sector-level localStorage) ───────────────────────
|
|
|
|
const SECTOR_SIZE = 512;
|
|
|
|
function crc32(data) {
|
|
const table = new Uint32Array(256);
|
|
for (let i = 0; i < 256; i++) {
|
|
let c = i;
|
|
for (let k = 0; k < 8; k++) {
|
|
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
|
}
|
|
table[i] = c >>> 0;
|
|
}
|
|
let crc = 0xFFFFFFFF;
|
|
for (let i = 0; i < data.length; i++) {
|
|
crc = (table[(crc ^ data[i]) & 0xFF] ^ (crc >>> 8)) >>> 0;
|
|
}
|
|
return (~crc) >>> 0;
|
|
}
|
|
|
|
let currentDiskPath = null;
|
|
let currentDiskOriginal = null;
|
|
let currentDiskData = null;
|
|
let currentDiskCrc = null;
|
|
let dbQueue = null;
|
|
let dbFlushTimer = null;
|
|
|
|
function storageEnabled() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
if (urlParams.get('storage') === '0') return false;
|
|
if (!window.localStorage) return false;
|
|
return true;
|
|
}
|
|
|
|
function initLocalStorage() {
|
|
if (!storageEnabled()) return;
|
|
if (!currentDiskOriginal) return;
|
|
|
|
console.log('Initialize local storage');
|
|
dbQueue = [];
|
|
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
if (urlParams.get('storage') === 'clear') {
|
|
console.log('Clear local storage!');
|
|
window.localStorage.clear();
|
|
}
|
|
|
|
currentDiskCrc = crc32(currentDiskOriginal).toString();
|
|
|
|
for (let i = 0; i < window.localStorage.length; i++) {
|
|
const key = window.localStorage.key(i);
|
|
if (!key) continue;
|
|
const pair = key.split(' ', 2);
|
|
if (pair[0] !== currentDiskCrc) continue;
|
|
const sector = parseInt(pair[1], 10);
|
|
if (isNaN(sector)) continue;
|
|
const data = JSON.parse(window.localStorage.getItem(key));
|
|
if (Array.isArray(data) && data.length === SECTOR_SIZE) {
|
|
for (let j = 0; j < SECTOR_SIZE; j++) {
|
|
currentDiskData[sector * SECTOR_SIZE + j] = data[j];
|
|
}
|
|
console.log('Restore sector:', sector);
|
|
}
|
|
}
|
|
|
|
dbFlushTimer = setInterval(() => {
|
|
flushDbQueue();
|
|
}, 1000);
|
|
}
|
|
|
|
function flushDbQueue() {
|
|
if (!dbQueue || dbQueue.length === 0) return;
|
|
|
|
let count = 0;
|
|
const processedKeys = new Set();
|
|
|
|
for (let i = 0; i < dbQueue.length; i++) {
|
|
const msg = dbQueue[i];
|
|
const key = currentDiskCrc + ' ' + msg.sector;
|
|
|
|
if (processedKeys.has(key)) continue;
|
|
processedKeys.add(key);
|
|
|
|
const str = JSON.stringify(msg.data);
|
|
try {
|
|
window.localStorage.setItem(key, str);
|
|
count++;
|
|
} catch (e) {
|
|
console.error('Local storage is full!', e);
|
|
alert('Local storage is full! All persistent data will be deleted.');
|
|
window.localStorage.clear();
|
|
return;
|
|
}
|
|
}
|
|
|
|
dbQueue.length = 0;
|
|
if (count > 0) {
|
|
console.log(count + ' sectors written to local storage!');
|
|
}
|
|
}
|
|
|
|
function queueSectors(offset, data) {
|
|
if (!dbQueue) return;
|
|
|
|
const startSector = Math.floor(offset / SECTOR_SIZE);
|
|
const endSector = Math.floor((offset + data.length - 1) / SECTOR_SIZE);
|
|
|
|
for (let sector = startSector; sector <= endSector; sector++) {
|
|
const sectorData = new Array(SECTOR_SIZE);
|
|
const sectorOffset = sector * SECTOR_SIZE;
|
|
for (let j = 0; j < SECTOR_SIZE; j++) {
|
|
sectorData[j] = currentDiskData[sectorOffset + j];
|
|
}
|
|
dbQueue.push({ sector: sector, data: sectorData });
|
|
}
|
|
}
|
|
|
|
function clearDiskStorage() {
|
|
if (dbFlushTimer) {
|
|
clearInterval(dbFlushTimer);
|
|
dbFlushTimer = null;
|
|
}
|
|
|
|
if (currentDiskCrc && window.localStorage) {
|
|
const keys = [];
|
|
for (let i = 0; i < window.localStorage.length; i++) {
|
|
const key = window.localStorage.key(i);
|
|
if (key && key.startsWith(currentDiskCrc + ' ')) {
|
|
keys.push(key);
|
|
}
|
|
}
|
|
for (const key of keys) {
|
|
window.localStorage.removeItem(key);
|
|
}
|
|
console.log('Removed', keys.length, 'persisted sectors');
|
|
}
|
|
|
|
dbQueue = [];
|
|
}
|
|
|
|
// ── ZIP / HostFS helpers ────────────────────────────────────────────────
|
|
|
|
function flattenTree(files) {
|
|
// If all entries share a single top-level directory, strip it
|
|
const keys = Object.keys(files);
|
|
if (keys.length === 0) return files;
|
|
|
|
let commonPrefix = null;
|
|
for (const path of keys) {
|
|
const slashIdx = path.indexOf('/');
|
|
const prefix = slashIdx >= 0 ? path.substring(0, slashIdx + 1) : null;
|
|
if (commonPrefix === null) {
|
|
commonPrefix = prefix;
|
|
} else if (commonPrefix !== prefix) {
|
|
return files;
|
|
}
|
|
}
|
|
|
|
if (!commonPrefix) return files;
|
|
|
|
const stripped = {};
|
|
for (const path of keys) {
|
|
const newPath = path.substring(commonPrefix.length);
|
|
if (newPath.length > 0) {
|
|
stripped[newPath] = files[path];
|
|
}
|
|
}
|
|
return stripped;
|
|
}
|
|
|
|
function extractZip(arrayBuffer) {
|
|
const data = new Uint8Array(arrayBuffer);
|
|
const unzipped = fflate.unzipSync(data);
|
|
const files = {};
|
|
for (const [path, entry] of Object.entries(unzipped)) {
|
|
if (entry.length !== undefined) {
|
|
files[path] = new Uint8Array(entry);
|
|
}
|
|
}
|
|
return flattenTree(files);
|
|
}
|
|
|
|
// ── Mounted disk list state ────────────────────────────────────────────
|
|
|
|
const mountedDisks = {
|
|
A: null, // floppy — null when not mounted
|
|
C: 'Default', // hard disk — always shown
|
|
Z: null, // HostFS — null when not mounted
|
|
};
|
|
|
|
let panelHideTimer = null;
|
|
|
|
function clearPanelHideTimer() {
|
|
if (panelHideTimer) {
|
|
clearTimeout(panelHideTimer);
|
|
panelHideTimer = null;
|
|
}
|
|
}
|
|
|
|
function updateDiskList() {
|
|
const entriesDiv = document.getElementById('disk-entries');
|
|
if (!entriesDiv) return;
|
|
|
|
let html = '';
|
|
|
|
if (mountedDisks.A) {
|
|
html += '<div>A: ' + mountedDisks.A +
|
|
' <button class="eject-btn" data-drive="A" title="Eject">⏏</button></div>';
|
|
}
|
|
html += '<div>C: ' + mountedDisks.C + '</div>';
|
|
if (mountedDisks.Z) {
|
|
html += '<div>Z: ' + mountedDisks.Z + '</div>';
|
|
}
|
|
|
|
entriesDiv.innerHTML = html;
|
|
|
|
// Wire eject buttons
|
|
entriesDiv.querySelectorAll('.eject-btn').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const drive = btn.getAttribute('data-drive');
|
|
if (drive === 'A') {
|
|
mountedDisks.A = null;
|
|
Module.ccall('wasm_mount_disk', 'number',
|
|
['string', 'number'], [null, 0]);
|
|
clearPanelHideTimer();
|
|
updateDiskList();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Main ────────────────────────────────────────────────────────────────
|
|
|
|
VirtualXTModule().then(function(Module) {
|
|
const canvas = document.getElementById('screen');
|
|
const ctx = canvas.getContext('2d');
|
|
const diskLed = document.getElementById('disk-led');
|
|
const startOverlay = document.getElementById('start-overlay');
|
|
const controlsPanel = document.getElementById('controls-panel');
|
|
const controls = document.getElementById('controls');
|
|
const startBtn = document.getElementById('start-btn');
|
|
const uploadBtn = document.getElementById('upload-btn');
|
|
const resetBtn = document.getElementById('reset-btn');
|
|
const clearDiskBtn = document.getElementById('clear-disk-btn');
|
|
const fullscreenBtn = document.getElementById('fullscreen-btn');
|
|
const crtBtn = document.getElementById('crt-btn');
|
|
const noiseBtn = document.getElementById('noise-btn');
|
|
const videoBtn = document.getElementById('video-btn');
|
|
const turboBtn = document.getElementById('turbo-btn');
|
|
const fileInput = document.getElementById('file-input');
|
|
|
|
let audioCtx = null;
|
|
let audioNode = null;
|
|
let running = false;
|
|
let lastTime = 0;
|
|
|
|
// Audio ring-buffer read
|
|
const audioTempSize = 4096;
|
|
const audioTempPtr = Module._malloc(audioTempSize * 2 * 2); // stereo int16
|
|
|
|
let lastFbWidth = 0;
|
|
let lastFbHeight = 0;
|
|
|
|
Module.onVfsWrite = function(path, offset, data) {
|
|
if (!currentDiskData) return;
|
|
if (!path.endsWith('.img')) return;
|
|
for (let i = 0; i < data.length; i++) {
|
|
const pos = offset + i;
|
|
if (pos < currentDiskData.length) {
|
|
currentDiskData[pos] = data[i];
|
|
}
|
|
}
|
|
queueSectors(offset, data);
|
|
};
|
|
|
|
function updateCanvasSize(fbWidth, fbHeight) {
|
|
// Set internal resolution
|
|
if (canvas.width !== fbWidth || canvas.height !== fbHeight) {
|
|
canvas.width = fbWidth;
|
|
canvas.height = fbHeight;
|
|
}
|
|
|
|
// Calculate 4:3 display box that fills viewport
|
|
const viewportW = window.innerWidth;
|
|
const viewportH = window.innerHeight;
|
|
const targetAspect = 4 / 3;
|
|
|
|
let displayW, displayH;
|
|
if (viewportW / viewportH > targetAspect) {
|
|
displayH = viewportH;
|
|
displayW = displayH * targetAspect;
|
|
} else {
|
|
displayW = viewportW;
|
|
displayH = displayW / targetAspect;
|
|
}
|
|
|
|
canvas.style.width = displayW + 'px';
|
|
canvas.style.height = displayH + 'px';
|
|
canvas.style.left = ((viewportW - displayW) / 2) + 'px';
|
|
canvas.style.top = ((viewportH - displayH) / 2) + 'px';
|
|
}
|
|
|
|
window.addEventListener('resize', () => {
|
|
if (lastFbWidth > 0 && lastFbHeight > 0) {
|
|
updateCanvasSize(lastFbWidth, lastFbHeight);
|
|
}
|
|
});
|
|
|
|
function initAudio() {
|
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)({
|
|
latencyHint: 'interactive',
|
|
sampleRate: 44100
|
|
});
|
|
|
|
const bufferSize = 2048;
|
|
audioNode = audioCtx.createScriptProcessor(bufferSize, 0, 2);
|
|
|
|
audioNode.onaudioprocess = function(e) {
|
|
const left = e.outputBuffer.getChannelData(0);
|
|
const right = e.outputBuffer.getChannelData(1);
|
|
|
|
const frames = Module.ccall('wasm_audio_read', 'number',
|
|
['number', 'number'], [audioTempPtr, bufferSize]);
|
|
|
|
if (frames > 0) {
|
|
const buf = new Int16Array(Module.HEAPU8.buffer, audioTempPtr, frames * 2);
|
|
for (let i = 0; i < frames; i++) {
|
|
left[i] = buf[i * 2] / 32768.0;
|
|
right[i] = buf[i * 2 + 1] / 32768.0;
|
|
}
|
|
}
|
|
|
|
// Fill remainder with silence
|
|
for (let i = frames; i < bufferSize; i++) {
|
|
left[i] = 0;
|
|
right[i] = 0;
|
|
}
|
|
};
|
|
|
|
audioNode.connect(audioCtx.destination);
|
|
}
|
|
|
|
function sendKey(scan, down) {
|
|
if (!scan) return;
|
|
const code = down ? scan : (scan | XT_BREAK);
|
|
Module.ccall('wasm_send_key', null, ['number'], [code]);
|
|
}
|
|
|
|
function handleKeyEvent(e, down) {
|
|
if (e.repeat) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
const scan = xtScan(e.key, e);
|
|
if (scan) {
|
|
sendKey(scan, down);
|
|
if (down) pressedKeys.add(e.key);
|
|
else pressedKeys.delete(e.key);
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
|
|
function releaseAllKeys() {
|
|
for (const key of pressedKeys) {
|
|
const scan = xtScan(key, null);
|
|
if (scan) sendKey(scan, false);
|
|
}
|
|
pressedKeys.clear();
|
|
}
|
|
|
|
function onPointerMove(e) {
|
|
if (document.pointerLockElement === canvas) {
|
|
const buttons = e.buttons & 3;
|
|
Module.ccall('wasm_send_mouse', null,
|
|
['number', 'number', 'number'],
|
|
[e.movementX, e.movementY, buttons]);
|
|
}
|
|
}
|
|
|
|
function onPointerDown(e) {
|
|
if (document.pointerLockElement !== canvas && e.target === canvas) {
|
|
canvas.requestPointerLock();
|
|
}
|
|
}
|
|
|
|
// ── Unified mount ─────────────────────────────────────────────────────
|
|
|
|
function mountDiskFile(data, size, filename) {
|
|
const arr = new Uint8Array(data);
|
|
|
|
// Track for persistence
|
|
currentDiskPath = '/upload.img';
|
|
currentDiskOriginal = new Uint8Array(arr);
|
|
currentDiskData = new Uint8Array(arr);
|
|
|
|
// Copy into WASM memory
|
|
const dataPtr = Module._malloc(size);
|
|
Module.HEAPU8.set(currentDiskData, dataPtr);
|
|
|
|
// Create VFS file
|
|
Module.ccall('wasm_create_vfs_file', 'number',
|
|
['string', 'number', 'number'],
|
|
[currentDiskPath, dataPtr, size]);
|
|
|
|
Module._free(dataPtr);
|
|
|
|
// Determine floppy vs HD by size
|
|
const isFloppy = size <= 1474560;
|
|
|
|
if (isFloppy) {
|
|
console.log('Mounting floppy:', filename);
|
|
mountedDisks.A = filename;
|
|
Module.ccall('wasm_mount_disk', 'number',
|
|
['string', 'number'], [currentDiskPath, 0]);
|
|
} else {
|
|
console.log('Loading HD image:', filename);
|
|
mountedDisks.C = filename;
|
|
Module.ccall('wasm_load_hd', null, ['string'], [currentDiskPath]);
|
|
}
|
|
|
|
initLocalStorage();
|
|
}
|
|
|
|
async function mountHostFs(files, launchPath, sourceName) {
|
|
// files: { relativePath: Uint8Array }
|
|
console.log('Mounting HostFS with', Object.keys(files).length, 'files');
|
|
|
|
mountedDisks.Z = sourceName || 'HostFS';
|
|
|
|
// Clear old HostFS entries
|
|
Module.ccall('wasm_hostfs_clear', null, [], []);
|
|
|
|
// Add all files to VFS
|
|
for (const [path, data] of Object.entries(files)) {
|
|
const vfsPath = '/hostfs/' + path;
|
|
const ptr = Module._malloc(data.length);
|
|
Module.HEAPU8.set(data, ptr);
|
|
Module.ccall('wasm_hostfs_add_file', 'number',
|
|
['string', 'number', 'number'], [vfsPath, ptr, data.length]);
|
|
Module._free(ptr);
|
|
}
|
|
|
|
// Set HostFS root
|
|
Module.ccall('wasm_hostfs_set_root', null, ['string'], ['/hostfs']);
|
|
|
|
// Set launch path if auto-launching
|
|
if (launchPath) {
|
|
const fullLaunch = '/hostfs/' + launchPath;
|
|
console.log('Auto-launch:', fullLaunch);
|
|
Module.ccall('wasm_hostfs_set_launch_path', null, ['string'], [fullLaunch]);
|
|
}
|
|
|
|
// Reset to pick up new HostFS state
|
|
Module.ccall('wasm_reset', null, [], []);
|
|
}
|
|
|
|
async function handleMount(fileList) {
|
|
if (!fileList || fileList.length === 0) return;
|
|
|
|
const file = fileList[0];
|
|
const name = file.name.toLowerCase();
|
|
|
|
// .img -> disk mount
|
|
if (name.endsWith('.img')) {
|
|
const reader = new FileReader();
|
|
reader.onload = function() {
|
|
mountDiskFile(reader.result, reader.result.byteLength, file.name);
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
return;
|
|
}
|
|
|
|
// .zip -> extract and mount as HostFS
|
|
if (name.endsWith('.zip')) {
|
|
const reader = new FileReader();
|
|
reader.onload = function() {
|
|
try {
|
|
const files = extractZip(reader.result);
|
|
const launchPath = findAutoLaunch(files);
|
|
mountHostFs(files, launchPath, file.name);
|
|
} catch (e) {
|
|
console.error('Failed to extract ZIP:', e);
|
|
alert('Failed to extract ZIP: ' + e.message);
|
|
}
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
return;
|
|
}
|
|
|
|
// .exe / .com -> mount as HostFS with auto-launch
|
|
if (name.endsWith('.exe') || name.endsWith('.com')) {
|
|
const files = {};
|
|
const reader = new FileReader();
|
|
reader.onload = function() {
|
|
files[file.name] = new Uint8Array(reader.result);
|
|
mountHostFs(files, file.name, 'HostFS');
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
return;
|
|
}
|
|
|
|
// Unknown type - try as disk image
|
|
const reader = new FileReader();
|
|
reader.onload = function() {
|
|
mountDiskFile(reader.result, reader.result.byteLength, file.name);
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
}
|
|
|
|
function findAutoLaunch(files) {
|
|
const exes = Object.keys(files).filter(p =>
|
|
p.toLowerCase().endsWith('.exe') || p.toLowerCase().endsWith('.com'));
|
|
if (exes.length === 1) return exes[0];
|
|
return null;
|
|
}
|
|
|
|
function onFileSelected(e) {
|
|
handleMount(e.target.files);
|
|
}
|
|
|
|
// ── Drag and drop ─────────────────────────────────────────────────────
|
|
|
|
function onDragOver(e) {
|
|
e.preventDefault();
|
|
canvas.style.border = '2px dashed #888';
|
|
}
|
|
|
|
function onDragLeave(e) {
|
|
e.preventDefault();
|
|
canvas.style.border = 'none';
|
|
}
|
|
|
|
async function onDrop(e) {
|
|
e.preventDefault();
|
|
canvas.style.border = 'none';
|
|
|
|
const items = e.dataTransfer.items;
|
|
if (!items || items.length === 0) return;
|
|
|
|
const entry = items[0].webkitGetAsEntry();
|
|
if (!entry) {
|
|
// Fallback to files
|
|
handleMount(e.dataTransfer.files);
|
|
return;
|
|
}
|
|
|
|
if (entry.isFile) {
|
|
entry.file(file => handleMount([file]));
|
|
} else if (entry.isDirectory) {
|
|
const files = {};
|
|
await readDirectoryEntry(entry, '', files);
|
|
const flattened = flattenTree(files);
|
|
const launchPath = findAutoLaunch(flattened);
|
|
mountHostFs(flattened, launchPath, entry.name);
|
|
}
|
|
}
|
|
|
|
function readDirectoryEntry(entry, path, out) {
|
|
return new Promise((resolve) => {
|
|
const reader = entry.createReader();
|
|
const readBatch = () => {
|
|
reader.readEntries(async (entries) => {
|
|
if (entries.length === 0) {
|
|
resolve();
|
|
return;
|
|
}
|
|
for (const child of entries) {
|
|
const childPath = path ? (path + '/' + child.name) : child.name;
|
|
if (child.isFile) {
|
|
await new Promise((r) => {
|
|
child.file((f) => {
|
|
const fr = new FileReader();
|
|
fr.onload = () => {
|
|
out[childPath] = new Uint8Array(fr.result);
|
|
r();
|
|
};
|
|
fr.readAsArrayBuffer(f);
|
|
});
|
|
});
|
|
} else if (child.isDirectory) {
|
|
await readDirectoryEntry(child, childPath, out);
|
|
}
|
|
}
|
|
readBatch();
|
|
});
|
|
};
|
|
readBatch();
|
|
});
|
|
}
|
|
|
|
// ── Frame loop ────────────────────────────────────────────────────────
|
|
|
|
function frame(now) {
|
|
if (!running) return;
|
|
|
|
if (lastTime === 0) lastTime = now;
|
|
const deltaUs = Math.floor((now - lastTime) * 1000);
|
|
lastTime = now;
|
|
|
|
// Run one frame
|
|
Module.ccall('wasm_run', null, ['number'], [deltaUs]);
|
|
|
|
// Read video state
|
|
const width = Module.ccall('wasm_framebuffer_width', 'number', [], []);
|
|
const height = Module.ccall('wasm_framebuffer_height', 'number', [], []);
|
|
const pitch = Module.ccall('wasm_framebuffer_pitch', 'number', [], []);
|
|
const ptr = Module.ccall('wasm_framebuffer_ptr', 'number', [], []);
|
|
|
|
// Blit to canvas
|
|
if (width > 0 && height > 0 && ptr !== 0) {
|
|
lastFbWidth = width;
|
|
lastFbHeight = height;
|
|
updateCanvasSize(width, height);
|
|
const imgData = ctx.createImageData(width, height);
|
|
for (let y = 0; y < height; y++) {
|
|
const src = new Uint8Array(Module.HEAPU8.buffer, ptr + y * pitch, width * 4);
|
|
const dst = imgData.data;
|
|
const dstOff = y * width * 4;
|
|
for (let x = 0; x < width; x++) {
|
|
const sx = x * 4;
|
|
const dx = dstOff + x * 4;
|
|
// XRGB8888 little-endian: BGRX -> Canvas RGBA
|
|
dst[dx] = src[sx + 2]; // R
|
|
dst[dx + 1] = src[sx + 1]; // G
|
|
dst[dx + 2] = src[sx]; // B
|
|
dst[dx + 3] = 255; // A (fix transparent black)
|
|
}
|
|
}
|
|
ctx.putImageData(imgData, 0, 0);
|
|
}
|
|
|
|
// Update disk LED
|
|
const led = Module.ccall('wasm_disk_led_active', 'number', [], []);
|
|
diskLed.style.display = led ? 'block' : 'none';
|
|
|
|
requestAnimationFrame(frame);
|
|
}
|
|
|
|
function start() {
|
|
startOverlay.style.display = 'none';
|
|
controlsPanel.style.display = 'block';
|
|
running = true;
|
|
lastTime = 0;
|
|
|
|
initAudio();
|
|
if (audioCtx.state === 'suspended') {
|
|
audioCtx.resume();
|
|
}
|
|
|
|
// Parse URL parameters and pass to C glue
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const options = [];
|
|
if (urlParams.has('video')) options.push('video=' + urlParams.get('video').toLowerCase());
|
|
if (urlParams.has('cpu')) options.push('cpu=' + urlParams.get('cpu'));
|
|
if (urlParams.has('crt')) options.push('crt=' + urlParams.get('crt').toLowerCase());
|
|
if (urlParams.has('noise')) options.push('noise=' + urlParams.get('noise').toLowerCase());
|
|
if (urlParams.has('boot')) options.push('boot=' + urlParams.get('boot').toLowerCase());
|
|
if (urlParams.has('reset')) options.push('reset=' + urlParams.get('reset').toLowerCase());
|
|
|
|
if (options.length > 0) {
|
|
const optsStr = options.join('&');
|
|
const ptr = Module.ccall('wasm_string_alloc', 'number', ['string'], [optsStr]);
|
|
Module.ccall('wasm_set_options', null, ['number'], [ptr]);
|
|
Module.ccall('wasm_string_free', null, ['number'], [ptr]);
|
|
}
|
|
|
|
// Initialize emulator (no content = default FreeDOS)
|
|
Module.ccall('wasm_initialize', null, [], []);
|
|
|
|
// Handle disk= parameter
|
|
const diskUrl = urlParams.get('disk');
|
|
if (diskUrl) {
|
|
fetch(diskUrl)
|
|
.then(r => r.arrayBuffer())
|
|
.then(buf => {
|
|
const data = new Uint8Array(buf);
|
|
|
|
currentDiskPath = '/disk.img';
|
|
currentDiskOriginal = new Uint8Array(data);
|
|
currentDiskData = new Uint8Array(data);
|
|
|
|
const diskName = diskUrl.split('/').pop();
|
|
|
|
const ptr = Module._malloc(data.length);
|
|
Module.HEAPU8.set(currentDiskData, ptr);
|
|
Module.ccall('wasm_create_vfs_file', 'number',
|
|
['string', 'number', 'number'], [currentDiskPath, ptr, data.length]);
|
|
Module._free(ptr);
|
|
|
|
const isFloppy = data.length <= 1474560;
|
|
if (isFloppy) {
|
|
mountedDisks.A = diskName;
|
|
Module.ccall('wasm_mount_disk', 'number',
|
|
['string', 'number'], [currentDiskPath, 0]);
|
|
} else {
|
|
mountedDisks.C = diskName;
|
|
Module.ccall('wasm_load_hd', null,
|
|
['string'], [currentDiskPath]);
|
|
}
|
|
|
|
initLocalStorage();
|
|
})
|
|
.catch(e => console.error('Failed to load disk:', e));
|
|
}
|
|
|
|
requestAnimationFrame(frame);
|
|
}
|
|
|
|
let videoState = 'CGA';
|
|
let videoNeedsReload = false;
|
|
const videoOrder = ['CGA', 'HGC', 'EGA'];
|
|
|
|
function cycleVideo() {
|
|
const idx = videoOrder.indexOf(videoState);
|
|
videoState = videoOrder[(idx + 1) % videoOrder.length];
|
|
Module.ccall('wasm_set_video', null, ['string'], [videoState]);
|
|
videoNeedsReload = true;
|
|
videoBtn.textContent = 'Video: ' + videoState;
|
|
}
|
|
|
|
function reset() {
|
|
if (!running) return;
|
|
if (videoNeedsReload) {
|
|
console.log('Reloading machine with video:', videoState);
|
|
Module.ccall('wasm_reload', null, [], []);
|
|
videoNeedsReload = false;
|
|
} else {
|
|
console.log('Reset');
|
|
Module.ccall('wasm_reset', null, [], []);
|
|
}
|
|
}
|
|
|
|
function clearDisk() {
|
|
if (!currentDiskOriginal) {
|
|
console.log('No custom disk to clear');
|
|
return;
|
|
}
|
|
|
|
console.log('Clearing disk storage and resetting...');
|
|
clearDiskStorage();
|
|
|
|
// Reset to original image
|
|
currentDiskData = new Uint8Array(currentDiskOriginal);
|
|
|
|
// Recreate VFS file with clean image
|
|
const ptr = Module._malloc(currentDiskData.length);
|
|
Module.HEAPU8.set(currentDiskData, ptr);
|
|
Module.ccall('wasm_create_vfs_file', 'number',
|
|
['string', 'number', 'number'],
|
|
[currentDiskPath, ptr, currentDiskData.length]);
|
|
Module._free(ptr);
|
|
|
|
// Re-mount/re-load
|
|
const isFloppy = currentDiskData.length <= 1474560;
|
|
if (isFloppy) {
|
|
Module.ccall('wasm_mount_disk', 'number',
|
|
['string', 'number'], [currentDiskPath, 0]);
|
|
} else {
|
|
Module.ccall('wasm_load_hd', null, ['string'], [currentDiskPath]);
|
|
}
|
|
|
|
// Restart local storage
|
|
initLocalStorage();
|
|
|
|
// Reset emulator
|
|
Module.ccall('wasm_reset', null, [], []);
|
|
}
|
|
|
|
function updateFullscreenButton() {
|
|
const isFull = !!document.fullscreenElement;
|
|
fullscreenBtn.textContent = 'Fullscreen: ' + (isFull ? 'On' : 'Off');
|
|
}
|
|
|
|
function toggleFullscreen() {
|
|
if (!document.fullscreenElement) {
|
|
document.documentElement.requestFullscreen().catch(err => {
|
|
console.warn('Fullscreen denied:', err);
|
|
});
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
}
|
|
|
|
let crtState = true;
|
|
let noiseState = true;
|
|
let turboState = false;
|
|
|
|
function updateToggleButton(btn, state, label) {
|
|
btn.textContent = label + ': ' + (state ? 'On' : 'Off');
|
|
}
|
|
|
|
function updateTurboButton() {
|
|
turboBtn.textContent = 'Turbo: ' + (turboState ? '10.0' : '4.77');
|
|
}
|
|
|
|
function toggleCrt() {
|
|
crtState = !crtState;
|
|
Module.ccall('wasm_set_crt', null, ['number'], [crtState ? 1 : 0]);
|
|
updateToggleButton(crtBtn, crtState, 'CRT');
|
|
}
|
|
|
|
function toggleNoise() {
|
|
noiseState = !noiseState;
|
|
Module.ccall('wasm_set_noise', null, ['number'], [noiseState ? 1 : 0]);
|
|
updateToggleButton(noiseBtn, noiseState, 'Noise');
|
|
}
|
|
|
|
function toggleTurbo() {
|
|
turboState = !turboState;
|
|
Module.ccall('wasm_set_turbo', null, ['number'], [turboState ? 1 : 0]);
|
|
updateTurboButton();
|
|
}
|
|
|
|
// Event listeners
|
|
startBtn.addEventListener('click', start);
|
|
uploadBtn.addEventListener('click', () => fileInput.click());
|
|
resetBtn.addEventListener('click', reset);
|
|
clearDiskBtn.addEventListener('click', clearDisk);
|
|
fullscreenBtn.addEventListener('click', toggleFullscreen);
|
|
crtBtn.addEventListener('click', toggleCrt);
|
|
noiseBtn.addEventListener('click', toggleNoise);
|
|
videoBtn.addEventListener('click', cycleVideo);
|
|
turboBtn.addEventListener('click', toggleTurbo);
|
|
fileInput.addEventListener('change', onFileSelected);
|
|
|
|
window.addEventListener('keydown', e => handleKeyEvent(e, true));
|
|
window.addEventListener('keyup', e => handleKeyEvent(e, false));
|
|
window.addEventListener('blur', releaseAllKeys);
|
|
document.addEventListener('fullscreenchange', updateFullscreenButton);
|
|
|
|
canvas.addEventListener('pointermove', onPointerMove);
|
|
canvas.addEventListener('pointerdown', onPointerDown);
|
|
|
|
// Drag and drop
|
|
canvas.addEventListener('dragover', onDragOver);
|
|
canvas.addEventListener('dragleave', onDragLeave);
|
|
canvas.addEventListener('drop', onDrop);
|
|
|
|
// Panel visibility (right-edge trigger + bottom panel hover)
|
|
const diskList = document.getElementById('disk-list');
|
|
const rightTrigger = document.getElementById('right-trigger');
|
|
|
|
function showPanels() {
|
|
clearPanelHideTimer();
|
|
updateDiskList();
|
|
if (diskList) diskList.classList.add('visible');
|
|
if (controls) controls.style.transform = 'translateY(0)';
|
|
}
|
|
|
|
function hidePanels() {
|
|
panelHideTimer = setTimeout(() => {
|
|
if (diskList) diskList.classList.remove('visible');
|
|
if (controls) controls.style.transform = '';
|
|
}, 300);
|
|
}
|
|
|
|
// Right edge trigger
|
|
if (rightTrigger) {
|
|
rightTrigger.addEventListener('mouseenter', showPanels);
|
|
rightTrigger.addEventListener('mouseleave', hidePanels);
|
|
}
|
|
|
|
// Bottom panel trigger
|
|
controlsPanel.addEventListener('mouseenter', showPanels);
|
|
controlsPanel.addEventListener('mouseleave', hidePanels);
|
|
|
|
// Keep visible when hovering panels themselves
|
|
if (diskList) {
|
|
diskList.addEventListener('mouseenter', showPanels);
|
|
diskList.addEventListener('mouseleave', hidePanels);
|
|
}
|
|
|
|
// Set initial state from URL params (default On)
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
crtState = urlParams.get('crt') !== 'off';
|
|
noiseState = urlParams.get('noise') !== 'off';
|
|
const cpuParam = urlParams.get('cpu');
|
|
if (cpuParam && cpuParam.toLowerCase() === '4.77') {
|
|
turboState = false;
|
|
} else if (cpuParam) {
|
|
turboState = true;
|
|
}
|
|
if (cpuParam) {
|
|
Module.ccall('wasm_set_turbo', null, ['number'], [turboState ? 1 : 0]);
|
|
}
|
|
const videoParam = urlParams.get('video');
|
|
if (videoParam && videoOrder.includes(videoParam.toUpperCase())) {
|
|
videoState = videoParam.toUpperCase();
|
|
Module.ccall('wasm_set_video', null, ['string'], [videoState]);
|
|
}
|
|
updateToggleButton(crtBtn, crtState, 'CRT');
|
|
updateToggleButton(noiseBtn, noiseState, 'Noise');
|
|
videoBtn.textContent = 'Video: ' + videoState;
|
|
updateTurboButton();
|
|
updateFullscreenButton();
|
|
|
|
// Auto-start if embedded in an iframe; otherwise wait for start button
|
|
if (window.self !== window.top) {
|
|
start();
|
|
}
|
|
});
|