diff --git a/cmd/emulator/doc.go b/cmd/emulator/doc.go deleted file mode 100644 index f14c564..0000000 --- a/cmd/emulator/doc.go +++ /dev/null @@ -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 diff --git a/cmd/emulator/emulator_host.go b/cmd/emulator/emulator_host.go new file mode 100644 index 0000000..a1c9f42 --- /dev/null +++ b/cmd/emulator/emulator_host.go @@ -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) +} diff --git a/cmd/emulator/main.go b/cmd/emulator/main.go index 2a947b3..ef19873 100644 --- a/cmd/emulator/main.go +++ b/cmd/emulator/main.go @@ -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 import ( "fmt" - "os" + "image" + "image/color" + "image/draw" + "strconv" + "syscall/js" + + "github.com/mineracks/seedhammer-v1-companion/platform/v1" ) -func main() { - fmt.Fprintln(os.Stderr, "emulator: not yet implemented — see docs/architecture/v1-buttons-and-ui.md and cmd/emulator/doc.go") - os.Exit(2) +const emulatorVersion = "v0.1-phase2-stub" + +// 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 . 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 } diff --git a/platform/v1/platform.go b/platform/v1/platform.go index 72e75fe..940ca1c 100644 --- a/platform/v1/platform.go +++ b/platform/v1/platform.go @@ -19,8 +19,6 @@ package v1 import ( "image" - - "github.com/mineracks/seedhammer-v1-companion/font/constant" ) // 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 // full screen at the platform's native resolution (240×240 for v1). 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 diff --git a/web/emulator/app.css b/web/emulator/app.css new file mode 100644 index 0000000..263b96d --- /dev/null +++ b/web/emulator/app.css @@ -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; +} diff --git a/web/emulator/app.js b/web/emulator/app.js new file mode 100644 index 0000000..819c2d0 --- /dev/null +++ b/web/emulator/app.js @@ -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); +}); diff --git a/web/emulator/build.sh b/web/emulator/build.sh new file mode 100755 index 0000000..a42bdb5 --- /dev/null +++ b/web/emulator/build.sh @@ -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/" diff --git a/web/emulator/index.html b/web/emulator/index.html new file mode 100644 index 0000000..68c1f9e --- /dev/null +++ b/web/emulator/index.html @@ -0,0 +1,80 @@ + + + + + + + +SeedHammer v1 — emulator + + + + + + + +
+

SeedHammer v1 Emulator

+
+ Loading… +
+
+ +
+
+
+
+ +
+
+
+ + + + + +
+
+ + + +
+
+
+
+ +
+
+

Keyboard shortcuts

+ Hardware buttons map 1:1 to keys. +
+ + + + + + + + + + + +
UpJoystick north
DownJoystick south
LeftJoystick west
RightJoystick east
Enter / SpaceCenterJoystick press / primary confirm
1Button 1Back
2Button 2Secondary action
3Button 3Primary confirm
+
+ +
+

+ 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 the repo. +

+
+
+ + + + +