Files
Andreas T Jonsson fc6c3fb473 fix(web,libretro): align default video adapter and CPU speed across builds
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.
2026-06-10 10:10:50 +02:00

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();
}
});