seedhammer-v1-companion/cmd/emulator/main.go
mineracks ded32c8f07 #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>
2026-05-28 20:38:43 +10:00

191 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//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"
"image"
"image/color"
"image/draw"
"strconv"
"syscall/js"
"github.com/mineracks/seedhammer-v1-companion/platform/v1"
)
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 <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
}