Skip to content

Instantly share code, notes, and snippets.

@peterhellberg
Last active February 6, 2025 10:57
Show Gist options
  • Save peterhellberg/b165f24d4392de8519df2d2606549351 to your computer and use it in GitHub Desktop.
Save peterhellberg/b165f24d4392de8519df2d2606549351 to your computer and use it in GitHub Desktop.
Rudimentary emulator for Firefly Zero written in Go
package main
import (
"context"
"flag"
"fmt"
"image"
"image/color"
"image/draw"
"io"
"math/rand"
"os"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/peterhellberg/gfx"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
)
const (
width = 240
height = 160
)
var palette = [17]color.RGBA{
{0x00, 0x00, 0x00, 0x00},
{0x1A, 0x1C, 0x2C, 0xFF},
{0x5D, 0x27, 0x5D, 0xFF},
{0xB1, 0x3E, 0x53, 0xFF},
{0xEF, 0x7D, 0x57, 0xFF},
{0xFF, 0xCD, 0x75, 0xFF},
{0xA7, 0xF0, 0x70, 0xFF},
{0x38, 0xB7, 0x64, 0xFF},
{0x25, 0x71, 0x79, 0xFF},
{0x29, 0x36, 0x6F, 0xFF},
{0x3B, 0x5D, 0xC9, 0xFF},
{0x41, 0xA6, 0xF6, 0xFF},
{0x73, 0xEF, 0xF7, 0xFF},
{0xF4, 0xF4, 0xF4, 0xFF},
{0x94, 0xB0, 0xC2, 0xFF},
{0x56, 0x6C, 0x86, 0xFF},
{0x33, 0x3C, 0x57, 0xFF},
}
func main() {
if err := run(context.Background(), os.Args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string) error {
cfg, err := parse(args)
if err != nil {
return err
}
ebiten.SetWindowSize(width*cfg.scale, height*cfg.scale)
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
ebiten.SetWindowTitle(cfg.title)
emu, err := newGame(ctx, cfg)
if err != nil {
return err
}
defer emu.runtime.Close(ctx)
return ebiten.RunGame(emu)
}
func newGame(ctx context.Context, cfg config) (*Game, error) {
g := &Game{
config: cfg,
frame: newFrame(),
}
r := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig())
if _, err := r.
NewHostModuleBuilder("fs").
NewFunctionBuilder().WithFunc(g.fsLoadfile).Export("load_file").
NewFunctionBuilder().WithFunc(g.fsGetFileSize).Export("get_file_size").
Instantiate(ctx); err != nil {
return nil, err
}
if _, err := r.
NewHostModuleBuilder("input").
NewFunctionBuilder().WithFunc(g.inputReadPad).Export("read_pad").
NewFunctionBuilder().WithFunc(g.inputReadButtons).Export("read_buttons").
Instantiate(ctx); err != nil {
return nil, err
}
if _, err := r.
NewHostModuleBuilder("misc").
NewFunctionBuilder().WithFunc(g.miscGetRandom).Export("get_random").
NewFunctionBuilder().WithFunc(g.miscSetSeed).Export("set_seed").
Instantiate(ctx); err != nil {
return nil, err
}
if _, err := r.
NewHostModuleBuilder("graphics").
NewFunctionBuilder().WithFunc(g.graphicsClearScreen).Export("clear_screen").
NewFunctionBuilder().WithFunc(g.graphicsSetColor).Export("set_color").
NewFunctionBuilder().WithFunc(g.graphicsDrawLine).Export("draw_line").
NewFunctionBuilder().WithFunc(g.graphicsDrawPoint).Export("draw_point").
NewFunctionBuilder().WithFunc(g.graphicsDrawCircle).Export("draw_circle").
NewFunctionBuilder().WithFunc(g.graphicsDrawText).Export("draw_text").
NewFunctionBuilder().WithFunc(g.graphicsDrawImage).Export("draw_image").
NewFunctionBuilder().WithFunc(g.graphicsDrawSubImage).Export("draw_sub_image").
Instantiate(ctx); err != nil {
return nil, err
}
if _, err := r.
NewHostModuleBuilder("net").
NewFunctionBuilder().WithFunc(g.netGetPeers).Export("get_peers").
NewFunctionBuilder().WithFunc(g.netGetMe).Export("get_me").
Instantiate(ctx); err != nil {
return nil, err
}
if _, err := r.
NewHostModuleBuilder("stats").
NewFunctionBuilder().WithFunc(g.statsAddProgress).Export("add_progress").
Instantiate(ctx); err != nil {
return nil, err
}
if _, err := r.
NewHostModuleBuilder("audio").
NewFunctionBuilder().WithFunc(g.audioClear).Export("clear").
NewFunctionBuilder().WithFunc(g.audioAddFile).Export("add_file").
NewFunctionBuilder().WithFunc(g.audioAddGain).Export("add_gain").
Instantiate(ctx); err != nil {
return nil, err
}
if _, err := r.
NewHostModuleBuilder("menu").
NewFunctionBuilder().WithFunc(g.menuAddMenuItem).Export("add_menu_item").
Instantiate(ctx); err != nil {
return nil, err
}
g.runtime = r
mod, err := g.runtime.InstantiateWithConfig(ctx, cfg.source, wazero.
NewModuleConfig().
WithStdin(os.Stdin),
)
if err != nil {
return nil, err
}
g.module = mod
g.memory = g.module.Memory()
if boot := g.module.ExportedFunction("boot"); boot != nil {
if _, err := boot.Call(ctx); err != nil {
return g, err
}
}
return g, nil
}
type Game struct {
config config
frame *image.RGBA
runtime wazero.Runtime
module api.Module
memory api.Memory
buttons buttons
dpad dpad
texts []text
}
type dpad struct {
up bool
down bool
left bool
right bool
}
func newDPad() dpad {
return dpad{
up: ebiten.IsKeyPressed(ebiten.KeyW),
down: ebiten.IsKeyPressed(ebiten.KeyS),
left: ebiten.IsKeyPressed(ebiten.KeyA),
right: ebiten.IsKeyPressed(ebiten.KeyD),
}
}
func (d dpad) state() int32 {
var v int32
if d.up {
v += 800 & 0xFFFF
}
if d.down {
v -= 800 & 0xFFFF
}
if d.left {
v -= (800 << 16) & -0xFFFF
}
if d.right {
v += (800 << 16) & -0xFFFF
}
return v
}
type buttons struct {
a bool
b bool
x bool
y bool
m bool
}
func newButtons() buttons {
return buttons{
a: ebiten.IsKeyPressed(ebiten.KeyH),
b: ebiten.IsKeyPressed(ebiten.KeyJ),
x: ebiten.IsKeyPressed(ebiten.KeyK),
y: ebiten.IsKeyPressed(ebiten.KeyL),
m: ebiten.IsKeyPressed(ebiten.KeyEnter),
}
}
func (b buttons) state() uint32 {
var v uint32
if b.a {
v += 0b1
}
if b.b {
v += 0b10
}
if b.x {
v += 0b100
}
if b.y {
v += 0b1000
}
if b.m {
v += 0b10000
}
return v
}
type text struct {
str string
x int
y int
}
func (g *Game) Update() error {
ctx := context.Background()
g.texts = []text{}
g.buttons = newButtons()
g.dpad = newDPad()
if update := g.module.ExportedFunction("update"); update != nil {
if _, err := update.Call(ctx); err != nil {
return err
}
}
return nil
}
func (g *Game) Draw(screen *ebiten.Image) {
ctx := context.Background()
if render := g.module.ExportedFunction("render"); render != nil {
render.Call(ctx)
}
screen.WritePixels(g.frame.Pix)
for _, t := range g.texts {
ebitenutil.DebugPrintAt(screen, t.str, t.x, t.y)
}
}
func (g *Game) Layout(_, _ int) (int, int) {
return width, height
}
func newFrame() *image.RGBA {
return image.NewRGBA(image.Rect(0, 0, width, height))
}
type config struct {
scale int
title string
file string
source []byte
}
func parse(args []string) (config, error) {
var cfg config
if len(args) == 0 {
return cfg, fmt.Errorf("no arguments")
}
flags := flag.NewFlagSet(args[0], flag.ExitOnError)
flags.IntVar(&cfg.scale, "scale", 3, "Scale to render in [1-5]")
if err := flags.Parse(args[1:]); err != nil {
return cfg, err
}
if cfg.scale < 1 || cfg.scale > 5 {
return cfg, fmt.Errorf("scale must be a positive number between 1-5")
}
if rest := flags.Args(); len(rest) > 0 {
cfg.file = rest[0]
}
if cfg.file == "" {
return cfg, fmt.Errorf("no .wasm file specified")
}
source, err := os.ReadFile(cfg.file)
if err != nil {
return cfg, err
}
cfg.source = source
cfg.title = fmt.Sprintf("Firefly Zero Emulator in Go: %s", cfg.file)
return cfg, nil
}
func pal(c int32) color.RGBA {
return palette[c]
}
func fill(m draw.Image, c color.Color) {
draw.Draw(m, m.Bounds(), &image.Uniform{c}, image.Point{}, draw.Src)
}
// -- GRAPHICS -- //
// DONE: func clearScreen(c int32)
func (g *Game) graphicsClearScreen(c int32) {
fmt.Fprintln(io.Discard, "graphics.clear_screen", c)
fill(g.frame, pal(c))
}
// MOCK: func setColor(c, r, g, b int32)
func (g *Game) graphicsSetColor(c, R, G, B int32) {
if c > 0 && c < 17 {
palette[c] = color.RGBA{
uint8(R), uint8(G), uint8(B), 0xFF,
}
}
}
// DONE: func drawPoint(x, y, c int32)
func (g *Game) graphicsDrawPoint(x, y, c int32) {
fmt.Fprintln(os.Stderr, "graphics.draw_point", x, y, c)
gfx.Set(g.frame, int(x), int(y), pal(c))
}
func (g *Game) graphicsDrawLine(x1, y1, x2, y2, c, sw int32) {
fmt.Fprintln(io.Discard, "graphics.draw_line", x1, y1, x2, y1, c, sw)
if sw == 0 {
sw = 1
}
polyline := gfx.NewPolyline(
gfx.Polygon{
gfx.IV(int(x1), int(y1)),
gfx.IV(int(x2), int(y2)),
}, 0)
gfx.DrawPolyline(g.frame, polyline, float64(sw/2), pal(c))
}
// TODO: func drawRect(x, y, w, h, fc, sc, sw int32)
// TODO: func drawRoundedRect(x, y, w, h, cw, ch, fc, sc, sw int32)
// MOCK: func drawCircle(x, y, d, fc, sc, sw int32)
func (g *Game) graphicsDrawCircle(x, y, d, fc, sc, sw int32) {
fmt.Fprintln(io.Discard, "graphics.draw_circle", x, y, d, fc, sc, sw)
r := d / 2
ix := int(x + r)
iy := int(y + r)
ir := int(r)
if fc != 0 {
gfx.DrawIntFilledCircle(g.frame, ix, iy, ir, pal(fc))
}
if sc != 0 {
gfx.DrawIntCircle(g.frame, ix, iy, ir, pal(sc))
}
}
// TODO: func drawEllipse(x, y, w, h, fc, sc, sw int32)
// TODO: func drawTriangle(x1, y1, x2, y2, x3, y3, fc, sc, sw int32)
// TODO: func drawArc(x, y, d int32, ast, asw float32, fc, sc, sw int32)
// TODO: func drawSector(x, y, d int32, ast, asw float32, fc, sc, sw int32)
// MOCK: func drawText(textPtr unsafe.Pointer, textLen uint32, fontPtr unsafe.Pointer, fontLen uint32, x, y, color int32)
func (g *Game) graphicsDrawText(textPtr, textLen, fontPtr, fontLen uint32, x, y, c int32) {
fmt.Fprintln(io.Discard, "graphics.draw_text", textPtr, textLen, fontPtr, fontLen, x, y, c)
if data, ok := g.memory.Read(textPtr, textLen); ok {
g.texts = append(g.texts, text{str: string(data), x: int(x), y: int(y)})
}
}
// MOCK: func drawImage(ptr unsafe.Pointer, len uint32, x, y int32)
func (g *Game) graphicsDrawImage(ptr, ptrLen uint32, x, y int32) {
fmt.Fprintln(io.Discard, "graphics.draw_image", ptr, ptrLen, x, y)
gfx.DrawIntRectangle(g.frame, int(x), int(y), 10, 10, pal(1))
}
// MOCK: func drawSubImage(ptr unsafe.Pointer, len uint32, x, y, subX, subY int32, subWidth, subHeight uint32)
func (g *Game) graphicsDrawSubImage(ptr, ptrLen uint32, x, y, subX, subY int32, subWidth, subHeight uint32) {
fmt.Fprintln(os.Stderr, "graphics.draw_sub_image", ptr, ptrLen, x, y, subX, subY, subWidth, subHeight)
}
// TODO: func setCanvas(ptr unsafe.Pointer, len uint32)
// TODO: func unsetCanvas()
// -- INPUT -- //
// MOCK: func readPad(player uint32) int32
func (g *Game) inputReadPad(player uint32) int32 {
fmt.Fprintln(io.Discard, "input.read_pad", player)
return g.dpad.state()
}
// MOCK: func readButtons(player uint32) uint32
func (g *Game) inputReadButtons(player uint32) uint32 {
fmt.Fprintln(io.Discard, "input.read_buttons", player)
return g.buttons.state()
}
// -- FS -- //
// MOCK: func getFileSize(pathPtr unsafe.Pointer, pathLen uint32) uint32
func (g *Game) fsGetFileSize(pathPtr, pathLen uint32) uint32 {
fmt.Fprintln(io.Discard, "fs.get_file_size", pathPtr, pathLen)
return 0
}
// MOCK: func loadFile(pathPtr unsafe.Pointer, pathLen uint32, bufPtr unsafe.Pointer, bufLen uint32) uint32
func (g *Game) fsLoadfile(pathPtr, pathLen, bufPtr, bufLen uint32) uint32 {
fmt.Fprintln(io.Discard, "fs.load_file", pathPtr, pathLen, bufPtr, bufLen)
return 0
}
// TODO: func dumpFile(pathPtr unsafe.Pointer, pathLen uint32, bufPtr unsafe.Pointer, bufLen uint32) uint32
// TODO: func removeFile(pathPtr unsafe.Pointer, pathLen uint32) uint32
// -- NET -- //
// MOCK: func getMe() uint32
func (g *Game) netGetMe() uint32 {
fmt.Fprintln(os.Stderr, "net.get_me")
return 1
}
// MOCK: func getPeers() uint32
func (g *Game) netGetPeers() uint32 {
fmt.Fprintln(io.Discard, "net.get_peers")
return 2
}
// TODO: func saveStash(peerID uint32, bufPtr unsafe.Pointer, bufLen uint32)
// TODO: func loadStash(peerID uint32, bufPtr unsafe.Pointer, bufLen uint32) uint32
// -- STATS -- //
// MOCK: func addProgress(peerID, badgeID uint32, val int32) uint32
func (g *Game) statsAddProgress(peerID, badgeID uint32, val int32) uint32 {
fmt.Fprintln(io.Discard, "stats.add_progress", peerID, badgeID, val)
return 0
}
// TODO: func addScore(peerID, boardID uint32, val int32) int32
// -- MISC -- //
// TODO: func logDebug(ptr unsafe.Pointer, len uint32)
// TODO: func logError(ptr unsafe.Pointer, len uint32)
// DONE: func setSeed(seed uint32)
func (g *Game) miscSetSeed(seed uint32) {
fmt.Fprintln(os.Stderr, "misc.set_seed", seed)
rand.Seed(int64(seed))
}
// DONE: func getRandom() uint32
func (g *Game) miscGetRandom() uint32 {
fmt.Fprintln(io.Discard, "misc.get_random")
return rand.Uint32()
}
// TODO: func restart()
// TODO: func quit()
// -- AUDIO -- //
// TODO: func addSine(parentID uint32, freq float32, phase float32) uint32
// TODO: func addSquare(parentID uint32, freq float32, phase float32) uint32
// TODO: func addSawtooth(parentID uint32, freq float32, phase float32) uint32
// TODO: func addTriangle(parentID uint32, freq float32, phase float32) uint32
// TODO: func addNoise(parentID uint32, seed int32) uint32
// TODO: func addEmpty(parentID uint32) uint32
// TODO: func addZero(parentID uint32) uint32
// MOCK: func addFile(parentID uint32, ptr unsafe.Pointer, len uint32) uint32
func (g *Game) audioAddFile(parentID, ptr, ptrLen uint32) uint32 {
fmt.Fprintln(os.Stderr, "audio.add_file", parentID, ptr, ptrLen)
return 7
}
// TODO: func addMix(parentID uint32) uint32
// TODO: func addAllForOne(parentID uint32) uint32
// MOCK: func addGain(parentID uint32, lvl float32) uint32
func (g *Game) audioAddGain(parentID uint32, lvl float32) uint32 {
fmt.Fprintln(os.Stderr, "audio.add_gain", parentID, lvl)
return 0
}
// TODO: func addLoop(parentID uint32) uint32
// TODO: func addConcat(parentID uint32) uint32
// TODO: func addPan(parentID uint32, lvl float32) uint32
// TODO: func addMute(parentID uint32) uint32
// TODO: func addPause(parentID uint32) uint32
// TODO: func addTrackPosition(parentID uint32) uint32
// TODO: func addLowPass(parentID uint32, freq float32, q float32) uint32
// TODO: func addHighPass(parentID uint32, freq float32, q float32) uint32
// TODO: func addTakeLeft(parentID uint32) uint32
// TODO: func addTakeRight(parentID uint32) uint32
// TODO: func addSwap(parentID uint32) uint32
// TODO: func addClip(parentID uint32, low float32, high float32) uint32
// TODO: func modLinear(nodeID uint32, param uint32, start float32, end float32, startAt uint32, endAt uint32)
// TODO: func modHold(nodeID uint32, param uint32, v1 float32, v2 float32, time uint32)
// TODO: func modSine(nodeID uint32, param uint32, freq float32, low float32, high float32)
// TODO: func reset(nodeID uint32)
// TODO: func resetAll(nodeID uint32)
// MOCK: func clear(nodeID uint32)
func (g *Game) audioClear(nodeID uint32) {
fmt.Fprintln(os.Stderr, "audio.clear", nodeID)
}
// -- MENU -- //
func (g *Game) menuAddMenuItem(a, b, c int32) {
fmt.Fprintln(os.Stderr, "menu.add_menu_item", a, b, c)
}
// -- SUDO -- //
// TODO: func listDirsBufSize(pathPtr unsafe.Pointer, pathLen uint32) uint32
// TODO: func listDirs(pathPtr unsafe.Pointer, pathLen uint32, bufPtr unsafe.Pointer, bufLen uint32) uint32
// TODO: func runApp(authorPtr unsafe.Pointer, authorLen uint32, appPtr unsafe.Pointer, appLen uint32)
// TODO: func loadFile(pathPtr unsafe.Pointer, pathLen uint32, bufPtr unsafe.Pointer, bufLen uint32) uint32
// TODO: func getFileSize(pathPtr unsafe.Pointer, pathLen uint32) uint32
module experiments/ff-emu
go 1.23.5
require (
github.com/hajimehoshi/ebiten/v2 v2.8.6
github.com/peterhellberg/gfx v0.0.0-20240717094052-4fa835cea5a4
github.com/tetratelabs/wazero v1.8.2
)
require (
github.com/ebitengine/gomobile v0.0.0-20241016134836-cc2e38a7c0ee // indirect
github.com/ebitengine/hideconsole v1.0.0 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/jezek/xgb v1.1.1 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
)
github.com/ebitengine/gomobile v0.0.0-20241016134836-cc2e38a7c0ee h1:YoNt0DHeZ92kjR78SfyUn1yEf7KnBypOFlFZO14cJ6w=
github.com/ebitengine/gomobile v0.0.0-20241016134836-cc2e38a7c0ee/go.mod h1:ZDIonJlTRW7gahIn5dEXZtN4cM8Qwtlduob8cOCflmg=
github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE=
github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/hajimehoshi/ebiten/v2 v2.8.6 h1:Dkd/sYI0TYyZRCE7GVxV59XC+WCi2BbGAbIBjXeVC1U=
github.com/hajimehoshi/ebiten/v2 v2.8.6/go.mod h1:cCQ3np7rdmaJa1ZnvslraVlpxNb3wCjEnAP1LHNyXNA=
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
github.com/peterhellberg/gfx v0.0.0-20240717094052-4fa835cea5a4 h1:Iv17i2mukPLwXTftiwj1bzOamqw1UVXm4vHGxKxi+1U=
github.com/peterhellberg/gfx v0.0.0-20240717094052-4fa835cea5a4/go.mod h1:ipo3f7y1+RHNR32fj+4TETou6OF4VXvJBhb9rwncKuw=
github.com/tetratelabs/wazero v1.8.2 h1:yIgLR/b2bN31bjxwXHD8a3d+BogigR952csSDdLYEv4=
github.com/tetratelabs/wazero v1.8.2/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@peterhellberg
Copy link
Author

peterhellberg commented Feb 6, 2025

Progress of the emulation




Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment