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
|
# 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
|
## Resolved: SD card remained in `receivingdata`
|
||||||
Invalid read at addr 0x253D7474, size 4, region '(null)'
|
|
||||||
Guru Meditation Error: Core 0 panic'ed (Load access fault)
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
`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`.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Reader page can render completely black
|
## Reader page can render completely black
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ QEMU_WASM_SRC ?= third_party/qemu
|
|||||||
WASM_OUT ?= dist/wasm
|
WASM_OUT ?= dist/wasm
|
||||||
VARIANT ?= x3
|
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:
|
firmware:
|
||||||
esptool --chip esp32c3 merge-bin -o flash.bin \
|
esptool --chip esp32c3 merge-bin -o flash.bin \
|
||||||
@@ -39,6 +39,12 @@ web: qemu-wasm
|
|||||||
web-test:
|
web-test:
|
||||||
node scripts/test-web.mjs
|
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.
|
# Select X4 with `make run VARIANT=x4`. Add `-display none` for headless.
|
||||||
run: qemu
|
run: qemu
|
||||||
@sd=sd.img; tmpdir=; \
|
@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
|
serves the required COOP/COEP headers for Emscripten pthreads; a generic static
|
||||||
server without those headers will not work.
|
server without those headers will not work.
|
||||||
|
|
||||||
Real firmware boots in WASM and drives the X3 panel (528×792). The browser SD
|
Real firmware boots in WASM and drives the X3 panel (528×792). Browser SD files
|
||||||
uses QEMU's directory-backed FAT provider: the guest sees a writable, ephemeral
|
are persisted in OPFS and edited in the Files tab. At boot, the browser builds a
|
||||||
32 GiB FAT32 card while memory use scales with actual files instead of logical
|
64 MiB raw FAT32 image with nested-directory and VFAT long-name support; QEMU
|
||||||
capacity. It starts with `Test/example.txt`; inspect it in the Files tab. The
|
mounts that image directly, avoiding `vvfat`. Choose **Apply & reboot** to expose
|
||||||
Debug tab reports committed WASM heap, visible MEMFS data, SD file bytes, and
|
edits to the guest. Guest writes modify only the ephemeral image. The Debug tab
|
||||||
browser JS heap where supported. X4 remains a white display stub.
|
reports committed WASM heap, visible MEMFS data, and browser JS heap where
|
||||||
|
supported. X4 remains a white display stub.
|
||||||
|
|
||||||
## Contents
|
## 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
|
- `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
|
(`nix run .#run-upstream -- flash.bin`), plus a devShell with QEMU's full build
|
||||||
environment. `Makefile` — `qemu`, `firmware`, `sdimage`, `run`, `chip`.
|
environment. `Makefile` — `qemu`, `firmware`, `sdimage`, `run`, `chip`.
|
||||||
- `scripts/mksd.sh` — build the FAT32 SD backing image (local-disk impl of the
|
- `web/fat32.js` and `web/sdstore.js` — dependency-free FAT32 image generation
|
||||||
SD block interface).
|
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
|
- `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.
|
as a Wokwi chip and now the reference for the QEMU display device.
|
||||||
`make -C chip test` runs its host self-check.
|
`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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## Remaining phases
|
||||||
|
|
||||||
1. **Browser SD editing/persistence** — add file controls and optionally OPFS;
|
1. **Full X4 panel** — implement SSD1677 RAM/window/update commands when visible
|
||||||
the current 32 GiB logical card is writable and ephemeral.
|
|
||||||
2. **Full X4 panel** — implement SSD1677 RAM/window/update commands when visible
|
|
||||||
X4 rendering is needed; the selection, dimensions, wiring, and stub exist.
|
X4 rendering is needed; the selection, dimensions, wiring, and stub exist.
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { BUTTONS, DEFAULT_SD_FILES, EXAMPLE_SD_TEXT, formatBytes, setButton, mergeFirmware } from '../web/app.js';
|
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.
|
// 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]);
|
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(512), '512 B');
|
||||||
assert.equal(formatBytes(64 * 1024 * 1024), '64.0 MiB');
|
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 calls = [];
|
||||||
const module = {
|
const module = {
|
||||||
_xteink_wasm_set_adc: (...args) => calls.push(['adc', ...args]),
|
_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 FLASH_SIZE = 16 * 1024 * 1024;
|
||||||
const BOOTLOADER_OFFSET = 0x0;
|
const BOOTLOADER_OFFSET = 0x0;
|
||||||
const PARTITIONS_OFFSET = 0x8000;
|
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 EXAMPLE_SD_TEXT = Array(10).fill(LOREM_IPSUM).join('\n\n');
|
||||||
export const DEFAULT_SD_FILES = new Map([['Test/example.txt', EXAMPLE_SD_TEXT]]);
|
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.
|
// 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) {
|
async function prepareFirmware(bytes, assetRoot) {
|
||||||
if (bytes.length === FLASH_SIZE) {
|
if (bytes.length === FLASH_SIZE) {
|
||||||
@@ -113,6 +109,9 @@ function startDisplayLoop(module, canvas, setStatus, onReady) {
|
|||||||
const nextGeneration = module._xteink_wasm_frame_generation();
|
const nextGeneration = module._xteink_wasm_frame_generation();
|
||||||
if (nextGeneration !== generation && drawFrame(module, canvas)) {
|
if (nextGeneration !== generation && drawFrame(module, canvas)) {
|
||||||
generation = nextGeneration;
|
generation = nextGeneration;
|
||||||
|
const indicator = document.querySelector('#frame-generation');
|
||||||
|
indicator.value = String(generation);
|
||||||
|
document.dispatchEvent(new CustomEvent('xteink-frame', { detail: { generation } }));
|
||||||
setStatus('Running.');
|
setStatus('Running.');
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
ready = true;
|
ready = true;
|
||||||
@@ -188,46 +187,46 @@ export function formatBytes(bytes) {
|
|||||||
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unit]}`;
|
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unit]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sdEntries(module = activeModule) {
|
const TEXT_EXTENSIONS = new Set(['txt', 'md', 'json', 'cfg', 'csv', 'log', 'html', 'xml', 'ini', 'yml', 'yaml']);
|
||||||
if (!module) {
|
let selectedSdPath = null;
|
||||||
return [
|
|
||||||
{ path: 'Test/', directory: true },
|
|
||||||
...[...DEFAULT_SD_FILES].map(([path, contents]) => ({
|
|
||||||
path,
|
|
||||||
size: new TextEncoder().encode(contents).length,
|
|
||||||
read: () => contents,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = [];
|
function isTextPath(path) {
|
||||||
const visit = (directory, relative = '') => {
|
return TEXT_EXTENSIONS.has(path.split('.').pop().toLowerCase());
|
||||||
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 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 list = document.querySelector('#file-list');
|
||||||
const preview = document.querySelector('#file-preview');
|
const editor = document.querySelector('#file-editor');
|
||||||
const entries = sdEntries();
|
const entries = await listSdFiles();
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
let firstFile = null;
|
let firstPath = null;
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.directory) {
|
if (entry.directory) {
|
||||||
@@ -239,26 +238,27 @@ function renderFiles() {
|
|||||||
}
|
}
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
|
button.dataset.path = entry.path;
|
||||||
const path = document.createElement('span');
|
const path = document.createElement('span');
|
||||||
path.textContent = entry.path;
|
path.textContent = entry.path;
|
||||||
const size = document.createElement('small');
|
const size = document.createElement('small');
|
||||||
size.textContent = formatBytes(entry.size);
|
size.textContent = formatBytes(entry.size);
|
||||||
button.append(path, size);
|
button.append(path, size);
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => selectSdFile(entry.path));
|
||||||
try {
|
|
||||||
preview.textContent = entry.read();
|
|
||||||
} catch (error) {
|
|
||||||
preview.textContent = `Could not read ${entry.path}: ${error.message || error}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
list.append(button);
|
list.append(button);
|
||||||
firstFile ||= button;
|
firstPath ||= entry.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (firstFile) {
|
const stillPresent = entries.some(entry => entry.path === selectedSdPath);
|
||||||
firstFile.click();
|
const target = stillPresent ? selectedSdPath : firstPath;
|
||||||
|
if (target) {
|
||||||
|
await selectSdFile(target);
|
||||||
} else {
|
} 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) {
|
if (!activeModule) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const visible = ['/flash.bin', '/bios', '/sdcard', '/var/tmp']
|
const visible = ['/flash.bin', '/bios', '/sd.img', '/var/tmp']
|
||||||
.map(path => pathBytes(activeModule.FS, path))
|
.map(path => pathBytes(activeModule.FS, path))
|
||||||
.reduce((total, item) => ({ bytes: total.bytes + item.bytes, files: total.files + item.files }), { bytes: 0, files: 0 });
|
.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-wasm-heap').textContent = formatBytes(activeModule.HEAPU8.byteLength);
|
||||||
document.querySelector('#debug-memfs').textContent = `${formatBytes(visible.bytes)} in ${visible.files} files`;
|
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() {
|
function initializeInspector() {
|
||||||
@@ -307,11 +306,47 @@ function initializeInspector() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
renderFiles();
|
wireFileControls();
|
||||||
|
seedDefaultsIfEmpty(DEFAULT_SD_FILES).then(renderFiles);
|
||||||
renderDebug();
|
renderDebug();
|
||||||
setInterval(renderDebug, 1000);
|
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) {
|
function logLine(message) {
|
||||||
logBuffer.push(message);
|
logBuffer.push(message);
|
||||||
if (logBuffer.length > LOG_MAX_LINES) {
|
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),
|
prepareFirmware(new Uint8Array(await file.arrayBuffer()), import.meta.url),
|
||||||
fetchBin(new URL('esp32c3-rom.bin', artifactRoot)),
|
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);
|
console.log(sdMessage);
|
||||||
logLine(sdMessage);
|
logLine(sdMessage);
|
||||||
const { default: createQemu } = await import(qemuUrl);
|
const { default: createQemu } = await import(qemuUrl);
|
||||||
@@ -360,7 +399,7 @@ async function boot(file, variant, setStatus) {
|
|||||||
'-nic', 'none',
|
'-nic', 'none',
|
||||||
'-d', qemuLogFlags,
|
'-d', qemuLogFlags,
|
||||||
'-drive', 'file=/flash.bin,if=mtd,format=raw',
|
'-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,
|
locateFile: path => new URL(path, artifactRoot).href,
|
||||||
mainScriptUrlOrBlob: qemuUrl.href,
|
mainScriptUrlOrBlob: qemuUrl.href,
|
||||||
@@ -373,7 +412,7 @@ async function boot(file, variant, setStatus) {
|
|||||||
options.FS.mkdirTree('/var/tmp');
|
options.FS.mkdirTree('/var/tmp');
|
||||||
options.FS.writeFile('/bios/esp32c3-rom.bin', rom);
|
options.FS.writeFile('/bios/esp32c3-rom.bin', rom);
|
||||||
options.FS.writeFile('/flash.bin', firmware);
|
options.FS.writeFile('/flash.bin', firmware);
|
||||||
mountEphemeralSdCard(options.FS);
|
options.FS.writeFile('/sd.img', sdImage);
|
||||||
}];
|
}];
|
||||||
|
|
||||||
setStatus('Starting emulator…');
|
setStatus('Starting emulator…');
|
||||||
@@ -428,13 +467,14 @@ function initialize() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
form.addEventListener('submit', event => {
|
form.addEventListener('submit', async event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const file = firmwareInput.files[0];
|
const file = firmwareInput.files[0];
|
||||||
if (!file) {
|
if (!file) {
|
||||||
setStatus('Choose a firmware image first.', true);
|
setStatus('Choose a firmware image first.', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await saveBootState(file, variantInput.value);
|
||||||
start(file);
|
start(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -447,7 +487,21 @@ function initialize() {
|
|||||||
}
|
}
|
||||||
return response.blob();
|
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));
|
.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>
|
<button type="button" class="btn-side btn-up" data-button="up" disabled aria-label="Up"></button>
|
||||||
<div class="screen-shell">
|
<div class="screen-shell">
|
||||||
<canvas id="screen" width="528" height="792" aria-label="E-ink display"></canvas>
|
<canvas id="screen" width="528" height="792" aria-label="E-ink display"></canvas>
|
||||||
|
<output id="frame-generation" hidden aria-hidden="true">-1</output>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn-side btn-down" data-button="down" disabled aria-label="Down"></button>
|
<button type="button" class="btn-side btn-down" data-button="down" disabled aria-label="Down"></button>
|
||||||
<div class="btn-bottom">
|
<div class="btn-bottom">
|
||||||
@@ -65,8 +66,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="panel-files" class="tab-panel" role="tabpanel" aria-labelledby="tab-files" hidden>
|
<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>
|
<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>
|
||||||
|
|
||||||
<div id="panel-debug" class="tab-panel" role="tabpanel" aria-labelledby="tab-debug" hidden>
|
<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 { 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-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; }
|
.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-list { display: grid; gap: .4rem; margin-bottom: 1rem; }
|
||||||
.file-directory { padding: .35rem .55rem; color: #625e55; font-weight: 700; }
|
.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 { 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-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; }
|
.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 { 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 div { display: flex; justify-content: space-between; gap: 1rem; padding-bottom: .65rem; border-bottom: 1px solid #d8d2c7; }
|
||||||
.debug-stats dt { font-weight: 700; }
|
.debug-stats dt { font-weight: 700; }
|
||||||
|
|||||||
Reference in New Issue
Block a user