#4 v1 emulator scaffolding — boots in browser

Phase 2 kickoff. The cmd/emulator binary now builds to WebAssembly,
loads a 240×240 LCD-equivalent canvas in the browser, and accepts
8-button input from either on-screen buttons or mapped keyboard
keys. The actual v1 gui/ package isn't lifted yet — this is the
scaffolding that proves the platform/v1.Platform interface contract
and the build/serve pipeline so the GUI lift can land cleanly later.

  platform/v1/platform.go (interface)
    Button enum: Up Down Left Right Center Button1 Button2 Button3
    Event{Button, Pressed}
    Platform interface: Events() <-chan Event, Display(image.Image)

    The interface is intentionally minimal — engrave/font/QR access
    happens via direct package imports, not through Platform.
    Future additions (camera, persistent storage) widen the
    interface, not the data model.

  cmd/emulator/main.go (//go:build js && wasm)
    browserPlatform implements Platform against the browser:
      - Display() copies the RGBA buffer into a JS Uint8ClampedArray
        and calls back into emulatorPaint(pixels, w, h)
      - Events() returns a 32-slot buffered channel that
        exportPushEvent feeds from JS
    Boot screen draws a stub frame (orange border, centre cross-hair
    tick) so the canvas isn't blank during the GUI-less stage.
    Future: replace drawBootScreen with gui.Loop() once gui/ is
    lifted.

  cmd/emulator/emulator_host.go (//go:build !js || !wasm)
    Host placeholder so `go build ./...` succeeds on non-WASM
    targets. Emits a helpful "build with GOOS=js GOARCH=wasm".

  web/emulator/{index.html, app.css, app.js, build.sh}
    Static PWA shell. Lays out an LCD frame (canvas, 320×320
    rendered with image-rendering:pixelated for crisp 240×240
    upscale), an on-screen joystick grid, three side-key buttons,
    and a keyboard-shortcut reference table. Keyboard mapping
    matches docs/architecture/v1-buttons-and-ui.md proposal.
    Reuses ../composer/{wasm_exec.js, app.css} — DRY base styling,
    no duplication.

The static server now needs to run one directory level higher
(`-d ./web` instead of `-d ./web/composer`) so both /composer/
and /emulator/ are reachable.

Build with: ./web/emulator/build.sh
Serve with: python3 -m http.server -d ./web 38080
Open:       http://localhost:38080/emulator/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mineracks 2026-05-28 20:38:43 +10:00
parent 0203be2e8f
commit ded32c8f07
8 changed files with 536 additions and 41 deletions

View File

@ -1,24 +0,0 @@
// Command emulator runs the SeedHammer v1 controller firmware in a browser.
//
// Compiles to WebAssembly via GOOS=js GOARCH=wasm. The HTML/CSS shell that
// hosts this WASM lives under web/emulator/.
//
// The WASM hosts the real v1 gui/, input/, and engrave/ packages, with
// hardware-side drivers (driver/wshat for buttons, driver/drm for the LCD,
// driver/libcamera for the camera, driver/mjolnir for the engraver)
// replaced by browser-side mocks:
//
// - Buttons → keyboard events (arrows / Enter / 1, 2, 3)
// - LCD → HTML5 canvas
// - Camera → mock that reads QRs from sibling panes on the same page
// - Engraver → null sink (preview mode) or visual playback animation
//
// Status: STUB — implementation will lift upstream v1.3.0 gui/ and input/
// packages, with browser-side platform adapters in platform/v1/.
//
// Modelled architecturally on Gangleri42's cmd/wasmemu at:
// https://github.com/Gangleri42/seedhammer/tree/seedhammer-features/cmd/wasmemu
// (pinned at 0a3c63efb125d17d8ec86ce739ecd058c8747cfe), but his wasmemu
// emulates SH-II's touch UI — v1 is button-driven, so we rebuild from scratch
// while keeping his PWA shell pattern.
package main

View File

@ -0,0 +1,16 @@
//go:build !js || !wasm
// Host-build placeholder for cmd/emulator. The emulator is WASM-only.
package main
import (
"fmt"
"os"
)
func main() {
fmt.Fprintln(os.Stderr, "emulator: this binary is WebAssembly-only.")
fmt.Fprintln(os.Stderr, "Build it with:")
fmt.Fprintln(os.Stderr, " GOOS=js GOARCH=wasm go build -o ./web/emulator/emulator.wasm ./cmd/emulator")
os.Exit(2)
}

View File

@ -1,11 +1,190 @@
//go:build js && wasm
// Command emulator is a browser-based SeedHammer v1 firmware runner.
//
// Phase 2 scaffolding stage. This binary boots a 240×240 canvas-backed
// LCD mock and an 8-button input layer that maps keyboard events to the
// v1's joystick + 3 keys. The real firmware GUI lift (upstream's gui/
// package + its assets/layout/op/saver/text/widget subpackages) lands in
// a follow-up commit; today this stage proves the build pipeline + the
// platform/v1.Platform interface contract.
//
// Build:
//
// GOOS=js GOARCH=wasm go build -o ./web/emulator/emulator.wasm ./cmd/emulator
//
// The static shell (HTML/CSS/JS) lives under web/emulator/. JS exports
// documented in web/emulator/app.js.
package main package main
import ( import (
"fmt" "fmt"
"os" "image"
"image/color"
"image/draw"
"strconv"
"syscall/js"
"github.com/mineracks/seedhammer-v1-companion/platform/v1"
) )
func main() { const emulatorVersion = "v0.1-phase2-stub"
fmt.Fprintln(os.Stderr, "emulator: not yet implemented — see docs/architecture/v1-buttons-and-ui.md and cmd/emulator/doc.go")
os.Exit(2) // v1 hardware native LCD resolution.
const (
lcdWidth = 240
lcdHeight = 240
)
// browserPlatform implements v1.Platform against the JS host.
//
// Display() draws into an *image.RGBA buffer then ships it to JS via a
// Uint8ClampedArray that the JS shell paints onto a <canvas>. Events()
// returns a channel fed by exportPushEvent calls from JS.
type browserPlatform struct {
frame *image.RGBA
events chan v1.Event
}
func newBrowserPlatform() *browserPlatform {
return &browserPlatform{
frame: image.NewRGBA(image.Rect(0, 0, lcdWidth, lcdHeight)),
events: make(chan v1.Event, 32),
}
}
func (p *browserPlatform) Events() <-chan v1.Event { return p.events }
func (p *browserPlatform) Display(frame image.Image) {
draw.Draw(p.frame, p.frame.Bounds(), frame, frame.Bounds().Min, draw.Src)
// Convert RGBA buffer → JS Uint8ClampedArray and call back into JS.
jsBuf := js.Global().Get("Uint8ClampedArray").New(len(p.frame.Pix))
js.CopyBytesToJS(jsBuf, p.frame.Pix)
js.Global().Call("emulatorPaint", jsBuf, lcdWidth, lcdHeight)
}
// Push an event from JS into the events channel.
func (p *browserPlatform) push(button v1.Button, pressed bool) {
select {
case p.events <- v1.Event{Button: button, Pressed: pressed}:
default:
// Drop on full — shouldn't happen with the modest buffer the
// firmware needs, but better than blocking the JS thread.
}
}
var plat *browserPlatform
func main() {
plat = newBrowserPlatform()
js.Global().Set("emulatorVersion", js.FuncOf(exportVersion))
js.Global().Set("emulatorPushEvent", js.FuncOf(exportPushEvent))
js.Global().Set("emulatorBootScreen", js.FuncOf(exportBootScreen))
js.Global().Set("emulatorLCDSize", js.ValueOf(map[string]any{
"w": lcdWidth,
"h": lcdHeight,
}))
// Show the placeholder boot screen so the canvas isn't blank on load.
drawBootScreen(plat.frame)
plat.Display(plat.frame)
// Future: wire plat to gui.Loop or whatever the lifted GUI exposes.
// For now, just consume events so the channel doesn't fill up.
go func() {
for ev := range plat.events {
// Echo to console for debugging; the real GUI will replace this.
js.Global().Get("console").Call("log",
fmt.Sprintf("emu: %s %s",
buttonName(ev.Button),
boolStr(ev.Pressed, "press", "release"),
),
)
}
}()
select {} // keep the runtime alive
}
func exportVersion(this js.Value, args []js.Value) any {
return emulatorVersion
}
// exportPushEvent: emulatorPushEvent(buttonId:number, pressed:boolean)
func exportPushEvent(this js.Value, args []js.Value) any {
if len(args) != 2 {
return nil
}
id := args[0].Int()
pressed := args[1].Bool()
if id < 0 || id > int(v1.Button3) {
return nil
}
plat.push(v1.Button(id), pressed)
return nil
}
// exportBootScreen redraws the boot placeholder, useful for re-testing
// after manual mucking.
func exportBootScreen(this js.Value, args []js.Value) any {
drawBootScreen(plat.frame)
plat.Display(plat.frame)
return nil
}
// drawBootScreen fills the frame with a minimal welcome image so users see
// SOMETHING the moment the WASM finishes loading. Future: this is replaced
// by gui.Loop()'s first frame.
func drawBootScreen(dst *image.RGBA) {
// Background.
draw.Draw(dst, dst.Bounds(), &image.Uniform{color.RGBA{0x12, 0x12, 0x12, 0xff}}, image.Point{}, draw.Src)
// Frame border to make the LCD area unambiguous.
border := color.RGBA{0xff, 0x88, 0x00, 0xff} // accent orange
for x := 0; x < lcdWidth; x++ {
dst.SetRGBA(x, 0, border)
dst.SetRGBA(x, lcdHeight-1, border)
}
for y := 0; y < lcdHeight; y++ {
dst.SetRGBA(0, y, border)
dst.SetRGBA(lcdWidth-1, y, border)
}
// Eight tick marks around the perimeter to show the button positions.
// Just a visual cue that this is real hardware-resolution rendering.
tick := color.RGBA{0xaa, 0xaa, 0xaa, 0xff}
for i := 0; i < 16; i++ {
dst.SetRGBA(lcdWidth/2-1+i-8, lcdHeight/2, tick)
dst.SetRGBA(lcdWidth/2, lcdHeight/2-1+i-8, tick)
}
}
func buttonName(b v1.Button) string {
switch b {
case v1.ButtonUp:
return "Up"
case v1.ButtonDown:
return "Down"
case v1.ButtonLeft:
return "Left"
case v1.ButtonRight:
return "Right"
case v1.ButtonCenter:
return "Center"
case v1.Button1:
return "Button1"
case v1.Button2:
return "Button2"
case v1.Button3:
return "Button3"
}
return "btn-" + strconv.Itoa(int(b))
}
func boolStr(b bool, t, f string) string {
if b {
return t
}
return f
} }

View File

@ -19,8 +19,6 @@ package v1
import ( import (
"image" "image"
"github.com/mineracks/seedhammer-v1-companion/font/constant"
) )
// Button identifies one of the eight physical inputs on the v1 hardware. // Button identifies one of the eight physical inputs on the v1 hardware.
@ -55,15 +53,4 @@ type Platform interface {
// Display writes a frame to the LCD-equivalent. The image is the // Display writes a frame to the LCD-equivalent. The image is the
// full screen at the platform's native resolution (240×240 for v1). // full screen at the platform's native resolution (240×240 for v1).
Display(frame image.Image) Display(frame image.Image)
// EngraveFont returns the vector engraving face. Both real and
// emulator backends return the same data — it's bundled with the
// firmware.
EngraveFont() *constant.Face
} }
// constant.Face is the type alias we use in the public surface, re-exported
// here so callers don't have to import font/vector directly.
// (Today this is just *vector.Face under the hood; the alias lets us swap
// implementations without rippling change through the GUI.)
type Face = constant.Face

104
web/emulator/app.css Normal file
View File

@ -0,0 +1,104 @@
/* SeedHammer v1 emulator-specific styles, layered on top of the
composer's app.css for the shared shell/card/glass primitives. */
.emu-device-card { padding: 24px; }
.emu-device {
display: flex;
gap: 32px;
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
.emu-lcd-frame {
background: #0a0a0a;
border-radius: 18px;
padding: 18px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35), inset 0 0 0 2px #2a2a2a;
}
#lcd {
display: block;
width: 320px;
height: 320px;
image-rendering: pixelated;
background: #000;
border-radius: 6px;
}
.emu-controls {
display: grid;
grid-template-columns: auto auto;
gap: 28px;
align-items: center;
}
.emu-joystick {
display: grid;
grid-template-columns: repeat(3, 56px);
grid-template-rows: repeat(3, 56px);
gap: 4px;
}
.emu-joystick .jb.up { grid-column: 2; grid-row: 1; }
.emu-joystick .jb.left { grid-column: 1; grid-row: 2; }
.emu-joystick .jb.center { grid-column: 2; grid-row: 2; }
.emu-joystick .jb.right { grid-column: 3; grid-row: 2; }
.emu-joystick .jb.down { grid-column: 2; grid-row: 3; }
.emu-keys {
display: flex;
flex-direction: column;
gap: 12px;
}
.emu-btn {
background: var(--surface-strong);
border: 1px solid var(--hairline);
border-radius: 12px;
color: var(--text);
font: inherit;
font-weight: 600;
cursor: pointer;
font-size: 18px;
user-select: none;
transition: transform 0.05s, background 0.1s;
display: flex;
align-items: center;
justify-content: center;
}
.emu-btn:active,
.emu-btn.pressed {
background: var(--accent);
color: var(--accent-text);
transform: scale(0.94);
box-shadow: 0 2px 8px rgb(255 136 0 / 0.4);
}
.emu-btn.key {
width: 64px;
height: 56px;
font-size: 14px;
}
.emu-keymap {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.emu-keymap td {
padding: 6px 12px;
border-bottom: 1px solid var(--hairline);
vertical-align: top;
}
.emu-keymap td:first-child { width: 14em; }
.emu-keymap td:nth-child(2) { width: 8em; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.emu-keymap tr:last-child td { border-bottom: none; }
.emu-keymap kbd {
background: var(--surface-strong);
border: 1px solid var(--hairline);
border-radius: 4px;
padding: 2px 8px;
font-family: ui-monospace, monospace;
font-size: 12px;
display: inline-block;
}

136
web/emulator/app.js Normal file
View File

@ -0,0 +1,136 @@
// SeedHammer v1 emulator — DOM shell + WASM bridge.
//
// Go exports (built from cmd/emulator):
//
// emulatorVersion() -> string
// emulatorLCDSize -> {w, h}
// emulatorPushEvent(buttonId, pressed) -> void
// emulatorBootScreen() -> void (debug helper)
//
// The Go runtime calls back into JS via:
// globalThis.emulatorPaint(pixels: Uint8ClampedArray, w: number, h: number)
// every time the firmware redraws the LCD.
const els = {
status: document.getElementById("status"),
lcd: document.getElementById("lcd"),
buttons: document.querySelectorAll(".emu-btn"),
};
const ctx = els.lcd.getContext("2d", { alpha: false });
// Button enum mirrors platform/v1.Button.
const BTN = {
Up: 0, Down: 1, Left: 2, Right: 3, Center: 4,
Button1: 5, Button2: 6, Button3: 7,
};
const KEYMAP = {
ArrowUp: BTN.Up,
ArrowDown: BTN.Down,
ArrowLeft: BTN.Left,
ArrowRight: BTN.Right,
Enter: BTN.Center,
" ": BTN.Center, // spacebar
"1": BTN.Button1,
"2": BTN.Button2,
"3": BTN.Button3,
};
let wasmReady = false;
function setStatus(text, error = false) {
els.status.textContent = text;
els.status.classList.toggle("error", error);
}
function pushEvent(id, pressed) {
if (!wasmReady) return;
globalThis.emulatorPushEvent(id, pressed);
}
// emulatorPaint: called from Go after every Display(). pixels is an
// RGBA byte buffer w*h*4 bytes long, row-major, top-left origin.
globalThis.emulatorPaint = function (pixels, w, h) {
const img = new ImageData(pixels, w, h);
// Resize canvas if Go reports a different LCD resolution.
if (els.lcd.width !== w || els.lcd.height !== h) {
els.lcd.width = w;
els.lcd.height = h;
}
ctx.putImageData(img, 0, 0);
};
// Wire up the on-screen buttons. Pointerdown/up gives us press+release
// semantics that match the underlying GPIO event model.
for (const b of els.buttons) {
const id = Number(b.dataset.btn);
b.addEventListener("pointerdown", (e) => {
e.preventDefault();
b.classList.add("pressed");
pushEvent(id, true);
});
const release = (e) => {
e?.preventDefault();
if (!b.classList.contains("pressed")) return;
b.classList.remove("pressed");
pushEvent(id, false);
};
b.addEventListener("pointerup", release);
b.addEventListener("pointerleave", release);
b.addEventListener("pointercancel", release);
// Stop the button itself from grabbing keyboard focus on click.
b.addEventListener("mousedown", (e) => e.preventDefault());
}
// Track currently-pressed keys so a held key doesn't repeatedly fire
// press events on autorepeat.
const heldKeys = new Set();
document.addEventListener("keydown", (e) => {
const id = KEYMAP[e.key];
if (id === undefined) return;
if (heldKeys.has(e.key)) return; // autorepeat
heldKeys.add(e.key);
e.preventDefault();
// Visual feedback on the on-screen button too.
const dom = document.querySelector(`.emu-btn[data-btn="${id}"]`);
if (dom) dom.classList.add("pressed");
pushEvent(id, true);
});
document.addEventListener("keyup", (e) => {
const id = KEYMAP[e.key];
if (id === undefined) return;
heldKeys.delete(e.key);
e.preventDefault();
const dom = document.querySelector(`.emu-btn[data-btn="${id}"]`);
if (dom) dom.classList.remove("pressed");
pushEvent(id, false);
});
async function loadWasm() {
setStatus("Loading WASM…");
const go = new Go();
const resp = await fetch("./emulator.wasm");
if (!resp.ok) {
setStatus(`Failed to fetch emulator.wasm (${resp.status})`, true);
return;
}
const result = await WebAssembly.instantiateStreaming(resp, go.importObject);
go.run(result.instance);
for (let i = 0; i < 100; i++) {
if (typeof globalThis.emulatorVersion === "function") break;
await new Promise((r) => setTimeout(r, 20));
}
if (typeof globalThis.emulatorVersion !== "function") {
setStatus("WASM loaded but exports never appeared", true);
return;
}
wasmReady = true;
setStatus(`Ready — ${globalThis.emulatorVersion()}`);
}
loadWasm().catch((e) => {
setStatus(`Boot failed: ${e?.message ?? e}`, true);
});

17
web/emulator/build.sh Executable file
View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Build the SeedHammer v1 emulator WASM bundle.
# wasm_exec.js is shared with the composer at ../composer/wasm_exec.js.
set -euo pipefail
cd "$(dirname "$0")"
REPO_ROOT=$(cd ../.. && pwd)
cd "$REPO_ROOT"
GOOS=js GOARCH=wasm go build -trimpath -ldflags="-s -w" \
-o ./web/emulator/emulator.wasm \
./cmd/emulator
size=$(stat -f%z ./web/emulator/emulator.wasm 2>/dev/null || stat -c%s ./web/emulator/emulator.wasm)
echo "built: ./web/emulator/emulator.wasm (${size} bytes)"
echo "serve: python3 -m http.server -d ./web 38080"
echo " → open http://localhost:38080/emulator/"

80
web/emulator/index.html Normal file
View File

@ -0,0 +1,80 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#1a1a1a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f5" media="(prefers-color-scheme: light)">
<title>SeedHammer v1 — emulator</title>
<link rel="stylesheet" href="../composer/app.css">
<link rel="stylesheet" href="./app.css">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36.6 36.6'%3E%3Crect width='36.6' height='36.6' rx='7' fill='%23ff8800'/%3E%3C/svg%3E">
</head>
<body>
<noscript>This app requires JavaScript and WebAssembly.</noscript>
<header class="bar glass">
<h1>SeedHammer v1 Emulator</h1>
<div class="bar-actions">
<span id="status" class="status" aria-live="polite">Loading…</span>
</div>
</header>
<main>
<section class="card glass emu-device-card" aria-label="Device">
<div class="emu-device">
<div class="emu-lcd-frame">
<canvas id="lcd" width="240" height="240" aria-label="v1 LCD"></canvas>
</div>
<div class="emu-controls">
<div class="emu-joystick" role="group" aria-label="Joystick">
<button class="emu-btn jb up" data-btn="0" aria-label="Up"></button>
<button class="emu-btn jb left" data-btn="2" aria-label="Left"></button>
<button class="emu-btn jb center" data-btn="4" aria-label="Center"></button>
<button class="emu-btn jb right" data-btn="3" aria-label="Right"></button>
<button class="emu-btn jb down" data-btn="1" aria-label="Down"></button>
</div>
<div class="emu-keys" role="group" aria-label="Side keys">
<button class="emu-btn key" data-btn="5" aria-label="Button 1">K1</button>
<button class="emu-btn key" data-btn="6" aria-label="Button 2">K2</button>
<button class="emu-btn key" data-btn="7" aria-label="Button 3">K3</button>
</div>
</div>
</div>
</section>
<section class="card glass" aria-label="Keyboard mapping">
<div class="editor-head">
<h2>Keyboard shortcuts</h2>
<small>Hardware buttons map 1:1 to keys.</small>
</div>
<table class="emu-keymap">
<tbody>
<tr><td><kbd></kbd></td><td>Up</td><td>Joystick north</td></tr>
<tr><td><kbd></kbd></td><td>Down</td><td>Joystick south</td></tr>
<tr><td><kbd></kbd></td><td>Left</td><td>Joystick west</td></tr>
<tr><td><kbd></kbd></td><td>Right</td><td>Joystick east</td></tr>
<tr><td><kbd>Enter</kbd> / <kbd>Space</kbd></td><td>Center</td><td>Joystick press / primary confirm</td></tr>
<tr><td><kbd>1</kbd></td><td>Button 1</td><td>Back</td></tr>
<tr><td><kbd>2</kbd></td><td>Button 2</td><td>Secondary action</td></tr>
<tr><td><kbd>3</kbd></td><td>Button 3</td><td>Primary confirm</td></tr>
</tbody>
</table>
</section>
<footer class="card glass footer-card">
<p>
Phase 2 scaffolding stage — the GUI itself isn't lifted yet, but the
LCD canvas, button input layer, and platform.Platform contract are
all in place. The boot screen below shows the 240×240 frame rendered
by the Go runtime; press any mapped key (or click any button) and
the Go side echoes the event to the browser console.
See <a href="https://github.com/mineracks/seedhammer-v1-companion">the repo</a>.
</p>
</footer>
</main>
<script src="../composer/wasm_exec.js"></script>
<script src="./app.js" type="module"></script>
</body>
</html>