Replace vvfat SD with browser-built raw FAT32 from OPFS
Browser SD files are persisted in OPFS (web/sdstore.js) and edited in the Files tab. At boot the browser builds a deterministic 64 MiB FAT32 image (web/fat32.js) with nested directories and VFAT long names, then mounts it as a raw block device, bypassing QEMU's broken populated-vvfat WASM coroutine path. Guest writes mutate only the ephemeral image. Exposes a hidden #frame-generation indicator that increments on every e-ink flush, so automation can wait on real display updates instead of fixed sleeps. Adds tests/ with a Makefile runner: 'make test' for JS + FAT32/mtools checks, 'make browser-test' for a headless-Firefox boot of the official CrossPoint 1.4.1 firmware that navigates to Test/example.txt. Bumps third_party/qemu to the SPI-SD/CMD13 fixes and updates README/ROADMAP/ISSUES for the new architecture.
This commit is contained in:
@@ -1,28 +1,12 @@
|
||||
# Known Issues
|
||||
|
||||
## SD reads can corrupt guest memory
|
||||
## Resolved: directory-backed SD reads corrupted WASM state
|
||||
|
||||
Opening `/Test/example.txt` can panic consistently:
|
||||
Opening `/Test/example.txt` through QEMU `vvfat` could corrupt coroutine/guest state. The browser now builds a standards-compliant 64 MiB FAT32 image from OPFS and mounts it as a raw block device, bypassing the failing host-file mapping path. The official CrossPoint 1.4.1 firmware can traverse `Test/example.txt` without a WASM trap or guest panic.
|
||||
|
||||
```text
|
||||
Invalid read at addr 0x253D7474, size 4, region '(null)'
|
||||
Guru Meditation Error: Core 0 panic'ed (Load access fault)
|
||||
```
|
||||
## Resolved: SD card remained in `receivingdata`
|
||||
|
||||
The fault occurs in `spiTransferByteNL()` after the `SdSpiCard` SPI pointer at `0x3fc94b40` becomes `0x253D7474`, which resembles file data. The crash is deterministic with the directory-backed `vvfat` card. A raw FAT32 diagnostic image fails to mount, so it has not yet provided a clean backend comparison.
|
||||
|
||||
The crash dump also shows `SdSpiCard::readData()` reaching its CRC reads while its destination pointer and end pointer differ. Keeping conditional translation blocks on TCI did not fix the corruption. The next diagnostic build keeps guest-store TBs on TCI and watches writes to `0x3fc94b40`–`0x3fc94b57`.
|
||||
|
||||
## SD card can remain in `receivingdata`
|
||||
|
||||
The SD model can become stuck after an incomplete `CMD24` write:
|
||||
|
||||
```text
|
||||
SPI: CMD17 in a wrong state: receivingdata (spec v3.01)
|
||||
SPI: CMD24 in a wrong state: receivingdata (spec v3.01)
|
||||
```
|
||||
|
||||
Subsequent reads and writes then fail. This may be related to the guest-memory corruption, but remains tracked separately until the first incomplete transfer is identified.
|
||||
`ssi-sd` consumed a write-data token while leaving response mode, then parsed payload bytes as commands. The adapter now enters token mode immediately after the final R1 byte. SPI CMD13 also uses the upstream-correct `sd_r1` internal response so status polling no longer loops on `Unexpected response to cmd 13`.
|
||||
|
||||
## Reader page can render completely black
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ QEMU_WASM_SRC ?= third_party/qemu
|
||||
WASM_OUT ?= dist/wasm
|
||||
VARIANT ?= x3
|
||||
|
||||
.PHONY: firmware sdimage qemu qemu-wasm web web-test run run-upstream chip clean
|
||||
.PHONY: firmware sdimage qemu qemu-wasm web web-test test browser-test run run-upstream chip clean
|
||||
|
||||
firmware:
|
||||
esptool --chip esp32c3 merge-bin -o flash.bin \
|
||||
@@ -39,6 +39,12 @@ web: qemu-wasm
|
||||
web-test:
|
||||
node scripts/test-web.mjs
|
||||
|
||||
test:
|
||||
$(MAKE) -C tests test
|
||||
|
||||
browser-test:
|
||||
$(MAKE) -C tests browser
|
||||
|
||||
# Select X4 with `make run VARIANT=x4`. Add `-display none` for headless.
|
||||
run: qemu
|
||||
@sd=sd.img; tmpdir=; \
|
||||
|
||||
@@ -60,12 +60,13 @@ map to the device's ADC button ladders and active-low Power GPIO. `make web`
|
||||
serves the required COOP/COEP headers for Emscripten pthreads; a generic static
|
||||
server without those headers will not work.
|
||||
|
||||
Real firmware boots in WASM and drives the X3 panel (528×792). The browser SD
|
||||
uses QEMU's directory-backed FAT provider: the guest sees a writable, ephemeral
|
||||
32 GiB FAT32 card while memory use scales with actual files instead of logical
|
||||
capacity. It starts with `Test/example.txt`; inspect it in the Files tab. The
|
||||
Debug tab reports committed WASM heap, visible MEMFS data, SD file bytes, and
|
||||
browser JS heap where supported. X4 remains a white display stub.
|
||||
Real firmware boots in WASM and drives the X3 panel (528×792). Browser SD files
|
||||
are persisted in OPFS and edited in the Files tab. At boot, the browser builds a
|
||||
64 MiB raw FAT32 image with nested-directory and VFAT long-name support; QEMU
|
||||
mounts that image directly, avoiding `vvfat`. Choose **Apply & reboot** to expose
|
||||
edits to the guest. Guest writes modify only the ephemeral image. The Debug tab
|
||||
reports committed WASM heap, visible MEMFS data, and browser JS heap where
|
||||
supported. X4 remains a white display stub.
|
||||
|
||||
## Contents
|
||||
|
||||
@@ -81,8 +82,12 @@ browser JS heap where supported. X4 remains a white display stub.
|
||||
- `flake.nix` — the espressif-qemu *release* binary as the fast generic baseline
|
||||
(`nix run .#run-upstream -- flash.bin`), plus a devShell with QEMU's full build
|
||||
environment. `Makefile` — `qemu`, `firmware`, `sdimage`, `run`, `chip`.
|
||||
- `scripts/mksd.sh` — build the FAT32 SD backing image (local-disk impl of the
|
||||
SD block interface).
|
||||
- `web/fat32.js` and `web/sdstore.js` — dependency-free FAT32 image generation
|
||||
and persistent OPFS file storage.
|
||||
- `scripts/mksd.sh` — build a FAT32 SD backing image for native QEMU.
|
||||
- `tests/` — `make test` runs JS/FAT32 checks; `make browser-test` boots the
|
||||
official CrossPoint 1.4.1 firmware and navigates to `Test/example.txt` in
|
||||
headless Firefox.
|
||||
- `chip/eink-x3.chip.c` — the reverse-engineered X3 e-ink protocol, first written
|
||||
as a Wokwi chip and now the reference for the QEMU display device.
|
||||
`make -C chip test` runs its host self-check.
|
||||
|
||||
+2
-4
@@ -58,7 +58,7 @@ reuses unchanged — no bespoke abstraction layer:
|
||||
| Peripheral | Native seam | Browser binding |
|
||||
|------------|-------------|---------------------------|
|
||||
| e-ink panel | X3 UC8253 (528×792) or blank X4 SSD1677 stub (480×800) → QEMU graphical console | `<canvas>` from the same display surface |
|
||||
| SD card | `ssi-sd` + `sd-card-spi` backed by QEMU block layer (`-drive if=sd`) | QEMU `vvfat` over a JS-created MEMFS directory; future persistence can use OPFS |
|
||||
| SD card | `ssi-sd` + `sd-card-spi` backed by QEMU block layer (`-drive if=sd`) | OPFS files → browser-built 64 MiB FAT32 image → raw MEMFS block device |
|
||||
| Buttons + battery | ADC QOM props `adci[1]`/`adci[2]`; GPIO QOM `input-level[3]` for Power | DOM buttons → `qom-set` equivalent |
|
||||
| Firmware image | flash via `-drive if=mtd` | uploaded `.bin` written to emulated flash |
|
||||
|
||||
@@ -85,9 +85,7 @@ reuses unchanged — no bespoke abstraction layer:
|
||||
|
||||
## Remaining phases
|
||||
|
||||
1. **Browser SD editing/persistence** — add file controls and optionally OPFS;
|
||||
the current 32 GiB logical card is writable and ephemeral.
|
||||
2. **Full X4 panel** — implement SSD1677 RAM/window/update commands when visible
|
||||
1. **Full X4 panel** — implement SSD1677 RAM/window/update commands when visible
|
||||
X4 rendering is needed; the selection, dimensions, wiring, and stub exist.
|
||||
|
||||
## Repo layout
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { BUTTONS, DEFAULT_SD_FILES, EXAMPLE_SD_TEXT, formatBytes, setButton, mergeFirmware } from '../web/app.js';
|
||||
import { buildFat32Image, FAT32_IMAGE_SIZE } from '../web/fat32.js';
|
||||
|
||||
// Merge - app image is placed at 0x10000 in a 0xFF-erased 16 MB flash with bootloader and partitions.
|
||||
const bootloader = new Uint8Array([1, 2, 3]);
|
||||
@@ -19,6 +20,36 @@ assert.equal(EXAMPLE_SD_TEXT.split('Lorem ipsum').length - 1, 10);
|
||||
assert.equal(formatBytes(512), '512 B');
|
||||
assert.equal(formatBytes(64 * 1024 * 1024), '64.0 MiB');
|
||||
|
||||
const fatPayload = new TextEncoder().encode('A'.repeat(700));
|
||||
const sdImage = buildFat32Image([
|
||||
{ path: 'Test/example.txt', bytes: fatPayload },
|
||||
{ path: 'Books/A long filename.txt', bytes: new TextEncoder().encode('book') },
|
||||
]);
|
||||
const sdView = new DataView(sdImage.buffer);
|
||||
assert.equal(sdImage.length, FAT32_IMAGE_SIZE);
|
||||
assert.equal(sdView.getUint16(11, true), 512);
|
||||
assert.equal(sdImage[13], 1);
|
||||
assert.equal(sdView.getUint32(32, true), FAT32_IMAGE_SIZE / 512);
|
||||
assert.equal(sdView.getUint32(44, true), 2);
|
||||
assert.deepEqual([...sdImage.slice(510, 512)], [0x55, 0xaa]);
|
||||
const fatSectors = sdView.getUint32(36, true);
|
||||
const fatOffset = 32 * 512;
|
||||
const dataOffset = (32 + 2 * fatSectors) * 512;
|
||||
const root = sdImage.subarray(dataOffset, dataOffset + 512);
|
||||
const testEntryOffset = Array.from({ length: 16 }, (_, i) => i * 32)
|
||||
.find(offset => root[offset + 11] !== 0x0f && new TextDecoder().decode(root.subarray(offset, offset + 8)).trim() === 'TEST');
|
||||
assert.notEqual(testEntryOffset, undefined);
|
||||
const testCluster = new DataView(root.buffer, root.byteOffset + testEntryOffset, 32).getUint16(26, true);
|
||||
const testDirectory = sdImage.subarray(dataOffset + (testCluster - 2) * 512, dataOffset + (testCluster - 1) * 512);
|
||||
const fileOffset = Array.from({ length: 16 }, (_, i) => i * 32)
|
||||
.find(offset => new TextDecoder().decode(testDirectory.subarray(offset, offset + 8)).trim() === 'EXAMPLE');
|
||||
const fileEntry = new DataView(testDirectory.buffer, testDirectory.byteOffset + fileOffset, 32);
|
||||
const fileCluster = fileEntry.getUint16(26, true);
|
||||
assert.equal(fileEntry.getUint32(28, true), fatPayload.length);
|
||||
assert.equal(sdView.getUint32(fatOffset + fileCluster * 4, true), fileCluster + 1);
|
||||
assert.equal(sdView.getUint32(fatOffset + (fileCluster + 1) * 4, true), 0x0fffffff);
|
||||
assert.deepEqual(sdImage.slice(dataOffset + (fileCluster - 2) * 512, dataOffset + (fileCluster - 2) * 512 + fatPayload.length), fatPayload);
|
||||
|
||||
const calls = [];
|
||||
const module = {
|
||||
_xteink_wasm_set_adc: (...args) => calls.push(['adc', ...args]),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
.PHONY: test unit fat32 browser
|
||||
|
||||
test: unit fat32
|
||||
|
||||
unit:
|
||||
cd .. && node scripts/test-web.mjs
|
||||
|
||||
fat32:
|
||||
node generate-fat32-fixture.mjs
|
||||
mdir -i ../_scratch/test-fat32.img :: >/dev/null
|
||||
mdir -i ../_scratch/test-fat32.img ::/Test >/dev/null
|
||||
mdir -i ../_scratch/test-fat32.img ::/Books >/dev/null
|
||||
test "$$(mtype -i ../_scratch/test-fat32.img ::/Test/example.txt)" = "hello from OPFS"
|
||||
test "$$(mtype -i ../_scratch/test-fat32.img '::/Books/A long book filename.txt')" = "chapter one"
|
||||
|
||||
browser:
|
||||
./run-browser-raw-sd.sh
|
||||
Binary file not shown.
@@ -0,0 +1,77 @@
|
||||
from pathlib import Path
|
||||
import time
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.firefox.options import Options
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
options = Options()
|
||||
options.add_argument('-headless')
|
||||
driver = webdriver.Firefox(options=options)
|
||||
driver.set_page_load_timeout(60)
|
||||
try:
|
||||
driver.get('http://127.0.0.1:8180/web/?firmware=/_scratch/crosspoint-reader-1.4.1-release-firmware.bin&variant=x3')
|
||||
confirm = driver.find_element(By.CSS_SELECTOR, '[data-button="confirm"]')
|
||||
WebDriverWait(driver, 8 * 60).until(lambda _: confirm.get_attribute('disabled') is None)
|
||||
|
||||
indicator = driver.find_element(By.ID, 'frame-generation')
|
||||
|
||||
def generation():
|
||||
return int(indicator.get_attribute('value') or '-1')
|
||||
|
||||
def wait_frame(previous, timeout=180):
|
||||
WebDriverWait(driver, timeout).until(lambda _: generation() > previous)
|
||||
|
||||
def wait_stable(quiet=5, timeout=180):
|
||||
deadline = time.time() + timeout
|
||||
last = generation()
|
||||
stable_since = time.time()
|
||||
while time.time() < deadline:
|
||||
time.sleep(0.25)
|
||||
current = generation()
|
||||
if current != last:
|
||||
last = current
|
||||
stable_since = time.time()
|
||||
elif time.time() - stable_since >= quiet:
|
||||
return current
|
||||
raise RuntimeError('display did not settle')
|
||||
|
||||
power = driver.find_element(By.CSS_SELECTOR, '[data-button="power"]')
|
||||
before = generation()
|
||||
ActionChains(driver).click_and_hold(power).perform()
|
||||
wait_frame(before)
|
||||
ActionChains(driver).release(power).perform()
|
||||
wait_stable()
|
||||
|
||||
def press(button):
|
||||
driver.execute_script('arguments[0].scrollIntoView({block: "center"})', button)
|
||||
before = wait_stable()
|
||||
ActionChains(driver).click_and_hold(button).perform()
|
||||
time.sleep(0.3)
|
||||
ActionChains(driver).release(button).perform()
|
||||
wait_frame(before)
|
||||
wait_stable()
|
||||
|
||||
press(confirm) # Browse Files
|
||||
press(confirm) # Test
|
||||
press(confirm) # example.txt
|
||||
time.sleep(60)
|
||||
|
||||
status = driver.find_element(By.ID, 'status').text
|
||||
log = driver.find_element(By.ID, 'log').text
|
||||
(ROOT / '_scratch/browser-raw-sd.log').write_text(log)
|
||||
driver.save_screenshot(str(ROOT / '_scratch/browser-raw-sd.png'))
|
||||
|
||||
bad = (
|
||||
'RuntimeError', 'Guru Meditation', "panic'ed", 'Invalid SD card size',
|
||||
'FS error', 'SPI:', 'SD:', 'Unexpected response to cmd',
|
||||
)
|
||||
matched = [marker for marker in bad if marker in log or marker in status]
|
||||
if matched:
|
||||
raise RuntimeError(f'emulator failure detected: {matched}')
|
||||
print(f'browser raw-SD test passed at frame generation {generation()}')
|
||||
finally:
|
||||
driver.quit()
|
||||
@@ -0,0 +1,10 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { buildFat32Image } from '../web/fat32.js';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
await mkdir('../_scratch', { recursive: true });
|
||||
await writeFile('../_scratch/test-fat32.img', buildFat32Image([
|
||||
{ path: 'Test/example.txt', bytes: encoder.encode('hello from OPFS\n') },
|
||||
{ path: 'Books/A long book filename.txt', bytes: encoder.encode('chapter one\n') },
|
||||
{ path: 'empty.bin', bytes: new Uint8Array() },
|
||||
]));
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
cd "$ROOT"
|
||||
mkdir -p _scratch
|
||||
|
||||
FIRMWARE=_scratch/crosspoint-reader-1.4.1-release-firmware.bin
|
||||
EXPECTED=c3735b487378650f7fb4be5b286db1a9796768ff02bc8831dc4ba0a3771659a1
|
||||
if [[ ! -f $FIRMWARE ]] || [[ $(sha256sum "$FIRMWARE" | cut -d' ' -f1) != "$EXPECTED" ]]; then
|
||||
curl -fL https://github.com/crosspoint-reader/crosspoint-reader/releases/download/1.4.1/firmware.bin -o "$FIRMWARE"
|
||||
fi
|
||||
|
||||
python3 scripts/serve-web.py --host 127.0.0.1 --port 8180 >_scratch/browser-raw-sd-server.log 2>&1 &
|
||||
server=$!
|
||||
trap 'kill "$server" 2>/dev/null || true' EXIT
|
||||
sleep 2
|
||||
|
||||
nix shell --impure --expr \
|
||||
'with import <nixpkgs> {}; buildEnv { name = "xteink-selenium"; paths = [ (python313.withPackages (p: [p.selenium])) geckodriver firefox ]; }' \
|
||||
-c python3 tests/browser_raw_sd.py
|
||||
Vendored
+1
-1
Submodule third_party/qemu updated: 13b9231f6e...3623a36693
+119
-65
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
listSdFiles, readSdFile, writeSdFile, deleteSdFile, readAllSdFiles,
|
||||
seedDefaultsIfEmpty, saveBootState, loadBootState,
|
||||
} from './sdstore.js';
|
||||
import { buildFat32Image } from './fat32.js';
|
||||
|
||||
const FLASH_SIZE = 16 * 1024 * 1024;
|
||||
const BOOTLOADER_OFFSET = 0x0;
|
||||
const PARTITIONS_OFFSET = 0x8000;
|
||||
@@ -42,16 +48,6 @@ const LOREM_IPSUM = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Se
|
||||
export const EXAMPLE_SD_TEXT = Array(10).fill(LOREM_IPSUM).join('\n\n');
|
||||
export const DEFAULT_SD_FILES = new Map([['Test/example.txt', EXAMPLE_SD_TEXT]]);
|
||||
|
||||
function mountEphemeralSdCard(fs) {
|
||||
for (const [path, contents] of DEFAULT_SD_FILES) {
|
||||
const parts = path.split('/');
|
||||
const filename = parts.pop();
|
||||
const directory = `/sdcard/${parts.join('/')}`;
|
||||
fs.mkdirTree(directory);
|
||||
fs.writeFile(`${directory}/${filename}`, contents);
|
||||
}
|
||||
}
|
||||
|
||||
// A full 16 MB image is used as-is; anything else is treated as a CrossPoint app image and merged with the bundled bootloader and partition table.
|
||||
async function prepareFirmware(bytes, assetRoot) {
|
||||
if (bytes.length === FLASH_SIZE) {
|
||||
@@ -113,6 +109,9 @@ function startDisplayLoop(module, canvas, setStatus, onReady) {
|
||||
const nextGeneration = module._xteink_wasm_frame_generation();
|
||||
if (nextGeneration !== generation && drawFrame(module, canvas)) {
|
||||
generation = nextGeneration;
|
||||
const indicator = document.querySelector('#frame-generation');
|
||||
indicator.value = String(generation);
|
||||
document.dispatchEvent(new CustomEvent('xteink-frame', { detail: { generation } }));
|
||||
setStatus('Running.');
|
||||
if (!ready) {
|
||||
ready = true;
|
||||
@@ -188,46 +187,46 @@ export function formatBytes(bytes) {
|
||||
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function sdEntries(module = activeModule) {
|
||||
if (!module) {
|
||||
return [
|
||||
{ path: 'Test/', directory: true },
|
||||
...[...DEFAULT_SD_FILES].map(([path, contents]) => ({
|
||||
path,
|
||||
size: new TextEncoder().encode(contents).length,
|
||||
read: () => contents,
|
||||
})),
|
||||
];
|
||||
}
|
||||
const TEXT_EXTENSIONS = new Set(['txt', 'md', 'json', 'cfg', 'csv', 'log', 'html', 'xml', 'ini', 'yml', 'yaml']);
|
||||
let selectedSdPath = null;
|
||||
|
||||
const entries = [];
|
||||
const visit = (directory, relative = '') => {
|
||||
for (const name of module.FS.readdir(directory).filter(name => name !== '.' && name !== '..').sort()) {
|
||||
const absolute = `${directory}/${name}`;
|
||||
const path = relative ? `${relative}/${name}` : name;
|
||||
const stat = module.FS.stat(absolute);
|
||||
if (module.FS.isDir(stat.mode)) {
|
||||
entries.push({ path: `${path}/`, directory: true });
|
||||
visit(absolute, path);
|
||||
} else {
|
||||
entries.push({
|
||||
path,
|
||||
size: stat.size,
|
||||
read: () => module.FS.readFile(absolute, { encoding: 'utf8' }),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
visit('/sdcard');
|
||||
return entries;
|
||||
function isTextPath(path) {
|
||||
return TEXT_EXTENSIONS.has(path.split('.').pop().toLowerCase());
|
||||
}
|
||||
|
||||
function renderFiles() {
|
||||
async function selectSdFile(path) {
|
||||
selectedSdPath = path;
|
||||
const editor = document.querySelector('#file-editor');
|
||||
const save = document.querySelector('#file-save');
|
||||
const del = document.querySelector('#file-delete');
|
||||
del.hidden = false;
|
||||
try {
|
||||
const bytes = await readSdFile(path);
|
||||
if (isTextPath(path)) {
|
||||
editor.value = new TextDecoder().decode(bytes);
|
||||
editor.readOnly = false;
|
||||
save.hidden = false;
|
||||
} else {
|
||||
editor.value = `(binary file — ${formatBytes(bytes.length)}; not editable here)`;
|
||||
editor.readOnly = true;
|
||||
save.hidden = true;
|
||||
}
|
||||
} catch (error) {
|
||||
editor.value = `Could not read ${path}: ${error.message || error}`;
|
||||
editor.readOnly = true;
|
||||
save.hidden = true;
|
||||
}
|
||||
for (const button of document.querySelectorAll('#file-list button')) {
|
||||
button.classList.toggle('selected', button.dataset.path === path);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderFiles() {
|
||||
const list = document.querySelector('#file-list');
|
||||
const preview = document.querySelector('#file-preview');
|
||||
const entries = sdEntries();
|
||||
const editor = document.querySelector('#file-editor');
|
||||
const entries = await listSdFiles();
|
||||
list.replaceChildren();
|
||||
let firstFile = null;
|
||||
let firstPath = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.directory) {
|
||||
@@ -239,26 +238,27 @@ function renderFiles() {
|
||||
}
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.dataset.path = entry.path;
|
||||
const path = document.createElement('span');
|
||||
path.textContent = entry.path;
|
||||
const size = document.createElement('small');
|
||||
size.textContent = formatBytes(entry.size);
|
||||
button.append(path, size);
|
||||
button.addEventListener('click', () => {
|
||||
try {
|
||||
preview.textContent = entry.read();
|
||||
} catch (error) {
|
||||
preview.textContent = `Could not read ${entry.path}: ${error.message || error}`;
|
||||
}
|
||||
});
|
||||
button.addEventListener('click', () => selectSdFile(entry.path));
|
||||
list.append(button);
|
||||
firstFile ||= button;
|
||||
firstPath ||= entry.path;
|
||||
}
|
||||
|
||||
if (firstFile) {
|
||||
firstFile.click();
|
||||
const stillPresent = entries.some(entry => entry.path === selectedSdPath);
|
||||
const target = stillPresent ? selectedSdPath : firstPath;
|
||||
if (target) {
|
||||
await selectSdFile(target);
|
||||
} else {
|
||||
preview.textContent = 'The SD card is empty.';
|
||||
selectedSdPath = null;
|
||||
editor.value = 'The SD card is empty.';
|
||||
editor.readOnly = true;
|
||||
document.querySelector('#file-save').hidden = true;
|
||||
document.querySelector('#file-delete').hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,13 +283,12 @@ function renderDebug() {
|
||||
if (!activeModule) {
|
||||
return;
|
||||
}
|
||||
const visible = ['/flash.bin', '/bios', '/sdcard', '/var/tmp']
|
||||
const visible = ['/flash.bin', '/bios', '/sd.img', '/var/tmp']
|
||||
.map(path => pathBytes(activeModule.FS, path))
|
||||
.reduce((total, item) => ({ bytes: total.bytes + item.bytes, files: total.files + item.files }), { bytes: 0, files: 0 });
|
||||
const sd = pathBytes(activeModule.FS, '/sdcard');
|
||||
document.querySelector('#debug-wasm-heap').textContent = formatBytes(activeModule.HEAPU8.byteLength);
|
||||
document.querySelector('#debug-memfs').textContent = `${formatBytes(visible.bytes)} in ${visible.files} files`;
|
||||
document.querySelector('#debug-sd-files').textContent = `${formatBytes(sd.bytes)} in ${sd.files} files`;
|
||||
document.querySelector('#debug-sd-files').textContent = '64.0 MiB raw image (OPFS source)';
|
||||
}
|
||||
|
||||
function initializeInspector() {
|
||||
@@ -307,11 +306,47 @@ function initializeInspector() {
|
||||
}
|
||||
});
|
||||
}
|
||||
renderFiles();
|
||||
wireFileControls();
|
||||
seedDefaultsIfEmpty(DEFAULT_SD_FILES).then(renderFiles);
|
||||
renderDebug();
|
||||
setInterval(renderDebug, 1000);
|
||||
}
|
||||
|
||||
function wireFileControls() {
|
||||
document.querySelector('#file-add').addEventListener('change', async event => {
|
||||
for (const file of event.target.files) {
|
||||
await writeSdFile(file.name, new Uint8Array(await file.arrayBuffer()));
|
||||
}
|
||||
event.target.value = '';
|
||||
await renderFiles();
|
||||
});
|
||||
document.querySelector('#file-new').addEventListener('click', async () => {
|
||||
const name = prompt('New file path (e.g. Notes/todo.txt):');
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
await writeSdFile(name, '');
|
||||
selectedSdPath = name;
|
||||
await renderFiles();
|
||||
});
|
||||
document.querySelector('#file-save').addEventListener('click', async () => {
|
||||
if (!selectedSdPath) {
|
||||
return;
|
||||
}
|
||||
await writeSdFile(selectedSdPath, document.querySelector('#file-editor').value);
|
||||
await renderFiles();
|
||||
});
|
||||
document.querySelector('#file-delete').addEventListener('click', async () => {
|
||||
if (!selectedSdPath || !confirm(`Delete ${selectedSdPath}?`)) {
|
||||
return;
|
||||
}
|
||||
await deleteSdFile(selectedSdPath);
|
||||
selectedSdPath = null;
|
||||
await renderFiles();
|
||||
});
|
||||
document.querySelector('#file-reboot').addEventListener('click', () => location.reload());
|
||||
}
|
||||
|
||||
function logLine(message) {
|
||||
logBuffer.push(message);
|
||||
if (logBuffer.length > LOG_MAX_LINES) {
|
||||
@@ -341,7 +376,11 @@ async function boot(file, variant, setStatus) {
|
||||
prepareFirmware(new Uint8Array(await file.arrayBuffer()), import.meta.url),
|
||||
fetchBin(new URL('esp32c3-rom.bin', artifactRoot)),
|
||||
]);
|
||||
const sdMessage = 'Using a 32 GiB ephemeral directory-backed SD card; writes will be discarded when the emulator restarts.';
|
||||
await seedDefaultsIfEmpty(DEFAULT_SD_FILES);
|
||||
const sdFiles = await readAllSdFiles();
|
||||
setStatus(`Building SD card image from ${sdFiles.length} browser file(s)…`);
|
||||
const sdImage = buildFat32Image(sdFiles);
|
||||
const sdMessage = `Using a 64 MiB FAT32 image built from ${sdFiles.length} browser file(s); guest writes are discarded on reboot.`;
|
||||
console.log(sdMessage);
|
||||
logLine(sdMessage);
|
||||
const { default: createQemu } = await import(qemuUrl);
|
||||
@@ -360,7 +399,7 @@ async function boot(file, variant, setStatus) {
|
||||
'-nic', 'none',
|
||||
'-d', qemuLogFlags,
|
||||
'-drive', 'file=/flash.bin,if=mtd,format=raw',
|
||||
'-drive', 'file=fat:32:rw:/sdcard,if=sd,format=raw',
|
||||
'-drive', 'file=/sd.img,if=sd,format=raw',
|
||||
],
|
||||
locateFile: path => new URL(path, artifactRoot).href,
|
||||
mainScriptUrlOrBlob: qemuUrl.href,
|
||||
@@ -373,7 +412,7 @@ async function boot(file, variant, setStatus) {
|
||||
options.FS.mkdirTree('/var/tmp');
|
||||
options.FS.writeFile('/bios/esp32c3-rom.bin', rom);
|
||||
options.FS.writeFile('/flash.bin', firmware);
|
||||
mountEphemeralSdCard(options.FS);
|
||||
options.FS.writeFile('/sd.img', sdImage);
|
||||
}];
|
||||
|
||||
setStatus('Starting emulator…');
|
||||
@@ -428,13 +467,14 @@ function initialize() {
|
||||
}
|
||||
};
|
||||
|
||||
form.addEventListener('submit', event => {
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const file = firmwareInput.files[0];
|
||||
if (!file) {
|
||||
setStatus('Choose a firmware image first.', true);
|
||||
return;
|
||||
}
|
||||
await saveBootState(file, variantInput.value);
|
||||
start(file);
|
||||
});
|
||||
|
||||
@@ -447,7 +487,21 @@ function initialize() {
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then(blob => start(new File([blob], 'firmware.bin'), params.get('variant') || 'x3'))
|
||||
.then(async blob => {
|
||||
const variant = params.get('variant') || 'x3';
|
||||
const firmware = new File([blob], 'firmware.bin');
|
||||
await saveBootState(firmware, variant);
|
||||
await start(firmware, variant);
|
||||
})
|
||||
.catch(error => setStatus(error.message || String(error), true));
|
||||
} else {
|
||||
loadBootState()
|
||||
.then(saved => {
|
||||
if (saved) {
|
||||
variantInput.value = saved.variant;
|
||||
start(saved.firmware, saved.variant);
|
||||
}
|
||||
})
|
||||
.catch(error => setStatus(error.message || String(error), true));
|
||||
}
|
||||
}
|
||||
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
export const FAT32_IMAGE_SIZE = 64 * 1024 * 1024;
|
||||
|
||||
const SECTOR_SIZE = 512;
|
||||
const RESERVED_SECTORS = 32;
|
||||
const FAT_COUNT = 2;
|
||||
const ROOT_CLUSTER = 2;
|
||||
const FAT_EOC = 0x0fffffff;
|
||||
|
||||
function setAscii(bytes, offset, value, length) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
bytes[offset + i] = i < value.length ? value.charCodeAt(i) : 0x20;
|
||||
}
|
||||
}
|
||||
|
||||
function fatGeometry(imageSize) {
|
||||
const totalSectors = imageSize / SECTOR_SIZE;
|
||||
let fatSectors = 1;
|
||||
while (true) {
|
||||
const clusters = totalSectors - RESERVED_SECTORS - FAT_COUNT * fatSectors;
|
||||
const required = Math.ceil((clusters + 2) * 4 / SECTOR_SIZE);
|
||||
if (required <= fatSectors) {
|
||||
return { totalSectors, fatSectors, clusters };
|
||||
}
|
||||
fatSectors = required;
|
||||
}
|
||||
}
|
||||
|
||||
function makeNode(name, directory, parent = null) {
|
||||
return { name, directory, parent, children: new Map(), bytes: null, shortName: null, firstCluster: 0, clusters: [] };
|
||||
}
|
||||
|
||||
function normalizeFiles(files) {
|
||||
const root = makeNode('', true);
|
||||
for (const { path, bytes } of files) {
|
||||
const parts = path.replaceAll('\\', '/').split('/').filter(Boolean);
|
||||
if (!parts.length || parts.some(part => part === '.' || part === '..' || part.includes('\0'))) {
|
||||
throw new Error(`Invalid SD path: ${path}`);
|
||||
}
|
||||
let directory = root;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
const existing = directory.children.get(part);
|
||||
if (existing && !existing.directory) {
|
||||
throw new Error(`SD path conflicts with a file: ${path}`);
|
||||
}
|
||||
if (!existing) {
|
||||
directory.children.set(part, makeNode(part, true, directory));
|
||||
}
|
||||
directory = directory.children.get(part);
|
||||
}
|
||||
const name = parts.at(-1);
|
||||
if (directory.children.has(name)) {
|
||||
throw new Error(`Duplicate SD path: ${path}`);
|
||||
}
|
||||
const file = makeNode(name, false, directory);
|
||||
file.bytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
directory.children.set(name, file);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function sanitizeShortPart(value) {
|
||||
return value.toUpperCase().replace(/[^A-Z0-9$%'-_@~`!(){}^#&]/g, '_');
|
||||
}
|
||||
|
||||
function assignShortNames(directory) {
|
||||
const used = new Set();
|
||||
for (const child of directory.children.values()) {
|
||||
const dot = child.directory ? -1 : child.name.lastIndexOf('.');
|
||||
const rawBase = dot > 0 ? child.name.slice(0, dot) : child.name;
|
||||
const rawExt = dot > 0 ? child.name.slice(dot + 1) : '';
|
||||
const base = sanitizeShortPart(rawBase);
|
||||
const ext = sanitizeShortPart(rawExt).slice(0, 3);
|
||||
const direct = base.length >= 1 && base.length <= 8 && rawExt.length <= 3 && `${base}${ext ? `.${ext}` : ''}` === child.name.toUpperCase();
|
||||
let short;
|
||||
if (direct) {
|
||||
short = `${base.padEnd(8)}${ext.padEnd(3)}`;
|
||||
} else {
|
||||
for (let n = 1; ; n++) {
|
||||
const suffix = `~${n}`;
|
||||
short = `${base.slice(0, 8 - suffix.length).padEnd(8 - suffix.length, '_')}${suffix}${ext.padEnd(3)}`;
|
||||
if (!used.has(short)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (used.has(short)) {
|
||||
throw new Error(`Duplicate FAT short name for ${child.name}`);
|
||||
}
|
||||
used.add(short);
|
||||
child.shortName = short;
|
||||
if (child.directory) {
|
||||
assignShortNames(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function lfnChecksum(shortName) {
|
||||
let sum = 0;
|
||||
for (const char of shortName) {
|
||||
sum = ((sum & 1) << 7) + (sum >> 1) + char.charCodeAt(0);
|
||||
sum &= 0xff;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
function lfnEntries(node) {
|
||||
const shortDisplay = `${node.shortName.slice(0, 8).trimEnd()}${node.shortName.slice(8).trimEnd() ? `.${node.shortName.slice(8).trimEnd()}` : ''}`;
|
||||
if (node.name === shortDisplay) {
|
||||
return [];
|
||||
}
|
||||
const units = Array.from(node.name).flatMap(char => {
|
||||
const code = char.codePointAt(0);
|
||||
return code <= 0xffff ? [code] : [0xd800 + ((code - 0x10000) >> 10), 0xdc00 + ((code - 0x10000) & 0x3ff)];
|
||||
});
|
||||
if (units.length > 255) {
|
||||
throw new Error(`FAT filename is too long: ${node.name}`);
|
||||
}
|
||||
units.push(0);
|
||||
while (units.length % 13) {
|
||||
units.push(0xffff);
|
||||
}
|
||||
const count = units.length / 13;
|
||||
const checksum = lfnChecksum(node.shortName);
|
||||
const entries = [];
|
||||
const positions = [1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30];
|
||||
for (let sequence = count; sequence >= 1; sequence--) {
|
||||
const entry = new Uint8Array(32).fill(0xff);
|
||||
entry[0] = sequence | (sequence === count ? 0x40 : 0);
|
||||
entry[11] = 0x0f;
|
||||
entry[12] = 0;
|
||||
entry[13] = checksum;
|
||||
entry[26] = 0;
|
||||
entry[27] = 0;
|
||||
const chunk = units.slice((sequence - 1) * 13, sequence * 13);
|
||||
const view = new DataView(entry.buffer);
|
||||
positions.forEach((position, index) => view.setUint16(position, chunk[index], true));
|
||||
entries.push(entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function shortEntry(node) {
|
||||
const entry = new Uint8Array(32);
|
||||
setAscii(entry, 0, node.shortName, 11);
|
||||
entry[11] = node.directory ? 0x10 : 0x20;
|
||||
const view = new DataView(entry.buffer);
|
||||
const fatDate = ((2024 - 1980) << 9) | (1 << 5) | 1;
|
||||
view.setUint16(14, 0, true);
|
||||
view.setUint16(16, fatDate, true);
|
||||
view.setUint16(18, fatDate, true);
|
||||
view.setUint16(22, 0, true);
|
||||
view.setUint16(24, fatDate, true);
|
||||
view.setUint16(20, node.firstCluster >>> 16, true);
|
||||
view.setUint16(26, node.firstCluster & 0xffff, true);
|
||||
view.setUint32(28, node.directory ? 0 : node.bytes.length, true);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function directoryEntryCount(directory) {
|
||||
let count = directory.parent ? 2 : 0;
|
||||
for (const child of directory.children.values()) {
|
||||
count += lfnEntries(child).length + 1;
|
||||
}
|
||||
return count + 1;
|
||||
}
|
||||
|
||||
function allNodes(root) {
|
||||
const nodes = [];
|
||||
const visit = node => {
|
||||
nodes.push(node);
|
||||
for (const child of node.children.values()) {
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function allocateClusters(root, availableClusters) {
|
||||
let next = ROOT_CLUSTER;
|
||||
const allocate = (node, count) => {
|
||||
if (next + count - ROOT_CLUSTER > availableClusters) {
|
||||
throw new Error('SD card contents exceed the 64 MiB image capacity.');
|
||||
}
|
||||
node.clusters = Array.from({ length: count }, (_, i) => next + i);
|
||||
node.firstCluster = count ? next : 0;
|
||||
next += count;
|
||||
};
|
||||
for (const node of allNodes(root).filter(node => node.directory)) {
|
||||
allocate(node, Math.max(1, Math.ceil(directoryEntryCount(node) * 32 / SECTOR_SIZE)));
|
||||
}
|
||||
for (const node of allNodes(root).filter(node => !node.directory)) {
|
||||
allocate(node, Math.ceil(node.bytes.length / SECTOR_SIZE));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function writeBootSector(image, geometry) {
|
||||
const boot = image.subarray(0, SECTOR_SIZE);
|
||||
const view = new DataView(image.buffer);
|
||||
boot.set([0xeb, 0x58, 0x90]);
|
||||
setAscii(boot, 3, 'XTEINK ', 8);
|
||||
view.setUint16(11, SECTOR_SIZE, true);
|
||||
boot[13] = 1;
|
||||
view.setUint16(14, RESERVED_SECTORS, true);
|
||||
boot[16] = FAT_COUNT;
|
||||
view.setUint16(17, 0, true);
|
||||
view.setUint16(19, 0, true);
|
||||
boot[21] = 0xf8;
|
||||
view.setUint16(22, 0, true);
|
||||
view.setUint16(24, 63, true);
|
||||
view.setUint16(26, 255, true);
|
||||
view.setUint32(28, 0, true);
|
||||
view.setUint32(32, geometry.totalSectors, true);
|
||||
view.setUint32(36, geometry.fatSectors, true);
|
||||
view.setUint16(40, 0, true);
|
||||
view.setUint16(42, 0, true);
|
||||
view.setUint32(44, ROOT_CLUSTER, true);
|
||||
view.setUint16(48, 1, true);
|
||||
view.setUint16(50, 6, true);
|
||||
boot[64] = 0x80;
|
||||
boot[66] = 0x29;
|
||||
view.setUint32(67, 0x58544549, true);
|
||||
setAscii(boot, 71, 'XTEINK', 11);
|
||||
setAscii(boot, 82, 'FAT32', 8);
|
||||
boot[510] = 0x55;
|
||||
boot[511] = 0xaa;
|
||||
image.set(boot, 6 * SECTOR_SIZE);
|
||||
|
||||
const fsInfo = new DataView(image.buffer, SECTOR_SIZE, SECTOR_SIZE);
|
||||
fsInfo.setUint32(0, 0x41615252, true);
|
||||
fsInfo.setUint32(484, 0x61417272, true);
|
||||
fsInfo.setUint32(488, 0xffffffff, true);
|
||||
fsInfo.setUint32(492, ROOT_CLUSTER + 1, true);
|
||||
fsInfo.setUint32(508, 0xaa550000, true);
|
||||
image.copyWithin(7 * SECTOR_SIZE, SECTOR_SIZE, 2 * SECTOR_SIZE);
|
||||
}
|
||||
|
||||
function dotEntry(name, cluster) {
|
||||
const entry = new Uint8Array(32);
|
||||
setAscii(entry, 0, name, 11);
|
||||
entry[11] = 0x10;
|
||||
const view = new DataView(entry.buffer);
|
||||
view.setUint16(20, cluster >>> 16, true);
|
||||
view.setUint16(26, cluster & 0xffff, true);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function buildFat32Image(files, imageSize = FAT32_IMAGE_SIZE) {
|
||||
if (imageSize < 64 * 1024 * 1024 || imageSize % SECTOR_SIZE || (imageSize & (imageSize - 1))) {
|
||||
throw new Error('FAT32 SD image size must be a power of two and at least 64 MiB.');
|
||||
}
|
||||
const geometry = fatGeometry(imageSize);
|
||||
const root = normalizeFiles(files);
|
||||
assignShortNames(root);
|
||||
const nextCluster = allocateClusters(root, geometry.clusters);
|
||||
const image = new Uint8Array(imageSize);
|
||||
writeBootSector(image, geometry);
|
||||
|
||||
const fatOffset = RESERVED_SECTORS * SECTOR_SIZE;
|
||||
const fatView = new DataView(image.buffer, fatOffset, geometry.fatSectors * SECTOR_SIZE);
|
||||
fatView.setUint32(0, 0x0ffffff8, true);
|
||||
fatView.setUint32(4, FAT_EOC, true);
|
||||
for (const node of allNodes(root)) {
|
||||
node.clusters.forEach((cluster, index) => fatView.setUint32(cluster * 4, node.clusters[index + 1] ?? FAT_EOC, true));
|
||||
}
|
||||
image.copyWithin(fatOffset + geometry.fatSectors * SECTOR_SIZE, fatOffset, fatOffset + geometry.fatSectors * SECTOR_SIZE);
|
||||
|
||||
const dataOffset = (RESERVED_SECTORS + FAT_COUNT * geometry.fatSectors) * SECTOR_SIZE;
|
||||
const clusterOffset = cluster => dataOffset + (cluster - ROOT_CLUSTER) * SECTOR_SIZE;
|
||||
for (const directory of allNodes(root).filter(node => node.directory)) {
|
||||
const entries = [];
|
||||
if (directory.parent) {
|
||||
entries.push(dotEntry('. ', directory.firstCluster));
|
||||
entries.push(dotEntry('.. ', directory.parent.parent ? directory.parent.firstCluster : ROOT_CLUSTER));
|
||||
}
|
||||
for (const child of directory.children.values()) {
|
||||
entries.push(...lfnEntries(child), shortEntry(child));
|
||||
}
|
||||
entries.push(new Uint8Array(32));
|
||||
const bytes = new Uint8Array(directory.clusters.length * SECTOR_SIZE);
|
||||
entries.forEach((entry, index) => bytes.set(entry, index * 32));
|
||||
directory.clusters.forEach((cluster, index) => image.set(bytes.subarray(index * SECTOR_SIZE, (index + 1) * SECTOR_SIZE), clusterOffset(cluster)));
|
||||
}
|
||||
for (const file of allNodes(root).filter(node => !node.directory)) {
|
||||
file.clusters.forEach((cluster, index) => image.set(file.bytes.subarray(index * SECTOR_SIZE, (index + 1) * SECTOR_SIZE), clusterOffset(cluster)));
|
||||
}
|
||||
|
||||
const freeClusters = geometry.clusters - (nextCluster - ROOT_CLUSTER);
|
||||
new DataView(image.buffer).setUint32(SECTOR_SIZE + 488, freeClusters, true);
|
||||
image.copyWithin(7 * SECTOR_SIZE, SECTOR_SIZE, 2 * SECTOR_SIZE);
|
||||
return image;
|
||||
}
|
||||
+11
-1
@@ -38,6 +38,7 @@
|
||||
<button type="button" class="btn-side btn-up" data-button="up" disabled aria-label="Up"></button>
|
||||
<div class="screen-shell">
|
||||
<canvas id="screen" width="528" height="792" aria-label="E-ink display"></canvas>
|
||||
<output id="frame-generation" hidden aria-hidden="true">-1</output>
|
||||
</div>
|
||||
<button type="button" class="btn-side btn-down" data-button="down" disabled aria-label="Down"></button>
|
||||
<div class="btn-bottom">
|
||||
@@ -65,8 +66,17 @@
|
||||
</div>
|
||||
|
||||
<div id="panel-files" class="tab-panel" role="tabpanel" aria-labelledby="tab-files" hidden>
|
||||
<div class="file-toolbar">
|
||||
<label class="file-button">Add files<input id="file-add" type="file" multiple hidden></label>
|
||||
<button id="file-new" type="button" class="file-button">New text file</button>
|
||||
<button id="file-reboot" type="button" class="file-button file-reboot">Apply & reboot</button>
|
||||
</div>
|
||||
<div id="file-list" class="file-list" aria-label="SD card files"></div>
|
||||
<pre id="file-preview" class="file-preview">Select a file to view it.</pre>
|
||||
<textarea id="file-editor" class="file-preview" spellcheck="false" readonly>Select a file to view it.</textarea>
|
||||
<div class="file-actions">
|
||||
<button id="file-save" type="button" class="file-button" hidden>Save</button>
|
||||
<button id="file-delete" type="button" class="file-button" hidden>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="panel-debug" class="tab-panel" role="tabpanel" aria-labelledby="tab-debug" hidden>
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
// OPFS-backed SD card store — the source of truth for /sdcard contents.
|
||||
// It persists across page reloads, so "edit files, then reboot to apply" works
|
||||
// by reloading the page: edits survive in OPFS and are seeded into the guest's
|
||||
// MEMFS /sdcard on the next boot. Guest-side writes remain ephemeral.
|
||||
|
||||
const SD_DIR = 'sdcard';
|
||||
const STATE_DIR = 'emulator';
|
||||
|
||||
async function sdRoot() {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
return root.getDirectoryHandle(SD_DIR, { create: true });
|
||||
}
|
||||
|
||||
async function resolveParent(path, create) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
const filename = parts.pop();
|
||||
let dir = await sdRoot();
|
||||
for (const part of parts) {
|
||||
dir = await dir.getDirectoryHandle(part, { create });
|
||||
}
|
||||
return [dir, filename];
|
||||
}
|
||||
|
||||
export async function listSdFiles() {
|
||||
const entries = [];
|
||||
const walk = async (dir, prefix) => {
|
||||
for await (const [name, handle] of dir.entries()) {
|
||||
const path = prefix ? `${prefix}/${name}` : name;
|
||||
if (handle.kind === 'directory') {
|
||||
entries.push({ path: `${path}/`, directory: true });
|
||||
await walk(handle, path);
|
||||
} else {
|
||||
entries.push({ path, size: (await handle.getFile()).size });
|
||||
}
|
||||
}
|
||||
};
|
||||
await walk(await sdRoot(), '');
|
||||
entries.sort((a, b) => a.path.localeCompare(b.path));
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function readSdFile(path) {
|
||||
const [dir, name] = await resolveParent(path, false);
|
||||
const handle = await dir.getFileHandle(name);
|
||||
return new Uint8Array(await (await handle.getFile()).arrayBuffer());
|
||||
}
|
||||
|
||||
export async function writeSdFile(path, data) {
|
||||
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
||||
const [dir, name] = await resolveParent(path, true);
|
||||
const handle = await dir.getFileHandle(name, { create: true });
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(bytes);
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
export async function deleteSdFile(path) {
|
||||
const [dir, name] = await resolveParent(path, false);
|
||||
await dir.removeEntry(name, { recursive: true });
|
||||
}
|
||||
|
||||
// Flatten to [{ path, bytes }] for seeding the guest's MEMFS /sdcard at boot.
|
||||
export async function readAllSdFiles() {
|
||||
const out = [];
|
||||
for (const entry of await listSdFiles()) {
|
||||
if (!entry.directory) {
|
||||
out.push({ path: entry.path, bytes: await readSdFile(entry.path) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function seedDefaultsIfEmpty(defaults) {
|
||||
const entries = await listSdFiles();
|
||||
if (entries.some(entry => !entry.directory)) {
|
||||
return;
|
||||
}
|
||||
for (const [path, contents] of defaults) {
|
||||
await writeSdFile(path, contents);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBootState(firmware, variant) {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const state = await root.getDirectoryHandle(STATE_DIR, { create: true });
|
||||
const firmwareHandle = await state.getFileHandle('firmware.bin', { create: true });
|
||||
const firmwareWriter = await firmwareHandle.createWritable();
|
||||
await firmwareWriter.write(firmware);
|
||||
await firmwareWriter.close();
|
||||
const settingsHandle = await state.getFileHandle('settings.json', { create: true });
|
||||
const settingsWriter = await settingsHandle.createWritable();
|
||||
await settingsWriter.write(JSON.stringify({ variant, name: firmware.name || 'firmware.bin' }));
|
||||
await settingsWriter.close();
|
||||
}
|
||||
|
||||
export async function loadBootState() {
|
||||
try {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const state = await root.getDirectoryHandle(STATE_DIR);
|
||||
const settings = JSON.parse(await (await state.getFileHandle('settings.json')).getFile().then(file => file.text()));
|
||||
const firmware = await (await state.getFileHandle('firmware.bin')).getFile();
|
||||
return { firmware: new File([firmware], settings.name || 'firmware.bin'), variant: settings.variant || 'x3' };
|
||||
} catch (error) {
|
||||
if (error.name === 'NotFoundError') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -80,11 +80,17 @@ canvas { display: block; width: 100%; height: auto; background: #fff; image-rend
|
||||
.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; font-size: .85rem; color: #625e55; }
|
||||
.log-header button { min-height: auto; padding: .25rem .75rem; font-size: .8rem; }
|
||||
.log { margin: 0; max-height: 38rem; overflow: auto; padding: .75rem 1rem; border-radius: .6rem; background: #1c1d1a; color: #d6d0c2; font: .78rem/1.4 ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; }
|
||||
.file-toolbar, .file-actions { display: flex; gap: .5rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||
.file-actions { margin: .75rem 0 0; }
|
||||
.file-button { display: inline-flex; align-items: center; padding: .4rem .8rem; border-radius: .5rem; cursor: pointer; }
|
||||
.file-reboot { margin-left: auto; }
|
||||
.file-list { display: grid; gap: .4rem; margin-bottom: 1rem; }
|
||||
.file-directory { padding: .35rem .55rem; color: #625e55; font-weight: 700; }
|
||||
.file-list button { display: flex; justify-content: space-between; gap: 1rem; width: 100%; min-height: 2.4rem; text-align: left; }
|
||||
.file-list button.selected { outline: 2px solid #625e55; }
|
||||
.file-list small { color: #cbc5b9; font-weight: 400; }
|
||||
.file-preview { min-height: 12rem; max-height: 28rem; overflow: auto; margin: 0; padding: .75rem 1rem; border-radius: .6rem; background: #fff; white-space: pre-wrap; }
|
||||
textarea.file-preview { width: 100%; box-sizing: border-box; resize: vertical; font-family: inherit; }
|
||||
.debug-stats { display: grid; gap: .65rem; margin: 0; }
|
||||
.debug-stats div { display: flex; justify-content: space-between; gap: 1rem; padding-bottom: .65rem; border-bottom: 1px solid #d8d2c7; }
|
||||
.debug-stats dt { font-weight: 700; }
|
||||
|
||||
Reference in New Issue
Block a user