commit 0798933b05672972b3eb38177ab29b945e98ba43 Author: root Date: Sat Sep 19 20:21:47 2026 +0200 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..faef1e6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +*.db +*.db-shm +*.db-wal +Dockerfile +.dockerignore +web/e2e +node_modules diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8112687 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1 + +# ---- deps: module download, cached until go.mod/go.sum change ---------------- +FROM golang:1.25-alpine AS deps +WORKDIR /src +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download + +# ---- test: `docker build --target test .` runs the test suite ---------------- +FROM deps AS test +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + go vet ./... && go test ./... + +# ---- build: static binaries (SQLite and Postgres drivers are pure Go) -------- +FROM deps AS build +COPY . . +ENV CGO_ENABLED=0 +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + go build -trimpath -ldflags="-s -w" -o /out/ ./cmd/server ./cmd/daily ./cmd/asm + +# ---- final: small runtime image ---------------------------------------------- +FROM alpine:3.20 +RUN addgroup -S -g 10001 wh && adduser -S -u 10001 -G wh wh \ + && mkdir /data && chown wh:wh /data +COPY --from=build /out/server /out/daily /out/asm /usr/local/bin/ + +USER wh +WORKDIR /data + +# Where the world lives: "sqlite:" or "postgres://user:pass@host/db". +# The default is a SQLite file on the /data volume; override it at run time, +# e.g. -e DATABASE_URL=postgres://... (the -db flag also overrides it). +ENV DATABASE_URL=sqlite:/data/wh.db +VOLUME /data +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ + CMD wget -q -O /dev/null http://127.0.0.1:8080/info || exit 1 + +# Default: the API and website. Run one simulated day with: +# docker run --rm -e DATABASE_URL=... daily +CMD ["server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4e4b7a --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# Halcyon belt + +A once-a-day asteroid-belt simulator. Players cannot fly their ships: they write a +program for the ship's flight computer, launch it, and then exchange 1 KB a day with +it over a "wormhole" link. The belt is simulated once a day, deterministically. + +- `docs/manual.txt` -- the HC-33 flight computer programmer's manual (also served at `/manual.txt`) +- `examples/programs/` -- sample ship programs (assembly) + +## Run it + +```sh +go run ./cmd/server -db sqlite:wh.db # API + website on :8080 +go run ./cmd/daily -db sqlite:wh.db # simulate one day (run once a day, e.g. from cron) +``` + +`-db` is `sqlite:` or a `postgres://` URL. The world parameters (`-seed`, +`-ticks-per-day`, `-asteroids`, ...) or their `WH_*` variables must be the same for `server` and +`daily`, and cannot be changed for an existing database. + +Open , create an account (you are shown an access key once; +it is your login), write a program, seal it into your ship, launch it, then run `daily`. + +## Docker + +```sh +docker build -t halcyon . +docker run -d -p 8080:8080 -v whdata:/data halcyon # SQLite on a volume +docker run --rm -v whdata:/data halcyon daily # simulate one day +docker run -d -p 8080:8080 -e DATABASE_URL=postgres://user:pw@db/wh halcyon # or Postgres +docker build --target test . # run the tests +``` + +With Compose, Caddy serves the site over HTTPS at `sandbox.clearsky.dev` (edit `caddy/Caddyfile` +for another name; DNS must point at the host and ports 80/443 must be open). The app itself is +also on , bound to localhost only. SQLite lives in the `halcyon-data` volume: + +```sh +docker compose up -d --build +scripts/daily.sh # one simulated day via `docker exec`; schedule it once a day +``` + +The world settings are environment variables (`WH_SEED`, `WH_TICKS_PER_DAY`, `WH_CYCLES_PER_TICK`, +`WH_PROGRAM_BYTES`, `WH_RAM_BYTES`, `WH_COMM_BYTES`, `WH_ASTEROIDS`); flags of the same name override +them. In Compose they are set once on the `web` service, so `scripts/daily.sh` always matches the +server. Change them only before a world has run. + +`DATABASE_URL` sets the database for both `server` and `daily` (default in the image: +`sqlite:/data/wh.db`); the `-db` flag overrides it. The image also contains `asm`. + +## Layout + +| path | what | +|---|---| +| `fixed`, `rng` | deterministic Q32.32 maths and PRNG | +| `vm` | the ship CPU and its assembler (`cmd/asm`) | +| `world`, `sim`, `market` | belt generation, the daily simulation, ore prices | +| `store`, `runner` | SQLite/Postgres persistence and the daily run | +| `api`, `web`, `cmd/server` | HTTP API and the browser front end | +| `examples`, `docs` | embedded sample programs and manual | + +## Tests + +`go test ./...` covers everything; set `WH_TEST_PG=postgres://...` to also run the API +test against PostgreSQL (it wipes that database's `public` schema). diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..5ecba01 --- /dev/null +++ b/api/api.go @@ -0,0 +1,289 @@ +// Package api exposes the player-facing HTTP interface. +package api + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + + "wh/comms" + "wh/config" + "wh/runner" + "wh/store" + "wh/vm" + "wh/world" +) + +type Server struct { + st *store.Store + cfg config.Config +} + +// New returns the HTTP handler. +// +// POST /register {"name": "..."} -> player, api_key, ship_id +// GET /me -> name, credits, day +// GET /market -> ore prices +// GET /info -> day and the belt's limits +// POST /assemble assembly text -> program (base64), size +// GET /ships -> your ships +// PUT /ships/{id}/program raw bytecode (ship must be in inventory) +// POST /ships/{id}/launch (enters the belt on the next run) +// PUT /ships/{id}/uplink raw bytes (<= comm size) (replaces any queued uplink) +// GET /ships/{id}/downlink raw bytes, X-Downlink-Day header +// +// Authenticated routes take "Authorization: Bearer ". +func New(st *store.Store, cfg config.Config) *http.ServeMux { + s := &Server{st: st, cfg: cfg} + mux := http.NewServeMux() + mux.HandleFunc("POST /register", s.register) + mux.HandleFunc("GET /market", s.market) + mux.HandleFunc("GET /info", s.info) + mux.HandleFunc("POST /assemble", s.assemble) + mux.Handle("GET /me", s.auth(s.me)) + mux.Handle("GET /ships", s.auth(s.ships)) + mux.Handle("PUT /ships/{id}/program", s.auth(s.program)) + mux.Handle("POST /ships/{id}/launch", s.auth(s.launch)) + mux.Handle("PUT /ships/{id}/uplink", s.auth(s.uplink)) + mux.Handle("GET /ships/{id}/downlink", s.auth(s.downlink)) + return mux +} + +func (s *Server) auth(h func(http.ResponseWriter, *http.Request, store.Player)) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok { + httpError(w, http.StatusUnauthorized, "missing bearer token") + return + } + p, err := s.st.PlayerByKey(r.Context(), key) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + httpError(w, http.StatusUnauthorized, "invalid api key") + } else { + fail(w, err) + } + return + } + h(w, r, p) + }) +} + +func httpError(w http.ResponseWriter, code int, msg string) { + writeJSON(w, code, map[string]string{"error": msg}) +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(v) +} + +// fail maps store errors to HTTP statuses. +func fail(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, store.ErrNotFound): + httpError(w, http.StatusNotFound, "not found") + case errors.Is(err, store.ErrState): + httpError(w, http.StatusConflict, err.Error()) + case errors.Is(err, store.ErrTaken): + httpError(w, http.StatusConflict, err.Error()) + default: + httpError(w, http.StatusInternalServerError, "internal error") + } +} + +func (s *Server) register(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<10)).Decode(&req); err != nil || len(req.Name) == 0 || len(req.Name) > 40 { + httpError(w, http.StatusBadRequest, `body must be {"name": "<1-40 chars>"}`) + return + } + p, key, ship, err := s.st.Register(r.Context(), req.Name) + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"player_id": p.ID, "api_key": key, "ship_id": ship}) +} + +func (s *Server) currentDay(ctx context.Context) int64 { + v, ok, _ := s.st.GetMeta(ctx, runner.MetaDay) + if !ok { + return 0 + } + n, _ := strconv.ParseInt(v, 10, 64) + return n +} + +func (s *Server) me(w http.ResponseWriter, r *http.Request, p store.Player) { + writeJSON(w, http.StatusOK, map[string]any{"name": p.Name, "credits": p.Credits, "day": s.currentDay(r.Context())}) +} + +func (s *Server) market(w http.ResponseWriter, r *http.Request) { + v, ok, err := s.st.GetMeta(r.Context(), runner.MetaMarket) + if err != nil { + fail(w, err) + return + } + if !ok { + v = "null" + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, v) +} + +// info reports the day and the limits that apply to programs and messages. +func (s *Server) info(w http.ResponseWriter, r *http.Request) { + ores := make([]string, world.NumOre) + for o := range ores { + ores[o] = world.Ore(o).String() + } + writeJSON(w, http.StatusOK, map[string]any{ + "day": s.currentDay(r.Context()), + "ticks_per_day": s.cfg.TicksPerDay, + "tick_seconds": s.cfg.TickSeconds(), + "cycles_per_tick": s.cfg.CyclesPerTick, + "program_bytes": s.cfg.ProgramBytes, + "ram_bytes": s.cfg.RAMBytes, + "comm_bytes": s.cfg.CommBytes, + "ores": ores, + }) +} + +// assemble compiles assembly source with the same assembler as cmd/asm, so +// that clients need no assembler of their own. Nothing is stored. +func (s *Server) assemble(w http.ResponseWriter, r *http.Request) { + b, ok := readBody(w, r, 64<<10) + if !ok { + return + } + prog, err := vm.Assemble(string(b)) + if err != nil { + httpError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "size": len(prog), + "instructions": len(prog) / 4, + "limit": s.cfg.ProgramBytes, + "fits": len(prog) > 0 && len(prog) <= s.cfg.ProgramBytes, + "program": base64.StdEncoding.EncodeToString(prog), + }) +} + +func (s *Server) ships(w http.ResponseWriter, r *http.Request, p store.Player) { + ships, err := s.st.PlayerShips(r.Context(), p.ID) + if err != nil { + fail(w, err) + return + } + type out struct { + ID int64 `json:"id"` + Status string `json:"status"` + ProgramSize int `json:"program_bytes"` + DownlinkDay int64 `json:"downlink_day"` + } + res := make([]out, 0, len(ships)) + for _, sh := range ships { + res = append(res, out{sh.ID, sh.Status, sh.ProgramSize, sh.DownlinkDay}) + } + writeJSON(w, http.StatusOK, res) +} + +func shipID(w http.ResponseWriter, r *http.Request) (int64, bool) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + httpError(w, http.StatusBadRequest, "bad ship id") + return 0, false + } + return id, true +} + +// readBody reads at most limit bytes and rejects larger bodies. +func readBody(w http.ResponseWriter, r *http.Request, limit int) ([]byte, bool) { + b, err := io.ReadAll(io.LimitReader(r.Body, int64(limit)+1)) + if err != nil || len(b) > limit { + httpError(w, http.StatusRequestEntityTooLarge, "body too large") + return nil, false + } + return b, true +} + +func (s *Server) program(w http.ResponseWriter, r *http.Request, p store.Player) { + id, ok := shipID(w, r) + if !ok { + return + } + b, ok := readBody(w, r, s.cfg.ProgramBytes) + if !ok { + return + } + if err := comms.CheckProgram(b, s.cfg.ProgramBytes); err != nil { + httpError(w, http.StatusBadRequest, err.Error()) + return + } + if err := s.st.SetProgram(r.Context(), p.ID, id, b); err != nil { + fail(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) launch(w http.ResponseWriter, r *http.Request, p store.Player) { + id, ok := shipID(w, r) + if !ok { + return + } + if err := s.st.Launch(r.Context(), p.ID, id); err != nil { + fail(w, err) + return + } + w.WriteHeader(http.StatusAccepted) +} + +func (s *Server) uplink(w http.ResponseWriter, r *http.Request, p store.Player) { + id, ok := shipID(w, r) + if !ok { + return + } + b, ok := readBody(w, r, s.cfg.CommBytes) + if !ok { + return + } + if err := comms.CheckUplink(b, s.cfg.CommBytes); err != nil { + httpError(w, http.StatusBadRequest, err.Error()) + return + } + if err := s.st.SetUplink(r.Context(), p.ID, id, b); err != nil { + fail(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) downlink(w http.ResponseWriter, r *http.Request, p store.Player) { + id, ok := shipID(w, r) + if !ok { + return + } + data, day, err := s.st.Downlink(r.Context(), p.ID, id) + if err != nil { + fail(w, err) + return + } + if day < 0 { + httpError(w, http.StatusNotFound, "no downlink yet") + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("X-Downlink-Day", strconv.FormatInt(day, 10)) + w.Write(data) +} diff --git a/api/api_test.go b/api/api_test.go new file mode 100644 index 0000000..5e0d30c --- /dev/null +++ b/api/api_test.go @@ -0,0 +1,160 @@ +package api_test + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "wh/api" + "wh/config" + "wh/runner" + "wh/sim" + "wh/store" + "wh/vm" +) + +func TestEndToEnd(t *testing.T) { + ctx := context.Background() + cfg := config.Default() + cfg.AsteroidCount = 20 + + // Set WH_TEST_PG to a postgres:// URL to run this against PostgreSQL + // (its public schema is wiped first). + dsn := "sqlite:" + filepath.Join(t.TempDir(), "t.db") + if pg := os.Getenv("WH_TEST_PG"); pg != "" { + db, err := sql.Open("pgx", pg) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public"); err != nil { + t.Fatal(err) + } + db.Close() + dsn = pg + } + st, err := store.Open(dsn) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(api.New(st, cfg)) + defer srv.Close() + + var key string + do := func(method, path string, body []byte, want int) []byte { + t.Helper() + req, _ := http.NewRequest(method, srv.URL+path, bytes.NewReader(body)) + if key != "" { + req.Header.Set("Authorization", "Bearer "+key) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode != want { + t.Fatalf("%s %s: status %d want %d: %s", method, path, resp.StatusCode, want, b) + } + return b + } + + var reg struct { + APIKey string `json:"api_key"` + ShipID int64 `json:"ship_id"` + } + json.Unmarshal(do("POST", "/register", []byte(`{"name":"ann"}`), 201), ®) + do("POST", "/register", []byte(`{"name":"ann"}`), 409) + do("GET", "/me", nil, 401) + key = reg.APIKey + ship := "/ships/" + itoa(reg.ShipID) + + // Launch needs a program first; program limits are enforced. + do("POST", ship+"/launch", nil, 409) + do("PUT", ship+"/program", []byte{1, 2, 3}, 400) + do("PUT", ship+"/program", make([]byte, cfg.ProgramBytes+4), 413) + + // Echo the uplink back on the downlink, then idle. + prog, err := vm.Assemble(` + .equ TX 1024 + in r1, 0x70 + ldi r2, 0 + beq r1, r2, idle + ldw r3, [r2] + stw r3, [r2+TX] + out 0x70, r1 + idle: + yield + jmp idle + `) + if err != nil { + t.Fatal(err) + } + do("PUT", ship+"/program", prog, 204) + do("PUT", ship+"/uplink", []byte("hi"), 409) // not launched yet + do("POST", ship+"/launch", nil, 202) + do("PUT", ship+"/program", prog, 409) // frozen once launching + do("PUT", ship+"/uplink", []byte("PING"), 204) + do("PUT", ship+"/uplink", make([]byte, cfg.CommBytes+1), 413) + do("GET", ship+"/downlink", nil, 404) + + res, err := runner.RunNextDay(ctx, st, cfg) + if err != nil { + t.Fatal(err) + } + if res.Day != 0 { + t.Fatalf("first run should be day 0, got %d", res.Day) + } + if b := do("GET", ship+"/downlink", nil, 200); len(b) != cfg.CommBytes || string(b[:4]) != "PING" { + t.Fatalf("downlink = %q", b[:8]) + } + var ships []struct{ Status string } + json.Unmarshal(do("GET", "/ships", nil, 200), &ships) + if len(ships) != 1 || ships[0].Status != store.StatusActive { + t.Fatalf("ships = %+v", ships) + } + do("GET", "/market", nil, 200) + + // A second day from the persisted snapshot must match an uninterrupted + // in-memory run: persistence may not change the simulation. + res2, err := runner.RunNextDay(ctx, st, cfg) + if err != nil { + t.Fatal(err) + } + mem := sim.NewState(cfg) + in := sim.DayInput{ + Launches: []sim.Launch{{ShipID: reg.ShipID, Owner: 1, Program: prog}}, + Uplinks: []sim.Uplink{{ShipID: reg.ShipID, Data: []byte("PING")}}, + } + var want sim.DayResult + for d := 0; d < 2; d++ { + if want, err = mem.RunDay(cfg, in); err != nil { + t.Fatal(err) + } + in = sim.DayInput{} + } + if res2.Hash != want.Hash { + t.Fatal("state after DB round-trip differs from in-memory run") + } + + // Changing the config on an existing world is refused. + cfg.Seed = 99 + if _, err := runner.RunNextDay(ctx, st, cfg); err == nil { + t.Fatal("expected config mismatch error") + } +} + +func itoa(n int64) string { + b, _ := json.Marshal(n) + return string(b) +} diff --git a/api/site_test.go b/api/site_test.go new file mode 100644 index 0000000..b1e9df1 --- /dev/null +++ b/api/site_test.go @@ -0,0 +1,115 @@ +package api_test + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "wh/api" + "wh/config" + "wh/store" + "wh/web" +) + +// newSite starts the API with the web front end mounted, as cmd/server does. +func newSite(t *testing.T) (*httptest.Server, config.Config) { + t.Helper() + cfg := config.Default() + st, err := store.Open("sqlite:" + filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.Migrate(context.Background()); err != nil { + t.Fatal(err) + } + mux := api.New(st, cfg) + mux.Handle("/", web.Handler()) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv, cfg +} + +func get(t *testing.T, url string) (*http.Response, string) { + t.Helper() + resp, err := http.Get(url) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp, string(b) +} + +func TestInfoAndAssemble(t *testing.T) { + srv, cfg := newSite(t) + + _, body := get(t, srv.URL+"/info") + var info struct { + ProgramBytes int `json:"program_bytes"` + CommBytes int `json:"comm_bytes"` + Ores []string `json:"ores"` + } + if err := json.Unmarshal([]byte(body), &info); err != nil { + t.Fatal(err) + } + if info.ProgramBytes != cfg.ProgramBytes || info.CommBytes != cfg.CommBytes || len(info.Ores) != 4 { + t.Fatalf("info = %+v", info) + } + + post := func(src string) (int, map[string]any) { + resp, err := http.Post(srv.URL+"/assemble", "text/plain", strings.NewReader(src)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var m map[string]any + json.NewDecoder(resp.Body).Decode(&m) + return resp.StatusCode, m + } + code, m := post("ldi r1, 5\nhalt") + if code != 200 || m["size"].(float64) != 8 || m["fits"] != true { + t.Fatalf("assemble ok case: %d %v", code, m) + } + if raw, _ := base64.StdEncoding.DecodeString(m["program"].(string)); len(raw) != 8 { + t.Fatalf("program bytes = %d", len(raw)) + } + code, m = post("ldi r1, 5\nbogus r1") + if code != 400 || !strings.Contains(m["error"].(string), "line 2") { + t.Fatalf("assemble error case: %d %v", code, m) + } + // Too big for the belt: assembles, but reports that it does not fit. + code, m = post(strings.Repeat("nop\n", cfg.ProgramBytes/4+1)) + if code != 200 || m["fits"] != false { + t.Fatalf("oversize case: %d %v", code, m) + } +} + +func TestWebAssets(t *testing.T) { + srv, _ := newSite(t) + for path, want := range map[string]string{ + "/": "", + "/manual.txt": "PROGRAMMER'S REFERENCE MANUAL", + "/examples/echo.s": "ECHO", + "/examples/index.json": `"pursue"`, + } { + resp, body := get(t, srv.URL+path) + if resp.StatusCode != 200 || !strings.Contains(body, want) { + t.Errorf("%s: status %d, missing %q", path, resp.StatusCode, want) + } + } + resp, _ := get(t, srv.URL+"/") + if !strings.Contains(resp.Header.Get("Content-Security-Policy"), "default-src 'self'") { + t.Error("missing CSP header") + } + // The API still wins over the catch-all. + if resp, _ := get(t, srv.URL+"/market"); !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") { + t.Errorf("/market served as %q", resp.Header.Get("Content-Type")) + } +} diff --git a/caddy/Caddyfile b/caddy/Caddyfile new file mode 100644 index 0000000..45699b4 --- /dev/null +++ b/caddy/Caddyfile @@ -0,0 +1,8 @@ +# TLS certificates are obtained and renewed automatically (Let's Encrypt). +# Requirements: DNS for the name below points at this host, and ports 80 and +# 443 are reachable from the internet (80 is used for the ACME challenge and +# redirects to HTTPS). +sandbox.clearsky.dev { + encode zstd gzip + reverse_proxy web:8080 +} diff --git a/cmd/asm/main.go b/cmd/asm/main.go new file mode 100644 index 0000000..fcbdc08 --- /dev/null +++ b/cmd/asm/main.go @@ -0,0 +1,28 @@ +// Command asm assembles ship-program source into the binary that the +// program endpoint accepts: asm prog.s > prog.bin +package main + +import ( + "fmt" + "os" + + "wh/vm" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: asm <source.s> > program.bin") + os.Exit(2) + } + src, err := os.ReadFile(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + prog, err := vm.Assemble(string(src)) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Stdout.Write(prog) +} diff --git a/cmd/daily/main.go b/cmd/daily/main.go new file mode 100644 index 0000000..c14e0b1 --- /dev/null +++ b/cmd/daily/main.go @@ -0,0 +1,41 @@ +// Command daily runs one simulated day against the database. Schedule it +// once a day (cron/systemd timer). +package main + +import ( + "context" + "flag" + "fmt" + "log" + + "wh/config" + "wh/runner" + "wh/store" +) + +func main() { + dsn := flag.String("db", config.DatabaseURL(), "sqlite:<path> or postgres:// URL (default: $DATABASE_URL)") + cfg, err := config.Bind(flag.CommandLine) + if err != nil { + log.Fatal(err) + } + flag.Parse() + + ctx := context.Background() + st, err := store.Open(*dsn) + if err != nil { + log.Fatal(err) + } + defer st.Close() + if err := st.Migrate(ctx); err != nil { + log.Fatal(err) + } + res, err := runner.RunNextDay(ctx, st, *cfg) + if err != nil { + log.Fatal(err) + } + fmt.Printf("day %d complete: %d events, %d downlinks, hash %x\n", res.Day, len(res.Events), len(res.Downlinks), res.Hash[:8]) + for _, e := range res.Events { + fmt.Println(" ", e) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..7b0a16a --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,40 @@ +// Command server serves the player HTTP API and the web front end. +package main + +import ( + "context" + "flag" + "log" + "net/http" + + "wh/api" + "wh/config" + "wh/store" + "wh/web" +) + +func main() { + dsn := flag.String("db", config.DatabaseURL(), "sqlite:<path> or postgres:// URL (default: $DATABASE_URL)") + addr := flag.String("addr", ":8080", "listen address") + cfg, err := config.Bind(flag.CommandLine) + if err != nil { + log.Fatal(err) + } + flag.Parse() + if err := cfg.Validate(); err != nil { + log.Fatal(err) + } + + st, err := store.Open(*dsn) + if err != nil { + log.Fatal(err) + } + defer st.Close() + if err := st.Migrate(context.Background()); err != nil { + log.Fatal(err) + } + mux := api.New(st, *cfg) + mux.Handle("/", web.Handler()) // the site; API routes are more specific and win + log.Printf("listening on %s", *addr) + log.Fatal(http.ListenAndServe(*addr, mux)) +} diff --git a/comms/comms.go b/comms/comms.go new file mode 100644 index 0000000..078b60b --- /dev/null +++ b/comms/comms.go @@ -0,0 +1,25 @@ +// Package comms validates the data that crosses the wormhole link. The +// content of uplinks and downlinks is opaque bytes defined by the player's +// ship program; only sizes are enforced. +package comms + +import "fmt" + +func CheckUplink(data []byte, limit int) error { + if len(data) > limit { + return fmt.Errorf("uplink is %d bytes, limit is %d", len(data), limit) + } + return nil +} + +func CheckProgram(prog []byte, limit int) error { + switch { + case len(prog) == 0: + return fmt.Errorf("program is empty") + case len(prog)%4 != 0: + return fmt.Errorf("program length %d is not a multiple of 4", len(prog)) + case len(prog) > limit: + return fmt.Errorf("program is %d bytes, limit is %d", len(prog), limit) + } + return nil +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..37da5fd --- /dev/null +++ b/config/config.go @@ -0,0 +1,145 @@ +// Package config holds tunable simulation parameters. +// +// Units used throughout the simulation: distance in kilometres, time in +// seconds, mass in kilograms, force in newtons. Angles are radians. +package config + +import ( + "flag" + "fmt" + "os" + "strconv" + + "wh/fixed" +) + +const SecondsPerDay = 86400 + +type Config struct { + // Seed identifies the world. The same seed and inputs always produce the + // same simulation. + Seed uint64 + + // Time resolution. + TicksPerDay int // must divide SecondsPerDay + CyclesPerTick int // VM instructions each ship may execute per tick + + // Ship computer sizes. + ProgramBytes int // max program size + RAMBytes int // data RAM, includes comm buffers + CommBytes int // size of each of the uplink and downlink buffers + + // World. + OrbitSpeed fixed.F // km/s; speed of every circular orbit (star pulls with v^2/r) + AsteroidCount int + BeltInner fixed.F // km + BeltOuter fixed.F // km + MaxInclination fixed.F // radians; asteroid inclinations are drawn up to this +} + +func Default() Config { + return Config{ + Seed: 1, + TicksPerDay: 1440, + CyclesPerTick: 2000, + ProgramBytes: 4096, + RAMBytes: 8192, + CommBytes: 1024, + OrbitSpeed: fixed.FromInt(3), + AsteroidCount: 500, + BeltInner: fixed.FromInt(1_500_000), + BeltOuter: fixed.FromInt(3_000_000), + MaxInclination: fixed.FromRatio(1, 5), // ~11.5 degrees + } +} + +// TickSeconds is the simulated duration of one tick. +func (c Config) TickSeconds() int { return SecondsPerDay / c.TicksPerDay } + +func (c Config) Validate() error { + switch { + case c.TicksPerDay <= 0 || SecondsPerDay%c.TicksPerDay != 0: + return fmt.Errorf("TicksPerDay must divide %d, got %d", SecondsPerDay, c.TicksPerDay) + case c.CyclesPerTick <= 0: + return fmt.Errorf("CyclesPerTick must be positive") + case c.ProgramBytes <= 0 || c.ProgramBytes%4 != 0: + return fmt.Errorf("ProgramBytes must be a positive multiple of 4") + case c.CommBytes <= 0 || c.RAMBytes < 2*c.CommBytes: + return fmt.Errorf("RAMBytes must hold both comm buffers") + case c.MaxInclination < 0 || c.MaxInclination > fixed.HalfPi: + return fmt.Errorf("MaxInclination must be within [0, pi/2]") + case c.OrbitSpeed <= 0: + return fmt.Errorf("OrbitSpeed must be positive") + case c.BeltInner <= 0 || c.BeltOuter <= c.BeltInner: + return fmt.Errorf("invalid belt radii") + } + return nil +} + +// Bind registers a flag for each tunable on fs and returns the config that +// the flags will fill in when fs is parsed. Each tunable can also be set from +// an environment variable (see EnvVars); a flag on the command line overrides +// the environment. A malformed environment value is an error. +func Bind(fs *flag.FlagSet) (*Config, error) { + c := Default() + if err := c.applyEnv(os.Getenv); err != nil { + return nil, err + } + fs.Uint64Var(&c.Seed, "seed", c.Seed, "world seed (env WH_SEED)") + fs.IntVar(&c.TicksPerDay, "ticks-per-day", c.TicksPerDay, "simulation ticks per day (env WH_TICKS_PER_DAY)") + fs.IntVar(&c.CyclesPerTick, "cycles-per-tick", c.CyclesPerTick, "VM cycles per ship per tick (env WH_CYCLES_PER_TICK)") + fs.IntVar(&c.ProgramBytes, "program-bytes", c.ProgramBytes, "max ship program size (env WH_PROGRAM_BYTES)") + fs.IntVar(&c.RAMBytes, "ram-bytes", c.RAMBytes, "ship RAM size (env WH_RAM_BYTES)") + fs.IntVar(&c.CommBytes, "comm-bytes", c.CommBytes, "uplink/downlink buffer size (env WH_COMM_BYTES)") + fs.IntVar(&c.AsteroidCount, "asteroids", c.AsteroidCount, "number of asteroids (env WH_ASTEROIDS)") + return &c, nil +} + +// EnvVars lists the environment variables that set the world, for documentation. +var EnvVars = []string{ + "WH_SEED", "WH_TICKS_PER_DAY", "WH_CYCLES_PER_TICK", "WH_PROGRAM_BYTES", + "WH_RAM_BYTES", "WH_COMM_BYTES", "WH_ASTEROIDS", +} + +func (c *Config) applyEnv(get func(string) string) error { + var err error + num := func(name string, dst any) { + v := get(name) + if v == "" || err != nil { + return + } + switch d := dst.(type) { + case *uint64: + var n uint64 + if n, err = strconv.ParseUint(v, 10, 64); err == nil { + *d = n + } + case *int: + var n int + if n, err = strconv.Atoi(v); err == nil { + *d = n + } + } + if err != nil { + err = fmt.Errorf("%s=%q: %w", name, v, err) + } + } + num("WH_SEED", &c.Seed) + num("WH_TICKS_PER_DAY", &c.TicksPerDay) + num("WH_CYCLES_PER_TICK", &c.CyclesPerTick) + num("WH_PROGRAM_BYTES", &c.ProgramBytes) + num("WH_RAM_BYTES", &c.RAMBytes) + num("WH_COMM_BYTES", &c.CommBytes) + num("WH_ASTEROIDS", &c.AsteroidCount) + return err +} + +// DatabaseURL returns the default database location: $DATABASE_URL if set, +// else a SQLite file in the working directory. The -db flag overrides it. +// Accepted forms are "sqlite:<path>" and "postgres://...". +func DatabaseURL() string { + if v := os.Getenv("DATABASE_URL"); v != "" { + return v + } + return "sqlite:wh.db" +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..81e080a --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,73 @@ +package config + +import ( + "flag" + "testing" +) + +func bind(t *testing.T, args ...string) (*Config, error) { + t.Helper() + fs := flag.NewFlagSet("t", flag.ContinueOnError) + c, err := Bind(fs) + if err != nil { + return nil, err + } + if err := fs.Parse(args); err != nil { + t.Fatal(err) + } + return c, nil +} + +func TestBindDefaults(t *testing.T) { + c, err := bind(t) + if err != nil || *c != Default() { + t.Fatalf("got %+v, %v", c, err) + } +} + +func TestBindEnvAndFlagPrecedence(t *testing.T) { + t.Setenv("WH_SEED", "42") + t.Setenv("WH_TICKS_PER_DAY", "720") + t.Setenv("WH_ASTEROIDS", "60") + + c, err := bind(t) + if err != nil { + t.Fatal(err) + } + if c.Seed != 42 || c.TicksPerDay != 720 || c.AsteroidCount != 60 || c.CyclesPerTick != Default().CyclesPerTick { + t.Fatalf("env not applied: %+v", c) + } + if err := c.Validate(); err != nil { + t.Fatal(err) + } + + c, err = bind(t, "-seed", "7") + if err != nil || c.Seed != 7 || c.TicksPerDay != 720 { + t.Fatalf("flag should override env only for itself: %+v, %v", c, err) + } +} + +func TestBindRejectsBadEnv(t *testing.T) { + for _, v := range []string{"lots", "-3", "1.5"} { + t.Setenv("WH_SEED", v) + if _, err := bind(t); err == nil { + t.Errorf("WH_SEED=%q should be rejected", v) + } + } + t.Setenv("WH_SEED", "") + t.Setenv("WH_COMM_BYTES", "big") + if _, err := bind(t); err == nil { + t.Error("WH_COMM_BYTES=big should be rejected") + } +} + +func TestDatabaseURL(t *testing.T) { + t.Setenv("DATABASE_URL", "") + if DatabaseURL() != "sqlite:wh.db" { + t.Fatal("default") + } + t.Setenv("DATABASE_URL", "postgres://x") + if DatabaseURL() != "postgres://x" { + t.Fatal("env") + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..df592d5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +services: + web: + build: . + image: halcyon:latest + # A fixed name lets scripts/daily.sh find the container with `docker exec`. + container_name: halcyon + restart: unless-stopped + ports: + # Local only: the public entrance is Caddy, which terminates TLS. + - "127.0.0.1:8070:8080" + environment: + DATABASE_URL: sqlite:/data/wh.db + # The world. `docker exec ... daily` inherits these, so the daily job always + # matches the server. A world refuses changed settings once it has run, so + # only change them on a fresh volume. Defaults shown; override in .env. + WH_SEED: ${WH_SEED:-1} + WH_TICKS_PER_DAY: ${WH_TICKS_PER_DAY:-1440} + WH_CYCLES_PER_TICK: ${WH_CYCLES_PER_TICK:-2000} + WH_PROGRAM_BYTES: ${WH_PROGRAM_BYTES:-4096} + WH_RAM_BYTES: ${WH_RAM_BYTES:-8192} + WH_COMM_BYTES: ${WH_COMM_BYTES:-1024} + WH_ASTEROIDS: ${WH_ASTEROIDS:-500} + volumes: + - halcyon-data:/data + command: ["server"] + + caddy: + image: caddy:2-alpine + container_name: halcyon-caddy + restart: unless-stopped + depends_on: + - web + ports: + - "80:80" + - "443:443" + - "443:443/udp" # HTTP/3 + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data # certificates: keep this, or you will hit rate limits + - caddy-config:/config + +volumes: + halcyon-data: + caddy-data: + caddy-config: diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..49e9ac2 --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,7 @@ +// Package docs embeds the HC-33 programmer's manual. +package docs + +import _ "embed" + +//go:embed manual.txt +var Manual []byte diff --git a/docs/manual.txt b/docs/manual.txt new file mode 100644 index 0000000..19ce531 --- /dev/null +++ b/docs/manual.txt @@ -0,0 +1,1556 @@ + + + + +================================================================================ + + H A L C Y O N I N S T R U M E N T & C O N T R O L + +================================================================================ + + + + + MODEL HC-33 FLIGHT COMPUTER + + PROGRAMMER'S REFERENCE MANUAL + + Including the Ship Peripheral Interface + + + + + +----------------------------------+ + | | + | 32-BIT * 16 REGISTERS | + | 35 INSTRUCTIONS * 8 KB RAM | + | 1 KB DAILY WORMHOLE LINK | + | | + +----------------------------------+ + + + + + Publication No. HIC-0033-A + First Edition + + For use with command ships of the Halcyon + Prospector class and compatible hulls. + + + + + RETAIN THIS MANUAL WITH THE SHIP RECORDS. + THE COMPUTER CANNOT BE REPROGRAMMED + AFTER LAUNCH. READ BEFORE YOU FLY. + + + + +-------------------------------------------------------------------------------- +NOTICE + +The information in this manual is subject to change at the discretion of +Halcyon Instrument & Control. Constants quoted as "standard" are the values +set at the factory; belt operators may adjust them. Halcyon accepts no +liability for ships lost to the Star, to the Belt, or to programmer error. +The last of these is by far the most common. + +Halcyon and the Halcyon crescent are trademarks of Halcyon Instrument & +Control. All other names are the property of their respective owners. +-------------------------------------------------------------------------------- + + + + +================================================================================ +CONTENTS +================================================================================ + + 1. INTRODUCTION + 1.1 What the HC-33 Is + 1.2 Specifications + 1.3 A Day in the Life of a Ship + 1.4 Conventions Used in This Manual + + 2. ARCHITECTURE + 2.1 Registers + 2.2 Program Memory + 2.3 Data Memory and the Memory Map + 2.4 The Stack + 2.5 Execution, Ticks and the Cycle Budget + 2.6 Processor States + + 3. INSTRUCTION SET + 3.1 Instruction Format + 3.2 Summary of Instructions + 3.3 Instruction Reference + 3.4 Missing Instructions and How to Live Without Them + + 4. THE ASSEMBLER + 4.1 Source Format + 4.2 Directives and Pseudo-Instructions + 4.3 Numbers and Names + 4.4 Running the Assembler + + 5. PERIPHERALS + 5.1 The Port Interface + 5.2 Units and Conventions + 5.3 System Clock + 5.4 Navigation Unit + 5.5 Main Engine + 5.6 Scanner + 5.7 Mining Laser and Cargo Hold + 5.8 Dropoff Station and Market + 5.9 Wormhole Link + 5.10 Math Coprocessor + + 6. OPERATIONS + 6.1 Life Cycle of a Ship + 6.2 The Daily Run + 6.3 Ground Interface + + 7. THE SHIP AND ITS ENVIRONMENT + 7.1 The Prospector Hull + 7.2 Space, Orbits and the Star + 7.3 Fuel and Delta-V + + 8. PROGRAMMING NOTES + + 9. SAMPLE PROGRAMS + + APPENDIX A Opcode Table + APPENDIX B Port Map + APPENDIX C Standard Equate File + APPENDIX D Fault Conditions + APPENDIX E Quick Reference Card + + + + +================================================================================ +CHAPTER 1 INTRODUCTION +================================================================================ + +1.1 WHAT THE HC-33 IS +------------------------ + +The HC-33 is the flight computer fitted to every Halcyon command ship. It is +a small, slow, utterly predictable 32-bit processor. It has no operating +system, no clock interrupts and no operator. Once your ship leaves the dock +the HC-33 is the only intelligence on board. + +You cannot fly the ship. You can only tell the computer, in advance, how to +fly the ship. The program is written by you, assembled on the ground into +raw machine code, and sealed into the computer's program memory before launch. +From that moment it is fixed for the life of the ship. + +The computer talks to the outside world through two channels: + + o PERIPHERAL PORTS, which connect it to the engine, scanner, mining laser + and the rest of the ship. (Chapter 5.) + + o THE WORMHOLE LINK, a message channel through which you may send the + computer exactly one kilobyte per day and receive exactly one kilobyte + per day. The link has no delay. The content is entirely up to you and + your program. (Section 5.9.) + +Because the link is so narrow, a good program is one that can run the ship +for days on its own and needs only a few bytes of guidance from home. + + +1.2 SPECIFICATIONS +------------------------ + + Word size ............................ 32 bits, two's complement + Registers ............................ 16 general purpose (r15 = stack ptr) + Instruction length ................... 4 bytes, fixed + Instruction set ...................... 35 instructions + Program memory (ROM) ................. 4096 bytes standard (1024 words) + Data memory (RAM) .................... 8192 bytes standard + Wormhole buffers ..................... 1024 bytes uplink, 1024 bytes downlink + Cycle budget ......................... 2000 cycles per tick, standard + Ticks per day ........................ 1440 standard (one tick = 60 s) + Effective speed ...................... 33.3 cycles per second of ship time + Cycles per day ....................... 2,880,000 + I/O .................................. IN and OUT to 16-bit port numbers + Byte order ........................... little-endian + + NOTE: The program size, RAM size, cycle budget and tick rate are settings + of the belt, not of the computer. Your belt operator can tell you the + values in force. This manual quotes the standard values. + + +1.3 A DAY IN THE LIFE OF A SHIP +------------------------ + +Once every day the belt is simulated from start to finish. For your ship the +day runs as follows: + + 1. Any waiting uplink is placed in the computer's uplink buffer. + 2. The day is divided into ticks. On each tick, the computer runs + until it executes a YIELD or uses up its cycle budget. + 3. After the computer has stopped, the ship's physical world advances by + one tick: the engine fires, the Star pulls, the ship moves, the mining + laser works. + 4. After the last tick the contents of the downlink buffer are captured + and made available to you. + +At standard settings that is 1440 rounds of "think, then move". Between days +nothing happens to your ship: it is frozen with its registers and memory intact. + + +1.4 CONVENTIONS USED IN THIS MANUAL +------------------------ + + ra, rb ......... any of the sixteen registers r0..r15 + imm ............ a signed 16-bit immediate value, -32768 .. 32767 + port ........... a port number, 0..32767 (all standard ports are below 256) + [rb+imm] ....... the RAM byte address obtained by adding imm to register rb + <- ............. "is assigned" + 0x ............. prefix for hexadecimal numbers + +Text in this manual marked NOTE is helpful. Text marked CAUTION describes +something that can cost you a ship. + + + + +================================================================================ +CHAPTER 2 ARCHITECTURE +================================================================================ + +The HC-33 is a HARVARD MACHINE: program and data live in separate memories. +The program cannot be read or written by the program itself. + + +2.1 REGISTERS +------------------------ + +There are sixteen 32-bit registers, r0 through r15. They are interchangeable +except for r15, which the CALL, RET, PUSH and POP instructions use as the +STACK POINTER. The assembler accepts the name SP as a synonym for r15. + +There is also a PROGRAM COUNTER, which holds the byte address of the next +instruction. It cannot be read or written directly; it is changed only by +jumps, branches, CALL and RET. + +There are no condition flags. Comparison and branching are a single +operation (BEQ, BNE, BLT, BGE), so no state is left behind. + +At launch every register is zero, except r15, which holds the size of RAM +(8192) so that the stack starts empty at the top of memory. + + +2.2 PROGRAM MEMORY +------------------------ + +The program occupies up to 4096 bytes (1024 instructions) starting at byte +address 0. Each instruction is four bytes, so a program is always a whole +number of words. Program addresses are byte addresses and are always a +multiple of four. + +If execution runs off the end of the program the computer HALTS. This is not +a fault. + + +2.3 DATA MEMORY AND THE MEMORY MAP +------------------------ + +RAM is byte addressable from address 0 and is standard 8192 bytes. Words and +half-words are stored least-significant byte first. No alignment is +required. Any access outside RAM is a FAULT. + + +-------------------------+ 8192 + | | + | STACK (grows down) | r15 starts here + | | | + | v | + | | + | . . . . . . . | + | | + | GENERAL DATA | + | (about 6 KB) | + +-------------------------+ 2048 + | DOWNLINK BUFFER (TX) | + | 1024 bytes | + +-------------------------+ 1024 + | UPLINK BUFFER (RX) | + | 1024 bytes | + +-------------------------+ 0 + +The two wormhole buffers are ordinary RAM. The link hardware writes the +uplink into the bottom kilobyte before the first tick of the day and copies +the second kilobyte out at the end of the day. Your program may read and +write both buffers freely. The buffer boundaries are 0, 1024 and 2048 at +standard settings. + +RAM is not cleared between days, and there is no reset. + +CAUTION: There is no memory protection. A runaway stack, or a store through +a bad pointer, will happily overwrite your own buffers or data. + + +2.4 THE STACK +------------------------ + +The stack grows downward from the top of RAM. PUSH first subtracts four from +r15 and then stores a word at the new address. POP loads the word at r15 and +then adds four. CALL pushes the address of the instruction following it and +jumps. RET pops an address and jumps to it. + +Popping from an empty stack, or pushing past the bottom of RAM, is a fault. +Nothing stops the stack from growing down into your data. + + +2.5 EXECUTION, TICKS AND THE CYCLE BUDGET +------------------------ + +Time on the HC-33 is measured in TICKS. On each tick the computer is given a +CYCLE BUDGET, standard 2000 cycles, and runs until one of these happens: + + o the program executes YIELD; + o the budget is used up; + o the program HALTs or FAULTs. + +Most instructions cost one cycle. MUL costs two. DIV and MOD cost eight. +The budget is checked before each instruction, so the last instruction of a +tick may overrun the budget by a few cycles. + +WHEN THE BUDGET RUNS OUT THE COMPUTER IS NOT RESET. It is simply stopped +where it was, and on the next tick it carries on from the very next +instruction. A long calculation is therefore spread across as many ticks as +it needs. Meanwhile the ship goes on moving, which may or may not be what +you want. + +YIELD ends the computer's turn immediately and gives up the rest of the +budget. The next tick begins with the instruction after the YIELD. A +typical control program is a loop that reads sensors, decides, writes the +controls, YIELDs, and jumps back to the top. + +CAUTION: The ship's controls are LATCHED. Throttle, azimuth and pitch keep +whatever value was last written to them until you write another. A ship whose +computer has halted or faulted continues to burn at its last setting until it +runs out of fuel. + + +2.6 PROCESSOR STATES +------------------------ + +At any moment the computer is in one of four states: + + RUNNING Executing, or interrupted by the end of its budget. + YIELDED Gave up the rest of the tick with YIELD. Resumes next tick. + HALTED Executed HALT or ran off the end of the program. Permanent. + FAULTED Executed an illegal instruction or made an illegal access. + Permanent. + +HALTED and FAULTED are final. The processor never runs again, its engine +settings are frozen, and there is no way to restart it. Appendix D lists the +fault conditions. + + + + +================================================================================ +CHAPTER 3 INSTRUCTION SET +================================================================================ + +3.1 INSTRUCTION FORMAT +------------------------ + +Every instruction is one 32-bit word: + + 31 16 15 12 11 8 7 0 + +--------------------+--------+--------+-----------------+ + | IMM16 | RA | RB | OPCODE | + +--------------------+--------+--------+-----------------+ + + OPCODE bits 0-7 which instruction (Appendix A) + RB bits 8-11 second register field + RA bits 12-15 first register field + IMM16 bits 16-31 signed 16-bit immediate, sign-extended to 32 bits + +The word is stored in program memory least-significant byte first, so the +four bytes of an instruction appear in the file as: + + byte 0 = opcode byte 1 = (RA * 16) + RB bytes 2-3 = IMM16, low first + +Unused fields are zero. You will normally never build these words by hand; +the assembler does it for you. + +Jump and branch offsets are counted in INSTRUCTIONS, not bytes, and are +relative to the instruction FOLLOWING the jump. An offset of zero therefore +continues at the next instruction, and an offset of -1 jumps to the +instruction just executed. The reach is 32K instructions either way, which +is more than the whole program memory. + + +3.2 SUMMARY OF INSTRUCTIONS +------------------------ + + CONTROL NOP YIELD HALT JMP BEQ BNE BLT BGE CALL RET + LOAD CONSTANT LDI LUI + REGISTER MOV ADD SUB MUL DIV MOD AND OR XOR + SHL SHR SAR ADDI + STACK PUSH POP + MEMORY LDB LDH LDW STB STH STW + INPUT/OUTPUT IN OUT + + +3.3 INSTRUCTION REFERENCE +------------------------ + +All arithmetic is 32-bit two's complement and WRAPS on overflow. No +instruction sets flags. + +NOP 1 cycle + No operation. + +YIELD 1 cycle + End this tick. Execution resumes with the next instruction on the next + tick. + +HALT 1 cycle + Stop the computer permanently. + +LDI ra, imm 1 cycle + ra <- imm (sign-extended) + Loads a constant from -32768 to 32767. + +LUI ra, imm 1 cycle + ra <- (imm << 16) OR (ra AND 0xFFFF) + Replaces the upper half of ra and keeps the lower half. LDI followed by + LUI builds any 32-bit constant; the assembler's LI does exactly this. + +MOV ra, rb 1 cycle + ra <- rb + +ADD ra, rb 1 cycle + ra <- ra + rb + +SUB ra, rb 1 cycle + ra <- ra - rb + +MUL ra, rb 2 cycles + ra <- ra * rb (low 32 bits of the product) + +DIV ra, rb 8 cycles + ra <- ra / rb (signed, truncated toward zero) + Dividing by zero is a FAULT. Dividing by -1 negates ra. + +MOD ra, rb 8 cycles + ra <- ra REM rb (signed; the sign of the result follows ra) + Modulus by zero is a FAULT. Modulus by -1 gives zero. + +AND ra, rb 1 cycle +OR ra, rb 1 cycle +XOR ra, rb 1 cycle + Bitwise operations: ra <- ra AND/OR/XOR rb. + +SHL ra, rb 1 cycle +SHR ra, rb 1 cycle +SAR ra, rb 1 cycle + Shift ra left (SHL), right logical with zero fill (SHR) or right + arithmetic with sign fill (SAR). The shift count is the low five bits + of rb, so only counts 0..31 are possible. + +ADDI ra, imm 1 cycle + ra <- ra + imm (imm sign-extended) + +JMP imm 1 cycle + PC <- next instruction + imm * 4 + +BEQ ra, rb, imm 1 cycle +BNE ra, rb, imm 1 cycle +BLT ra, rb, imm 1 cycle +BGE ra, rb, imm 1 cycle + If the condition holds, PC <- next instruction + imm * 4. + Conditions: ra = rb, ra <> rb, ra < rb, ra >= rb. BLT and BGE compare as + SIGNED numbers. In assembly source, imm is normally a label. + +CALL imm 1 cycle + Push the address of the next instruction, then jump as for JMP. + Faults if the stack cannot be pushed. + +RET 1 cycle + Pop an address from the stack and jump to it. Faults on an empty stack. + +PUSH ra 1 cycle + r15 <- r15 - 4; memory word at [r15] <- ra + +POP ra 1 cycle + ra <- memory word at [r15]; r15 <- r15 + 4 + +LDB ra, [rb+imm] 1 cycle +LDH ra, [rb+imm] 1 cycle +LDW ra, [rb+imm] 1 cycle + Load a byte, half-word (2 bytes) or word (4 bytes) from RAM address + rb + imm. Bytes and half-words are ZERO-EXTENDED. Fault if any part of + the access lies outside RAM. + +STB ra, [rb+imm] 1 cycle +STH ra, [rb+imm] 1 cycle +STW ra, [rb+imm] 1 cycle + Store the low byte, half-word or word of ra at RAM address rb + imm. + Note that ra is the SOURCE. Fault if outside RAM. + +IN ra, port 1 cycle + ra <- the value of the peripheral port. Ports that do not exist read + as zero. + +OUT port, ra 1 cycle + Write ra to the peripheral port. Writes to ports that do not exist, or + that are read-only, are ignored. + +NOTE: In IN and OUT the port number occupies the immediate field. Port +numbers should be kept below 32768. + + +3.4 MISSING INSTRUCTIONS AND HOW TO LIVE WITHOUT THEM +------------------------ + +The HC-33 is a small machine. Everything below is done with what exists. + + Negate ra ........... ldi r9, 0 / sub r9, ra / mov ra, r9 + Invert bits ......... ldi r9, -1 / xor ra, r9 + Zero a register ..... ldi ra, 0 + Branch if ra > rb ... blt rb, ra, label + Branch if ra <= rb .. bge rb, ra, label + Branch if ra = 0 .... keep a zero in some register and use BEQ + Compare to constant . load the constant into a register first + Absolute value ...... ldi r9, 0 / bge ra, r9, skip / sub r9, ra / mov ra, r9 + Unsigned compare .... not available; keep values below 2^31 + Computed jump ....... not available; use a chain of compares + +A register may be used for an unusual purpose provided you keep track of it. +Conventional practice: r0 as a permanent zero for base addressing, r1-r7 as +scratch, r8-r14 as saved values, r15 as the stack pointer. This is a habit, +not a rule. + + + + +================================================================================ +CHAPTER 4 THE ASSEMBLER +================================================================================ + +Programs are written in assembly language and converted to machine code by +the assembler ASM. The output is the raw binary program that is uplinked to +the ship (Chapter 6). + +4.1 SOURCE FORMAT +------------------------ + +One statement per line. A line may hold a label, an instruction, or both. + + label: mnemonic operand, operand ; comment + + o Case does not matter in mnemonics and register names. It DOES matter in + labels and constant names. + o Comments start with a semicolon (;) or a hash (#) and run to the end of + the line. + o Labels end with a colon and may stand alone on a line. A label names + the position of the next instruction. + o Operands are separated by commas. + o Memory operands are written [rb], [rb+off] or [rb-off]. There must be + no arithmetic beyond a single register and a single offset. + o Registers are r0 to r15. SP is another name for r15. + +Examples: + + start: ldi r1, 100 + add r1, r2 + beq r1, r2, start + ldw r3, [r2+8] + stb r3, [sp-4] + in r4, 0x10 + out 0x20, r4 + +4.2 DIRECTIVES AND PSEUDO-INSTRUCTIONS +------------------------ + +.EQU name value + Defines a constant. The name may then be used in place of a number + anywhere a number is expected, including as a port number or a memory + offset. A definition may appear anywhere, but its value may refer only + to constants defined on earlier lines; in practice put all .EQU lines at + the top. + +LI rd, value + Load a full 32-bit constant. The assembler always generates TWO + instructions (LDI then LUI) so that labels and branch distances are never + surprised. The value must be a number or a constant name, not a label. + +Jump, branch and CALL instructions take a label. They also accept a plain +number, which is then used unchanged as the instruction offset. + +LDI, LUI and ADDI accept values from -32768 to 65535. + +CAUTION: The immediate is stored in 16 bits and SIGN-EXTENDED by the machine. +LDI r1, 65535 therefore loads -1, and ADDI r1, 40000 subtracts 25536. If you +want a large positive value, use LI. + +4.3 NUMBERS AND NAMES +------------------------ + +Numbers may be written in decimal (100, -7), hexadecimal (0x64) or binary +(0b1100100). A number with a leading zero and no other prefix is read as +OCTAL: 010 is eight, not ten. This has cost many programmers an afternoon. + +There is no expression evaluation. Write 1028, not 1024+4. + +4.4 RUNNING THE ASSEMBLER +------------------------ + + asm prog.s > prog.bin + +reads the source file and writes the binary program to standard output. +Errors are reported with the line number and the assembler stops at the first. +The size of the file must be a multiple of four (it always is) and must not +exceed the program memory of your belt (4096 bytes standard). The ground +interface rejects programs that are too large. + +Typical assembler messages: + + line 12: unknown mnemonic "lod" + line 15: bad register "r16" + line 20: immediate 70000 out of 16-bit range (use li) + line 31: bad number or unknown name "LOOP" + line 40: offset 40000 out of range + + + + +================================================================================ +CHAPTER 5 PERIPHERALS +================================================================================ + +5.1 THE PORT INTERFACE +------------------------ + +Everything the ship can sense or do is reached through PORTS. A port is a +numbered 32-bit register outside the computer's memory, accessed with IN and +OUT. + + in r1, 0x10 ; read port 0x10 (position X) into r1 + out 0x20, r2 ; write r2 to port 0x20 (throttle) + +Ports come in two kinds, and it matters which: + + o An INPUT port can be read. Writing to it has no effect. + o An OUTPUT port can be written. READING AN OUTPUT PORT RETURNS ZERO, + not the last value written. If you need to remember what you sent, + keep a copy in a register or in RAM. + +A port that does not exist reads as zero and ignores writes. No port +operation ever faults. Port numbers are grouped by function in blocks of 16 +(0x10), as listed below and collected in Appendix B. + +There are no interrupts. The computer must poll. + +5.2 UNITS AND CONVENTIONS +------------------------ + +Every port value is a signed 32-bit integer. There are no fractions. + + Distances and positions ...... kilometres (km) + Velocities ................... metres per second (m/s) + Angles ....................... milliradians (1000 = 1 radian, 3142 = pi) + Mass ......................... kilograms (kg) + Time ......................... ticks, and days + Money ........................ credits + +Fractional readings are ROUNDED DOWN (toward minus infinity). A position of +-0.4 km reads as -1; 0.9 km reads as 0. So position is known to no better +than one kilometre and velocity to no better than one metre per second. + +COORDINATES. The frame is fixed in space and centred on the Star. It is +right-handed. The X and Y axes lie in the ECLIPTIC, the plane in which the +Station orbits. The Z axis is perpendicular to it, positive "north". + + AZIMUTH is the direction in the XY plane, measured from +X towards +Y. + ELEVATION (called PITCH on the engine) is the angle above the XY plane, + positive towards +Z. + + Azimuth 0, pitch 0 ........ along +X + Azimuth 1571, pitch 0 ..... along +Y + Azimuth any, pitch 1571 ... along +Z + +5.3 SYSTEM CLOCK +------------------------ + + 0x00 TICK input Tick number within the current day, 0 upwards. + 0x01 DAY input Day number. The first day of the belt is day 0. + 0x02 TICKS input Ticks per day (standard 1440). + +The ship does not have a wristwatch. These three ports are how a program +knows when it is. Tick length is 86400 divided by TICKS seconds. + + +5.4 NAVIGATION UNIT +------------------------ + +Reports the ship's position relative to the Star, and its velocity. + + 0x10 POSX input Position X, km + 0x11 POSY input Position Y, km + 0x12 POSZ input Position Z, km + 0x13 VELX input Velocity X, m/s + 0x14 VELY input Velocity Y, m/s + 0x15 VELZ input Velocity Z, m/s + +The belt lies between 1,500,000 and 3,000,000 km from the Star, so the +positions fit comfortably in a word. Velocity is the ship's velocity in the +fixed frame, not relative to anything else. + + +5.5 MAIN ENGINE +------------------------ + +The engine produces thrust along a direction chosen by two angles, at a power +chosen by the throttle. + + 0x20 THROTTLE output Engine power, 0 to 1000 (permille of full thrust). + Values above 1000 act as 1000; zero or negative + switches the engine off. + 0x21 AZIMUTH output Thrust direction azimuth, milliradians. + 0x22 PITCH output Thrust direction elevation, milliradians. + 0x23 FUEL input Fuel remaining, kg. + 0x24 MASS input Total ship mass (hull + fuel + cargo), kg. + +THE ENGINE FIRES ALONG THE DIRECTION GIVEN, for the whole of the tick, at +the throttle in effect when the computer stopped. Direction is: + + ( cos(pitch) cos(azimuth), cos(pitch) sin(azimuth), sin(pitch) ) + +The three controls are latched, as described in section 2.5. Set them once +and the ship keeps burning. Set THROTTLE to zero to coast. + +Thrust and fuel. For a Prospector at standard tick length: + + Full thrust ................ 6000 newtons + Exhaust velocity ........... 30,000 m/s + Fuel used at full throttle . 12 kg per tick (thrust x tick / exhaust vel.) + Acceleration ............... thrust / mass, 0.33 to 0.75 m/s^2 + +Fuel use is proportional to throttle. When fuel runs out the engine falls +silent; the throttle setting is remembered but has no effect. If the tank has +less fuel than a full tick needs, the engine delivers a proportionally +shortened burn. + +CAUTION: Fuel is your ship's only means of changing course. There is no +way to refuel. See section 7.3 before designing a manoeuvre. + + +5.6 SCANNER +------------------------ + +The scanner tracks one asteroid at a time, which you choose by number. +Asteroids are numbered from 1. The standard belt has 500. The scanner has +unlimited range: every asteroid in the belt is visible from anywhere in it. + + 0x30 SELECT output Asteroid number to track. Zero, or a number that + does not exist, clears the selection. + 0x31 NEAREST input Number of the asteroid nearest to the ship. + +The following ports describe the TRACKED asteroid and all read zero if none +is tracked. + + 0x32 RELX input Position of the target relative to the ship, X, km + 0x33 RELY input ... Y, km + 0x34 RELZ input ... Z, km + 0x35 RELVX input Velocity of the target relative to the ship, X, m/s + 0x36 RELVY input ... Y, m/s + 0x37 RELVZ input ... Z, m/s + 0x38 DIST input Straight-line distance to the target, km + 0x40 ORE0 input Iron remaining in the target, kg + 0x41 ORE1 input Nickel remaining, kg + 0x42 ORE2 input Ice remaining, kg + 0x43 ORE3 input Platinum remaining, kg + (0x44-0x47 exist for future ores and read zero.) + +Relative values are TARGET MINUS SHIP: RELX is positive if the target is on +the +X side of you. To fly towards the target, point the engine along +(RELX, RELY, RELZ). To match its velocity, apply a thrust along +(RELVX, RELVY, RELVZ). + +NEAREST is decided among all asteroids by true three-dimensional distance; +if two are exactly equally near, the lower number wins. + +NOTE: Every asteroid in the belt moves at the same speed, 3 km/s (section +7.2). For a ship travelling at that speed, the velocity of a target relative +to the ship is therefore decided by the difference in DIRECTION of travel, not +by any difference in speed. + + +5.7 MINING LASER AND CARGO HOLD +------------------------ + + 0x50 MINE output Non-zero: mine the tracked asteroid. Zero: stop. + 0x51 CARGO input Total mass in the hold, kg. + 0x52 CARGOCAP input Capacity of the hold, kg (Prospector: 6000). + 0x58 CARGO0 input Iron in the hold, kg + 0x59 CARGO1 input Nickel in the hold, kg + 0x5A CARGO2 input Ice in the hold, kg + 0x5B CARGO3 input Platinum in the hold, kg + (0x5C-0x5F exist for future ores and read zero.) + +The laser stays on, once MINE has been set, until MINE is set to zero. In +each tick in which ALL of the following are true, it moves ore from the +asteroid into the hold: + + o an asteroid is tracked and still has ore; + o the asteroid is within 5 km of the ship; + o the ship's velocity relative to the asteroid is at most 100 m/s; + o the hold is not full. + +The Prospector mines 10 kg per tick. The ore taken is a mixture in the same +proportions as the asteroid's remaining ore. You cannot choose what to mine. +The conditions are tested at the END of the tick, after the ship has moved. + +To fill an empty hold takes 600 ticks, 10 hours of ship time. + +Ore has to be brought to the Station before it is worth anything. An asteroid +does not replenish. + +NOTE: The scanner reports whole kilometres (rounded down). The range to +mine is 5 km. Aim for the middle of the range; do not try to close to the +last kilometre. + + +5.8 DROPOFF STATION AND MARKET +------------------------ + +The Station is the belt's only dropoff point. It sits on a circular orbit in +the ecliptic, half way through the belt, at a radius of 2,250,000 km. Your +ship is launched from it. + + 0x60 STNX input Position of the Station relative to the ship, X, km + 0x61 STNY input ... Y, km + 0x62 STNZ input ... Z, km + 0x63 STNVX input Velocity of the Station relative to the ship, X, m/s + 0x64 STNVY input ... Y, m/s + 0x65 STNVZ input ... Z, m/s + 0x66 SELL output Non-zero: sell the entire hold. + 0x67 EARNED input Credits earned by this ship so far + +As with the scanner, relative values are STATION MINUS SHIP. + +Writing a non-zero value to SELL sells everything in the hold at once, if +ALL of the following are true, and otherwise does nothing (there is no error +signal): + + o the ship is within 20 km of the Station; + o its velocity relative to the Station is at most 100 m/s; + o the hold is not empty. + +The hold is emptied, the credits go to your account, and EARNED goes up. + +PRICES. Each ore has a standard price per kilogram. The price FALLS as ore +floods the market and recovers as it is used up. Prices are set once per day +and do not change during the day, so it does not matter when in the day you +sell. + + ore number standard price price is halved at + (credits per kg) supply of (kg) + ---------------------------------------------------------------- + iron 0 2 400,000 + nickel 1 6 200,000 + ice 2 3 300,000 + platinum 3 300 5,000 + + price = standard price x half-point / (half-point + supply) + +"Supply" is a running total of the ore sold by everyone. At the end of each +day today's sales are added to it and 10 percent of the old total is +forgotten. A price never falls below one credit. Current prices are +available from the ground interface (section 6.3). Your program cannot read +them: it must be told, by uplink, if it should care. + + +5.9 WORMHOLE LINK +------------------------ + +The link is a pair of one-kilobyte buffers in RAM and two ports. + + 0x70 UPNEW input 1 if an uplink arrived for today and has not been + acknowledged, else 0. + UPNEW output Any value: acknowledge (clear the flag). + 0x71 UPLEN input Length of today's uplink in bytes, 0 to 1024. + +UPLINK. Before tick 0 of the day, the uplink is copied to RAM addresses 0 to +UPLEN-1. Bytes beyond the end of the message are left as they were. UPNEW is +set and stays set until acknowledged or until the day ends. An uplink is +meant for the day on which it arrives; the flag is cleared at the end of every +day whether or not you noticed. + +If you send more than one uplink in a day, only the last is delivered. + +DOWNLINK. At the end of the last tick of the day the ENTIRE transmit buffer, +RAM addresses 1024 to 2047, is captured and made available to you. It is +always exactly 1024 bytes. Whatever is in it is sent; the link does not care +whether it was written today. + +The wormhole has no delay and is not affected by distance. It is affected by +size: the buffers are all you have. The format of the data is entirely your +own invention. The link does not check it, compress it, or understand it. + +NOTE: A ship that is destroyed sends no more downlinks. Its last message +remains on file. A ship that has merely halted still sends its buffer, +unchanged, every day. + + +5.10 MATH COPROCESSOR +------------------------ + +The HC-33 cannot take a square root or an arctangent. The coprocessor +can, at no cost in cycles. Write the operands, then read the answer. + + 0x80 MATHX output Operand x + 0x81 MATHY output Operand y + 0x82 MATHZ output Operand z + 0x83 ATAN2 input atan2(y, x), in milliradians (-3142 to 3141) + 0x84 HYPOT input sqrt(x*x + y*y) + 0x85 NORM3 input sqrt(x*x + y*y + z*z) + +Operands are whole numbers, and results are rounded down. The internal +arithmetic is wide enough that squaring even large operands cannot overflow, +so you may feed it positions in kilometres directly. ATAN2 of (0, 0) is zero. + +To find the direction from the ship to a target (dx, dy, dz): + + azimuth = ATAN2 with x = dx, y = dy + range = HYPOT with x = dx, y = dy + pitch = ATAN2 with x = range, y = dz + +Sample program 3 (Chapter 9) does exactly this. + + + + +================================================================================ +CHAPTER 6 OPERATIONS +================================================================================ + +6.1 LIFE CYCLE OF A SHIP +------------------------ + +A ship is always in one of four conditions. + + INVENTORY Built and waiting on the dock. It can be given a program. + LAUNCHING Cleared to launch. It will enter the belt on the next daily run. + Its program can no longer be changed. + ACTIVE In the belt. + DESTROYED Lost. It cannot be recovered. + +On registration each player is issued one command ship, in INVENTORY, with +no program. + + 1. Write a program and assemble it. + 2. Upload it. It replaces any earlier program. Repeat as often as you + like. + 3. Launch. There is no going back. + 4. On the next daily run the ship is placed at the Station, moving with the + Station, with full tanks and a zeroed computer, and the program starts. + +A launched ship can be sent an uplink, and you can collect its downlink, but +that is all. You cannot alter the program, recall the ship, or restart it. + +A ship is destroyed if it comes within 200,000 km of the Star. + + +6.2 THE DAILY RUN +------------------------ + +The belt is simulated once per day, in a single run for every ship. The run is +DETERMINISTIC: the same belt, the same programs and the same uplinks always +produce the same day, bit for bit. There is no luck in it. + +Order of events within the run: + + 1. Ships waiting to launch are placed at the Station. + 2. Uplinks are delivered to their ships. + 3. For each tick of the day, for each ship in order of ship number: + a. the computer runs until it yields or exhausts its budget; + b. the engine fires and the Star pulls: velocity, then position, are + updated; + c. the mining laser, if on, works. + 4. Downlinks are captured, and the market is updated for tomorrow. + +Ships do not collide with one another or with asteroids. They cannot see each +other. Each ship's sales, however, feed the same market as everybody else's. + + +6.3 GROUND INTERFACE +------------------------ + +You reach your ship over the HTTP interface of the belt operator. Every +request except registration and the market carries your API key: + + Authorization: Bearer <your key> + + POST /register {"name": "yourname"} + Create a player and its command ship. The reply gives the player + number, the API KEY (shown once only: keep it) and the ship number. + + GET /me Your name, your credits, the current day. + + GET /market Current ore prices, in the order iron, + nickel, ice, platinum. No key needed. + + GET /ships Your ships: number, condition, program size + in bytes, and day of the latest downlink. + + PUT /ships/{n}/program Body: the assembled program, raw bytes. + Allowed only while the ship is in INVENTORY. The program must be + between 4 and 4096 bytes and a multiple of four. + + POST /ships/{n}/launch Launch the ship on the next run. The ship + must be in INVENTORY and have a program. + + PUT /ships/{n}/uplink Body: up to 1024 raw bytes. Replaces any + message queued for the next run. Allowed while LAUNCHING or ACTIVE. + + GET /ships/{n}/downlink The latest downlink, exactly 1024 raw + bytes. The header X-Downlink-Day tells you which day's run + produced it. A ship's first downlink appears after its first day. + +Typical use: + + asm miner.s > miner.bin + curl -X PUT -H "Authorization: Bearer $KEY" \ + --data-binary @miner.bin http://belt.example/ships/1/program + curl -X POST -H "Authorization: Bearer $KEY" \ + http://belt.example/ships/1/launch + +An uplink queued today is delivered at the START of the next run and is the +day's message; the downlink you collect afterwards was written during that +run. In steady state you therefore exchange one kilobyte in each direction +per day, as the name of the link says. + +Errors are reported in the usual way: 401 for a missing or wrong key, 404 for +a ship that is not yours, 409 when the ship is in the wrong condition for the +request, 413 when your data is too large, 400 for malformed data. + + + + +================================================================================ +CHAPTER 7 THE SHIP AND ITS ENVIRONMENT +================================================================================ + +7.1 THE PROSPECTOR HULL +------------------------ + + Dry mass ............................. 8000 kg + Fuel tank ............................ 4000 kg + Hold ................................. 6000 kg + Total mass, full tank, empty hold .... 12,000 kg + Total mass, empty tank, full hold .... 14,000 kg + Engine ............................... 6000 N, exhaust velocity 30 km/s + Mining laser ......................... 10 kg per tick + +The ship is a point. It has no orientation of its own; the engine simply +pushes in whatever direction the two angles command. There are no +thrusters, no spin, no fuel used to turn. + + +7.2 SPACE, ORBITS AND THE STAR +------------------------ + +The Star sits at the origin and pulls on everything with an acceleration + + a = v^2 / r + +directed towards it, where r is the distance from the Star and v is a +constant, the ORBIT SPEED, standard 3 km/s. (In a real solar system the pull +falls with the square of the distance. In the belt it falls only with the +distance. Ask a cosmologist.) + +The remarkable consequence is that a body on a circular orbit at ANY radius +travels at exactly the same speed, v. Every asteroid, the Station, and any +ship that has matched their motion, all travel at 3 km/s. What differs is the +time taken to go round: + + radius (km) period of one orbit + ------------------------------------------------------ + 1,500,000 (inner edge of belt) 36.4 days + 2,250,000 (the Station) 54.5 days + 3,000,000 (outer edge of belt) 72.7 days + +Asteroids travel on circles, in slightly different planes. Each orbit is tilted +from the ecliptic by up to about 0.2 radian (11 degrees), most of them by a +good deal less, so the belt is a thick disc with the Station in the middle. At +the outer edge an asteroid may lie as much as 600,000 km above or below the +ecliptic. +Asteroids are not disturbed by anything you do; they follow their circles for +ever. + +A ship in free flight has a great deal of velocity and very little spare +acceleration. At the Station's radius the Star pulls at about 4 millimetres +per second squared; your engine, at 0.33 to 0.75 m/s^2, is around a hundred +times stronger. The engine is the master, but the Star never relents, and a +ship left alone drifts from its orbit at once if its velocity differs from +3 km/s. A ship at 3 km/s, moving tangentially, stays on its circle for ever +and needs no fuel to do it. + +CAUTION: A ship that comes within 200,000 km of the Star is destroyed. A +ship that is too slow at the wrong radius falls inward and will not stop +until it hits this limit. + + +7.3 FUEL AND DELTA-V +------------------------ + +The total change of velocity available from one tank is given by + + dv = exhaust velocity x ln( mass before / mass after ) + +For a Prospector with an empty hold and full tank: + + 30 km/s x ln( 12000 / 8000 ) = about 12.2 km/s + +That is roughly four times the orbit speed. Nothing is ever free: every +metre per second gained going out must be paid again to slow down, and every +kilogram of ore carried home makes the ship heavier to turn around. The +engine at full power uses the whole tank in 333 ticks, a little over five +hours of ship time, and yet the day is 24. Plan accordingly. + +A rendezvous takes two burns: one to intercept, and one to match velocity. A +program that only does the first will arrive, at speed, and pass on. See the +comments in sample program 3. + + + + +================================================================================ +CHAPTER 8 PROGRAMMING NOTES +================================================================================ + +1. THE BUDGET IS A BUDGET. At standard settings a tick allows 2000 cycles. + A loop that copies the full kilobyte uplink a byte at a time uses over + 5000, and needs three ticks. That is fine, as long as the rest of the + program can cope with the ship moving on meanwhile. If a control loop + matters, keep it short and let the slow work run between calls. + +2. ALWAYS YIELD. A program with no YIELD uses its whole budget every tick, + all day. Its main loop will read the sensors dozens of times per tick and + see the same numbers each time, since the world does not move until the + computer stops. Finish your work, YIELD, and start again next tick. + +3. 32 BITS IS NOT MANY. Positions of two million kilometres times a + velocity of a few thousand metres per second overflows a word at once. + MUL wraps silently. Scale before you multiply, or divide first. + +4. THE PORTS ROUND DOWN. The error is up to one kilometre and one metre per + second on every reading. Differences of nearby readings are meaningless. + Distance, sampled over a few ticks, is a much better guide to closing + speed than the RELV ports on a slow approach. + +5. PORTS ARE NOT MEMORY. Output ports cannot be read back. Keep a copy of + every control value you might need. + +6. THE WORLD ONLY MOVES BETWEEN TICKS. Whatever you write to the engine takes + effect when the computer stops for the tick and stays in force until you + write something else. Do not expect any reaction in the readings until + your next YIELD. + +7. DEFEND AGAINST FAULTS. A faulted computer is dead, and the engine + remains latched at its last throttle. Check array bounds. Never divide + by a value that might be zero. Never POP more than you PUSHed. + +8. DO NOT WASTE THE UPLINK. A kilobyte a day is 1024 bytes. A program can + use it to change a target number, a threshold, or a mode. It cannot + carry the whole belt. Design the ship so that most days need no uplink at + all. + +9. TEST ON THE GROUND. The belt is deterministic. A program that misbehaves + once will misbehave in exactly the same way every time, so a run of the + belt simulator, on the same belt seed, will find the fault. Do this + before you launch. + +10. AN IDLE SHIP IS A SAFE SHIP. A program that does nothing at all keeps + the ship on its launch orbit for ever. You can only do worse. + + + + +================================================================================ +CHAPTER 9 SAMPLE PROGRAMS +================================================================================ + +The three programs below are complete. Each has been assembled and run. +Program sizes are those of the assembled binary. + + +PROGRAM 1: ECHO 52 bytes +------------------------ + +Sends back whatever you send. A useful first test of the link: uplink a +message, wait for the next run, and read the same message in the downlink. + +Notice how the copy loop uses the UPLEN port to stop at the length of the +message, and how the acknowledge (OUT to UPNEW) comes last, so that the flag +stays set for as long as the copy is unfinished. A full kilobyte takes three +ticks to copy at standard settings; the computer simply carries on where it +left off (section 2.5). + + ; ECHO -- copy each day's uplink into the downlink buffer. + ; + .equ RX 0 ; uplink buffer + .equ TX 1024 ; downlink buffer + .equ P_UPNEW 0x70 ; uplink-arrived flag / acknowledge + .equ P_UPLEN 0x71 ; uplink length in bytes + + wait: in r1, P_UPNEW + ldi r2, 0 + beq r1, r2, sleep ; nothing new today + in r4, P_UPLEN + ldi r5, 0 ; r5 = byte index + copy: bge r5, r4, done + ldb r6, [r5+RX] + stb r6, [r5+TX] + addi r5, 1 + jmp copy + done: out P_UPNEW, r1 ; acknowledge + sleep: yield + jmp wait + + +PROGRAM 2: TELEMETRY 52 bytes +------------------------ + +Reports tick number, fuel, and position to home. The five words appear in the +downlink at byte offsets 0, 4, 8, 12 and 16, least-significant byte first. +Because it overwrites them every tick, what you receive is the ship's state as +sampled at the start of the last tick of the day. + + ; TELEMETRY -- every tick, write tick number, fuel and position to the + ; downlink buffer as five 32-bit words. + ; + .equ P_TICK 0x00 + .equ P_POSX 0x10 + .equ P_POSY 0x11 + .equ P_POSZ 0x12 + .equ P_FUEL 0x23 + .equ T_TICK 1024 ; TX + 0 + .equ T_FUEL 1028 ; TX + 4 + .equ T_X 1032 ; TX + 8 + .equ T_Y 1036 ; TX + 12 + .equ T_Z 1040 ; TX + 16 + + ldi r0, 0 ; r0 = 0, the base register + loop: in r1, P_TICK + stw r1, [r0+T_TICK] + in r1, P_FUEL + stw r1, [r0+T_FUEL] + in r1, P_POSX + stw r1, [r0+T_X] + in r1, P_POSY + stw r1, [r0+T_Y] + in r1, P_POSZ + stw r1, [r0+T_Z] + yield + jmp loop + + +PROGRAM 3: PURSUE 72 bytes +------------------------ + +Selects the nearest asteroid, points the engine at it, and burns at full +throttle. The bearing is recomputed every tick with the math coprocessor. + +This program is a beginning, not an end. It will reach its target and fly +straight past it at several kilometres per second, and it will burn its whole +tank in about five hours doing so. A working program brakes: it must compare +its velocity with the target's (RELVX, RELVY, RELVZ), turn the engine towards +the difference, and throttle back as the distance closes. It must also +watch its fuel. That is left as an exercise. + + ; PURSUE -- point the engine at the nearest asteroid and burn. + ; Crude: it makes no attempt to match velocity, so it will fly straight past. + ; + .equ P_THROTTLE 0x20 + .equ P_AZIMUTH 0x21 + .equ P_PITCH 0x22 + .equ P_SELECT 0x30 + .equ P_NEAREST 0x31 + .equ P_RELX 0x32 + .equ P_RELY 0x33 + .equ P_RELZ 0x34 + .equ P_MATHX 0x80 + .equ P_MATHY 0x81 + .equ P_ATAN2 0x83 + .equ P_HYPOT 0x84 + + in r1, P_NEAREST ; id of the nearest asteroid + out P_SELECT, r1 ; track it + ldi r7, 1000 + out P_THROTTLE, r7 ; full power + + aim: in r1, P_RELX ; where is it? + in r2, P_RELY + in r3, P_RELZ + out P_MATHX, r1 + out P_MATHY, r2 + in r4, P_ATAN2 ; azimuth = atan2(dy, dx) + out P_AZIMUTH, r4 + in r5, P_HYPOT ; horizontal range = hypot(dx, dy) + out P_MATHX, r5 + out P_MATHY, r3 + in r6, P_ATAN2 ; elevation = atan2(dz, range) + out P_PITCH, r6 + yield + jmp aim + + +================================================================================ +APPENDIX A OPCODE TABLE +================================================================================ + + hex mnemonic operands cycles operation + --- -------- -------------------- ------ ------------------------------- + 00 NOP 1 no operation + 01 YIELD 1 end this tick + 02 HALT 1 stop permanently + 03 LDI ra, imm 1 ra <- sign-extended imm + 04 LUI ra, imm 1 ra <- imm<<16 | (ra & 0xFFFF) + 05 MOV ra, rb 1 ra <- rb + 06 ADD ra, rb 1 ra <- ra + rb + 07 SUB ra, rb 1 ra <- ra - rb + 08 MUL ra, rb 2 ra <- ra * rb + 09 DIV ra, rb 8 ra <- ra / rb (fault if 0) + 0A MOD ra, rb 8 ra <- ra rem rb (fault if 0) + 0B AND ra, rb 1 ra <- ra & rb + 0C OR ra, rb 1 ra <- ra | rb + 0D XOR ra, rb 1 ra <- ra ^ rb + 0E SHL ra, rb 1 ra <- ra << (rb & 31) + 0F SHR ra, rb 1 ra <- ra >> (rb & 31), logical + 10 SAR ra, rb 1 ra <- ra >> (rb & 31), signed + 11 ADDI ra, imm 1 ra <- ra + imm + 12 JMP imm 1 pc <- next + imm*4 + 13 BEQ ra, rb, imm 1 if ra = rb branch + 14 BNE ra, rb, imm 1 if ra <> rb branch + 15 BLT ra, rb, imm 1 if ra < rb branch (signed) + 16 BGE ra, rb, imm 1 if ra >= rb branch (signed) + 17 CALL imm 1 push next; jump + 18 RET 1 pop pc + 19 PUSH ra 1 sp -= 4; [sp] <- ra + 1A POP ra 1 ra <- [sp]; sp += 4 + 1B LDB ra, [rb+imm] 1 ra <- byte, zero-extended + 1C LDH ra, [rb+imm] 1 ra <- half-word, zero-extended + 1D LDW ra, [rb+imm] 1 ra <- word + 1E STB ra, [rb+imm] 1 byte <- ra + 1F STH ra, [rb+imm] 1 half-word <- ra + 20 STW ra, [rb+imm] 1 word <- ra + 21 IN ra, port 1 ra <- port + 22 OUT port, ra 1 port <- ra + + Any opcode of 23 hex or above is illegal and faults the computer. + + Pseudo-instruction (assembler only): + LI rd, value 2 LDI rd, low16 ; LUI rd, high16 + + +================================================================================ +APPENDIX B PORT MAP +================================================================================ + + port name dir description units + ---- --------- --- ---------------------------------------------- ----- + SYSTEM + 0x00 TICK in tick within the day ticks + 0x01 DAY in day number days + 0x02 TICKS in ticks per day ticks + + NAVIGATION + 0x10 POSX in position X (Star at origin) km + 0x11 POSY in position Y km + 0x12 POSZ in position Z km + 0x13 VELX in velocity X m/s + 0x14 VELY in velocity Y m/s + 0x15 VELZ in velocity Z m/s + + ENGINE + 0x20 THROTTLE out 0..1000, latched permille + 0x21 AZIMUTH out thrust direction, latched mrad + 0x22 PITCH out thrust elevation, latched mrad + 0x23 FUEL in fuel remaining kg + 0x24 MASS in total mass kg + + SCANNER + 0x30 SELECT out asteroid to track (0 = none) + 0x31 NEAREST in number of nearest asteroid + 0x32 RELX in target minus ship, X km + 0x33 RELY in target minus ship, Y km + 0x34 RELZ in target minus ship, Z km + 0x35 RELVX in target velocity minus ship, X m/s + 0x36 RELVY in ... Y m/s + 0x37 RELVZ in ... Z m/s + 0x38 DIST in distance to target km + 0x40 ORE0 in iron in target kg + 0x41 ORE1 in nickel in target kg + 0x42 ORE2 in ice in target kg + 0x43 ORE3 in platinum in target kg + + MINING AND HOLD + 0x50 MINE out non-zero = laser on, latched + 0x51 CARGO in hold contents kg + 0x52 CARGOCAP in hold capacity kg + 0x58 CARGO0 in iron in hold kg + 0x59 CARGO1 in nickel in hold kg + 0x5A CARGO2 in ice in hold kg + 0x5B CARGO3 in platinum in hold kg + + STATION + 0x60 STNX in station minus ship, X km + 0x61 STNY in ... Y km + 0x62 STNZ in ... Z km + 0x63 STNVX in station velocity minus ship, X m/s + 0x64 STNVY in ... Y m/s + 0x65 STNVZ in ... Z m/s + 0x66 SELL out non-zero = sell hold (if docked) + 0x67 EARNED in credits earned by this ship + + WORMHOLE LINK + 0x70 UPNEW i/o in: uplink pending; out: acknowledge + 0x71 UPLEN in uplink length bytes + + MATH COPROCESSOR + 0x80 MATHX out operand x + 0x81 MATHY out operand y + 0x82 MATHZ out operand z + 0x83 ATAN2 in atan2(y, x) mrad + 0x84 HYPOT in sqrt(x^2 + y^2) + 0x85 NORM3 in sqrt(x^2 + y^2 + z^2) + + All other ports read zero and ignore writes. + + +================================================================================ +APPENDIX C STANDARD EQUATE FILE +================================================================================ + +Paste this at the top of any program. Unused equates cost nothing. + + .equ RX 0 ; uplink buffer + .equ TX 1024 ; downlink buffer + .equ RAMTOP 8192 ; initial stack pointer + + .equ P_TICK 0x00 + .equ P_DAY 0x01 + .equ P_TICKS 0x02 + .equ P_POSX 0x10 + .equ P_POSY 0x11 + .equ P_POSZ 0x12 + .equ P_VELX 0x13 + .equ P_VELY 0x14 + .equ P_VELZ 0x15 + .equ P_THROTTLE 0x20 + .equ P_AZIMUTH 0x21 + .equ P_PITCH 0x22 + .equ P_FUEL 0x23 + .equ P_MASS 0x24 + .equ P_SELECT 0x30 + .equ P_NEAREST 0x31 + .equ P_RELX 0x32 + .equ P_RELY 0x33 + .equ P_RELZ 0x34 + .equ P_RELVX 0x35 + .equ P_RELVY 0x36 + .equ P_RELVZ 0x37 + .equ P_DIST 0x38 + .equ P_ORE0 0x40 + .equ P_ORE1 0x41 + .equ P_ORE2 0x42 + .equ P_ORE3 0x43 + .equ P_MINE 0x50 + .equ P_CARGO 0x51 + .equ P_CARGOCAP 0x52 + .equ P_CARGO0 0x58 + .equ P_CARGO1 0x59 + .equ P_CARGO2 0x5A + .equ P_CARGO3 0x5B + .equ P_STNX 0x60 + .equ P_STNY 0x61 + .equ P_STNZ 0x62 + .equ P_STNVX 0x63 + .equ P_STNVY 0x64 + .equ P_STNVZ 0x65 + .equ P_SELL 0x66 + .equ P_EARNED 0x67 + .equ P_UPNEW 0x70 + .equ P_UPLEN 0x71 + .equ P_MATHX 0x80 + .equ P_MATHY 0x81 + .equ P_MATHZ 0x82 + .equ P_ATAN2 0x83 + .equ P_HYPOT 0x84 + .equ P_NORM3 0x85 + + +================================================================================ +APPENDIX D FAULT CONDITIONS +================================================================================ + +A fault stops the computer permanently. The cause is recorded on the ship +but is not reported to you; the symptom is a ship that no longer responds. + + ILLEGAL OPCODE + The instruction word's opcode is 23 hex or greater. Usually means the + program has run into data, or the binary was not produced by ASM. + + DIVISION BY ZERO + DIV or MOD with a zero divisor. + + MEMORY ACCESS OUT OF RANGE + A load or store, a PUSH, a POP, a CALL or a RET touched an address + below 0 or above the end of RAM. Includes a pop from an empty stack and + a return with nothing to return to. + + Not faults: arithmetic overflow (wraps), shifts of 32 or more (the count + is masked), writing to a port that does not exist, running off the end of + the program (HALT), commanding a throttle greater than 1000 (clamped). + + +================================================================================ +APPENDIX E QUICK REFERENCE CARD +================================================================================ + ++------------------------------------------------------------------------------+ +| HC-33 QUICK REFERENCE | ++------------------------------------------------------------------------------+ +| REGISTERS r0-r14 general r15 = SP (starts at 8192) | +| MEMORY 0-1023 uplink 1024-2047 downlink 2048-8191 data + stack | +| WORD 32 bit, little-endian, no flags | ++------------------------------------------------------------------------------+ +| ldi ra,imm lui ra,imm li rd,val mov ra,rb | +| add sub mul div mod and or xor shl shr sar ra,rb addi ra,imm | +| jmp L beq/bne/blt/bge ra,rb,L call L ret push ra pop ra | +| ldb/ldh/ldw ra,[rb+imm] stb/sth/stw ra,[rb+imm] | +| in ra,port out port,ra yield halt nop | ++------------------------------------------------------------------------------+ +| COSTS most 1 mul 2 div,mod 8 BUDGET 2000/tick | +| UNITS km m/s milliradians kg READINGS ROUND DOWN | ++------------------------------------------------------------------------------+ +| 00 tick 01 day 02 ticks/day | +| 10-12 pos km 13-15 vel m/s | +| 20 throttle 0-1000 21 azimuth 22 pitch 23 fuel 24 mass | +| 30 select 31 nearest 32-34 rel pos 35-37 rel vel 38 dist 40-43 ore | +| 50 mine 51 cargo 52 cap 58-5B cargo by ore | +| 60-62 stn pos 63-65 stn vel 66 sell 67 earned | +| 70 uplink flag / ack 71 uplink length | +| 80 x 81 y 82 z 83 atan2 84 hypot 85 norm3 | ++------------------------------------------------------------------------------+ +| MINE within 5 km, rel speed <= 100 m/s 10 kg/tick | +| SELL within 20 km, rel speed <= 100 m/s whole hold | +| STAR a = v^2/r, v = 3 km/s. Inside 200,000 km = destroyed. | ++------------------------------------------------------------------------------+ + + + * * * END OF MANUAL * * * + +-------------------------------------------------------------------------------- +HALCYON INSTRUMENT & CONTROL Publication HIC-0033-A +Printed in the Outer System. First Edition diff --git a/examples/examples.go b/examples/examples.go new file mode 100644 index 0000000..6464c03 --- /dev/null +++ b/examples/examples.go @@ -0,0 +1,22 @@ +// Package examples embeds the sample ship programs so the web site can offer +// them in its editor. +package examples + +import ( + "embed" + "io/fs" +) + +//go:embed programs/*.s +var embedded embed.FS + +// FS holds the programs, one file per program, at its root. +var FS = mustSub() + +func mustSub() fs.FS { + sub, err := fs.Sub(embedded, "programs") + if err != nil { + panic(err) + } + return sub +} diff --git a/examples/examples_test.go b/examples/examples_test.go new file mode 100644 index 0000000..9f6fcc7 --- /dev/null +++ b/examples/examples_test.go @@ -0,0 +1,124 @@ +package examples_test + +import ( + "encoding/binary" + "math" + "os" + "testing" + + "wh/config" + "wh/fixed" + "wh/sim" + "wh/vm" +) + +func asm(t *testing.T, name string) []byte { + t.Helper() + src, err := os.ReadFile("programs/" + name) + if err != nil { + t.Fatal(err) + } + p, err := vm.Assemble(string(src)) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + return p +} + +func cfg() config.Config { + c := config.Default() + c.AsteroidCount = 50 + return c +} + +func launch(t *testing.T, c config.Config, prog []byte, uplink []byte) (*sim.State, sim.DayResult) { + t.Helper() + s := sim.NewState(c) + in := sim.DayInput{Launches: []sim.Launch{{ShipID: 1, Owner: 1, Program: prog}}} + if uplink != nil { + in.Uplinks = []sim.Uplink{{ShipID: 1, Data: uplink}} + } + res, err := s.RunDay(c, in) + if err != nil { + t.Fatal(err) + } + return s, res +} + +func TestEcho(t *testing.T) { + msg := make([]byte, 300) + for i := range msg { + msg[i] = byte(i*7 + 1) + } + _, res := launch(t, cfg(), asm(t, "echo.s"), msg) + got := res.Downlinks[0].Data + for i := range msg { + if got[i] != msg[i] { + t.Fatalf("byte %d: got %d want %d", i, got[i], msg[i]) + } + } + if got[300] != 0 { + t.Fatal("echoed too much") + } +} + +func TestTelemetry(t *testing.T) { + c := cfg() + s, res := launch(t, c, asm(t, "telemetry.s"), nil) + d := res.Downlinks[0].Data + w := func(i int) int32 { return int32(binary.LittleEndian.Uint32(d[i*4:])) } + if w(0) != int32(c.TicksPerDay-1) || w(1) != 4000 { + t.Fatalf("tick=%d fuel=%d", w(0), w(1)) + } + // The last report was taken one tick (<= ~180 km) before the final state. + end := s.Ships[0].Pos + for i, got := range []int32{w(2), w(3), w(4)} { + want := []fixed.F{end.X, end.Y, end.Z}[i].Floor() + if math.Abs(float64(int64(got)-want)) > 200 { + t.Fatalf("axis %d: reported %d, ship at %d", i, got, want) + } + } +} + +func TestPursueAims(t *testing.T) { + c := cfg() + c.TicksPerDay = 1 // one long tick: the program aims once, from t=0 state + s := sim.NewState(c) + stPos, _ := s.Station.Orbit.State(0) + + // Expected: nearest asteroid, and the bearing to it in whole km. + best, bestD := 0, fixed.Max + for i, a := range s.Asteroids { + p, _ := a.Orbit.State(0) + if d := p.Sub(stPos).Len(); d < bestD { + best, bestD = i, d + } + } + tp, _ := s.Asteroids[best].Orbit.State(0) + rel := tp.Sub(stPos) + dx, dy, dz := float64(rel.X.Floor()), float64(rel.Y.Floor()), float64(rel.Z.Floor()) + wantAz := math.Atan2(dy, dx) * 1000 + wantEl := math.Atan2(dz, math.Hypot(dx, dy)) * 1000 + + if _, err := s.RunDay(c, sim.DayInput{Launches: []sim.Launch{{ShipID: 1, Owner: 1, Program: asm(t, "pursue.s")}}}); err != nil { + t.Fatal(err) + } + sh := s.Ships[0] + if sh.Target != int64(best+1) || sh.Throttle != 1000 { + t.Fatalf("target=%d (want %d) throttle=%d", sh.Target, best+1, sh.Throttle) + } + if math.Abs(float64(sh.Azimuth)-wantAz) > 3 || math.Abs(float64(sh.Pitch)-wantEl) > 3 { + t.Fatalf("aim az=%d el=%d, want %.0f %.0f", sh.Azimuth, sh.Pitch, wantAz, wantEl) + } +} + +func TestPortsEquatesAssemble(t *testing.T) { + // The standard equate file must assemble and be usable alongside code. + src, err := os.ReadFile("programs/ports.s") + if err != nil { + t.Fatal(err) + } + if _, err := vm.Assemble(string(src) + "\n in r1, P_UPNEW\n out P_THROTTLE, r1\n halt\n"); err != nil { + t.Fatal(err) + } +} diff --git a/examples/programs/echo.s b/examples/programs/echo.s new file mode 100644 index 0000000..990c36b --- /dev/null +++ b/examples/programs/echo.s @@ -0,0 +1,20 @@ +; ECHO -- copy each day's uplink into the downlink buffer. +; + .equ RX 0 ; uplink buffer + .equ TX 1024 ; downlink buffer + .equ P_UPNEW 0x70 ; uplink-arrived flag / acknowledge + .equ P_UPLEN 0x71 ; uplink length in bytes + +wait: in r1, P_UPNEW + ldi r2, 0 + beq r1, r2, sleep ; nothing new today + in r4, P_UPLEN + ldi r5, 0 ; r5 = byte index +copy: bge r5, r4, done + ldb r6, [r5+RX] + stb r6, [r5+TX] + addi r5, 1 + jmp copy +done: out P_UPNEW, r1 ; acknowledge +sleep: yield + jmp wait diff --git a/examples/programs/ports.s b/examples/programs/ports.s new file mode 100644 index 0000000..67d6f27 --- /dev/null +++ b/examples/programs/ports.s @@ -0,0 +1,57 @@ +; PORTS -- standard equate file for the HC-33 (manual, Appendix C). +; Paste at the top of a program; unused equates cost nothing. +; + .equ RX 0 ; uplink buffer + .equ TX 1024 ; downlink buffer + .equ RAMTOP 8192 ; initial stack pointer + + .equ P_TICK 0x00 + .equ P_DAY 0x01 + .equ P_TICKS 0x02 + .equ P_POSX 0x10 + .equ P_POSY 0x11 + .equ P_POSZ 0x12 + .equ P_VELX 0x13 + .equ P_VELY 0x14 + .equ P_VELZ 0x15 + .equ P_THROTTLE 0x20 + .equ P_AZIMUTH 0x21 + .equ P_PITCH 0x22 + .equ P_FUEL 0x23 + .equ P_MASS 0x24 + .equ P_SELECT 0x30 + .equ P_NEAREST 0x31 + .equ P_RELX 0x32 + .equ P_RELY 0x33 + .equ P_RELZ 0x34 + .equ P_RELVX 0x35 + .equ P_RELVY 0x36 + .equ P_RELVZ 0x37 + .equ P_DIST 0x38 + .equ P_ORE0 0x40 + .equ P_ORE1 0x41 + .equ P_ORE2 0x42 + .equ P_ORE3 0x43 + .equ P_MINE 0x50 + .equ P_CARGO 0x51 + .equ P_CARGOCAP 0x52 + .equ P_CARGO0 0x58 + .equ P_CARGO1 0x59 + .equ P_CARGO2 0x5A + .equ P_CARGO3 0x5B + .equ P_STNX 0x60 + .equ P_STNY 0x61 + .equ P_STNZ 0x62 + .equ P_STNVX 0x63 + .equ P_STNVY 0x64 + .equ P_STNVZ 0x65 + .equ P_SELL 0x66 + .equ P_EARNED 0x67 + .equ P_UPNEW 0x70 + .equ P_UPLEN 0x71 + .equ P_MATHX 0x80 + .equ P_MATHY 0x81 + .equ P_MATHZ 0x82 + .equ P_ATAN2 0x83 + .equ P_HYPOT 0x84 + .equ P_NORM3 0x85 diff --git a/examples/programs/pursue.s b/examples/programs/pursue.s new file mode 100644 index 0000000..e8e7440 --- /dev/null +++ b/examples/programs/pursue.s @@ -0,0 +1,35 @@ +; PURSUE -- point the engine at the nearest asteroid and burn. +; Crude: it makes no attempt to match velocity, so it will fly straight past. +; + .equ P_THROTTLE 0x20 + .equ P_AZIMUTH 0x21 + .equ P_PITCH 0x22 + .equ P_SELECT 0x30 + .equ P_NEAREST 0x31 + .equ P_RELX 0x32 + .equ P_RELY 0x33 + .equ P_RELZ 0x34 + .equ P_MATHX 0x80 + .equ P_MATHY 0x81 + .equ P_ATAN2 0x83 + .equ P_HYPOT 0x84 + + in r1, P_NEAREST ; id of the nearest asteroid + out P_SELECT, r1 ; track it + ldi r7, 1000 + out P_THROTTLE, r7 ; full power + +aim: in r1, P_RELX ; where is it? + in r2, P_RELY + in r3, P_RELZ + out P_MATHX, r1 + out P_MATHY, r2 + in r4, P_ATAN2 ; azimuth = atan2(dy, dx) + out P_AZIMUTH, r4 + in r5, P_HYPOT ; horizontal range = hypot(dx, dy) + out P_MATHX, r5 + out P_MATHY, r3 + in r6, P_ATAN2 ; elevation = atan2(dz, range) + out P_PITCH, r6 + yield + jmp aim diff --git a/examples/programs/telemetry.s b/examples/programs/telemetry.s new file mode 100644 index 0000000..dc5cb3f --- /dev/null +++ b/examples/programs/telemetry.s @@ -0,0 +1,27 @@ +; TELEMETRY -- every tick, write tick number, fuel and position to the +; downlink buffer as five 32-bit words. +; + .equ P_TICK 0x00 + .equ P_POSX 0x10 + .equ P_POSY 0x11 + .equ P_POSZ 0x12 + .equ P_FUEL 0x23 + .equ T_TICK 1024 ; TX + 0 + .equ T_FUEL 1028 ; TX + 4 + .equ T_X 1032 ; TX + 8 + .equ T_Y 1036 ; TX + 12 + .equ T_Z 1040 ; TX + 16 + + ldi r0, 0 ; r0 = 0, the base register +loop: in r1, P_TICK + stw r1, [r0+T_TICK] + in r1, P_FUEL + stw r1, [r0+T_FUEL] + in r1, P_POSX + stw r1, [r0+T_X] + in r1, P_POSY + stw r1, [r0+T_Y] + in r1, P_POSZ + stw r1, [r0+T_Z] + yield + jmp loop diff --git a/fixed/cordic_tables.go b/fixed/cordic_tables.go new file mode 100644 index 0000000..6064d4d --- /dev/null +++ b/fixed/cordic_tables.go @@ -0,0 +1,48 @@ +package fixed + +// Generated with arbitrary-precision arithmetic; atan(2^-i) in Q2.62. +var atanTable = [...]int64{ + 3622009729038561421, + 2138197195906305896, + 1129764675555192497, + 573486189672913777, + 287855953345232184, + 144068303048368714, + 72051730834756821, + 36028064038054492, + 18014306884351854, + 9007187801521083, + 4503598195715549, + 2251799634728302, + 1125899884473003, + 562949950625109, + 281474976361130, + 140737488311637, + 70368744172202, + 35184372088149, + 17592186044330, + 8796093022197, + 4398046511102, + 2199023255551, + 1099511627775, + 549755813887, + 274877906943, + 137438953471, + 68719476735, + 34359738367, + 17179869183, + 8589934591, + 4294967295, + 2147483647, + 1073741823, + 536870911, + 268435455, + 134217727, + 67108863, + 33554431, + 16777215, + 8388607, +} + +// cordicInvK is 1/K (the CORDIC gain compensation) in Q2.62. +const cordicInvK int64 = 2800459870029452953 diff --git a/fixed/fixed.go b/fixed/fixed.go new file mode 100644 index 0000000..fd6a2c5 --- /dev/null +++ b/fixed/fixed.go @@ -0,0 +1,264 @@ +// Package fixed implements deterministic Q32.32 fixed-point arithmetic. +// +// All simulation state uses integer math only, so results are bit-exact on +// every platform. Values range over roughly ±2.1e9 with a resolution of +// about 2.3e-10. +package fixed + +import ( + "math/bits" +) + +// F is a signed Q32.32 fixed-point number. +type F int64 + +const ( + Frac = 32 + One F = 1 << Frac + Half F = One / 2 + Zero F = 0 + Max F = 1<<63 - 1 + Min F = -1 << 63 + + // Pi and friends, Q32.32. + Pi F = 13493037704 + TwoPi F = 26986075409 + HalfPi F = 6746518852 +) + +// FromInt converts an integer to fixed point. +func FromInt(i int64) F { return F(i) << Frac } + +// FromRatio returns n/d. +func FromRatio(n, d int64) F { return FromInt(n).Div(FromInt(d)) } + +// Int truncates toward zero. +func (a F) Int() int64 { + if a < 0 { + return -int64(-a >> Frac) + } + return int64(a >> Frac) +} + +// Floor returns the integer floor. +func (a F) Floor() int64 { return int64(a >> Frac) } + +// Float64 is for display/debugging only; never use it inside the simulation. +func (a F) Float64() float64 { return float64(a) / float64(One) } + +func (a F) Add(b F) F { return a + b } +func (a F) Sub(b F) F { return a - b } +func (a F) Neg() F { return -a } + +func (a F) Abs() F { + if a < 0 { + return -a + } + return a +} + +// Mul returns a*b, rounded toward negative infinity, saturating on overflow. +func (a F) Mul(b F) F { + neg := (a < 0) != (b < 0) + hi, lo := bits.Mul64(uabs(a), uabs(b)) + // Shift the 128-bit product right by Frac. + res := hi<<(64-Frac) | lo>>Frac + if hi>>Frac != 0 || res > 1<<63-1 { + if neg { + return Min + } + return Max + } + if neg { + // Floor for negatives keeps rounding consistent; simple truncation + // toward zero is also deterministic, we choose truncation. + return -F(res) + } + return F(res) +} + +// Div returns a/b, truncated toward zero, saturating on overflow. Division by +// zero saturates to Max/Min according to the sign of a (0/0 is 0). +func (a F) Div(b F) F { + if b == 0 { + switch { + case a > 0: + return Max + case a < 0: + return Min + } + return 0 + } + neg := (a < 0) != (b < 0) + ua, ub := uabs(a), uabs(b) + hi, lo := ua>>(64-Frac), ua<<Frac + if hi >= ub { + if neg { + return Min + } + return Max + } + q, _ := bits.Div64(hi, lo, ub) + if q > 1<<63-1 { + if neg { + return Min + } + return Max + } + if neg { + return -F(q) + } + return F(q) +} + +// MulInt multiplies by a plain integer. +func (a F) MulInt(n int64) F { return a * F(n) } + +// DivInt divides by a plain integer. +func (a F) DivInt(n int64) F { return a / F(n) } + +func uabs(a F) uint64 { + if a < 0 { + return uint64(-a) + } + return uint64(a) +} + +func Min2(a, b F) F { + if a < b { + return a + } + return b +} + +func Max2(a, b F) F { + if a > b { + return a + } + return b +} + +func Clamp(v, lo, hi F) F { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// Sqrt returns the square root of a. Negative input returns 0. +func (a F) Sqrt() F { + if a <= 0 { + return 0 + } + // sqrt(a/2^32)*2^32 = sqrt(a*2^32); a*2^32 fits in 96 bits. + hi, lo := uint64(a)>>(64-Frac), uint64(a)<<Frac + return F(isqrt128(hi, lo)) +} + +// isqrt128 returns floor(sqrt(hi:lo)) using the restoring bit-by-bit method. +func isqrt128(hi, lo uint64) uint64 { + var root uint64 + var remHi, remLo uint64 + for i := 0; i < 64; i++ { + // Bring down the next two bits of the radicand. + remHi = remHi<<2 | remLo>>62 + remLo = remLo<<2 | hi>>62 + hi = hi<<2 | lo>>62 + lo <<= 2 + // trial = (oldRoot<<2)|1 = (newRoot<<1)|1 + root <<= 1 + trialHi, trialLo := root>>63, root<<1|1 + if remHi > trialHi || (remHi == trialHi && remLo >= trialLo) { + var borrow uint64 + remLo, borrow = bits.Sub64(remLo, trialLo, 0) + remHi, _ = bits.Sub64(remHi, trialHi, borrow) + root |= 1 + } + } + return root +} + +// IntSqrt returns floor(sqrt(n)) for n >= 0. +func IntSqrt(n uint64) uint64 { return isqrt128(0, n) } + +const cordicIters = len(atanTable) + +// SinCos returns sin and cos of the angle a (radians). +func SinCos(a F) (sin, cos F) { + // Reduce to [-pi, pi). + a = a % TwoPi + if a >= Pi { + a -= TwoPi + } else if a < -Pi { + a += TwoPi + } + // Reduce to [-pi/2, pi/2] with a cosine sign flip. + negCos := false + if a > HalfPi { + a = Pi - a + negCos = true + } else if a < -HalfPi { + a = -Pi - a + negCos = true + } + x, y, z := cordicInvK, int64(0), int64(a)<<30 + for i := 0; i < cordicIters; i++ { + dx, dy := y>>uint(i), x>>uint(i) + if z >= 0 { + x, y, z = x-dx, y+dy, z-atanTable[i] + } else { + x, y, z = x+dx, y-dy, z+atanTable[i] + } + } + s, c := F(round30(y)), F(round30(x)) + if negCos { + c = -c + } + return s, c +} + +func round30(v int64) int64 { return (v + 1<<29) >> 30 } + +func Sin(a F) F { s, _ := SinCos(a); return s } +func Cos(a F) F { _, c := SinCos(a); return c } + +// Atan2 returns the angle of the vector (x, y) in (-pi, pi]. +func Atan2(y, x F) F { + if x == 0 && y == 0 { + return 0 + } + // Scale so the vector is large but cannot overflow during iteration. + vx, vy := int64(x), int64(y) + for (vx > 1<<60 || vx < -(1<<60)) || (vy > 1<<60 || vy < -(1<<60)) { + vx >>= 1 + vy >>= 1 + } + var offset int64 // multiples of pi, in Q32 + if vx < 0 { + // Rotate by pi so x >= 0. + vx, vy = -vx, -vy + if y >= 0 { + offset = int64(Pi) + } else { + offset = -int64(Pi) + } + } + // Normalize magnitude up to use precision (keep < 2^61). + for (vx < 1<<59) && (vy < 1<<59) && (vy > -(1 << 59)) { + vx <<= 1 + vy <<= 1 + } + var z int64 + for i := 0; i < cordicIters; i++ { + dx, dy := vy>>uint(i), vx>>uint(i) + if vy > 0 { + vx, vy, z = vx+dx, vy-dy, z+atanTable[i] + } else { + vx, vy, z = vx-dx, vy+dy, z-atanTable[i] + } + } + return F(round30(z) + offset) +} diff --git a/fixed/fixed_test.go b/fixed/fixed_test.go new file mode 100644 index 0000000..5b44c2d --- /dev/null +++ b/fixed/fixed_test.go @@ -0,0 +1,78 @@ +package fixed + +import ( + "math" + "math/rand" + "testing" +) + +func near(t *testing.T, name string, got F, want, tol float64) { + t.Helper() + if d := math.Abs(got.Float64() - want); d > tol { + t.Errorf("%s: got %v want %v (diff %g)", name, got.Float64(), want, d) + } +} + +func TestMulDiv(t *testing.T) { + near(t, "mul", FromInt(3).Mul(FromRatio(1, 2)), 1.5, 1e-9) + near(t, "mulneg", FromInt(-3).Mul(FromRatio(1, 2)), -1.5, 1e-9) + near(t, "div", FromInt(1).Div(FromInt(3)), 1.0/3, 1e-9) + near(t, "divneg", FromInt(-7).Div(FromInt(2)), -3.5, 1e-9) + if FromInt(1<<30).Mul(FromInt(1<<30)) != Max { + t.Error("expected saturation") + } + if FromInt(1).Div(0) != Max || FromInt(-1).Div(0) != Min { + t.Error("div by zero should saturate") + } +} + +func TestSqrt(t *testing.T) { + for _, v := range []float64{0.25, 1, 2, 3, 100, 1e6, 2e9} { + f := F(v * float64(One)) + near(t, "sqrt", f.Sqrt(), math.Sqrt(v), 1e-8) + } + if IntSqrt(1<<62) != 1<<31 || IntSqrt(99) != 9 { + t.Error("IntSqrt") + } +} + +func TestSinCos(t *testing.T) { + r := rand.New(rand.NewSource(1)) + for i := 0; i < 2000; i++ { + a := (r.Float64() - 0.5) * 40 + f := F(a * float64(One)) + s, c := SinCos(f) + af := f.Float64() + near(t, "sin", s, math.Sin(af), 2e-9) + near(t, "cos", c, math.Cos(af), 2e-9) + } +} + +func TestAtan2(t *testing.T) { + r := rand.New(rand.NewSource(2)) + for i := 0; i < 2000; i++ { + x, y := (r.Float64()-0.5)*1e6, (r.Float64()-0.5)*1e6 + got := Atan2(F(y*float64(One)), F(x*float64(One))) + near(t, "atan2", got, math.Atan2(y, x), 2e-8) + } + near(t, "atan2(0,-1)", Atan2(0, -One), math.Pi, 1e-8) +} + +func TestVec3(t *testing.T) { + // A 3-4-12 vector has length 13; scale it far past what a naive x*x would allow. + v := Vec{FromInt(3_000_000), FromInt(4_000_000), FromInt(12_000_000)} + near(t, "norm3", v.Len(), 13_000_000, 1e-6) + u := Vec{FromInt(3), FromInt(4), FromInt(12)}.Unit() + near(t, "unit", u.Len(), 1, 1e-8) + + d := FromSpherical(FromRatio(1, 2), FromRatio(3, 10)) + near(t, "spherical len", d.Len(), 1, 1e-8) + near(t, "spherical z", d.Z, math.Sin(0.3), 1e-8) + near(t, "spherical x", d.X, math.Cos(0.3)*math.Cos(0.5), 1e-8) + + r := Vec{One, 0, 0}.RotateX(HalfPi).RotateZ(HalfPi) + near(t, "rot x", r.X, 0, 1e-8) + near(t, "rot y", r.Y, 1, 1e-8) + r = Vec{0, One, 0}.RotateX(HalfPi) + near(t, "rotx z", r.Z, 1, 1e-8) +} diff --git a/fixed/vec.go b/fixed/vec.go new file mode 100644 index 0000000..25f5c0b --- /dev/null +++ b/fixed/vec.go @@ -0,0 +1,62 @@ +package fixed + +import "math/bits" + +// Vec is a 3D fixed-point vector. +type Vec struct{ X, Y, Z F } + +func (a Vec) Add(b Vec) Vec { return Vec{a.X + b.X, a.Y + b.Y, a.Z + b.Z} } +func (a Vec) Sub(b Vec) Vec { return Vec{a.X - b.X, a.Y - b.Y, a.Z - b.Z} } +func (a Vec) Scale(k F) Vec { return Vec{a.X.Mul(k), a.Y.Mul(k), a.Z.Mul(k)} } + +// Len returns the vector magnitude. The squares are accumulated in 128 bits +// (three squares of 63-bit values cannot overflow that), so it is exact to the +// last bit over the whole F range. +func (a Vec) Len() F { return Norm3(a.X, a.Y, a.Z) } + +// Hypot returns sqrt(x*x + y*y) without intermediate overflow. +func Hypot(x, y F) F { return Norm3(x, y, 0) } + +// Norm3 returns sqrt(x*x + y*y + z*z) without intermediate overflow. +func Norm3(x, y, z F) F { + h1, l1 := bits.Mul64(uabs(x), uabs(x)) + h2, l2 := bits.Mul64(uabs(y), uabs(y)) + h3, l3 := bits.Mul64(uabs(z), uabs(z)) + lo, c := bits.Add64(l1, l2, 0) + hi, _ := bits.Add64(h1, h2, c) + lo, c = bits.Add64(lo, l3, 0) + hi, _ = bits.Add64(hi, h3, c) + return F(isqrt128(hi, lo)) +} + +// Unit returns the unit vector, or zero for the zero vector. +func (a Vec) Unit() Vec { + l := a.Len() + if l == 0 { + return Vec{} + } + return Vec{a.X.Div(l), a.Y.Div(l), a.Z.Div(l)} +} + +// FromSpherical returns the unit vector with the given azimuth (angle from +X +// in the XY plane) and elevation (angle above the XY plane). +func FromSpherical(azimuth, elevation F) Vec { + sa, ca := SinCos(azimuth) + se, ce := SinCos(elevation) + return Vec{ce.Mul(ca), ce.Mul(sa), se} +} + +// RotateZ rotates counter-clockwise about the Z axis. +func (a Vec) RotateZ(ang F) Vec { + s, c := SinCos(ang) + return Vec{a.X.Mul(c) - a.Y.Mul(s), a.X.Mul(s) + a.Y.Mul(c), a.Z} +} + +// RotateX rotates counter-clockwise about the X axis. +func (a Vec) RotateX(ang F) Vec { + s, c := SinCos(ang) + return Vec{a.X, a.Y.Mul(c) - a.Z.Mul(s), a.Y.Mul(s) + a.Z.Mul(c)} +} + +// Dot returns the dot product, saturating on overflow. +func (a Vec) Dot(b Vec) F { return a.X.Mul(b.X) + a.Y.Mul(b.Y) + a.Z.Mul(b.Z) } diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9561f29 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module wh + +go 1.25.0 + +require ( + github.com/jackc/pgx/v5 v5.11.0 + modernc.org/sqlite v1.59.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.29.0 // indirect + modernc.org/libc v1.75.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4fcd32a --- /dev/null +++ b/go.sum @@ -0,0 +1,74 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A= +modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/market/market.go b/market/market.go new file mode 100644 index 0000000..d9a9b76 --- /dev/null +++ b/market/market.go @@ -0,0 +1,48 @@ +// Package market prices ore and settles deliveries. Prices are integers +// (credits per kg) that fall as supply accumulates and recover over time. +package market + +import "wh/world" + +var basePrice = [world.NumOre]int64{world.Iron: 2, world.Nickel: 6, world.Ice: 3, world.Platinum: 300} + +// saturation is the supply (kg) at which an ore's price has halved. +var saturation = [world.NumOre]int64{world.Iron: 400_000, world.Nickel: 200_000, world.Ice: 300_000, world.Platinum: 5_000} + +type Market struct { + Supply [world.NumOre]int64 // decayed running total of kg sold + Prices [world.NumOre]int64 // credits per kg, fixed for the day +} + +func New() Market { + m := Market{} + m.reprice() + return m +} + +func (m *Market) reprice() { + for o := range m.Prices { + p := basePrice[o] * saturation[o] / (saturation[o] + m.Supply[o]) + if p < 1 { + p = 1 + } + m.Prices[o] = p + } +} + +// Value returns the payout for a cargo at today's prices. +func (m *Market) Value(cargo [world.NumOre]int64) int64 { + var v int64 + for o, kg := range cargo { + v += kg * m.Prices[o] + } + return v +} + +// EndOfDay folds the day's sales into supply (with 10% decay) and reprices. +func (m *Market) EndOfDay(sold [world.NumOre]int64) { + for o := range m.Supply { + m.Supply[o] = m.Supply[o]*9/10 + sold[o] + } + m.reprice() +} diff --git a/rng/rng.go b/rng/rng.go new file mode 100644 index 0000000..14a5037 --- /dev/null +++ b/rng/rng.go @@ -0,0 +1,74 @@ +// Package rng provides a small deterministic PRNG (xoshiro256**) seeded via +// splitmix64. Streams are derived from a seed plus labels so that independent +// parts of the simulation never share (and thus never perturb) each other's +// random sequence. +package rng + +import ( + "math/bits" + + "wh/fixed" +) + +type R struct{ s [4]uint64 } + +func splitmix(x *uint64) uint64 { + *x += 0x9e3779b97f4a7c15 + z := *x + z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9 + z = (z ^ (z >> 27)) * 0x94d049bb133111eb + return z ^ (z >> 31) +} + +// New returns a generator for the given seed. +func New(seed uint64) *R { + r := &R{} + for i := range r.s { + r.s[i] = splitmix(&seed) + } + return r +} + +// Derive returns a generator for an independent stream identified by seed and +// labels, e.g. Derive(worldSeed, day, shipID). +func Derive(seed uint64, labels ...uint64) *R { + h := seed + for _, l := range labels { + h ^= l + 0x9e3779b97f4a7c15 + (h << 6) + (h >> 2) + splitmix(&h) + } + return New(h) +} + +func (r *R) Uint64() uint64 { + s := &r.s + res := bits.RotateLeft64(s[1]*5, 7) * 9 + t := s[1] << 17 + s[2] ^= s[0] + s[3] ^= s[1] + s[1] ^= s[2] + s[0] ^= s[3] + s[2] ^= t + s[3] = bits.RotateLeft64(s[3], 45) + return res +} + +// Intn returns a uniform value in [0, n). n must be > 0. +func (r *R) Intn(n uint64) uint64 { + hi, lo := bits.Mul64(r.Uint64(), n) + if lo < n { + thresh := -n % n + for lo < thresh { + hi, lo = bits.Mul64(r.Uint64(), n) + } + } + return hi +} + +// Fixed returns a uniform value in [0, 1). +func (r *R) Fixed() fixed.F { return fixed.F(r.Uint64() >> 32) } + +// FixedRange returns a uniform value in [lo, hi). +func (r *R) FixedRange(lo, hi fixed.F) fixed.F { + return lo + r.Fixed().Mul(hi-lo) +} diff --git a/runner/runner.go b/runner/runner.go new file mode 100644 index 0000000..8c0a81f --- /dev/null +++ b/runner/runner.go @@ -0,0 +1,80 @@ +// Package runner orchestrates the once-a-day simulation against the store. +package runner + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "wh/config" + "wh/sim" + "wh/store" +) + +const ( + MetaConfig = "config" + MetaDay = "day" + MetaMarket = "market" +) + +// RunNextDay simulates the next day atomically: everything is committed +// together or not at all, so a crashed run can simply be repeated. +func RunNextDay(ctx context.Context, s *store.Store, cfg config.Config) (sim.DayResult, error) { + var res sim.DayResult + cfgJSON, err := json.Marshal(cfg) + if err != nil { + return res, err + } + err = s.WithTx(ctx, func(q *store.Q) error { + if err := q.LockWorld(ctx); err != nil { + return err + } + // Refuse to continue a world with different parameters. + if prev, ok, err := q.GetMeta(ctx, MetaConfig); err != nil { + return err + } else if ok && prev != string(cfgJSON) { + return fmt.Errorf("config differs from the one this world was created with:\n stored: %s\n current: %s", prev, cfgJSON) + } + + st, err := q.LoadState(ctx) + if err != nil { + return err + } + if st == nil { + st = sim.NewState(cfg) + if err := q.SetMeta(ctx, MetaConfig, string(cfgJSON)); err != nil { + return err + } + if err := q.SaveState(ctx, st); err != nil { // day 0 snapshot + return err + } + } + + var in sim.DayInput + if in.Launches, err = q.PendingLaunches(ctx); err != nil { + return err + } + if in.Uplinks, err = q.PendingUplinks(ctx); err != nil { + return err + } + if res, err = st.RunDay(cfg, in); err != nil { + return err + } + if err := q.SaveState(ctx, st); err != nil { + return err + } + if err := q.RecordRun(ctx, res, time.Now().UTC().Format(time.RFC3339)); err != nil { + return err + } + if err := q.AfterRun(ctx, st); err != nil { + return err + } + mk, _ := json.Marshal(st.Market.Prices) + if err := q.SetMeta(ctx, MetaMarket, string(mk)); err != nil { + return err + } + return q.SetMeta(ctx, MetaDay, fmt.Sprint(st.Day)) + }) + return res, err +} diff --git a/scripts/daily.sh b/scripts/daily.sh new file mode 100755 index 0000000..968c769 --- /dev/null +++ b/scripts/daily.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Run one simulated day inside the running container. +# +# scripts/daily.sh [daily flags...] +# +# Schedule it once a day, e.g. in crontab: +# 0 6 * * * /path/to/scripts/daily.sh >> /var/log/halcyon-daily.log 2>&1 +# +# Environment: +# HALCYON_CONTAINER container name (default: halcyon, as in docker-compose.yml) +# +# The world settings (WH_SEED, WH_ASTEROIDS, ...) come from the container's +# environment, so the daily run always matches the server. +set -euo pipefail + +container="${HALCYON_CONTAINER:-halcyon}" + +if ! docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -qx true; then + echo "error: container '$container' is not running (try: docker compose up -d)" >&2 + exit 1 +fi + +echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) running daily job in $container" +# No -t: this must work from cron, where there is no terminal. +exec docker exec "$container" daily "$@" diff --git a/sim/bus.go b/sim/bus.go new file mode 100644 index 0000000..d41e088 --- /dev/null +++ b/sim/bus.go @@ -0,0 +1,219 @@ +package sim + +import ( + "wh/config" + "wh/fixed" + "wh/world" +) + +// tickCtx is per-tick shared context; asteroid positions are computed lazily +// and cached because several ships may query them in one tick. +type tickCtx struct { + s *State + cfg config.Config + tick int + now int64 + stPos fixed.Vec + stVel fixed.Vec + res *DayResult + sold *[world.NumOre]int64 + + astPos, astVel []fixed.Vec + astDone []bool +} + +func newTickCtx(s *State, cfg config.Config, tick int, now int64, stPos, stVel fixed.Vec, res *DayResult, sold *[world.NumOre]int64) *tickCtx { + n := len(s.Asteroids) + return &tickCtx{s: s, cfg: cfg, tick: tick, now: now, stPos: stPos, stVel: stVel, res: res, sold: sold, + astPos: make([]fixed.Vec, n), astVel: make([]fixed.Vec, n), astDone: make([]bool, n)} +} + +func (tc *tickCtx) asteroid(i int) (fixed.Vec, fixed.Vec) { + if !tc.astDone[i] { + tc.astPos[i], tc.astVel[i] = tc.s.Asteroids[i].Orbit.State(tc.now) + tc.astDone[i] = true + } + return tc.astPos[i], tc.astVel[i] +} + +// shipBus is one ship's view of its peripherals for one tick. +type shipBus struct { + tc *tickCtx + sh *Ship +} + +func sat32(v int64) int32 { + if v > 1<<31-1 { + return 1<<31 - 1 + } + if v < -1<<31 { + return -1 << 31 + } + return int32(v) +} + +func kmInt(f fixed.F) int32 { return sat32(f.Floor()) } + +// mps converts km/s to whole m/s. +func mps(f fixed.F) int32 { return sat32(f.MulInt(1000).Floor()) } + +func (b *shipBus) target() (pos, vel fixed.Vec, ok bool) { + id := b.sh.Target + if id < 1 || id > int64(len(b.tc.s.Asteroids)) { + return pos, vel, false + } + pos, vel = b.tc.asteroid(int(id - 1)) + return pos, vel, true +} + +// axis returns component (port-base) of v (0=X, 1=Y, 2=Z) using conv, for +// ports laid out as three consecutive X, Y, Z registers. +func axis(v fixed.Vec, port, base uint16, conv func(fixed.F) int32) int32 { + switch port - base { + case 0: + return conv(v.X) + case 1: + return conv(v.Y) + } + return conv(v.Z) +} + +func (b *shipBus) In(port uint16) int32 { + sh, tc := b.sh, b.tc + switch { + case port == PortTick: + return int32(tc.tick) + case port == PortDay: + return sat32(tc.s.Day) + case port == PortTicks: + return int32(tc.cfg.TicksPerDay) + case port >= PortPosX && port <= PortPosZ: + return axis(sh.Pos, port, PortPosX, kmInt) + case port >= PortVelX && port <= PortVelZ: + return axis(sh.Vel, port, PortVelX, mps) + case port == PortFuel: + return sat32(sh.Fuel.Floor()) + case port == PortMass: + return sat32(sh.Mass().Floor()) + case port == PortScanNearest: + best, bestD := int64(0), fixed.Max + for i := range tc.s.Asteroids { + p, _ := tc.asteroid(i) + if d := p.Sub(sh.Pos).Len(); d < bestD { + best, bestD = int64(i+1), d + } + } + return int32(best) + case port >= PortScanRelX && port <= PortScanDist: + p, v, ok := b.target() + if !ok { + return 0 + } + rel, relV := p.Sub(sh.Pos), v.Sub(sh.Vel) + switch { + case port <= PortScanRelZ: + return axis(rel, port, PortScanRelX, kmInt) + case port <= PortScanRelVZ: + return axis(relV, port, PortScanRelVX, mps) + } + return kmInt(rel.Len()) + case port >= PortScanOre && port < PortScanOre+8: + if a := tc.s.asteroid(sh.Target); a != nil && port-PortScanOre < uint16(world.NumOre) { + return sat32(a.Ore[port-PortScanOre]) + } + return 0 + case port == PortCargo: + return sat32(sh.CargoTotal()) + case port == PortCargoCap: + return sat32(sh.Hull.CargoCap) + case port >= PortCargoOre && port < PortCargoOre+8: + if port-PortCargoOre < uint16(world.NumOre) { + return sat32(sh.Cargo[port-PortCargoOre]) + } + return 0 + case port >= PortStnRelX && port <= PortStnRelVZ: + rel, relV := tc.stPos.Sub(sh.Pos), tc.stVel.Sub(sh.Vel) + if port <= PortStnRelZ { + return axis(rel, port, PortStnRelX, kmInt) + } + return axis(relV, port, PortStnRelVX, mps) + case port == PortCredits: + return sat32(sh.Earned) + case port == PortUplinkNew: + if sh.UplinkNew { + return 1 + } + return 0 + case port == PortUplinkLen: + return sh.UplinkLen + case port == PortMathAtan2: + a := fixed.Atan2(fixed.FromInt(int64(sh.MathY)), fixed.FromInt(int64(sh.MathX))) + return sat32(a.MulInt(1000).Floor()) + case port == PortMathHypot: + return sat32(fixed.Hypot(fixed.FromInt(int64(sh.MathX)), fixed.FromInt(int64(sh.MathY))).Floor()) + case port == PortMathNorm3: + return sat32(fixed.Norm3(fixed.FromInt(int64(sh.MathX)), fixed.FromInt(int64(sh.MathY)), fixed.FromInt(int64(sh.MathZ))).Floor()) + } + return 0 +} + +func (b *shipBus) Out(port uint16, v int32) { + sh, tc := b.sh, b.tc + switch port { + case PortThrottle: + sh.Throttle = v + case PortAzimuth: + sh.Azimuth = v + case PortPitch: + sh.Pitch = v + case PortScanSelect: + sh.Target = int64(v) + case PortMine: + sh.Mining = v != 0 + case PortSell: + if v == 0 || !tc.s.canReach(sh, tc.stPos, tc.stVel, DockRangeKm) { + return + } + value := tc.s.Market.Value(sh.Cargo) + if value == 0 { + return + } + tc.s.Credits[sh.Owner] += value + sh.Earned += value + for o, kg := range sh.Cargo { + tc.sold[o] += kg + sh.Cargo[o] = 0 + } + tc.res.Events = append(tc.res.Events, Event{tc.tick, sh.ID, "sold", itoa(value) + " credits"}) + case PortUplinkNew: + sh.UplinkNew = false + case PortMathX: + sh.MathX = v + case PortMathY: + sh.MathY = v + case PortMathZ: + sh.MathZ = v + } +} + +func itoa(v int64) string { + if v == 0 { + return "0" + } + neg := v < 0 + if neg { + v = -v + } + var b [20]byte + i := len(b) + for v > 0 { + i-- + b[i] = byte('0' + v%10) + v /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} diff --git a/sim/hash.go b/sim/hash.go new file mode 100644 index 0000000..29f1091 --- /dev/null +++ b/sim/hash.go @@ -0,0 +1,56 @@ +package sim + +import ( + "crypto/sha256" + "encoding/binary" + "sort" +) + +// Hash returns a digest of the complete simulation state. Two runs from the +// same seed and inputs must produce identical hashes. +func (s *State) Hash() [32]byte { + h := sha256.New() + w := func(vs ...int64) { + var b [8]byte + for _, v := range vs { + binary.LittleEndian.PutUint64(b[:], uint64(v)) + h.Write(b[:]) + } + } + w(s.Day, int64(len(s.Asteroids))) + for i := range s.Asteroids { + a := &s.Asteroids[i] + w(a.ID) + for _, o := range a.Ore { + w(o) + } + } + w(int64(len(s.Ships))) + for _, sh := range s.Ships { + alive := int64(0) + if sh.Alive { + alive = 1 + } + w(sh.ID, sh.Owner, alive, int64(sh.Pos.X), int64(sh.Pos.Y), int64(sh.Pos.Z), + int64(sh.Vel.X), int64(sh.Vel.Y), int64(sh.Vel.Z), + int64(sh.Fuel), sh.Earned, int64(sh.Throttle), int64(sh.Azimuth), int64(sh.Pitch), sh.Target) + for _, c := range sh.Cargo { + w(c) + } + h.Write(sh.CPU.MarshalState()) + } + owners := make([]int64, 0, len(s.Credits)) + for o := range s.Credits { + owners = append(owners, o) + } + sort.Slice(owners, func(i, j int) bool { return owners[i] < owners[j] }) + for _, o := range owners { + w(o, s.Credits[o]) + } + for _, v := range s.Market.Supply { + w(v) + } + var out [32]byte + copy(out[:], h.Sum(nil)) + return out +} diff --git a/sim/ports.go b/sim/ports.go new file mode 100644 index 0000000..363910a --- /dev/null +++ b/sim/ports.go @@ -0,0 +1,67 @@ +package sim + +// Peripheral port numbers. Values are int32; positions are kilometres, +// velocities metres/second, angles milliradians, masses kilograms. Axes are +// right-handed with Z perpendicular to the ecliptic; azimuth is measured from +// +X towards +Y and elevation (pitch) up from the XY plane towards +Z. +const ( + // System. + PortTick = 0x00 // in: tick within the current day + PortDay = 0x01 // in: day number + PortTicks = 0x02 // in: ticks per day + + // Navigation (relative to the star). + PortPosX = 0x10 + PortPosY = 0x11 + PortPosZ = 0x12 + PortVelX = 0x13 + PortVelY = 0x14 + PortVelZ = 0x15 + + // Engine. + PortThrottle = 0x20 // out: 0..1000 permille + PortAzimuth = 0x21 // out: thrust direction azimuth, milliradians + PortPitch = 0x22 // out: thrust direction elevation, milliradians + PortFuel = 0x23 // in: fuel kg + PortMass = 0x24 // in: total mass kg + + // Scanner. + PortScanSelect = 0x30 // out: asteroid id to track (0 clears) + PortScanNearest = 0x31 // in: id of nearest asteroid + PortScanRelX = 0x32 // in: target position relative to ship, km + PortScanRelY = 0x33 + PortScanRelZ = 0x34 + PortScanRelVX = 0x35 // in: target velocity relative to ship, m/s + PortScanRelVY = 0x36 + PortScanRelVZ = 0x37 + PortScanDist = 0x38 // in: distance to target, km + PortScanOre = 0x40 // in: PortScanOre+ore = kg of ore remaining (8 ports) + + // Mining laser and cargo hold. + PortMine = 0x50 // out: 1 to mine the selected asteroid, 0 to stop + PortCargo = 0x51 // in: total cargo kg + PortCargoCap = 0x52 // in: cargo capacity kg + PortCargoOre = 0x58 // in: PortCargoOre+ore = kg of ore carried (8 ports) + + // Dropoff station. + PortStnRelX = 0x60 // in: station position relative to ship, km + PortStnRelY = 0x61 + PortStnRelZ = 0x62 + PortStnRelVX = 0x63 // in: station velocity relative to ship, m/s + PortStnRelVY = 0x64 + PortStnRelVZ = 0x65 + PortSell = 0x66 // out: 1 to sell all cargo (needs to be docked) + PortCredits = 0x67 // in: credits earned by this ship (saturating) + + // Comms buffers live in RAM; these ports coordinate them. + PortUplinkNew = 0x70 // in: 1 if an uplink arrived this day; out: any value acknowledges + PortUplinkLen = 0x71 // in: uplink length in bytes + + // Math coprocessor. Write operands, then read a result. + PortMathX = 0x80 // out + PortMathY = 0x81 // out + PortMathZ = 0x82 // out + PortMathAtan2 = 0x83 // in: atan2(y, x) in milliradians + PortMathHypot = 0x84 // in: sqrt(x*x + y*y) + PortMathNorm3 = 0x85 // in: sqrt(x*x + y*y + z*z) +) diff --git a/sim/sim_test.go b/sim/sim_test.go new file mode 100644 index 0000000..c5eb1aa --- /dev/null +++ b/sim/sim_test.go @@ -0,0 +1,211 @@ +package sim + +import ( + "math" + "testing" + + "wh/config" + "wh/fixed" + "wh/vm" +) + +func testCfg() config.Config { + c := config.Default() + c.AsteroidCount = 20 + return c +} + +func mustAsm(t *testing.T, src string) []byte { + t.Helper() + p, err := vm.Assemble(src) + if err != nil { + t.Fatal(err) + } + return p +} + +func addShip(t *testing.T, s *State, cfg config.Config, id int64, prog []byte, pos, vel fixed.Vec) *Ship { + t.Helper() + cpu, err := vm.New(prog, cfg.RAMBytes) + if err != nil { + t.Fatal(err) + } + sh := &Ship{ID: id, Owner: 1, Hull: CommandShip, Pos: pos, Vel: vel, + Fuel: fixed.FromInt(CommandShip.FuelCap), CPU: cpu, Alive: true} + s.Ships = append(s.Ships, sh) + return sh +} + +const burner = ` + ldi r0, 1000 + out 0x20, r0 ; full throttle + ldi r0, 500 + out 0x21, r0 ; azimuth 0.5 rad + ldi r0, 300 + out 0x22, r0 ; pitch 0.3 rad (out of the ecliptic plane) +loop: + yield + jmp loop +` + +func TestDeterministic(t *testing.T) { + cfg := testCfg() + run := func() [32]byte { + s := NewState(cfg) + in := DayInput{ + Launches: []Launch{{ShipID: 1, Owner: 1, Program: mustAsm(t, burner)}}, + Uplinks: []Uplink{{ShipID: 1, Data: []byte("hello")}}, + } + var res DayResult + var err error + for d := 0; d < 3; d++ { + res, err = s.RunDay(cfg, in) + if err != nil { + t.Fatal(err) + } + in = DayInput{} + } + return res.Hash + } + if a, b := run(), run(); a != b { + t.Fatal("simulation is not deterministic") + } +} + +func TestCircularOrbitHolds(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + pos, vel := s.Station.Orbit.State(0) + sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), pos, vel) + r0 := pos.Len().Float64() + for d := 0; d < 5; d++ { + if _, err := s.RunDay(cfg, DayInput{}); err != nil { + t.Fatal(err) + } + } + r := sh.Pos.Len().Float64() + if !sh.Alive || r < r0*0.99 || r > r0*1.01 { + t.Fatalf("radius drifted from %.0f to %.0f km", r0, r) + } +} + +func TestBurnUsesFuelAndChangesVelocity(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + pos, vel := s.Station.Orbit.State(0) + sh := addShip(t, s, cfg, 1, mustAsm(t, burner), pos, vel) + if _, err := s.RunDay(cfg, DayInput{}); err != nil { + t.Fatal(err) + } + if sh.Fuel >= fixed.FromInt(CommandShip.FuelCap) || sh.Fuel < 0 { + t.Fatalf("fuel = %v", sh.Fuel.Float64()) + } + dv := sh.Vel.Sub(vel) + if dv.Len() < fixed.FromRatio(1, 10) { + t.Fatalf("velocity barely changed: %v", dv.Len().Float64()) + } + // Thrust at 0.3 rad pitch must push the ship out of the ecliptic: the + // station's orbit is in-plane and gravity is central, so any Z velocity + // comes from the engine. + if dv.Z <= 0 { + t.Fatalf("expected upward velocity change, got dz=%v", dv.Z.Float64()) + } + // The burn direction should match azimuth 0.5 / pitch 0.3 (gravity is + // small next to a ~10 km/s burn). + wantZ := math.Sin(0.3) + if got := dv.Z.Float64() / dv.Len().Float64(); math.Abs(got-wantZ) > 0.05 { + t.Fatalf("burn elevation: sin = %.3f, want %.3f", got, wantZ) + } + wantAz := 0.5 + if got := math.Atan2(dv.Y.Float64(), dv.X.Float64()); math.Abs(got-wantAz) > 0.05 { + t.Fatalf("burn azimuth = %.3f, want %.3f", got, wantAz) + } +} + +func TestInclinedOrbitLeavesPlane(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + // Find an asteroid with a noticeable inclination and confirm a ship on its + // orbit stays on a plane that is not the ecliptic. + var best int + for i := range s.Asteroids { + if s.Asteroids[i].Orbit.Inc > s.Asteroids[best].Orbit.Inc { + best = i + } + } + o := s.Asteroids[best].Orbit + if o.Inc < fixed.FromRatio(1, 20) { + t.Skip("no inclined asteroid in this seed") + } + var maxZ fixed.F + for k := int64(0); k < 20; k++ { + p, _ := o.State(k * o.Period / 20) + maxZ = fixed.Max2(maxZ, p.Z.Abs()) + } + if maxZ < fixed.FromInt(1000) { + t.Fatalf("inclined orbit never left the plane: max |z| = %v km", maxZ.Float64()) + } +} + +func TestMineAndSell(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + // Sit on asteroid 1, matching its motion for the whole first tick, and mine. + pos, vel := s.Asteroids[0].Orbit.State(0) + sh := addShip(t, s, cfg, 1, mustAsm(t, ` + ldi r0, 1 + out 0x30, r0 ; select asteroid 1 + out 0x50, r0 ; mine + yield + jmp -2 + `), pos, vel) + if _, err := s.RunDay(cfg, DayInput{}); err != nil { + t.Fatal(err) + } + // Gravity acts on the ship differently than the rails, so it soon drifts + // out of range; it should still have mined something at the start. + if sh.CargoTotal() == 0 { + t.Fatal("nothing mined") + } + + // Now dock at the station and sell. + s2 := NewState(cfg) + sp, sv := s2.Station.Orbit.State(0) + sh2 := addShip(t, s2, cfg, 1, mustAsm(t, "ldi r0, 1\n out 0x66, r0\n halt"), sp, sv) + sh2.Cargo[0] = 1000 // iron + if _, err := s2.RunDay(cfg, DayInput{}); err != nil { + t.Fatal(err) + } + if s2.Credits[1] != 2000 || sh2.CargoTotal() != 0 { + t.Fatalf("credits=%d cargo=%d", s2.Credits[1], sh2.CargoTotal()) + } + if s2.Market.Prices[0] >= 2 && s2.Market.Supply[0] != 1000 { + t.Fatalf("market not updated: %+v", s2.Market) + } +} + +func TestCommsBuffers(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + pos, vel := s.Station.Orbit.State(0) + // Copy the first uplink word into the TX buffer, then ack. + prog := mustAsm(t, ` + .equ TX 1024 + in r1, 0x70 + ldi r2, 0 + beq r1, r2, done + ldw r3, [r2] + stw r3, [r2+TX] + out 0x70, r1 + done: + halt + `) + addShip(t, s, cfg, 1, prog, pos, vel) + res, err := s.RunDay(cfg, DayInput{Uplinks: []Uplink{{ShipID: 1, Data: []byte("PING")}}}) + if err != nil { + t.Fatal(err) + } + if len(res.Downlinks) != 1 || string(res.Downlinks[0].Data[:4]) != "PING" { + t.Fatalf("downlink = %q", res.Downlinks) + } +} diff --git a/sim/state.go b/sim/state.go new file mode 100644 index 0000000..185454d --- /dev/null +++ b/sim/state.go @@ -0,0 +1,150 @@ +// Package sim is the deterministic core: given a State, the day's inputs and +// a Config it advances the world by one day. It performs no I/O. +package sim + +import ( + "fmt" + + "wh/config" + "wh/fixed" + "wh/market" + "wh/vm" + "wh/world" +) + +// Hull describes the physical properties of a ship design. +type Hull struct { + DryMass int64 // kg + FuelCap int64 // kg + CargoCap int64 // kg + Thrust int64 // newtons at full throttle + ExhaustVel int64 // m/s + MineRate int64 // kg mined per tick +} + +// CommandShip is the only hull for now. +var CommandShip = Hull{ + DryMass: 8000, + FuelCap: 4000, + CargoCap: 6000, + Thrust: 6000, + ExhaustVel: 30000, + MineRate: 10, +} + +// Docking / mining limits. +const ( + MineRangeKm = 5 // max distance to an asteroid for mining + DockRangeKm = 20 // max distance to the station for selling + MaxRelSpeedM = 100 // max relative speed (m/s) for mining or docking + StarRadiusKm = 200_000 +) + +type Ship struct { + ID int64 + Owner int64 + Hull Hull + Pos fixed.Vec + Vel fixed.Vec + Fuel fixed.F + Cargo [world.NumOre]int64 + CPU *vm.CPU + Alive bool + Earned int64 // credits earned so far + + // Peripheral state. + Throttle int32 + Azimuth int32 + Pitch int32 + Target int64 + Mining bool + UplinkNew bool + UplinkLen int32 + MathX int32 + MathY int32 + MathZ int32 +} + +func (s *Ship) CargoTotal() int64 { + var t int64 + for _, v := range s.Cargo { + t += v + } + return t +} + +func (s *Ship) Mass() fixed.F { + return fixed.FromInt(s.Hull.DryMass+s.CargoTotal()) + s.Fuel +} + +// State is everything that persists between daily runs. +type State struct { + Day int64 + Asteroids []world.Asteroid // sorted by ID + Station world.Station + Ships []*Ship // sorted by ID + Credits map[int64]int64 + Market market.Market +} + +// NewState builds the initial world from the config seed. +func NewState(cfg config.Config) *State { + asts, st := world.Generate(cfg) + return &State{ + Asteroids: asts, + Station: st, + Credits: map[int64]int64{}, + Market: market.New(), + } +} + +func (s *State) asteroid(id int64) *world.Asteroid { + // Asteroids are numbered 1..N in slice order. + if id >= 1 && id <= int64(len(s.Asteroids)) { + return &s.Asteroids[id-1] + } + return nil +} + +// Launch describes a ship entering the belt at the start of a day. +type Launch struct { + ShipID int64 + Owner int64 + Program []byte +} + +// Uplink is a message delivered to a ship's comm buffer at the start of a day. +type Uplink struct { + ShipID int64 + Data []byte +} + +// DayInput is everything external the simulation consumes for one day. +type DayInput struct { + Launches []Launch // processed in slice order (must be deterministic) + Uplinks []Uplink +} + +// Downlink is the content of a ship's transmit buffer at the end of the day. +type Downlink struct { + ShipID int64 + Data []byte +} + +type Event struct { + Tick int + ShipID int64 + Kind string + Detail string +} + +type DayResult struct { + Day int64 + Downlinks []Downlink + Events []Event + Hash [32]byte +} + +func (e Event) String() string { + return fmt.Sprintf("t%04d ship %d %s %s", e.Tick, e.ShipID, e.Kind, e.Detail) +} diff --git a/sim/step.go b/sim/step.go new file mode 100644 index 0000000..2b3219f --- /dev/null +++ b/sim/step.go @@ -0,0 +1,175 @@ +package sim + +import ( + "fmt" + "sort" + + "wh/config" + "wh/fixed" + "wh/vm" + "wh/world" +) + +// RunDay simulates one full day, mutating the state, and returns the result. +func (s *State) RunDay(cfg config.Config, in DayInput) (DayResult, error) { + if err := cfg.Validate(); err != nil { + return DayResult{}, err + } + res := DayResult{Day: s.Day} + dayStart := s.Day * config.SecondsPerDay + dt := int64(cfg.TickSeconds()) + + // Launches: ships appear at the station, matching its velocity. + stPos, stVel := s.Station.Orbit.State(dayStart) + for _, l := range in.Launches { + if len(l.Program) > cfg.ProgramBytes { + return res, fmt.Errorf("ship %d: program of %d bytes exceeds limit %d", l.ShipID, len(l.Program), cfg.ProgramBytes) + } + cpu, err := vm.New(l.Program, cfg.RAMBytes) + if err != nil { + return res, fmt.Errorf("ship %d: %w", l.ShipID, err) + } + s.Ships = append(s.Ships, &Ship{ + ID: l.ShipID, Owner: l.Owner, Hull: CommandShip, + Pos: stPos, Vel: stVel, Fuel: fixed.FromInt(CommandShip.FuelCap), + CPU: cpu, Alive: true, + }) + res.Events = append(res.Events, Event{0, l.ShipID, "launch", ""}) + } + sort.Slice(s.Ships, func(i, j int) bool { return s.Ships[i].ID < s.Ships[j].ID }) + + // Uplinks land in the RX buffer at the start of RAM. + for _, u := range in.Uplinks { + sh := s.ship(u.ShipID) + if sh == nil || !sh.Alive { + continue + } + n := len(u.Data) + if n > cfg.CommBytes { + n = cfg.CommBytes + } + copy(sh.CPU.RAM[:cfg.CommBytes], u.Data[:n]) + sh.UplinkNew, sh.UplinkLen = true, int32(n) + } + + var sold [world.NumOre]int64 + for tick := 0; tick < cfg.TicksPerDay; tick++ { + now := dayStart + int64(tick)*dt + stPos, stVel = s.Station.Orbit.State(now) + tc := newTickCtx(s, cfg, tick, now, stPos, stVel, &res, &sold) + for _, sh := range s.Ships { + if !sh.Alive { + continue + } + sh.CPU.Run(&shipBus{tc: tc, sh: sh}, cfg.CyclesPerTick) + s.physics(cfg, sh, dt, tick, &res) + if sh.Alive && sh.Mining { + s.mine(sh, tc.now+dt) + } + } + } + + for _, sh := range s.Ships { + if sh.Alive { + res.Downlinks = append(res.Downlinks, Downlink{ + ShipID: sh.ID, + Data: append([]byte(nil), sh.CPU.RAM[cfg.CommBytes:2*cfg.CommBytes]...), + }) + } + sh.UplinkNew = false + } + s.Market.EndOfDay(sold) + s.Day++ + res.Hash = s.Hash() + return res, nil +} + +func (s *State) ship(id int64) *Ship { + i := sort.Search(len(s.Ships), func(i int) bool { return s.Ships[i].ID >= id }) + if i < len(s.Ships) && s.Ships[i].ID == id { + return s.Ships[i] + } + return nil +} + +// physics applies gravity and engine thrust for one tick (semi-implicit Euler). +func (s *State) physics(cfg config.Config, sh *Ship, dt int64, tick int, res *DayResult) { + dtF := fixed.FromInt(dt) + // Engine. + if sh.Throttle > 0 && sh.Fuel > 0 { + th := int64(sh.Throttle) + if th > 1000 { + th = 1000 + } + thrust := sh.Hull.Thrust * th / 1000 + burn := fixed.FromInt(thrust * dt).Div(fixed.FromInt(sh.Hull.ExhaustVel)) + if burn > sh.Fuel { + // Partial burn: scale the impulse by the remaining fuel. + thrust = thrust * int64(sh.Fuel) / int64(burn) + burn = sh.Fuel + } + // dv (km/s) = thrust*dt / mass / 1000. + dv := fixed.FromInt(thrust * dt).Div(sh.Mass()).DivInt(1000) + sh.Vel = sh.Vel.Add(thrustDir(sh).Scale(dv)) + sh.Fuel -= burn + } + // The star pulls with acceleration v^2/r, so dv = v*v*dt/r toward it. + r := sh.Pos.Len() + if r < fixed.FromInt(StarRadiusKm) { + sh.Alive = false + res.Events = append(res.Events, Event{tick, sh.ID, "destroyed", "fell into the star"}) + return + } + g := cfg.OrbitSpeed.Mul(cfg.OrbitSpeed).Mul(dtF).Div(r) + sh.Vel = sh.Vel.Sub(sh.Pos.Unit().Scale(g)) + sh.Pos = sh.Pos.Add(sh.Vel.Scale(dtF)) +} + +func (s *State) canReach(sh *Ship, pos, vel fixed.Vec, rangeKm int64) bool { + rel := pos.Sub(sh.Pos) + relV := vel.Sub(sh.Vel) + return rel.Len() <= fixed.FromInt(rangeKm) && + relV.Len() <= fixed.FromRatio(MaxRelSpeedM, 1000) +} + +// mine moves ore from the target asteroid into the ship's hold, split +// proportionally to the asteroid's composition. at is the absolute time (s) +// at which range is evaluated. +func (s *State) mine(sh *Ship, at int64) { + a := s.asteroid(sh.Target) + if a == nil { + return + } + total := a.TotalOre() + room := sh.Hull.CargoCap - sh.CargoTotal() + amt := min(sh.Hull.MineRate, room, total) + if amt <= 0 { + return + } + pos, vel := a.Orbit.State(at) + if !s.canReach(sh, pos, vel, MineRangeKm) { + return + } + var taken [world.NumOre]int64 + var sum int64 + for o := range taken { + taken[o] = amt * a.Ore[o] / total + sum += taken[o] + } + for o := range taken { + extra := min(amt-sum, a.Ore[o]-taken[o]) + taken[o] += extra + sum += extra + } + for o, kg := range taken { + a.Ore[o] -= kg + sh.Cargo[o] += kg + } +} + +// thrustDir converts the ship's azimuth/pitch (milliradians) to a unit vector. +func thrustDir(sh *Ship) fixed.Vec { + az := fixed.FromRatio(int64(sh.Azimuth), 1000) + el := fixed.FromRatio(int64(sh.Pitch), 1000) + return fixed.FromSpherical(az, el) +} diff --git a/store/entities.go b/store/entities.go new file mode 100644 index 0000000..5427b75 --- /dev/null +++ b/store/entities.go @@ -0,0 +1,284 @@ +package store + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/gob" + "encoding/hex" + "errors" + "fmt" + + "wh/sim" +) + +// Ship statuses. +const ( + StatusInventory = "inventory" // built, can be programmed + StatusLaunching = "launching" // enters the belt on the next run + StatusActive = "active" // in the belt + StatusDestroyed = "destroyed" +) + +var ( + ErrNotFound = errors.New("not found") + ErrState = errors.New("ship is not in the right state for that") + ErrTaken = errors.New("name already taken") +) + +type Player struct { + ID int64 + Name string + Credits int64 +} + +type Ship struct { + ID int64 + OwnerID int64 + Status string + ProgramSize int + DownlinkDay int64 // -1 if none yet +} + +func HashKey(key string) string { + h := sha256.Sum256([]byte(key)) + return hex.EncodeToString(h[:]) +} + +// Register creates a player with one command ship in inventory and returns +// the player's API key (shown once; only its hash is stored). +func (s *Store) Register(ctx context.Context, name string) (Player, string, int64, error) { + var ( + p Player + shipID int64 + key string + ) + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return p, "", 0, err + } + key = hex.EncodeToString(b) + err := s.WithTx(ctx, func(q *Q) error { + var n int + if err := q.row(ctx, `SELECT COUNT(*) FROM players WHERE name = ?`, name).Scan(&n); err != nil { + return err + } + if n > 0 { + return ErrTaken + } + if err := q.row(ctx, `INSERT INTO players (name, key_hash) VALUES (?, ?) RETURNING id`, + name, HashKey(key)).Scan(&p.ID); err != nil { + return err + } + p.Name = name + return q.row(ctx, `INSERT INTO ships (owner_id, status) VALUES (?, ?) RETURNING id`, + p.ID, StatusInventory).Scan(&shipID) + }) + return p, key, shipID, err +} + +func (q *Q) PlayerByKey(ctx context.Context, key string) (Player, error) { + var p Player + err := q.row(ctx, `SELECT id, name, credits FROM players WHERE key_hash = ?`, HashKey(key)). + Scan(&p.ID, &p.Name, &p.Credits) + if err == sql.ErrNoRows { + return p, ErrNotFound + } + return p, err +} + +func (q *Q) PlayerShips(ctx context.Context, owner int64) ([]Ship, error) { + rows, err := q.query(ctx, `SELECT id, owner_id, status, COALESCE(LENGTH(program), 0), downlink_day + FROM ships WHERE owner_id = ? ORDER BY id`, owner) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Ship + for rows.Next() { + var sh Ship + if err := rows.Scan(&sh.ID, &sh.OwnerID, &sh.Status, &sh.ProgramSize, &sh.DownlinkDay); err != nil { + return nil, err + } + out = append(out, sh) + } + return out, rows.Err() +} + +// ownedStatus returns the ship's status if owner owns it. +func (q *Q) ownedStatus(ctx context.Context, owner, ship int64) (string, error) { + var st string + err := q.row(ctx, `SELECT status FROM ships WHERE id = ? AND owner_id = ?`, ship, owner).Scan(&st) + if err == sql.ErrNoRows { + return "", ErrNotFound + } + return st, err +} + +func (q *Q) requireOneRow(res sql.Result, err error) error { + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrState + } + return nil +} + +// SetProgram replaces the program of a ship that is still in inventory. +func (q *Q) SetProgram(ctx context.Context, owner, ship int64, prog []byte) error { + if _, err := q.ownedStatus(ctx, owner, ship); err != nil { + return err + } + res, err := q.exec(ctx, `UPDATE ships SET program = ? WHERE id = ? AND owner_id = ? AND status = ?`, + prog, ship, owner, StatusInventory) + return q.requireOneRow(res, err) +} + +// Launch queues an inventory ship (which must have a program) to enter the +// belt on the next daily run. +func (q *Q) Launch(ctx context.Context, owner, ship int64) error { + if _, err := q.ownedStatus(ctx, owner, ship); err != nil { + return err + } + res, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE id = ? AND owner_id = ? AND status = ? + AND program IS NOT NULL`, StatusLaunching, ship, owner, StatusInventory) + return q.requireOneRow(res, err) +} + +// SetUplink stores the message delivered on the next run, replacing any +// message already queued. +func (q *Q) SetUplink(ctx context.Context, owner, ship int64, data []byte) error { + if _, err := q.ownedStatus(ctx, owner, ship); err != nil { + return err + } + res, err := q.exec(ctx, `UPDATE ships SET uplink = ? WHERE id = ? AND owner_id = ? AND status IN (?, ?)`, + data, ship, owner, StatusActive, StatusLaunching) + return q.requireOneRow(res, err) +} + +// Downlink returns the latest downlink and the day it was produced. +func (q *Q) Downlink(ctx context.Context, owner, ship int64) ([]byte, int64, error) { + var ( + data []byte + day int64 + ) + err := q.row(ctx, `SELECT downlink, downlink_day FROM ships WHERE id = ? AND owner_id = ?`, ship, owner). + Scan(&data, &day) + if err == sql.ErrNoRows { + return nil, 0, ErrNotFound + } + return data, day, err +} + +// PendingLaunches returns ships due to launch, ordered by id. +func (q *Q) PendingLaunches(ctx context.Context) ([]sim.Launch, error) { + rows, err := q.query(ctx, `SELECT id, owner_id, program FROM ships WHERE status = ? ORDER BY id`, StatusLaunching) + if err != nil { + return nil, err + } + defer rows.Close() + var out []sim.Launch + for rows.Next() { + var l sim.Launch + if err := rows.Scan(&l.ShipID, &l.Owner, &l.Program); err != nil { + return nil, err + } + out = append(out, l) + } + return out, rows.Err() +} + +// PendingUplinks returns queued uplinks, ordered by ship id. +func (q *Q) PendingUplinks(ctx context.Context) ([]sim.Uplink, error) { + rows, err := q.query(ctx, `SELECT id, uplink FROM ships WHERE uplink IS NOT NULL ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []sim.Uplink + for rows.Next() { + var u sim.Uplink + if err := rows.Scan(&u.ShipID, &u.Data); err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +// LoadState returns the most recent world snapshot, or nil if none exists. +func (q *Q) LoadState(ctx context.Context) (*sim.State, error) { + var data []byte + err := q.row(ctx, `SELECT data FROM snapshots ORDER BY day DESC LIMIT 1`).Scan(&data) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + var st sim.State + if err := gob.NewDecoder(bytes.NewReader(data)).Decode(&st); err != nil { + return nil, fmt.Errorf("decode snapshot: %w", err) + } + return &st, nil +} + +func (q *Q) SaveState(ctx context.Context, st *sim.State) error { + var buf bytes.Buffer + if err := gob.NewEncoder(&buf).Encode(st); err != nil { + return err + } + h := st.Hash() + _, err := q.exec(ctx, `INSERT INTO snapshots (day, hash, data) VALUES (?, ?, ?)`, + st.Day, hex.EncodeToString(h[:]), buf.Bytes()) + return err +} + +// RecordRun writes the outputs of a completed day. +func (q *Q) RecordRun(ctx context.Context, res sim.DayResult, ranAt string) error { + h := res.Hash + if _, err := q.exec(ctx, `INSERT INTO day_runs (day, hash, ran_at) VALUES (?, ?, ?)`, + res.Day, hex.EncodeToString(h[:]), ranAt); err != nil { + return err + } + for _, e := range res.Events { + if _, err := q.exec(ctx, `INSERT INTO events (day, tick, ship_id, kind, detail) VALUES (?, ?, ?, ?, ?)`, + res.Day, e.Tick, e.ShipID, e.Kind, e.Detail); err != nil { + return err + } + } + for _, d := range res.Downlinks { + if _, err := q.exec(ctx, `UPDATE ships SET downlink = ?, downlink_day = ? WHERE id = ?`, + d.Data, res.Day, d.ShipID); err != nil { + return err + } + } + return nil +} + +// AfterRun clears delivered uplinks, activates launched ships, marks +// destroyed ones and updates player balances. +func (q *Q) AfterRun(ctx context.Context, st *sim.State) error { + if _, err := q.exec(ctx, `UPDATE ships SET uplink = NULL WHERE uplink IS NOT NULL`); err != nil { + return err + } + if _, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE status = ?`, StatusActive, StatusLaunching); err != nil { + return err + } + for _, sh := range st.Ships { + if !sh.Alive { + if _, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE id = ?`, StatusDestroyed, sh.ID); err != nil { + return err + } + } + } + for owner, credits := range st.Credits { + if _, err := q.exec(ctx, `UPDATE players SET credits = ? WHERE id = ?`, credits, owner); err != nil { + return err + } + } + return nil +} diff --git a/store/store.go b/store/store.go new file mode 100644 index 0000000..50d7e93 --- /dev/null +++ b/store/store.go @@ -0,0 +1,177 @@ +// Package store persists players, ships and world snapshots in SQLite or +// PostgreSQL through database/sql. +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" + + _ "github.com/jackc/pgx/v5/stdlib" + _ "modernc.org/sqlite" +) + +type Dialect int + +const ( + SQLite Dialect = iota + Postgres +) + +// Store is a database handle. Methods on the embedded Q run directly on the +// pool; use WithTx for atomic multi-step work. +type Store struct { + *Q + db *sql.DB +} + +type execer interface { + ExecContext(ctx context.Context, q string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, q string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, q string, args ...any) *sql.Row +} + +// Q holds the query methods; it wraps either a pool or a transaction. +type Q struct { + x execer + d Dialect + tx bool +} + +// Open connects using dsn. A "postgres://" or "postgresql://" URL selects +// PostgreSQL; anything else is a SQLite file path (optionally prefixed with +// "sqlite:"). +func Open(dsn string) (*Store, error) { + var ( + driver string + d Dialect + ) + switch { + case strings.HasPrefix(dsn, "postgres://"), strings.HasPrefix(dsn, "postgresql://"): + driver, d = "pgx", Postgres + default: + path := strings.TrimPrefix(dsn, "sqlite:") + sep := "?" + if strings.Contains(path, "?") { + sep = "&" + } + dsn = "file:" + path + sep + "_txlock=immediate&_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)" + driver, d = "sqlite", SQLite + } + db, err := sql.Open(driver, dsn) + if err != nil { + return nil, err + } + if d == SQLite { + db.SetMaxOpenConns(1) // SQLite allows a single writer; keep it simple + } + return &Store{Q: &Q{x: db, d: d}, db: db}, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +// WithTx runs fn in a transaction, committing if it returns nil. +func (s *Store) WithTx(ctx context.Context, fn func(q *Q) error) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + if err := fn(&Q{x: tx, d: s.d, tx: true}); err != nil { + tx.Rollback() + return err + } + return tx.Commit() +} + +// rebind converts ? placeholders to $n for PostgreSQL. +func (q *Q) rebind(query string) string { + if q.d != Postgres { + return query + } + var b strings.Builder + n := 0 + for _, r := range query { + if r == '?' { + n++ + fmt.Fprintf(&b, "$%d", n) + } else { + b.WriteRune(r) + } + } + return b.String() +} + +func (q *Q) exec(ctx context.Context, query string, args ...any) (sql.Result, error) { + return q.x.ExecContext(ctx, q.rebind(query), args...) +} + +func (q *Q) query(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + return q.x.QueryContext(ctx, q.rebind(query), args...) +} + +func (q *Q) row(ctx context.Context, query string, args ...any) *sql.Row { + return q.x.QueryRowContext(ctx, q.rebind(query), args...) +} + +var schema = []string{ + `CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS players ( + id {ID}, name TEXT NOT NULL UNIQUE, key_hash TEXT NOT NULL UNIQUE, + credits BIGINT NOT NULL DEFAULT 0)`, + `CREATE TABLE IF NOT EXISTS ships ( + id {ID}, owner_id BIGINT NOT NULL REFERENCES players(id), + status TEXT NOT NULL, program {BLOB}, uplink {BLOB}, downlink {BLOB}, + downlink_day BIGINT NOT NULL DEFAULT -1)`, + `CREATE INDEX IF NOT EXISTS ships_owner ON ships(owner_id)`, + `CREATE INDEX IF NOT EXISTS ships_status ON ships(status)`, + `CREATE TABLE IF NOT EXISTS snapshots ( + day BIGINT PRIMARY KEY, hash TEXT NOT NULL, data {BLOB} NOT NULL)`, + `CREATE TABLE IF NOT EXISTS events ( + id {ID}, day BIGINT NOT NULL, tick INTEGER NOT NULL, + ship_id BIGINT NOT NULL, kind TEXT NOT NULL, detail TEXT NOT NULL)`, + `CREATE INDEX IF NOT EXISTS events_day ON events(day)`, + `CREATE TABLE IF NOT EXISTS day_runs ( + day BIGINT PRIMARY KEY, hash TEXT NOT NULL, ran_at TEXT NOT NULL)`, + `INSERT INTO meta (key, value) VALUES ('lock', '') ON CONFLICT (key) DO NOTHING`, +} + +// Migrate creates the schema if it does not exist. +func (s *Store) Migrate(ctx context.Context) error { + id, blob := "INTEGER PRIMARY KEY AUTOINCREMENT", "BLOB" + if s.d == Postgres { + id, blob = "BIGSERIAL PRIMARY KEY", "BYTEA" + } + r := strings.NewReplacer("{ID}", id, "{BLOB}", blob) + for _, stmt := range schema { + if _, err := s.exec(ctx, r.Replace(stmt)); err != nil { + return fmt.Errorf("migrate: %w", err) + } + } + return nil +} + +// LockWorld serialises daily runs. SQLite transactions are already exclusive +// (immediate mode); PostgreSQL takes a row lock held until commit. +func (q *Q) LockWorld(ctx context.Context) error { + if q.d != Postgres { + return nil + } + var v string + return q.row(ctx, `SELECT value FROM meta WHERE key = 'lock' FOR UPDATE`).Scan(&v) +} + +func (q *Q) GetMeta(ctx context.Context, key string) (string, bool, error) { + var v string + err := q.row(ctx, `SELECT value FROM meta WHERE key = ?`, key).Scan(&v) + if err == sql.ErrNoRows { + return "", false, nil + } + return v, err == nil, err +} + +func (q *Q) SetMeta(ctx context.Context, key, value string) error { + _, err := q.exec(ctx, `INSERT INTO meta (key, value) VALUES (?, ?) + ON CONFLICT (key) DO UPDATE SET value = excluded.value`, key, value) + return err +} diff --git a/vm/asm.go b/vm/asm.go new file mode 100644 index 0000000..6ec9aec --- /dev/null +++ b/vm/asm.go @@ -0,0 +1,305 @@ +package vm + +import ( + "encoding/binary" + "fmt" + "strconv" + "strings" +) + +var mnemonics = map[string]Op{ + "nop": NOP, "yield": YIELD, "halt": HALT, "ldi": LDI, "lui": LUI, "mov": MOV, + "add": ADD, "sub": SUB, "mul": MUL, "div": DIV, "mod": MOD, "and": AND, + "or": OR, "xor": XOR, "shl": SHL, "shr": SHR, "sar": SAR, "addi": ADDI, + "jmp": JMP, "beq": BEQ, "bne": BNE, "blt": BLT, "bge": BGE, "call": CALL, + "ret": RET, "push": PUSH, "pop": POP, "ldb": LDB, "ldh": LDH, "ldw": LDW, + "stb": STB, "sth": STH, "stw": STW, "in": IN, "out": OUT, +} + +// Assemble converts assembly text into a program. Syntax: +// +// label: define a label +// .equ NAME value define a constant +// li rd, value pseudo-op: load a 32-bit constant (always 2 instructions) +// ldw rd, [rb+off] memory access (also ldb/ldh/stb/sth/stw) +// in rd, port | out port, rs +// beq ra, rb, label branches and jmp/call take labels +// +// Registers are r0..r15 (sp = r15). Comments start with ';' or '#'. +func Assemble(src string) ([]byte, error) { + type line struct { + no int + text string + } + var lines []line + consts := map[string]int64{} + labels := map[string]int{} + n := 0 // instruction count + for i, raw := range strings.Split(src, "\n") { + t := raw + if j := strings.IndexAny(t, ";#"); j >= 0 { + t = t[:j] + } + t = strings.TrimSpace(t) + for { + j := strings.Index(t, ":") + if j < 0 || strings.ContainsAny(t[:j], " \t,[") { + break + } + labels[t[:j]] = n + t = strings.TrimSpace(t[j+1:]) + } + if t == "" { + continue + } + f := strings.Fields(t) + f[0] = strings.ToLower(f[0]) + if f[0] == ".equ" { + if len(f) != 3 { + return nil, fmt.Errorf("line %d: .equ NAME value", i+1) + } + v, err := parseNum(f[2], consts) + if err != nil { + return nil, fmt.Errorf("line %d: %v", i+1, err) + } + consts[f[1]] = v + continue + } + if f[0] == "li" { + n += 2 + } else { + n++ + } + lines = append(lines, line{i + 1, t}) + } + + var out []byte + emit := func(op Op, ra, rb int, imm int32) { + out = binary.LittleEndian.AppendUint32(out, Encode(op, ra, rb, imm)) + } + for _, l := range lines { + name, rest, _ := strings.Cut(l.text, " ") + name = strings.ToLower(name) + args := splitArgs(rest) + err := func() error { + pc := len(out) / 4 + if name == "li" { + if len(args) != 2 { + return fmt.Errorf("li rd, value") + } + rd, err := parseReg(args[0]) + if err != nil { + return err + } + v, err := parseNum(args[1], consts) + if err != nil { + return err + } + emit(LDI, rd, 0, int32(int16(uint32(v)))) + emit(LUI, rd, 0, int32(int16(uint32(v)>>16))) + return nil + } + op, ok := mnemonics[name] + if !ok { + return fmt.Errorf("unknown mnemonic %q", name) + } + target := func(s string) (int32, error) { + if a, ok := labels[s]; ok { + return int32(a - (pc + 1)), nil + } + v, err := parseNum(s, consts) + return int32(v), err + } + need := func(k int) error { + if len(args) != k { + return fmt.Errorf("%s takes %d operands", name, k) + } + return nil + } + switch op { + case NOP, YIELD, HALT, RET: + if err := need(0); err != nil { + return err + } + emit(op, 0, 0, 0) + case LDI, LUI, ADDI: + if err := need(2); err != nil { + return err + } + ra, err := parseReg(args[0]) + if err != nil { + return err + } + v, err := parseNum(args[1], consts) + if err != nil { + return err + } + if v < -32768 || v > 65535 { + return fmt.Errorf("immediate %d out of 16-bit range (use li)", v) + } + emit(op, ra, 0, int32(int16(v))) + case MOV, ADD, SUB, MUL, DIV, MOD, AND, OR, XOR, SHL, SHR, SAR: + if err := need(2); err != nil { + return err + } + ra, err := parseReg(args[0]) + if err != nil { + return err + } + rb, err := parseReg(args[1]) + if err != nil { + return err + } + emit(op, ra, rb, 0) + case JMP, CALL: + if err := need(1); err != nil { + return err + } + t, err := target(args[0]) + if err != nil { + return err + } + emit(op, 0, 0, t) + case BEQ, BNE, BLT, BGE: + if err := need(3); err != nil { + return err + } + ra, err := parseReg(args[0]) + if err != nil { + return err + } + rb, err := parseReg(args[1]) + if err != nil { + return err + } + t, err := target(args[2]) + if err != nil { + return err + } + emit(op, ra, rb, t) + case PUSH, POP: + if err := need(1); err != nil { + return err + } + ra, err := parseReg(args[0]) + if err != nil { + return err + } + emit(op, ra, 0, 0) + case LDB, LDH, LDW, STB, STH, STW: + if err := need(2); err != nil { + return err + } + ra, err := parseReg(args[0]) + if err != nil { + return err + } + rb, off, err := parseMem(args[1], consts) + if err != nil { + return err + } + emit(op, ra, rb, off) + case IN: + if err := need(2); err != nil { + return err + } + ra, err := parseReg(args[0]) + if err != nil { + return err + } + p, err := parseNum(args[1], consts) + if err != nil { + return err + } + emit(op, ra, 0, int32(int16(p))) + case OUT: + if err := need(2); err != nil { + return err + } + p, err := parseNum(args[0], consts) + if err != nil { + return err + } + ra, err := parseReg(args[1]) + if err != nil { + return err + } + emit(op, ra, 0, int32(int16(p))) + } + return nil + }() + if err != nil { + return nil, fmt.Errorf("line %d: %v", l.no, err) + } + } + return out, nil +} + +func splitArgs(s string) []string { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + parts := strings.Split(s, ",") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + return parts +} + +func parseReg(s string) (int, error) { + s = strings.ToLower(s) + if s == "sp" { + return SP, nil + } + if strings.HasPrefix(s, "r") { + if n, err := strconv.Atoi(s[1:]); err == nil && n >= 0 && n < NumRegs { + return n, nil + } + } + return 0, fmt.Errorf("bad register %q", s) +} + +func parseNum(s string, consts map[string]int64) (int64, error) { + if v, ok := consts[s]; ok { + return v, nil + } + v, err := strconv.ParseInt(s, 0, 64) + if err != nil { + return 0, fmt.Errorf("bad number or unknown name %q", s) + } + return v, nil +} + +// parseMem parses "[rb]" or "[rb+off]" / "[rb-off]". +func parseMem(s string, consts map[string]int64) (int, int32, error) { + if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") { + return 0, 0, fmt.Errorf("bad memory operand %q", s) + } + s = strings.TrimSpace(s[1 : len(s)-1]) + regPart, offPart := s, "" + if i := strings.IndexAny(s, "+-"); i >= 0 { + regPart, offPart = strings.TrimSpace(s[:i]), strings.TrimSpace(s[i:]) + offPart = strings.ReplaceAll(offPart, " ", "") + } + rb, err := parseReg(regPart) + if err != nil { + return 0, 0, err + } + var off int64 + if offPart != "" { + sign := int64(1) + if offPart[0] == '-' { + sign = -1 + } + v, err := parseNum(offPart[1:], consts) + if err != nil { + return 0, 0, err + } + off = sign * v + } + if off < -32768 || off > 32767 { + return 0, 0, fmt.Errorf("offset %d out of range", off) + } + return rb, int32(off), nil +} diff --git a/vm/vm.go b/vm/vm.go new file mode 100644 index 0000000..cfb6ae5 --- /dev/null +++ b/vm/vm.go @@ -0,0 +1,325 @@ +// Package vm implements the bytecode CPU that runs ship programs. +// +// The machine has 16 32-bit registers (r15 is the stack pointer), a program +// ROM and a byte-addressable little-endian data RAM. Peripherals are reached +// through IN/OUT port instructions. Every instruction is 4 bytes: +// +// byte 0: opcode | byte 1: ra<<4 | rb | bytes 2-3: signed 16-bit immediate +// +// Branch/jump immediates are offsets in instructions relative to the next +// instruction. Each tick a CPU runs up to a cycle budget or until it executes +// YIELD; state persists between ticks so an over-long computation simply +// continues on the next tick. +package vm + +import ( + "encoding/binary" + "fmt" +) + +type Op uint8 + +const ( + NOP Op = iota + YIELD + HALT + LDI + LUI + MOV + ADD + SUB + MUL + DIV + MOD + AND + OR + XOR + SHL + SHR + SAR + ADDI + JMP + BEQ + BNE + BLT + BGE + CALL + RET + PUSH + POP + LDB + LDH + LDW + STB + STH + STW + IN + OUT + numOps +) + +const ( + NumRegs = 16 + SP = 15 +) + +type Status uint8 + +const ( + Running Status = iota + Yielded // finished this tick's work voluntarily + Halted + Faulted +) + +func (s Status) String() string { + return [...]string{"running", "yielded", "halted", "faulted"}[s] +} + +// Bus connects the CPU to peripherals. +type Bus interface { + In(port uint16) int32 + Out(port uint16, v int32) +} + +type CPU struct { + R [NumRegs]int32 + PC uint32 // byte address into Prog + Prog []byte + RAM []byte + Status Status + Fault string +} + +// New creates a CPU with the program loaded and the stack pointer at the top +// of RAM. +func New(prog []byte, ramBytes int) (*CPU, error) { + if len(prog)%4 != 0 { + return nil, fmt.Errorf("program length %d is not a multiple of 4", len(prog)) + } + c := &CPU{Prog: append([]byte(nil), prog...), RAM: make([]byte, ramBytes)} + c.R[SP] = int32(ramBytes) + return c, nil +} + +func (c *CPU) fault(format string, args ...any) { + c.Status = Faulted + c.Fault = fmt.Sprintf(format, args...) +} + +func cost(op Op) int { + switch op { + case MUL: + return 2 + case DIV, MOD: + return 8 + } + return 1 +} + +// Run executes instructions until the budget is spent, YIELD, HALT or a +// fault, returning the cycles used. A yielded CPU resumes on the next call. +func (c *CPU) Run(bus Bus, budget int) int { + if c.Status == Halted || c.Status == Faulted { + return 0 + } + c.Status = Running + used := 0 + for used < budget { + if int(c.PC)+4 > len(c.Prog) { + // Falling off the end of the program halts the CPU. + c.Status = Halted + break + } + w := binary.LittleEndian.Uint32(c.Prog[c.PC:]) + op := Op(w) + ra, rb := (w>>12)&0xf, (w>>8)&0xf + imm := int32(int16(w >> 16)) + if op >= numOps { + c.fault("illegal opcode %d at pc=%d", op, c.PC) + break + } + used += cost(op) + next := c.PC + 4 + r := &c.R + switch op { + case NOP: + case YIELD: + c.Status = Yielded + case HALT: + c.Status = Halted + case LDI: + r[ra] = imm + case LUI: + r[ra] = int32(uint32(imm)<<16 | uint32(r[ra])&0xffff) + case MOV: + r[ra] = r[rb] + case ADD: + r[ra] += r[rb] + case SUB: + r[ra] -= r[rb] + case MUL: + r[ra] *= r[rb] + case DIV, MOD: + d := r[rb] + if d == 0 { + c.fault("division by zero at pc=%d", c.PC) + break + } + switch { + case d == -1: // avoid MinInt32 / -1 overflow panic semantics + if op == DIV { + r[ra] = -r[ra] + } else { + r[ra] = 0 + } + case op == DIV: + r[ra] /= d + default: + r[ra] %= d + } + case AND: + r[ra] &= r[rb] + case OR: + r[ra] |= r[rb] + case XOR: + r[ra] ^= r[rb] + case SHL: + r[ra] = int32(uint32(r[ra]) << (uint32(r[rb]) & 31)) + case SHR: + r[ra] = int32(uint32(r[ra]) >> (uint32(r[rb]) & 31)) + case SAR: + r[ra] >>= uint32(r[rb]) & 31 + case ADDI: + r[ra] += imm + case JMP: + next = uint32(int64(next) + int64(imm)*4) + case BEQ, BNE, BLT, BGE: + var t bool + switch op { + case BEQ: + t = r[ra] == r[rb] + case BNE: + t = r[ra] != r[rb] + case BLT: + t = r[ra] < r[rb] + case BGE: + t = r[ra] >= r[rb] + } + if t { + next = uint32(int64(next) + int64(imm)*4) + } + case CALL: + if c.push(int32(next)) { + next = uint32(int64(next) + int64(imm)*4) + } + case RET: + if v, ok := c.pop(); ok { + next = uint32(v) + } + case PUSH: + c.push(r[ra]) + case POP: + if v, ok := c.pop(); ok { + r[ra] = v + } + case LDB, LDH, LDW: + n := accessSize(op) + if a, ok := c.addr(r[rb]+imm, n); ok { + var v uint32 + for i := n - 1; i >= 0; i-- { + v = v<<8 | uint32(c.RAM[a+i]) + } + r[ra] = int32(v) + } + case STB, STH, STW: + n := accessSize(op) + if a, ok := c.addr(r[rb]+imm, n); ok { + v := uint32(r[ra]) + for i := 0; i < n; i++ { + c.RAM[a+i] = byte(v >> (8 * i)) + } + } + case IN: + r[ra] = bus.In(uint16(imm)) + case OUT: + bus.Out(uint16(imm), r[ra]) + } + if c.Status == Faulted { + break + } + c.PC = next + if c.Status != Running { + break + } + } + return used +} + +func accessSize(op Op) int { + switch op { + case LDB, STB: + return 1 + case LDH, STH: + return 2 + } + return 4 +} + +func (c *CPU) addr(a int32, n int) (int, bool) { + if a < 0 || int(a)+n > len(c.RAM) { + c.fault("memory access out of range: %d", a) + return 0, false + } + return int(a), true +} + +func (c *CPU) push(v int32) bool { + a, ok := c.addr(c.R[SP]-4, 4) + if !ok { + return false + } + c.R[SP] -= 4 + binary.LittleEndian.PutUint32(c.RAM[a:], uint32(v)) + return true +} + +func (c *CPU) pop() (int32, bool) { + a, ok := c.addr(c.R[SP], 4) + if !ok { + return 0, false + } + c.R[SP] += 4 + return int32(binary.LittleEndian.Uint32(c.RAM[a:])), true +} + +// Encode builds one instruction word. +func Encode(op Op, ra, rb int, imm int32) uint32 { + return uint32(op) | uint32(rb&0xf)<<8 | uint32(ra&0xf)<<12 | uint32(uint16(imm))<<16 +} + +// MarshalState serialises the mutable CPU state (not the program). +func (c *CPU) MarshalState() []byte { + b := make([]byte, 0, NumRegs*4+8+len(c.RAM)) + for _, r := range c.R { + b = binary.LittleEndian.AppendUint32(b, uint32(r)) + } + b = binary.LittleEndian.AppendUint32(b, c.PC) + b = append(b, byte(c.Status), 0, 0, 0) + return append(b, c.RAM...) +} + +// UnmarshalState restores state produced by MarshalState. +func (c *CPU) UnmarshalState(b []byte) error { + hdr := NumRegs*4 + 8 + if len(b) != hdr+len(c.RAM) { + return fmt.Errorf("state size %d does not match expected %d", len(b), hdr+len(c.RAM)) + } + for i := range c.R { + c.R[i] = int32(binary.LittleEndian.Uint32(b[i*4:])) + } + c.PC = binary.LittleEndian.Uint32(b[NumRegs*4:]) + c.Status = Status(b[NumRegs*4+4]) + copy(c.RAM, b[hdr:]) + return nil +} diff --git a/vm/vm_test.go b/vm/vm_test.go new file mode 100644 index 0000000..bc44430 --- /dev/null +++ b/vm/vm_test.go @@ -0,0 +1,114 @@ +package vm + +import "testing" + +type testBus struct { + in map[uint16]int32 + out map[uint16]int32 +} + +func (b *testBus) In(p uint16) int32 { return b.in[p] } +func (b *testBus) Out(p uint16, v int32) { b.out[p] = v } + +func run(t *testing.T, src string, budget int) (*CPU, *testBus) { + t.Helper() + prog, err := Assemble(src) + if err != nil { + t.Fatal(err) + } + c, err := New(prog, 256) + if err != nil { + t.Fatal(err) + } + b := &testBus{in: map[uint16]int32{5: 42}, out: map[uint16]int32{}} + c.Run(b, budget) + return c, b +} + +func TestSumLoop(t *testing.T) { + c, b := run(t, ` + ldi r0, 0 ; sum + ldi r1, 1 ; i + ldi r2, 11 + loop: + add r0, r1 + addi r1, 1 + blt r1, r2, loop + out 7, r0 + halt + `, 1000) + if c.Status != Halted || b.out[7] != 55 { + t.Fatalf("status=%v out=%d", c.Status, b.out[7]) + } +} + +func TestLiCallStackMemory(t *testing.T) { + c, b := run(t, ` + li r0, 0x12345678 + call double + stw r0, [sp-8] ; below current sp + ldw r3, [sp-8] + in r4, 5 + add r3, r4 + out 1, r3 + halt + double: + add r0, r0 + ret + `, 1000) + want := int32(0x12345678)*2 + 42 + if c.Status != Halted || b.out[1] != want { + t.Fatalf("status=%v fault=%q out=%x want %x", c.Status, c.Fault, b.out[1], want) + } +} + +func TestYieldAndBudget(t *testing.T) { + prog, _ := Assemble("l: addi r0, 1\n jmp l") + c, _ := New(prog, 64) + b := &testBus{} + if used := c.Run(b, 100); used != 100 || c.Status != Running { + t.Fatalf("used=%d status=%v", used, c.Status) + } + r0 := c.R[0] + c.Run(b, 100) + if c.R[0] != r0+50 { + t.Fatalf("did not resume: %d -> %d", r0, c.R[0]) + } +} + +func TestFaults(t *testing.T) { + c, _ := run(t, "ldi r0, 1\n ldi r1, 0\n div r0, r1", 100) + if c.Status != Faulted { + t.Fatal("expected div fault") + } + c, _ = run(t, "ldi r1, 300\n ldw r0, [r1]", 100) + if c.Status != Faulted { + t.Fatal("expected memory fault") + } +} + +func TestStateRoundTrip(t *testing.T) { + c, _ := run(t, "ldi r0, 9\n stb r0, [r1+3]\n yield\n ldi r0, 1", 100) + d, _ := New(c.Prog, 256) + if err := d.UnmarshalState(c.MarshalState()); err != nil { + t.Fatal(err) + } + if d.R != c.R || d.PC != c.PC || d.RAM[3] != 9 || d.Status != Yielded { + t.Fatal("state mismatch") + } +} + +func TestAssemblerCaseInsensitiveDirectives(t *testing.T) { + // LI expands to two instructions; label distances must agree in any case. + lower, err := Assemble(".equ K 5\n li r1, 0x12345\n jmp end\n nop\nend: halt") + if err != nil { + t.Fatal(err) + } + upper, err := Assemble(".EQU K 5\n LI r1, 0x12345\n JMP end\n NOP\nend: HALT") + if err != nil { + t.Fatal(err) + } + if string(lower) != string(upper) { + t.Fatal("upper- and lower-case source assembled differently") + } +} diff --git a/web/static/api.js b/web/static/api.js new file mode 100644 index 0000000..34bde4e --- /dev/null +++ b/web/static/api.js @@ -0,0 +1,93 @@ +// Client for the belt's HTTP API. + +export class ApiError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +const KEY = 'wh.key'; +export const keyStore = { + get() { + try { return localStorage.getItem(KEY); } catch { return null; } + }, + set(k) { + try { localStorage.setItem(KEY, k); } catch { /* private mode: session only */ } + memoryKey = k; + }, + clear() { + try { localStorage.removeItem(KEY); } catch { /* ignore */ } + memoryKey = null; + }, +}; +let memoryKey = null; +const currentKey = () => memoryKey ?? keyStore.get(); + +let unauthorized = () => {}; +export function onUnauthorized(cb) { unauthorized = cb; } + +async function call(method, path, { json, body, type, auth = true, raw = false, key } = {}) { + const headers = {}; + // An explicit key (used by login) is tried as given and never triggers the + // global "session rejected" handler. + const k = key ?? (auth ? currentKey() : null); + if (k) headers.Authorization = `Bearer ${k}`; + let payload = body; + if (json !== undefined) { + payload = JSON.stringify(json); + headers['Content-Type'] = 'application/json'; + } else if (type) { + headers['Content-Type'] = type; + } + let res; + try { + res = await fetch(path, { method, headers, body: payload }); + } catch { + throw new ApiError(0, 'LINK DOWN: cannot reach the server'); + } + if (!res.ok) { + let msg = `${res.status} ${res.statusText}`; + try { msg = (await res.json()).error ?? msg; } catch { /* not JSON */ } + if (res.status === 401 && auth && key === undefined) unauthorized(); + throw new ApiError(res.status, msg); + } + if (raw) return res; + if ((res.headers.get('Content-Type') ?? '').includes('json')) return res.json(); + return null; +} + +export const api = { + info: () => call('GET', '/info', { auth: false }), + market: () => call('GET', '/market', { auth: false }), + register: (name) => call('POST', '/register', { json: { name }, auth: false }), + me: (key) => call('GET', '/me', { key }), + ships: () => call('GET', '/ships'), + assemble: (source) => call('POST', '/assemble', { body: source, type: 'text/plain', auth: false }), + setProgram: (id, bytes) => call('PUT', `/ships/${id}/program`, { body: bytes, type: 'application/octet-stream' }), + launch: (id) => call('POST', `/ships/${id}/launch`), + setUplink: (id, bytes) => call('PUT', `/ships/${id}/uplink`, { body: bytes, type: 'application/octet-stream' }), + async downlink(id) { + const res = await call('GET', `/ships/${id}/downlink`, { raw: true }); + return { + bytes: new Uint8Array(await res.arrayBuffer()), + day: Number(res.headers.get('X-Downlink-Day')), + }; + }, + async example(name) { + const res = await fetch(`/examples/${name}.s`); + if (!res.ok) throw new ApiError(res.status, `example ${name} not found`); + return res.text(); + }, + async examples() { + const res = await fetch('/examples/index.json'); + return res.ok ? res.json() : []; + }, +}; + +export function b64ToBytes(b64) { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 0000000..e63e78f --- /dev/null +++ b/web/static/app.js @@ -0,0 +1,166 @@ +// Halcyon Flight Control: application shell and routing. + +import { api, keyStore, onUnauthorized } from './api.js'; +import { h, clear, toast } from './dom.js'; +import { startStarfield } from './starfield.js'; +import { statusOf } from './status.js'; +import { authView } from './view-auth.js'; +import { fleetView } from './view-fleet.js'; +import { codeView } from './view-code.js'; +import { uplinkView } from './view-uplink.js'; +import { downlinkView } from './view-downlink.js'; + +const root = document.getElementById('app'); +const TABS = [ + ['fleet', 'FLEET'], + ['code', 'CODE'], + ['uplink', 'UPLINK'], + ['downlink', 'DOWNLINK'], +]; +const VIEWS = { fleet: fleetView, code: codeView, uplink: uplinkView, downlink: downlinkView }; +const ORE_TAGS = { iron: 'FE', nickel: 'NI', ice: 'H2O', platinum: 'PT' }; + +const state = { info: null, me: null, market: null, ships: [], selected: null, tab: 'fleet' }; +let shell = null; // { top, nav, select, main } + +const tabFromHash = () => { + const t = location.hash.replace(/^#\/?/, ''); + return VIEWS[t] ? t : 'fleet'; +}; + +const ctx = { + state, + get ship() { return state.ships.find((s) => s.id === state.selected) ?? null; }, + go(tab) { + if (location.hash === `#/${tab}`) { state.tab = tab; renderMain(); } else location.hash = `#/${tab}`; + }, + selectShip(id, tab) { + state.selected = id; + try { localStorage.setItem('wh.ship', String(id)); } catch { /* ignore */ } + renderNav(); + if (tab) ctx.go(tab); else renderMain(); + }, + async refresh() { + await loadAccount(); + renderTop(); + renderNav(); + renderMain(); + }, +}; + +async function loadAccount() { + const [me, ships, market, info] = await Promise.all([api.me(), api.ships(), api.market().catch(() => null), api.info().catch(() => state.info)]); + Object.assign(state, { me, ships, market, info }); + const remembered = Number(safeGet('wh.ship')); + if (!ships.some((s) => s.id === state.selected)) { + state.selected = ships.some((s) => s.id === remembered) ? remembered : (ships[0]?.id ?? null); + } +} + +function safeGet(k) { try { return localStorage.getItem(k); } catch { return null; } } + +function logout(message) { + keyStore.clear(); + Object.assign(state, { me: null, ships: [], selected: null }); + shell = null; + if (message) toast(message, 'err'); + showAuth(); +} + +function showAuth() { + clear(root).append(authView({ onLogin: enter })); +} + +async function enter() { + try { + await loadAccount(); + } catch (e) { + if (e.status !== 401) toast(`ERROR: ${e.message}`, 'err'); + return logout(); + } + buildShell(); + state.tab = tabFromHash(); + renderTop(); + renderNav(); + renderMain(); +} + +// ---- shell ------------------------------------------------------------------ + +function buildShell() { + shell = { + top: h('header', { class: 'topbar' }), + nav: h('nav', { class: 'navbar', 'aria-label': 'Sections' }), + main: h('main', { id: 'main', class: 'main', tabindex: '-1' }), + }; + clear(root).append( + h('a', { class: 'skip', href: '#main' }, 'SKIP TO CONTENT'), + shell.top, + shell.nav, + shell.main, + h('footer', { class: 'foot' }, 'HALCYON INSTRUMENT & CONTROL // WORMHOLE LINK 1 KB/DAY // THE BELT IS SIMULATED ONCE A DAY'), + ); +} + +function renderTop() { + if (!shell) return; + const { me, info, market } = state; + const prices = market && info?.ores + ? info.ores.map((o, i) => h('span', { class: 'chip ore', title: `${o} price per kg` }, h('b', null, ORE_TAGS[o] ?? o.toUpperCase()), ` ${market[i]}`)) + : [h('span', { class: 'chip dim', title: 'Prices appear after the first daily run' }, 'MARKET: NO DATA YET')]; + clear(shell.top).append( + h('div', { class: 'top-brand' }, h('span', { class: 'logo sm' }, 'HALCYON'), h('span', { class: 'dim' }, 'FLIGHT CONTROL')), + h('div', { class: 'top-stats' }, + h('span', { class: 'chip' }, 'DAY ', h('b', null, String(info?.day ?? me?.day ?? 0))), + h('span', { class: 'chip' }, 'CREDITS ', h('b', null, (me?.credits ?? 0).toLocaleString('en-US'))), + ...prices, + ), + h('div', { class: 'top-user' }, + h('span', { class: 'callsign' }, me?.name?.toUpperCase() ?? ''), + h('button', { class: 'btn small', type: 'button', onclick: async () => { try { await ctx.refresh(); toast('DATA REFRESHED'); } catch (e) { toast(e.message, 'err'); } } }, 'REFRESH'), + h('button', { class: 'btn small', type: 'button', onclick: () => logout() }, 'LOGOUT'), + ), + ); +} + +function renderNav() { + if (!shell) return; + const select = h('select', { + id: 'ship-select', 'aria-label': 'Active ship', disabled: state.ships.length === 0, + onchange: (e) => ctx.selectShip(Number(e.target.value)), + }, state.ships.length + ? state.ships.map((s) => h('option', { value: s.id, selected: s.id === state.selected }, `#${s.id} ${statusOf(s.status).label}`)) + : [h('option', null, 'NO SHIPS')]); + clear(shell.nav).append( + h('div', { class: 'tabs' }, + TABS.map(([id, label]) => h('a', { class: `tab ${state.tab === id ? 'on' : ''}`, href: `#/${id}`, 'aria-current': state.tab === id ? 'page' : null }, label)), + h('a', { class: 'tab', href: '/manual.txt', target: '_blank', rel: 'noopener' }, 'MANUAL'), + ), + h('label', { class: 'shipsel' }, 'ACTIVE SHIP', select), + ); +} + +function renderMain() { + if (!shell) return; + clear(shell.main).append(VIEWS[state.tab](ctx)); +} + +window.addEventListener('hashchange', () => { + if (!shell) return; + state.tab = tabFromHash(); + renderNav(); + renderMain(); + shell.main.focus({ preventScroll: true }); +}); + +// A refresh when the tab regains focus, since the daily run happens while the page sits open. +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && shell && state.tab === 'fleet') ctx.refresh().catch(() => {}); +}); + +// ---- boot ------------------------------------------------------------------- + +startStarfield(document.getElementById('stars')); +onUnauthorized(() => logout('SESSION REJECTED: ACCESS KEY NOT RECOGNISED')); +api.info().then((i) => { state.info = i; }).catch(() => {}); +if (keyStore.get()) enter(); else showAuth(); diff --git a/web/static/dom.js b/web/static/dom.js new file mode 100644 index 0000000..a7e4393 --- /dev/null +++ b/web/static/dom.js @@ -0,0 +1,70 @@ +// Tiny DOM helpers: element builder, toasts and a confirm dialog. + +/** Build an element. Props: class, on<Event> handlers, booleans, attributes. */ +export function h(tag, props, ...kids) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(props ?? {})) { + if (v == null || v === false) continue; + if (k === 'class') el.className = v; + else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v); + else if (v === true) el.setAttribute(k, ''); + else el.setAttribute(k, v); + } + el.append(...kids.flat(Infinity).filter((c) => c != null && c !== false)); + return el; +} + +export function clear(el) { + el.replaceChildren(); + return el; +} + +export function toast(message, kind = 'info') { + const host = document.getElementById('toasts'); + const t = h('div', { class: `toast ${kind}`, role: kind === 'err' ? 'alert' : null }, message); + host.append(t); + setTimeout(() => { + t.classList.add('out'); + setTimeout(() => t.remove(), 400); + }, kind === 'err' ? 8000 : 4500); +} + +/** Resolve true if the user confirms. */ +export function confirmDialog({ title, body, confirmText = 'CONFIRM', cancelText = 'ABORT', danger = false }) { + const dlg = document.getElementById('dialog'); + return new Promise((resolve) => { + const finish = (v) => { + dlg.close(); + resolve(v); + }; + clear(dlg).append( + h('div', { class: `panel dialog ${danger ? 'danger' : ''}` }, + h('div', { class: 'panel-title' }, title), + h('div', { class: 'panel-body' }, ...[].concat(body).map((p) => h('p', null, p))), + h('div', { class: 'actions' }, + h('button', { class: 'btn', type: 'button', onclick: () => finish(false) }, cancelText), + h('button', { class: `btn ${danger ? 'danger' : 'primary'}`, type: 'button', onclick: () => finish(true) }, confirmText), + ), + ), + ); + dlg.oncancel = () => resolve(false); + dlg.showModal(); + }); +} + +export function download(filename, bytes, type = 'application/octet-stream') { + const url = URL.createObjectURL(new Blob([bytes], { type })); + const a = h('a', { href: url, download: filename }); + document.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +export function pad(n, width, ch = '0') { + return String(n).padStart(width, ch); +} + +export function plural(n, one, many = `${one}S`) { + return `${n} ${n === 1 ? one : many}`; +} diff --git a/web/static/editor.js b/web/static/editor.js new file mode 100644 index 0000000..960aaeb --- /dev/null +++ b/web/static/editor.js @@ -0,0 +1,131 @@ +// A small assembly editor: line numbers, syntax highlighting (a coloured <pre> +// underneath a transparent <textarea>), Tab-indents and error-line marking. + +import { h } from './dom.js'; + +const MNEMONICS = new Set( + ('nop yield halt ldi lui mov add sub mul div mod and or xor shl shr sar addi jmp beq bne blt bge ' + + 'call ret push pop ldb ldh ldw stb sth stw in out li').split(' '), +); + +const TOKEN = new RegExp( + [ + '(\\.[A-Za-z_]\\w*)', // 1 directive + '([A-Za-z_]\\w*)(?=:)', // 2 label definition + '\\b(r(?:1[0-5]|\\d)|sp)\\b', // 3 register + '(-?\\b(?:0x[0-9a-fA-F]+|0b[01]+|\\d+)\\b)', // 4 number + '([A-Za-z_]\\w*)', // 5 identifier + '([\\[\\]+,:-])', // 6 punctuation + ].join('|'), + 'gi', +); + +const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); +const span = (cls, text) => `<span class="${cls}">${esc(text)}</span>`; + +/** Highlight one line of assembly, returning HTML. Exported for testing. */ +export function highlightLine(line) { + const c = line.search(/[;#]/); + const code = c < 0 ? line : line.slice(0, c); + const comment = c < 0 ? '' : line.slice(c); + let out = ''; + let pos = 0; + TOKEN.lastIndex = 0; + for (let m; (m = TOKEN.exec(code)); ) { + out += esc(code.slice(pos, m.index)); + pos = m.index + m[0].length; + if (m[1]) out += span('hl-dir', m[0]); + else if (m[2]) out += span('hl-lbl', m[0]); + else if (m[3]) out += span('hl-reg', m[0]); + else if (m[4]) out += span('hl-num', m[0]); + else if (m[5]) out += span(MNEMONICS.has(m[5].toLowerCase()) ? 'hl-mn' : 'hl-sym', m[0]); + else out += span('hl-pun', m[0]); + } + out += esc(code.slice(pos)); + if (comment) out += span('hl-cmt', comment); + return out; +} + +export function createEditor({ value = '', onInput = () => {}, onCursor = () => {} } = {}) { + const gutter = h('div', { class: 'ed-gutter', 'aria-hidden': 'true' }); + const hl = h('div', { class: 'ed-hl', 'aria-hidden': 'true' }); + const input = h('textarea', { + class: 'ed-input', + spellcheck: 'false', + autocapitalize: 'off', + autocomplete: 'off', + autocorrect: 'off', + wrap: 'off', + 'aria-label': 'Assembly source code', + }); + const el = h('div', { class: 'editor' }, gutter, h('div', { class: 'ed-body' }, hl, input)); + + let errorLine = null; + let escaped = false; // Escape pressed: let the next Tab leave the editor + + function render() { + const lines = input.value.split('\n'); + hl.innerHTML = lines + .map((l, i) => `<div class="ed-line${i + 1 === errorLine ? ' err' : ''}">${highlightLine(l) || ' '}</div>`) + .join(''); + gutter.innerHTML = lines + .map((_, i) => `<div class="ed-num${i + 1 === errorLine ? ' err' : ''}">${i + 1}</div>`) + .join(''); + syncScroll(); + } + + function syncScroll() { + hl.scrollTop = gutter.scrollTop = input.scrollTop; + hl.scrollLeft = input.scrollLeft; + } + + function cursor() { + const upTo = input.value.slice(0, input.selectionStart); + const line = upTo.split('\n').length; + const col = upTo.length - upTo.lastIndexOf('\n'); + onCursor({ line, col }); + } + + input.addEventListener('input', () => { + errorLine = null; + render(); + onInput(input.value); + }); + input.addEventListener('scroll', syncScroll); + for (const ev of ['keyup', 'click', 'focus']) input.addEventListener(ev, cursor); + input.addEventListener('blur', () => { escaped = false; }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { escaped = true; return; } + if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && !escaped) { + e.preventDefault(); + input.setRangeText(' ', input.selectionStart, input.selectionEnd, 'end'); + input.dispatchEvent(new Event('input')); + } + }); + + input.value = value; + render(); + + return { + el, + get value() { return input.value; }, + set value(v) { input.value = v; errorLine = null; render(); onInput(v); }, + insertAtTop(text) { + input.value = text + input.value; + errorLine = null; + render(); + onInput(input.value); + }, + setError(line) { + errorLine = line; + render(); + if (line) { + const lh = parseFloat(getComputedStyle(input).lineHeight) || 22; + input.scrollTop = Math.max(0, (line - 3) * lh); + syncScroll(); + } + }, + focus() { input.focus(); }, + render, // re-measure after the element is attached + }; +} diff --git a/web/static/favicon.svg b/web/static/favicon.svg new file mode 100644 index 0000000..83dea7c --- /dev/null +++ b/web/static/favicon.svg @@ -0,0 +1,12 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> + <defs> + <linearGradient id="g" x1="0" y1="0" x2="0" y2="1"> + <stop offset="0" stop-color="#ffb62e"/><stop offset="1" stop-color="#ff3ad8"/> + </linearGradient> + </defs> + <rect width="64" height="64" rx="10" fill="#12072b"/> + <circle cx="32" cy="30" r="17" fill="url(#g)"/> + <rect x="12" y="35" width="40" height="2.5" fill="#12072b"/> + <rect x="12" y="41" width="40" height="3.5" fill="#12072b"/> + <path d="M6 50h52" stroke="#28f5ff" stroke-width="3"/> +</svg> diff --git a/web/static/hexview.js b/web/static/hexview.js new file mode 100644 index 0000000..027915d --- /dev/null +++ b/web/static/hexview.js @@ -0,0 +1,103 @@ +// Hex dump with an inspector: hover or click a byte to decode it. + +import { h, pad } from './dom.js'; + +const hex2 = (b) => pad(b.toString(16).toUpperCase(), 2); +const printable = (b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : '.'); + +/** Decode the bytes at offset i as little-endian integers. */ +export function inspect(bytes, i) { + const b = (k) => (i + k < bytes.length ? bytes[i + k] : null); + const have = (n) => i + n <= bytes.length; + const out = { offset: i, u8: bytes[i] }; + if (have(2)) { + const v = b(0) | (b(1) << 8); + out.u16 = v; + out.i16 = (v << 16) >> 16; + } + if (have(4)) { + const v = (b(0) | (b(1) << 8) | (b(2) << 16) | (b(3) << 24)); + out.i32 = v; + out.u32 = v >>> 0; + } + return out; +} + +export function describe(bytes, i) { + const d = inspect(bytes, i); + const parts = [`OFFSET 0x${pad(d.offset.toString(16).toUpperCase(), 4)} (${d.offset})`, `U8 ${d.u8}`]; + if (d.i16 !== undefined) parts.push(`I16 ${d.i16}`); + if (d.i32 !== undefined) parts.push(`I32 ${d.i32}`, `U32 ${d.u32}`); + return parts.join(' | '); +} + +/** + * Render a hex dump. Returns { el, inspector } where inspector is the element + * that shows the decoded value of the hovered/selected byte. + */ +export function hexView(bytes, { rows: maxRows = Infinity } = {}) { + const inspector = h('div', { class: 'hx-inspector', 'aria-live': 'polite' }, 'HOVER OR CLICK A BYTE TO DECODE IT'); + const body = h('div', { class: 'hx-body', tabindex: '0', role: 'group', 'aria-label': 'Hex dump' }); + let pinned = null; + const cells = new Map(); + + const rowCount = Math.min(Math.ceil(bytes.length / 16), maxRows); + for (let r = 0; r < rowCount; r++) { + const start = r * 16; + const hexCells = []; + const ascCells = []; + for (let k = 0; k < 16; k++) { + const i = start + k; + if (i >= bytes.length) { + hexCells.push(h('span', { class: 'hx-b pad' }, ' ')); + continue; + } + const v = bytes[i]; + const cls = v === 0 ? 'hx-b z' : 'hx-b'; + const a = h('span', { class: `${cls} a`, 'data-i': i }, printable(v)); + const x = h('span', { class: cls, 'data-i': i }, hex2(v)); + cells.set(i, [x, a]); + hexCells.push(x); + ascCells.push(a); + if (k === 7) hexCells.push(h('span', { class: 'hx-gap' }, ' ')); + } + body.append( + h('div', { class: 'hx-row' }, + h('span', { class: 'hx-off' }, pad(start.toString(16).toUpperCase(), 4)), + h('span', { class: 'hx-hex' }, hexCells), + h('span', { class: 'hx-asc' }, ascCells), + ), + ); + } + + const select = (i, pin) => { + if (pin) { + if (pinned !== null) cells.get(pinned)?.forEach((c) => c.classList.remove('sel')); + pinned = pinned === i ? null : i; + if (pinned !== null) cells.get(pinned).forEach((c) => c.classList.add('sel')); + } + const shown = pinned ?? i; + inspector.textContent = shown === null ? 'HOVER OR CLICK A BYTE TO DECODE IT' : describe(bytes, shown); + }; + const target = (e) => { + const t = e.target.closest('[data-i]'); + return t ? Number(t.dataset.i) : null; + }; + body.addEventListener('mouseover', (e) => { const i = target(e); if (i !== null && pinned === null) select(i, false); }); + body.addEventListener('mouseleave', () => { if (pinned === null) select(null, false); }); + body.addEventListener('click', (e) => { const i = target(e); if (i !== null) select(i, true); }); + + return { el: h('div', { class: 'hex' }, body, inspector), inspector }; +} + +/** Parse hex text ("de ad 0xBE, ef") into bytes; throws on bad input. */ +export function parseHex(text) { + const cleaned = text.replace(/0x/gi, ' ').replace(/[,\s]+/g, ' ').trim(); + if (!cleaned) return new Uint8Array(0); + const out = []; + for (const tok of cleaned.split(' ')) { + if (!/^[0-9a-fA-F]+$/.test(tok) || tok.length % 2) throw new Error(`"${tok}" is not whole bytes of hex`); + for (let i = 0; i < tok.length; i += 2) out.push(parseInt(tok.slice(i, i + 2), 16)); + } + return Uint8Array.from(out); +} diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..3aaa991 --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,32 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Halcyon Flight Control + + + + + + + + + + + +
+

INITIALISING TERMINAL...

+
+ + +
+ + + + diff --git a/web/static/starfield.js b/web/static/starfield.js new file mode 100644 index 0000000..f1eb917 --- /dev/null +++ b/web/static/starfield.js @@ -0,0 +1,90 @@ +// Slowly drifting, twinkling starfield with the odd shooting star. + +export function startStarfield(canvas) { + const ctx = canvas.getContext('2d'); + const reduce = window.matchMedia('(prefers-reduced-motion: reduce)'); + const tints = ['#ffffff', '#ffffff', '#ffffff', '#9ff8ff', '#ffb3f0']; + let w = 0; + let h = 0; + let stars = []; + let shooters = []; + let nextShot = 4000; + let last = performance.now(); + let raf = 0; + + function resize() { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + w = window.innerWidth; + h = window.innerHeight; + canvas.width = Math.round(w * dpr); + canvas.height = Math.round(h * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + const n = Math.min(320, Math.round((w * h) / 5500)); + stars = Array.from({ length: n }, () => ({ + x: Math.random() * w, + y: Math.random() * h, + z: 0.25 + Math.random() * 0.75, + phase: Math.random() * Math.PI * 2, + speed: 0.6 + Math.random() * 1.8, + tint: tints[Math.floor(Math.random() * tints.length)], + })); + draw(performance.now()); + } + + function draw(now) { + ctx.clearRect(0, 0, w, h); + for (const s of stars) { + const tw = 0.5 + 0.5 * Math.sin(now / 1000 * s.speed + s.phase); + ctx.globalAlpha = reduce.matches ? 0.5 * s.z : (0.25 + 0.75 * tw) * s.z; + ctx.fillStyle = s.tint; + const r = 0.4 + s.z * 1.3; + ctx.fillRect(s.x, s.y, r, r); + } + for (const s of shooters) { + const g = ctx.createLinearGradient(s.x, s.y, s.x - s.vx * 9, s.y - s.vy * 9); + g.addColorStop(0, 'rgba(255,255,255,0.95)'); + g.addColorStop(1, 'rgba(255,58,216,0)'); + ctx.globalAlpha = Math.min(1, s.life); + ctx.strokeStyle = g; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(s.x, s.y); + ctx.lineTo(s.x - s.vx * 9, s.y - s.vy * 9); + ctx.stroke(); + } + ctx.globalAlpha = 1; + } + + function frame(now) { + const dt = Math.min(64, now - last); + last = now; + for (const s of stars) { + s.x -= s.z * 0.012 * dt; + if (s.x < -2) { s.x = w + 2; s.y = Math.random() * h; } + } + nextShot -= dt; + if (nextShot <= 0) { + nextShot = 7000 + Math.random() * 9000; + shooters.push({ x: Math.random() * w * 0.8 + w * 0.2, y: Math.random() * h * 0.4, vx: -(6 + Math.random() * 4), vy: 2 + Math.random() * 2, life: 1.4 }); + } + for (const s of shooters) { + s.x += s.vx * dt / 16; + s.y += s.vy * dt / 16; + s.life -= dt / 900; + } + shooters = shooters.filter((s) => s.life > 0); + draw(now); + raf = requestAnimationFrame(frame); + } + + function sync() { + cancelAnimationFrame(raf); + if (!reduce.matches) raf = requestAnimationFrame((t) => { last = t; frame(t); }); + else draw(performance.now()); + } + + window.addEventListener('resize', resize); + reduce.addEventListener('change', sync); + resize(); + sync(); +} diff --git a/web/static/status.js b/web/static/status.js new file mode 100644 index 0000000..1ec1a81 --- /dev/null +++ b/web/static/status.js @@ -0,0 +1,10 @@ +// Ship status presentation shared by several views. + +export const STATUS = { + inventory: { label: 'DOCKED', cls: 'amber', hint: 'On the dock. Can be programmed and launched.' }, + launching: { label: 'LAUNCH PENDING', cls: 'mag', hint: 'Enters the belt on the next daily run.' }, + active: { label: 'IN BELT', cls: 'cyan', hint: 'Flying its program.' }, + destroyed: { label: 'LOST', cls: 'red', hint: 'Destroyed.' }, +}; + +export const statusOf = (s) => STATUS[s] ?? { label: String(s).toUpperCase(), cls: '', hint: '' }; diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..34bfdf3 --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,391 @@ +/* Halcyon Flight Control -- 1980s space terminal. */ + +:root { + --bg0: #07030f; + --bg1: #12072b; + --ink: #ece7ff; + --dim: #9a91c8; + --faint: #4a3f7a; + --cyan: #28f5ff; + --mag: #ff3ad8; + --amber: #ffb62e; + --lime: #7dff8a; + --red: #ff5577; + --violet: #b18cff; + --panel: rgba(13, 6, 34, 0.84); + --line: rgba(40, 245, 255, 0.5); + --mono: "VT323", "IBM Plex Mono", "SF Mono", "Cascadia Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace; + --display: Eurostile, "Microgramma D Extended", "Bank Gothic", Orbitron, "Avenir Next Condensed", "Arial Narrow", "Trebuchet MS", sans-serif; +} + +* { box-sizing: border-box; } + +html { color-scheme: dark; } + +body { + margin: 0; + min-height: 100vh; + color: var(--ink); + background: linear-gradient(180deg, #07030f 0%, #0f0626 40%, #2a0a4d 72%, #6a1268 100%) fixed; + font-family: var(--mono); + font-size: 17px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/* ---- backdrop ------------------------------------------------------------ */ + +#stars { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 0; } + +.horizon { + position: fixed; left: 0; right: 0; bottom: 0; height: 46vh; + z-index: 1; pointer-events: none; overflow: hidden; + --sun: clamp(180px, 34vmin, 400px); + --floor: 17vh; +} + +.sun { + position: absolute; left: 50%; margin-left: calc(var(--sun) / -2); + bottom: calc(var(--floor) - var(--sun) * 0.34); + width: var(--sun); height: var(--sun); border-radius: 50%; + background: linear-gradient(180deg, #fff27a 0%, #ffb62e 32%, #ff3ad8 72%, #8a2cff 100%); + box-shadow: 0 0 70px 12px rgba(255, 58, 216, 0.5); + -webkit-mask-image: linear-gradient(#000 0 46%, transparent 46% 49%, #000 49% 56%, transparent 56% 60%, #000 60% 67%, transparent 67% 72%, #000 72% 79%, transparent 79% 85%, #000 85% 91%, transparent 91% 98%, #000 98%); + mask-image: linear-gradient(#000 0 46%, transparent 46% 49%, #000 49% 56%, transparent 56% 60%, #000 60% 67%, transparent 67% 72%, #000 72% 79%, transparent 79% 85%, #000 85% 91%, transparent 91% 98%, #000 98%); +} + +.floor { + position: absolute; left: 0; right: 0; bottom: 0; height: var(--floor); + overflow: hidden; background: linear-gradient(#22093f, #0c0420); + border-top: 2px solid var(--cyan); + box-shadow: 0 -2px 26px rgba(40, 245, 255, 0.7); +} +.floor::before { + content: ""; position: absolute; left: -60%; right: -60%; top: 0; height: 400%; + transform-origin: 50% 0; transform: perspective(240px) rotateX(64deg); + background-image: + linear-gradient(rgba(255, 58, 216, 0.8) 2px, transparent 2px), + linear-gradient(90deg, rgba(255, 58, 216, 0.8) 2px, transparent 2px); + background-size: 64px 64px; + animation: grid-move 2.6s linear infinite; +} +@keyframes grid-move { to { background-position: 0 64px; } } + +.crt { + position: fixed; inset: 0; z-index: 100; pointer-events: none; + background: + repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0) 0 2px, rgba(0, 0, 0, 0.17) 3px, rgba(0, 0, 0, 0) 4px), + radial-gradient(ellipse at center, transparent 58%, rgba(0, 0, 0, 0.5) 100%); + animation: flicker 7s infinite; +} +@keyframes flicker { 0%, 100% { opacity: 1; } 47% { opacity: 1; } 48% { opacity: 0.86; } 50% { opacity: 1; } 83% { opacity: 0.93; } 84% { opacity: 1; } } + +#app, dialog, #toasts { position: relative; z-index: 10; } + +/* ---- type ---------------------------------------------------------------- */ + +h1, h2, h3, p { margin: 0; } + +.logo { + margin: 0; font-family: var(--display); font-style: italic; font-weight: 900; + letter-spacing: 0.16em; font-size: clamp(2.2rem, 9vw, 4.8rem); line-height: 1.05; + background: linear-gradient(180deg, #ffffff 0%, #d6fbff 36%, #ffb62e 50%, #ff3ad8 76%, #8a2cff 100%); + -webkit-background-clip: text; background-clip: text; color: transparent; + filter: drop-shadow(0 0 14px rgba(255, 58, 216, 0.55)); +} +.logo.sm { font-size: 1.15rem; letter-spacing: 0.3em; filter: drop-shadow(0 0 8px rgba(255, 58, 216, 0.6)); } + +.kicker { font-family: var(--display); letter-spacing: 0.5em; font-size: 0.8rem; color: var(--cyan); text-shadow: 0 0 8px var(--cyan); } +.tagline { letter-spacing: 0.3em; color: var(--dim); font-size: 0.95rem; margin-top: 0.4rem; } + +.dim { color: var(--dim); } +.hint { color: var(--dim); font-size: 0.9rem; line-height: 1.4; } +.warn { color: var(--amber); } +.ok { color: var(--lime); } +.bad { color: var(--red); } +a { color: var(--cyan); } +a:hover { color: var(--mag); } + +.boot { padding: 3rem 1rem; text-align: center; color: var(--lime); letter-spacing: 0.2em; } +.boot::after { content: "_"; animation: blink 1s steps(1) infinite; } +@keyframes blink { 50% { opacity: 0; } } + +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.skip { position: absolute; left: -999px; top: 0; background: var(--amber); color: #000; padding: 0.5rem 1rem; z-index: 200; } +.skip:focus { left: 0; } + +/* ---- layout -------------------------------------------------------------- */ + +.topbar { + position: sticky; top: 0; z-index: 20; + display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1.4rem; + padding: 0.6rem 1.2rem; + background: rgba(7, 3, 15, 0.88); backdrop-filter: blur(6px); + border-bottom: 1px solid var(--line); box-shadow: 0 2px 24px rgba(40, 245, 255, 0.12); +} +.top-brand { display: flex; align-items: baseline; gap: 0.9rem; letter-spacing: 0.25em; font-size: 0.85rem; } +.top-stats { display: flex; flex-wrap: wrap; gap: 0.5rem; flex: 1; } +.top-user { display: flex; align-items: center; gap: 0.6rem; margin-left: auto; } +.callsign { color: var(--lime); letter-spacing: 0.15em; text-shadow: 0 0 8px rgba(125, 255, 138, 0.6); } + +.chip { border: 1px solid var(--faint); padding: 0.05rem 0.65rem; font-size: 0.9rem; letter-spacing: 0.08em; background: rgba(0, 0, 0, 0.3); } +.chip b { color: var(--amber); font-weight: 700; } +.chip.ore b { color: var(--cyan); } + +.navbar { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 0.6rem; max-width: 1100px; margin: 1rem auto 0; padding: 0 1rem; } +.tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; } +.tab { + font-family: var(--display); font-size: 0.85rem; letter-spacing: 0.22em; text-transform: uppercase; text-decoration: none; + padding: 0.6rem 1.2rem; color: var(--dim); background: rgba(13, 6, 34, 0.7); + border: 1px solid var(--faint); cursor: pointer; +} +button.tab { font-size: 0.85rem; } +.tab:hover { color: var(--ink); border-color: var(--mag); } +.tab.on { color: var(--bg0); background: var(--cyan); border-color: var(--cyan); box-shadow: 0 0 16px rgba(40, 245, 255, 0.6); font-weight: 700; } +.shipsel { display: flex; align-items: center; gap: 0.6rem; font-size: 0.8rem; letter-spacing: 0.2em; color: var(--dim); } + +.main { max-width: 1100px; margin: 0 auto; padding: 1rem 1rem 8rem; outline: none; } +.foot { position: relative; z-index: 10; width: max-content; max-width: 94vw; margin: 0 auto 1.5rem; padding: 0.35rem 1rem; text-align: center; color: var(--dim); font-size: 0.75rem; letter-spacing: 0.18em; background: rgba(7, 3, 15, 0.82); border: 1px solid var(--faint); } + +.grid-2 { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 1.2rem; align-items: start; } +.stack { display: grid; gap: 0.8rem; } +.stack-lg { display: grid; gap: 1.2rem; } +.row { display: flex; gap: 0.6rem; flex-wrap: wrap; } +.spacer { flex: 1; } +.pad { padding: 0.6rem 1rem; } + +/* ---- panels -------------------------------------------------------------- */ + +.panel { + position: relative; background: var(--panel); border: 1px solid var(--line); + box-shadow: 0 0 26px rgba(40, 245, 255, 0.13), inset 0 0 36px rgba(255, 58, 216, 0.05); +} +.panel::before { + content: ""; position: absolute; inset: -3px; pointer-events: none; + --c: var(--mag); + background: + linear-gradient(var(--c), var(--c)) top left / 16px 2px, + linear-gradient(var(--c), var(--c)) top left / 2px 16px, + linear-gradient(var(--c), var(--c)) top right / 16px 2px, + linear-gradient(var(--c), var(--c)) top right / 2px 16px, + linear-gradient(var(--c), var(--c)) bottom left / 16px 2px, + linear-gradient(var(--c), var(--c)) bottom left / 2px 16px, + linear-gradient(var(--c), var(--c)) bottom right / 16px 2px, + linear-gradient(var(--c), var(--c)) bottom right / 2px 16px; + background-repeat: no-repeat; +} +.panel-title { + display: flex; align-items: center; gap: 0.8rem; padding: 0.55rem 1rem; + font-family: var(--display); font-size: 0.8rem; letter-spacing: 0.26em; text-transform: uppercase; color: var(--cyan); + text-shadow: 0 0 8px rgba(40, 245, 255, 0.7); + background: linear-gradient(90deg, rgba(255, 58, 216, 0.34), rgba(40, 245, 255, 0.08) 65%, transparent); + border-bottom: 1px solid var(--line); +} +summary.panel-title { cursor: pointer; list-style: none; } +summary.panel-title::-webkit-details-marker { display: none; } +summary.panel-title::before { content: "\25B6"; font-size: 0.7em; } +details[open] > summary.panel-title::before { content: "\25BC"; } +.panel-body { padding: 1rem; } +.panel-body.flush { padding: 0; } +.panel .dim.cursor, .panel-title .dim { text-shadow: none; letter-spacing: 0.1em; } + +.empty { padding: 1.4rem 1rem; color: var(--dim); letter-spacing: 0.15em; } + +/* ---- controls ------------------------------------------------------------ */ + +.btn { + font: inherit; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; text-decoration: none; + display: inline-block; text-align: center; + color: var(--cyan); background: rgba(40, 245, 255, 0.06); + border: 1px solid var(--cyan); padding: 0.5rem 1.1rem; cursor: pointer; + box-shadow: 0 0 10px rgba(40, 245, 255, 0.25); + transition: background 0.12s, color 0.12s, box-shadow 0.12s; +} +.btn:hover:not(:disabled) { background: var(--cyan); color: var(--bg0); box-shadow: 0 0 20px var(--cyan); } +.btn.primary { color: #fff; border-color: var(--mag); background: rgba(255, 58, 216, 0.2); box-shadow: 0 0 12px rgba(255, 58, 216, 0.45); } +.btn.primary:hover:not(:disabled) { background: var(--mag); color: var(--bg0); box-shadow: 0 0 22px var(--mag); } +.btn.danger { color: #fff; border-color: var(--red); background: rgba(255, 85, 119, 0.2); box-shadow: 0 0 12px rgba(255, 85, 119, 0.45); } +.btn.danger:hover:not(:disabled) { background: var(--red); color: var(--bg0); box-shadow: 0 0 22px var(--red); } +.btn.small { padding: 0.2rem 0.7rem; font-size: 0.8rem; } +.btn.wide { width: 100%; } +.btn:disabled { opacity: 0.38; cursor: not-allowed; box-shadow: none; } +.btn:focus-visible, .tab:focus-visible, .linklike:focus-visible, a:focus-visible, +input:focus-visible, select:focus-visible, textarea:focus-visible, .keybox:focus-visible, .hx-body:focus-visible { + outline: 2px solid var(--amber); outline-offset: 2px; +} + +.linklike { font: inherit; background: none; border: 0; color: var(--cyan); text-decoration: underline; cursor: pointer; padding: 0; } + +label { display: block; font-size: 0.85rem; letter-spacing: 0.22em; color: var(--cyan); text-transform: uppercase; } +label.check { display: flex; align-items: center; gap: 0.6rem; letter-spacing: 0.12em; color: var(--ink); cursor: pointer; } +input[type="checkbox"] { accent-color: var(--mag); width: 1.1rem; height: 1.1rem; } + +input[type="text"], input[type="password"], select, textarea { + width: 100%; font: inherit; color: var(--ink); + background: rgba(0, 0, 0, 0.5); border: 1px solid var(--faint); padding: 0.5rem 0.7rem; + caret-color: var(--amber); border-radius: 0; +} +select { width: auto; max-width: 100%; color: var(--cyan); border-color: var(--line); } +select option { background: var(--bg1); color: var(--ink); } +input:focus, select:focus, textarea:focus { border-color: var(--cyan); box-shadow: 0 0 14px rgba(40, 245, 255, 0.35); } +textarea { resize: vertical; } +.form-error { color: var(--red); min-height: 1.4em; letter-spacing: 0.06em; } + +.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0.6rem 0; } +.toolbar.pad { margin: 0; } +.sealrow { justify-content: space-between; margin-top: 0.8rem; } +.sealrow .grow { flex: 1 1 260px; } +.panel-title .dim { white-space: nowrap; } + +.badge { display: inline-block; padding: 0.05rem 0.7rem; font-size: 0.8rem; letter-spacing: 0.15em; border: 1px solid currentColor; text-shadow: 0 0 8px currentColor; white-space: nowrap; } +.badge.amber { color: var(--amber); } +.badge.mag { color: var(--mag); animation: pulse 1.6s ease-in-out infinite; } +.badge.cyan { color: var(--cyan); } +.badge.red { color: var(--red); } +@keyframes pulse { 50% { box-shadow: 0 0 14px var(--mag); } } + +/* ---- fleet --------------------------------------------------------------- */ + +.manifest { width: 100%; border-collapse: collapse; } +.manifest th { text-align: left; font-weight: 400; font-size: 0.8rem; letter-spacing: 0.2em; color: var(--dim); padding: 0.6rem 0.9rem; border-bottom: 1px solid var(--line); } +.manifest th, .manifest td { white-space: nowrap; } +.manifest td { padding: 0.7rem 0.9rem; border-bottom: 1px dashed var(--faint); vertical-align: middle; } +.manifest tr.sel td { background: rgba(255, 58, 216, 0.09); } +.manifest tr.sel td:first-child { box-shadow: inset 3px 0 var(--mag); } +.manifest td.actions { text-align: right; white-space: normal; } +.manifest td.actions .btn + .btn { margin-left: 0.4rem; } + +.steps { list-style: none; margin: 0; padding: 0.8rem 1rem 0; display: grid; gap: 0.7rem; } +.steps li { display: flex; gap: 0.8rem; align-items: baseline; } +.steps .n { flex: none; width: 1.7rem; height: 1.7rem; display: grid; place-items: center; border: 1px solid var(--mag); color: var(--mag); font-weight: 700; } +.steps strong { color: var(--amber); letter-spacing: 0.12em; } + +/* ---- editor -------------------------------------------------------------- */ + +.editor { + --lh: 22px; + display: flex; height: min(60vh, 540px); min-height: 300px; margin: 0.6rem 0; + background: #05020c; border: 1px solid var(--line); + font-family: var(--mono); font-size: 16px; line-height: var(--lh); +} +.ed-gutter { flex: none; width: 3.6em; overflow: hidden; padding: 8px 0.7em 8px 0; text-align: right; color: #7266b0; background: rgba(255, 58, 216, 0.06); border-right: 1px solid var(--faint); user-select: none; } +.ed-num { height: var(--lh); } +.ed-num.err { color: var(--red); font-weight: 700; } +.ed-body { position: relative; flex: 1; min-width: 0; } +.ed-hl, .ed-input { + position: absolute; inset: 0; margin: 0; padding: 8px 12px; border: 0; + font: inherit; line-height: var(--lh); letter-spacing: 0; white-space: pre; tab-size: 8; +} +.ed-hl { overflow: hidden; pointer-events: none; color: var(--ink); } +.ed-line { height: var(--lh); white-space: pre; } +.ed-line.err { background: rgba(255, 85, 119, 0.22); box-shadow: inset 3px 0 var(--red); } +.ed-input { overflow: auto; resize: none; background: transparent; color: transparent; caret-color: var(--amber); outline: none; box-shadow: none; width: auto; } +.ed-input:focus { box-shadow: none; } +.ed-input::selection { background: rgba(40, 245, 255, 0.35); } +.ed-body:focus-within { box-shadow: inset 0 0 0 1px var(--cyan), 0 0 14px rgba(40, 245, 255, 0.3); } + +.hl-mn { color: var(--mag); font-weight: 700; } +.hl-reg { color: var(--cyan); } +.hl-num { color: var(--amber); } +.hl-lbl { color: var(--lime); } +.hl-dir { color: var(--violet); } +.hl-sym { color: #ffd6f7; } +.hl-cmt { color: #8278bd; font-style: italic; } +.hl-pun { color: #a89fd6; } + +.console { background: #05020c; border: 1px solid var(--faint); padding: 0.7rem 1rem; min-height: 5.4rem; margin-top: 0.6rem; } +.console p + p { margin-top: 0.2rem; } + +.meter { height: 12px; border: 1px solid var(--cyan); background: #05020c; margin-top: 0.5rem; } +.meter .fill { height: 100%; width: 0; background: repeating-linear-gradient(90deg, var(--cyan) 0 6px, transparent 6px 8px); box-shadow: 0 0 10px var(--cyan); } +.meter.over { border-color: var(--red); } +.meter.over .fill { background: repeating-linear-gradient(90deg, var(--red) 0 6px, transparent 6px 8px); box-shadow: 0 0 10px var(--red); } + +.refcard { margin: 0; padding: 1rem; overflow-x: auto; font-size: 0.85rem; line-height: 1.3; color: var(--lime); background: #05020c; } + +/* ---- uplink -------------------------------------------------------------- */ + +.drop { + display: grid; place-items: center; gap: 0.3rem; padding: 2rem 1rem; text-align: center; cursor: pointer; + border: 2px dashed var(--cyan); background: rgba(40, 245, 255, 0.04); + letter-spacing: 0.15em; color: var(--ink); font-size: 1rem; +} +.drop-big { font-family: var(--display); letter-spacing: 0.25em; color: var(--cyan); } +.drop:hover, .drop.over { background: rgba(40, 245, 255, 0.12); box-shadow: 0 0 22px rgba(40, 245, 255, 0.4); border-color: var(--mag); } +.drop.disabled { opacity: 0.4; cursor: not-allowed; border-color: var(--faint); } +.uplink-grid { grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); } +.fleet-grid { grid-template-columns: minmax(0, 1fr) 330px; } +.uplink-preview .hx-body { font-size: 13px; } +.uplink-preview .hx-row { gap: 0.9rem; } +.panel-body > .hint, .panel-body > .hx-inspector + .hint { margin-top: 0.7rem; } +.uplink-preview:empty::before { content: "NOTHING TO SEND YET."; color: var(--dim); letter-spacing: 0.15em; } + +/* ---- hex viewer ---------------------------------------------------------- */ + +.hx-body { background: #05020c; border: 1px solid var(--faint); padding: 0.7rem 0.9rem; overflow-x: auto; font-size: 15px; line-height: 1.55; } +.hx-row { display: flex; gap: 1.4rem; white-space: pre; width: max-content; } +.hx-off { color: var(--amber); } +.hx-b { display: inline-block; width: 2ch; text-align: center; cursor: default; } +.hx-hex .hx-b { margin-right: 1ch; } +.hx-asc { color: var(--lime); } +.hx-asc .hx-b { width: 1ch; } +.hx-b.z { color: #5a4f96; } +.hx-b:hover { background: rgba(40, 245, 255, 0.4); color: #fff; } +.hx-b.sel { background: var(--mag); color: #fff; } +.hx-gap { display: inline-block; width: 1ch; } +.hx-inspector { margin-top: 0.7rem; padding: 0.5rem 0.9rem; min-height: 2.6rem; border: 1px solid var(--line); color: var(--cyan); font-size: 0.95rem; letter-spacing: 0.06em; overflow-wrap: anywhere; } + +.nosignal { text-align: center; padding: 2.5rem 1rem; } +.nosignal .big { font-family: var(--display); font-size: 2.4rem; letter-spacing: 0.4em; color: var(--red); text-shadow: 0 0 16px var(--red); animation: blink 1.4s steps(1) infinite; } + +/* ---- auth ---------------------------------------------------------------- */ + +.auth { min-height: 100vh; display: grid; place-content: center; justify-items: center; gap: 1.8rem; padding: 2rem 1rem 12rem; text-align: center; } +.auth-panel { width: min(460px, 92vw); text-align: left; } +.auth-panel .stack, .auth-panel .keyreveal { padding: 1.1rem; } +.auth-tabs { gap: 0; } +.auth-tabs .tab { flex: 1; text-align: center; border-top: 0; border-left: 0; border-right: 0; } +.keybox { display: block; padding: 0.9rem; font-size: 1.2rem; word-break: break-all; color: var(--amber); border: 1px solid var(--amber); background: rgba(255, 182, 46, 0.07); text-shadow: 0 0 8px rgba(255, 182, 46, 0.6); user-select: all; } + +/* ---- toasts & dialog ----------------------------------------------------- */ + +#toasts { position: fixed; right: 1rem; bottom: 1rem; display: grid; gap: 0.6rem; z-index: 300; } +.toast { max-width: min(430px, 92vw); padding: 0.7rem 1rem; background: rgba(7, 3, 15, 0.96); border: 1px solid var(--cyan); box-shadow: 0 0 18px rgba(40, 245, 255, 0.4); letter-spacing: 0.05em; animation: slide-in 0.25s ease-out; } +.toast.err { border-color: var(--red); box-shadow: 0 0 18px rgba(255, 85, 119, 0.5); color: #ffd0d9; } +.toast.out { opacity: 0; transition: opacity 0.4s; } +@keyframes slide-in { from { transform: translateX(30px); opacity: 0; } } + +dialog { background: transparent; border: 0; padding: 0; color: inherit; max-width: 100vw; } +dialog::backdrop { background: rgba(3, 1, 10, 0.78); backdrop-filter: blur(3px); } +.dialog { width: min(480px, 92vw); } +.dialog.danger { border-color: var(--red); } +.dialog .panel-body p + p { margin-top: 0.7rem; } +.dialog .actions { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0 1rem 1rem; } + +/* ---- small screens ------------------------------------------------------- */ + +@media (max-width: 900px) { + .grid-2, .uplink-grid, .fleet-grid { grid-template-columns: minmax(0, 1fr); } +} + +@media (max-width: 720px) { + body { font-size: 16px; } + .topbar { position: static; } + .toolbar .btn { padding: 0.4rem 0.8rem; font-size: 0.9rem; letter-spacing: 0.08em; } + .sealrow .btn { width: 100%; } + .panel-title { letter-spacing: 0.16em; } + .auth { padding-bottom: 10rem; } + .top-user { margin-left: 0; } + .manifest thead { display: none; } + .manifest tr { display: block; padding: 0.6rem 0; border-bottom: 1px dashed var(--faint); } + .manifest td { display: flex; justify-content: space-between; align-items: center; gap: 1rem; border: 0; padding: 0.25rem 1rem; } + .manifest td::before { content: attr(data-label); color: var(--dim); font-size: 0.75rem; letter-spacing: 0.2em; } + .manifest td.actions { justify-content: flex-end; } + .manifest td.actions::before { content: none; } + .tab { padding: 0.5rem 0.8rem; letter-spacing: 0.12em; } + .editor { height: 52vh; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; } +} diff --git a/web/static/view-auth.js b/web/static/view-auth.js new file mode 100644 index 0000000..442e81a --- /dev/null +++ b/web/static/view-auth.js @@ -0,0 +1,118 @@ +// Login and registration. + +import { api, keyStore } from './api.js'; +import { h, clear, download, toast } from './dom.js'; + +export function authView({ onLogin }) { + let mode = 'login'; + const host = h('section', { class: 'auth' }); + + const brand = h('header', { class: 'brand' }, + h('p', { class: 'kicker' }, 'HALCYON INSTRUMENT & CONTROL'), + h('h1', { class: 'logo' }, 'FLIGHT CONTROL'), + h('p', { class: 'tagline' }, 'BELT UPLINK TERMINAL // MODEL HC-33'), + ); + + const panel = h('div', { class: 'panel auth-panel' }); + host.append(brand, panel); + + function tabs() { + const tab = (id, label) => h('button', { + class: `tab ${mode === id ? 'on' : ''}`, type: 'button', role: 'tab', + 'aria-selected': mode === id ? 'true' : 'false', + onclick: () => { mode = id; render(); }, + }, label); + return h('div', { class: 'tabs auth-tabs', role: 'tablist' }, tab('login', 'LOGIN'), tab('register', 'NEW ACCOUNT')); + } + + function render() { + clear(panel).append(tabs(), mode === 'login' ? loginForm() : registerForm()); + panel.querySelector('input')?.focus(); + } + + function loginForm() { + const err = h('p', { class: 'form-error', role: 'alert' }); + const key = h('input', { id: 'key', type: 'password', autocomplete: 'current-password', required: true, spellcheck: 'false' }); + const show = h('input', { type: 'checkbox', id: 'show', onchange: (e) => { key.type = e.target.checked ? 'text' : 'password'; } }); + const btn = h('button', { class: 'btn primary wide', type: 'submit' }, 'ENGAGE LINK'); + return h('form', { + class: 'stack', + onsubmit: async (e) => { + e.preventDefault(); + const k = key.value.trim(); + if (!k) return; + btn.disabled = true; + err.textContent = ''; + try { + await api.me(k); + keyStore.set(k); + onLogin(); + } catch (ex) { + err.textContent = ex.status === 401 ? 'ACCESS DENIED: KEY NOT RECOGNISED' : `ERROR: ${ex.message}`; + btn.disabled = false; + } + }, + }, + h('label', { for: 'key' }, 'ACCESS KEY'), + key, + h('label', { class: 'check', for: 'show' }, show, 'SHOW KEY'), + err, + btn, + h('p', { class: 'hint' }, 'Your access key was shown once, when you registered. There is no password: the key is the login.'), + ); + } + + function registerForm() { + const err = h('p', { class: 'form-error', role: 'alert' }); + const name = h('input', { id: 'name', type: 'text', maxlength: '40', required: true, autocomplete: 'username', spellcheck: 'false' }); + const btn = h('button', { class: 'btn primary wide', type: 'submit' }, 'REGISTER'); + return h('form', { + class: 'stack', + onsubmit: async (e) => { + e.preventDefault(); + const callsign = name.value.trim(); + if (!callsign) { err.textContent = 'ENTER A CALLSIGN'; return; } + btn.disabled = true; + err.textContent = ''; + try { + const res = await api.register(callsign); + showKey(callsign, res); + } catch (ex) { + err.textContent = ex.status === 409 ? 'CALLSIGN ALREADY IN USE' : `ERROR: ${ex.message}`; + btn.disabled = false; + } + }, + }, + h('label', { for: 'name' }, 'CALLSIGN'), + name, + h('p', { class: 'hint' }, '1 to 40 characters. You will be issued one command ship and a secret access key.'), + err, + btn, + ); + } + + function showKey(callsign, res) { + const enter = h('button', { class: 'btn primary wide', type: 'button', disabled: true }, 'ENTER FLIGHT CONTROL'); + const ack = h('input', { type: 'checkbox', id: 'ack', onchange: (e) => { enter.disabled = !e.target.checked; } }); + enter.addEventListener('click', () => { keyStore.set(res.api_key); onLogin(); }); + clear(panel).append( + h('div', { class: 'panel-title' }, 'ACCOUNT CREATED'), + h('div', { class: 'stack keyreveal' }, + h('p', null, `WELCOME, ${callsign.toUpperCase()}. COMMAND SHIP #${res.ship_id} IS WAITING ON THE DOCK.`), + h('p', { class: 'warn' }, 'THIS IS YOUR ACCESS KEY. IT IS SHOWN ONCE AND CANNOT BE RECOVERED.'), + h('code', { class: 'keybox', tabindex: '0' }, res.api_key), + h('div', { class: 'row' }, + h('button', { class: 'btn', type: 'button', onclick: async () => { + try { await navigator.clipboard.writeText(res.api_key); toast('KEY COPIED TO CLIPBOARD'); } catch { toast('COPY FAILED: SELECT THE KEY AND COPY IT BY HAND', 'err'); } + } }, 'COPY'), + h('button', { class: 'btn', type: 'button', onclick: () => download(`halcyon-key-${callsign}.txt`, `${res.api_key}\n`, 'text/plain') }, 'SAVE AS FILE'), + ), + h('label', { class: 'check', for: 'ack' }, ack, 'I HAVE STORED MY KEY SAFELY'), + enter, + ), + ); + } + + render(); + return host; +} diff --git a/web/static/view-code.js b/web/static/view-code.js new file mode 100644 index 0000000..062eaea --- /dev/null +++ b/web/static/view-code.js @@ -0,0 +1,139 @@ +// Code editor: write assembly, check it, download it, seal it into a ship. + +import { api, b64ToBytes } from './api.js'; +import { h, clear, toast, confirmDialog, download } from './dom.js'; +import { createEditor } from './editor.js'; + +const draftKey = (ship) => `wh.draft.${ship?.id ?? 0}`; +const load = (k) => { try { return localStorage.getItem(k); } catch { return null; } }; +const save = (k, v) => { try { localStorage.setItem(k, v); } catch { /* ignore */ } }; + +export function codeView(ctx) { + const ship = ctx.ship; + const limit = ctx.state.info?.program_bytes ?? 4096; + const key = draftKey(ship); + const canSeal = ship?.status === 'inventory'; + + const cursor = h('span', { class: 'dim' }, 'LN 1 COL 1'); + let timer = 0; + const editor = createEditor({ + value: load(key) ?? '', + onInput: (v) => { clearTimeout(timer); timer = setTimeout(() => save(key, v), 300); }, + onCursor: ({ line, col }) => { cursor.textContent = `LN ${line} COL ${col}`; }, + }); + if (load(key) === null) api.example('telemetry').then((t) => { editor.value = t; }).catch(() => {}); + + // ---- console ----------------------------------------------------------- + const output = h('div', { class: 'console', role: 'log', 'aria-live': 'polite' }, + h('p', { class: 'dim' }, 'READY. ASSEMBLE TO CHECK YOUR PROGRAM.')); + const say = (...nodes) => clear(output).append(...nodes); + + let assembled = null; + async function assemble() { + try { + const r = await api.assemble(editor.value); + editor.setError(null); + const bytes = b64ToBytes(r.program); + assembled = { bytes, ...r }; + const pct = Math.min(100, Math.round((r.size / r.limit) * 100)); + say( + h('p', { class: r.fits ? 'ok' : 'bad' }, r.fits ? 'ASSEMBLED OK' : (r.size === 0 ? 'NOTHING TO ASSEMBLE' : 'PROGRAM TOO LARGE FOR THIS BELT')), + h('p', null, `${r.size} BYTES / ${r.instructions} INSTRUCTIONS / LIMIT ${r.limit} BYTES`), + h('div', { class: `meter ${r.fits ? '' : 'over'}`, role: 'meter', 'aria-valuemin': '0', 'aria-valuemax': String(r.limit), 'aria-valuenow': String(r.size), 'aria-label': 'Program size' }, + h('div', { class: 'fill', style: null, 'data-pct': String(pct) })), + ); + output.querySelector('.fill')?.style.setProperty('width', `${pct}%`); + return assembled; + } catch (e) { + assembled = null; + const m = /^line (\d+):/.exec(e.message); + if (m) editor.setError(Number(m[1])); + say(h('p', { class: 'bad' }, `ERROR: ${e.message}`)); + return null; + } + } + + // ---- toolbar ----------------------------------------------------------- + const btn = (label, onclick, opts = {}) => + h('button', { class: `btn ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label); + + const exampleSel = h('select', { id: 'example', 'aria-label': 'Example program' }, h('option', { value: '' }, 'EXAMPLES...')); + api.examples().then((names) => { + for (const n of names.filter((n) => n !== 'ports')) exampleSel.append(h('option', { value: n }, n.toUpperCase())); + }); + exampleSel.addEventListener('change', async () => { + const name = exampleSel.value; + exampleSel.value = ''; + if (!name) return; + if (editor.value.trim() && !(await confirmDialog({ title: 'REPLACE EDITOR CONTENTS?', body: `LOAD THE ${name.toUpperCase()} EXAMPLE OVER YOUR CURRENT TEXT.`, confirmText: 'REPLACE' }))) return; + try { editor.value = await api.example(name); say(h('p', { class: 'dim' }, `LOADED ${name.toUpperCase()}.`)); } catch (e) { toast(e.message, 'err'); } + }); + + const bytesOrNull = async () => { + const r = await assemble(); + if (r && r.size === 0) { toast('NOTHING TO ASSEMBLE', 'err'); return null; } + return r; + }; + + const seal = btn(ship ? `SEAL INTO SHIP #${ship.id}` : 'NO SHIP', async () => { + const r = await bytesOrNull(); + if (!r) return; + if (!r.fits) { toast(`PROGRAM IS ${r.size} BYTES; THE LIMIT IS ${r.limit}`, 'err'); return; } + try { + await api.setProgram(ship.id, r.bytes); + toast(`PROGRAM SEALED IN SHIP #${ship.id} (${r.size} BYTES). READY TO LAUNCH.`); + await ctx.refresh(); + } catch (e) { toast(`UPLOAD FAILED: ${e.message}`, 'err'); } + }, { cls: 'primary', disabled: !canSeal, title: canSeal ? 'Store this program in the ship' : 'Only docked ships can be reprogrammed' }); + + const toolbar = h('div', { class: 'toolbar' }, + exampleSel, + btn('INSERT EQUATES', async () => { + if (/\bP_UPNEW\b/.test(editor.value)) { toast('EQUATES ALREADY PRESENT'); return; } + try { editor.insertAtTop(`${await api.example('ports')}\n`); } catch (e) { toast(e.message, 'err'); } + }), + h('span', { class: 'spacer' }), + btn('ASSEMBLE', assemble), + btn('DOWNLOAD .BIN', async () => { const r = await bytesOrNull(); if (r) download(`ship-${ship?.id ?? 'x'}-program.bin`, r.bytes); }), + btn('SAVE .S', () => download(`ship-${ship?.id ?? 'x'}.s`, editor.value, 'text/plain')), + ); + + // ---- reference --------------------------------------------------------- + const card = h('pre', { class: 'refcard' }, 'LOADING...'); + const ref = h('details', { class: 'panel ref', onToggle: async (e) => { + if (!e.target.open || card.dataset.loaded) return; + card.dataset.loaded = '1'; + try { + const txt = await (await fetch('/manual.txt')).text(); + const a = txt.indexOf('APPENDIX E QUICK REFERENCE CARD'); + const b = txt.indexOf('* * * END OF MANUAL'); + card.textContent = txt.slice(a, b).split('\n').slice(3).join('\n').trim(); + } catch { card.textContent = 'COULD NOT LOAD THE MANUAL.'; } + } }, + h('summary', { class: 'panel-title' }, 'QUICK REFERENCE (HC-33 MANUAL, APPENDIX E)'), + card, + h('p', { class: 'hint pad' }, h('a', { href: '/manual.txt', target: '_blank', rel: 'noopener' }, 'OPEN THE FULL MANUAL')), + ); + + const note = !ship + ? 'YOU HAVE NO SHIP TO PROGRAM.' + : canSeal + ? `PROGRAMMING SHIP #${ship.id}. SEALING REPLACES ANY EARLIER PROGRAM UNTIL LAUNCH.` + : `SHIP #${ship.id} IS ${ship.status.toUpperCase()}: ITS PROGRAM IS SEALED. YOU CAN STILL EDIT AND DOWNLOAD HERE.`; + + const view = h('div', { class: 'stack-lg' }, + h('section', { class: 'panel' }, + h('div', { class: 'panel-title' }, 'FLIGHT PROGRAM EDITOR', h('span', { class: 'spacer' }), cursor), + h('div', { class: 'panel-body' }, + toolbar, + editor.el, + h('div', { class: 'toolbar sealrow' }, h('p', { class: 'hint grow' }, note), seal), + h('p', { class: 'hint' }, 'DRAFTS ARE KEPT IN THIS BROWSER ONLY. TAB INDENTS; ESC THEN TAB LEAVES THE EDITOR.'), + output, + ), + ), + ref, + ); + queueMicrotask(() => editor.render()); + return view; +} diff --git a/web/static/view-downlink.js b/web/static/view-downlink.js new file mode 100644 index 0000000..eea2f10 --- /dev/null +++ b/web/static/view-downlink.js @@ -0,0 +1,59 @@ +// Downlink viewer: hex dump of the ship's transmit buffer, with download. + +import { api } from './api.js'; +import { h, clear, toast, download } from './dom.js'; +import { hexView } from './hexview.js'; + +export function downlinkView(ctx) { + const ship = ctx.ship; + const body = h('div', { class: 'panel-body' }, h('p', { class: 'dim' }, ship ? 'ACQUIRING SIGNAL...' : 'YOU HAVE NO SHIP.')); + const meta = h('span', { class: 'dim' }); + const dl = h('button', { class: 'btn', type: 'button', disabled: true }, 'DOWNLOAD .BIN'); + const refresh = h('button', { class: 'btn', type: 'button', disabled: !ship }, 'REFRESH'); + + let current = null; + async function fetchIt() { + if (!ship) return; + refresh.disabled = true; + try { + const { bytes, day } = await api.downlink(ship.id); + current = { bytes, day }; + const allZero = bytes.every((b) => b === 0); + meta.textContent = `SHIP #${ship.id} // FROM THE RUN OF DAY ${day} // ${bytes.length} BYTES`; + dl.disabled = false; + clear(body).append( + ...(allZero ? [h('p', { class: 'warn' }, 'BUFFER IS ALL ZEROS: THE SHIP HAS NOT WRITTEN ANYTHING TO ITS DOWNLINK BUFFER.')] : []), + hexView(bytes).el, + h('p', { class: 'hint' }, 'OFFSETS ARE INTO THE 1 KB DOWNLINK BUFFER (SHIP RAM 1024-2047). MULTI-BYTE VALUES ARE LITTLE-ENDIAN.'), + ); + } catch (e) { + current = null; + dl.disabled = true; + meta.textContent = ''; + clear(body).append( + e.status === 404 + ? h('div', { class: 'nosignal' }, + h('p', { class: 'big' }, 'NO SIGNAL'), + h('p', { class: 'dim' }, 'THIS SHIP HAS NOT TRANSMITTED YET. A SHIP\'S FIRST DOWNLINK ARRIVES AFTER ITS FIRST DAY IN THE BELT.')) + : h('p', { class: 'bad' }, `ERROR: ${e.message}`), + ); + } finally { + refresh.disabled = false; + } + } + + // Reloading the account too keeps the ship's status and the day current. + refresh.addEventListener('click', () => ctx.refresh().catch((e) => toast(e.message, 'err'))); + dl.addEventListener('click', () => { + if (!current) return; + download(`ship-${ship.id}-day-${current.day}-downlink.bin`, current.bytes); + toast(`SAVED ${current.bytes.length} BYTES`); + }); + fetchIt(); + + return h('section', { class: 'panel' }, + h('div', { class: 'panel-title' }, 'DOWNLINK RECEIVER', h('span', { class: 'spacer' }), meta), + h('div', { class: 'toolbar pad' }, refresh, h('span', { class: 'spacer' }), dl), + body, + ); +} diff --git a/web/static/view-fleet.js b/web/static/view-fleet.js new file mode 100644 index 0000000..34ffc79 --- /dev/null +++ b/web/static/view-fleet.js @@ -0,0 +1,84 @@ +// Fleet manifest with launch controls. + +import { api } from './api.js'; +import { h, toast, confirmDialog } from './dom.js'; +import { statusOf } from './status.js'; + +export function fleetView(ctx) { + const { ships, selected } = ctx.state; + + async function launch(ship) { + const ok = await confirmDialog({ + title: `LAUNCH SHIP #${ship.id}?`, + body: [ + 'THE SHIP ENTERS THE BELT ON THE NEXT DAILY RUN.', + 'ITS PROGRAM IS SEALED AT LAUNCH. IT CANNOT BE CHANGED, RECALLED OR RESTARTED.', + ], + confirmText: 'LAUNCH', + danger: true, + }); + if (!ok) return; + try { + await api.launch(ship.id); + toast(`SHIP #${ship.id} CLEARED FOR LAUNCH. IT FLIES ON THE NEXT DAILY RUN.`); + await ctx.refresh(); + } catch (e) { + toast(`LAUNCH FAILED: ${e.message}`, 'err'); + } + } + + const btn = (label, onclick, opts = {}) => + h('button', { class: `btn small ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label); + + function actions(s) { + const out = []; + if (s.status === 'inventory') { + out.push(btn('CODE', () => ctx.selectShip(s.id, 'code'))); + out.push(btn('LAUNCH', () => launch(s), { + cls: 'primary', + disabled: s.program_bytes === 0, + title: s.program_bytes === 0 ? 'Upload a program first' : 'Launch on the next daily run', + })); + } + if (s.status === 'launching' || s.status === 'active') out.push(btn('UPLINK', () => ctx.selectShip(s.id, 'uplink'))); + if (s.downlink_day >= 0) out.push(btn('DOWNLINK', () => ctx.selectShip(s.id, 'downlink'))); + return out; + } + + const rows = ships.map((s) => { + const st = statusOf(s.status); + return h('tr', { class: s.id === selected ? 'sel' : '' }, + h('td', { 'data-label': 'SHIP' }, h('button', { class: 'linklike', type: 'button', onclick: () => ctx.selectShip(s.id) }, `#${s.id}`)), + h('td', { 'data-label': 'STATUS' }, h('span', { class: `badge ${st.cls}`, title: st.hint }, st.label)), + h('td', { 'data-label': 'PROGRAM' }, s.program_bytes ? `${s.program_bytes} BYTES` : h('span', { class: 'dim' }, 'NONE')), + h('td', { 'data-label': 'LAST DOWNLINK' }, s.downlink_day >= 0 ? `DAY ${s.downlink_day}` : h('span', { class: 'dim' }, '--')), + h('td', { class: 'actions', 'data-label': '' }, actions(s)), + ); + }); + + const step = (n, title, text) => h('li', null, h('span', { class: 'n' }, n), h('div', null, h('strong', null, title), ' ', text)); + + return h('div', { class: 'grid-2 fleet-grid' }, + h('section', { class: 'panel' }, + h('div', { class: 'panel-title' }, 'FLEET MANIFEST'), + h('div', { class: 'panel-body flush' }, + ships.length + ? h('table', { class: 'manifest' }, + h('thead', null, h('tr', null, ['SHIP', 'STATUS', 'PROGRAM', 'LAST DOWNLINK', ''].map((t) => h('th', { scope: 'col' }, t)))), + h('tbody', null, rows)) + : h('p', { class: 'empty' }, 'NO SHIPS ON RECORD.'), + ), + ), + h('aside', { class: 'panel brief' }, + h('div', { class: 'panel-title' }, 'MISSION BRIEF'), + h('ol', { class: 'steps' }, + step('1', 'WRITE', 'a flight program in the CODE editor. Your ship cannot be flown by hand.'), + step('2', 'SEAL', 'it into a docked ship. You can replace it until launch.'), + step('3', 'LAUNCH', 'the ship. It enters the belt on the next daily run and the program is fixed for good.'), + step('4', 'TALK', 'to it once a day: 1 KB up, 1 KB down, over the wormhole link.'), + ), + h('p', { class: 'hint pad' }, 'The belt is simulated once a day. Ore delivered to the station earns credits.'), + h('p', { class: 'pad' }, h('a', { class: 'btn small', href: '/manual.txt', target: '_blank', rel: 'noopener' }, 'OPEN THE HC-33 MANUAL')), + ), + ); +} diff --git a/web/static/view-uplink.js b/web/static/view-uplink.js new file mode 100644 index 0000000..8bd4243 --- /dev/null +++ b/web/static/view-uplink.js @@ -0,0 +1,101 @@ +// Uplink form: choose a binary file (or type hex) and transmit it. + +import { api } from './api.js'; +import { h, clear, toast } from './dom.js'; +import { hexView, parseHex } from './hexview.js'; + +export function uplinkView(ctx) { + const ship = ctx.ship; + const limit = ctx.state.info?.comm_bytes ?? 1024; + const open = ship && (ship.status === 'launching' || ship.status === 'active'); + + let bytes = null; + let label = ''; + + const preview = h('div', { class: 'uplink-preview' }); + const meter = h('div', { class: 'meter', role: 'meter', 'aria-valuemin': '0', 'aria-valuemax': String(limit), 'aria-label': 'Message size' }, h('div', { class: 'fill' })); + const sizeText = h('p', { class: 'dim' }, `NO MESSAGE LOADED. LIMIT ${limit} BYTES.`); + const err = h('p', { class: 'form-error', role: 'alert' }); + const send = h('button', { class: 'btn primary', type: 'button', disabled: true }, ship ? `TRANSMIT TO SHIP #${ship.id}` : 'NO SHIP'); + + function set(b, from) { + bytes = b; + label = from; + err.textContent = ''; + const over = b.length > limit; + meter.classList.toggle('over', over); + meter.setAttribute('aria-valuenow', String(b.length)); + meter.querySelector('.fill').style.setProperty('width', `${Math.min(100, (b.length / limit) * 100)}%`); + sizeText.textContent = `${from}: ${b.length} BYTES OF ${limit}${over ? ' -- TOO LARGE' : ''}`; + if (over) err.textContent = `MESSAGE EXCEEDS THE ${limit}-BYTE LINK. REMOVE ${b.length - limit} BYTES.`; + else if (b.length === 0) err.textContent = 'MESSAGE IS EMPTY.'; + send.disabled = !open || over || b.length === 0; + clear(preview); + if (b.length) { + preview.append(hexView(b, { rows: 8 }).el); + if (b.length > 128) preview.append(h('p', { class: 'hint' }, `SHOWING THE FIRST 128 OF ${b.length} BYTES.`)); + } + } + + // ---- file chooser / drop zone ------------------------------------------ + const file = h('input', { type: 'file', id: 'uplink-file', class: 'sr-only' }); + const drop = h('label', { class: `drop ${open ? '' : 'disabled'}`, for: 'uplink-file' }, + h('span', { class: 'drop-big' }, 'DROP A BINARY FILE HERE'), + h('span', { class: 'dim' }, 'or click to choose one'), + ); + file.disabled = !open; + const takeFile = async (f) => { + if (!f) return; + if (f.size > 1 << 20) { err.textContent = 'THAT FILE IS FAR TOO LARGE FOR THE LINK.'; return; } + set(new Uint8Array(await f.arrayBuffer()), f.name.toUpperCase()); + }; + file.addEventListener('change', () => takeFile(file.files[0])); + for (const ev of ['dragenter', 'dragover']) drop.addEventListener(ev, (e) => { e.preventDefault(); if (open) drop.classList.add('over'); }); + for (const ev of ['dragleave', 'drop']) drop.addEventListener(ev, () => drop.classList.remove('over')); + drop.addEventListener('drop', (e) => { e.preventDefault(); if (open) takeFile(e.dataTransfer.files[0]); }); + + // ---- hex entry --------------------------------------------------------- + const hexIn = h('textarea', { id: 'hex-in', rows: '3', spellcheck: 'false', placeholder: 'DE AD BE EF 00 01 ...', disabled: !open, 'aria-label': 'Message as hexadecimal' }); + const useHex = h('button', { class: 'btn small', type: 'button', disabled: !open, onclick: () => { + try { set(parseHex(hexIn.value), 'HEX ENTRY'); } catch (e) { err.textContent = `HEX ERROR: ${e.message}`; } + } }, 'USE HEX'); + + send.addEventListener('click', async () => { + send.disabled = true; + try { + await api.setUplink(ship.id, bytes); + toast(`UPLINK QUEUED FOR SHIP #${ship.id}: ${bytes.length} BYTES. DELIVERED AT THE START OF THE NEXT RUN.`); + } catch (e) { + err.textContent = `TRANSMISSION FAILED: ${e.message}`; + send.disabled = false; + } + }); + + const status = !ship + ? 'YOU HAVE NO SHIP.' + : open + ? `TARGET: SHIP #${ship.id}. A NEW UPLINK REPLACES ONE ALREADY QUEUED.` + : `SHIP #${ship.id} IS ${ship.status.toUpperCase()}. UPLINKS CAN ONLY BE SENT TO SHIPS THAT ARE LAUNCHING OR IN THE BELT.`; + + return h('div', { class: 'grid-2 uplink-grid' }, + h('section', { class: 'panel' }, + h('div', { class: 'panel-title' }, 'UPLINK TRANSMITTER'), + h('div', { class: 'panel-body stack' }, + h('p', { class: open ? 'hint' : 'warn' }, status), + drop, file, + h('label', { for: 'hex-in' }, 'OR ENTER HEX'), + hexIn, + h('div', null, useHex), + meter, sizeText, err, + h('div', null, send), + ), + ), + h('section', { class: 'panel' }, + h('div', { class: 'panel-title' }, 'OUTGOING MESSAGE'), + h('div', { class: 'panel-body' }, + preview, + h('p', { class: 'hint' }, 'THE MESSAGE IS OPAQUE BYTES. YOUR SHIP\'S PROGRAM DECIDES WHAT IT MEANS. IT ARRIVES IN THE UPLINK BUFFER (RAM 0-1023) BEFORE THE FIRST TICK OF THE NEXT DAILY RUN.'), + ), + ), + ); +} diff --git a/web/web.go b/web/web.go new file mode 100644 index 0000000..32df320 --- /dev/null +++ b/web/web.go @@ -0,0 +1,56 @@ +// Package web serves the browser front end, the manual and sample programs. +package web + +import ( + "embed" + "encoding/json" + "io/fs" + "net/http" + "sort" + "strings" + + "wh/docs" + "wh/examples" +) + +//go:embed static +var static embed.FS + +// Handler serves the site. Mount it as the catch-all route; more specific API +// routes registered on the same mux take precedence. +func Handler() http.Handler { + sub, _ := fs.Sub(static, "static") + files := http.FileServerFS(sub) + + mux := http.NewServeMux() + mux.Handle("GET /", files) + mux.HandleFunc("GET /manual.txt", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write(docs.Manual) + }) + mux.HandleFunc("GET /examples/index.json", func(w http.ResponseWriter, r *http.Request) { + entries, _ := fs.ReadDir(examples.FS, ".") + names := []string{} + for _, e := range entries { + if n, ok := strings.CutSuffix(e.Name(), ".s"); ok { + names = append(names, n) + } + } + sort.Strings(names) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(names) + }) + mux.Handle("GET /examples/", http.StripPrefix("/examples/", http.FileServerFS(examples.FS))) + return secure(mux) +} + +// secure adds headers appropriate to a page that loads only its own assets. +func secure(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hd := w.Header() + hd.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'") + hd.Set("X-Content-Type-Options", "nosniff") + hd.Set("Referrer-Policy", "no-referrer") + h.ServeHTTP(w, r) + }) +} diff --git a/world/orbit.go b/world/orbit.go new file mode 100644 index 0000000..6b7eb55 --- /dev/null +++ b/world/orbit.go @@ -0,0 +1,39 @@ +// Package world defines the static shape of the belt: bodies on analytic +// (on-rails) orbits and the procedural generation that creates them. +// +// The star pulls with acceleration v^2/r (v = config OrbitSpeed), so a circular +// orbit at any radius has the same speed v. Every body on rails is therefore +// on a circle traversed at speed v; only the period (2*pi*r/v) varies. +package world + +import "wh/fixed" + +// Orbit is a circular orbit around the origin (the star). +type Orbit struct { + A fixed.F // radius, km + Period int64 // seconds + M0 fixed.F // phase at t=0, radians + Inc fixed.F // inclination to the ecliptic (XY plane), radians + Node fixed.F // longitude of the ascending node, radians +} + +// PeriodFor returns the period in seconds of a circle of radius a at speed v. +func PeriodFor(a, v fixed.F) int64 { + return fixed.TwoPi.Mul(a).Div(v).Int() +} + +// State returns position (km) and velocity (km/s) at absolute time t seconds. +func (o Orbit) State(t int64) (pos, vel fixed.Vec) { + frac := fixed.FromInt(t % o.Period).Div(fixed.FromInt(o.Period)) + s, c := fixed.SinCos(o.M0 + fixed.TwoPi.Mul(frac)) + k := fixed.TwoPi.Mul(o.A).Div(fixed.FromInt(o.Period)) // speed + pos = fixed.Vec{X: o.A.Mul(c), Y: o.A.Mul(s)} + vel = fixed.Vec{X: -k.Mul(s), Y: k.Mul(c)} + return o.orient(pos), o.orient(vel) +} + +// orient rotates a vector from the orbital plane into the ecliptic frame: +// Rz(Node) * Rx(Inc). +func (o Orbit) orient(v fixed.Vec) fixed.Vec { + return v.RotateX(o.Inc).RotateZ(o.Node) +} diff --git a/world/world.go b/world/world.go new file mode 100644 index 0000000..6f2a3be --- /dev/null +++ b/world/world.go @@ -0,0 +1,91 @@ +package world + +import ( + "wh/config" + "wh/fixed" + "wh/rng" +) + +// Ore identifies a raw material. +type Ore int + +const ( + Iron Ore = iota + Nickel + Ice + Platinum + NumOre +) + +func (o Ore) String() string { + return [...]string{"iron", "nickel", "ice", "platinum"}[o] +} + +type Asteroid struct { + ID int64 + Orbit Orbit + Ore [NumOre]int64 // remaining kilograms of each ore +} + +func (a *Asteroid) TotalOre() int64 { + var t int64 + for _, v := range a.Ore { + t += v + } + return t +} + +// Station is the dropoff point, on a circular orbit in the ecliptic plane. +type Station struct { + Orbit Orbit +} + +// Stream labels keep independent generation phases from sharing randomness. +const ( + labelBelt = iota + 1 + labelStation +) + +// Generate builds the asteroid field and station from the config seed. +func Generate(cfg config.Config) ([]Asteroid, Station) { + r := rng.Derive(cfg.Seed, labelBelt) + asts := make([]Asteroid, cfg.AsteroidCount) + for i := range asts { + a := r.FixedRange(cfg.BeltInner, cfg.BeltOuter) + asts[i] = Asteroid{ + ID: int64(i + 1), + Orbit: Orbit{ + A: a, + Period: PeriodFor(a, cfg.OrbitSpeed), + M0: r.FixedRange(0, fixed.TwoPi), + Node: r.FixedRange(0, fixed.TwoPi), + // Bias inclination low (squared uniform). + Inc: r.Fixed().Mul(r.Fixed()).Mul(cfg.MaxInclination), + }, + } + // Each asteroid has a total mass and a random mix of ores. + total := int64(50_000) + int64(r.Intn(950_000)) + var weights [NumOre]uint64 + var wsum uint64 + for o := range weights { + weights[o] = r.Intn(100) + 1 + wsum += weights[o] + } + // Platinum is rare: only a fifth of asteroids carry much of it. + if r.Intn(5) != 0 { + wsum -= weights[Platinum] + weights[Platinum] = 0 + } + for o := range weights { + asts[i].Ore[o] = total * int64(weights[o]) / int64(wsum) + } + } + sr := rng.Derive(cfg.Seed, labelStation) + a := (cfg.BeltInner + cfg.BeltOuter).DivInt(2) + st := Station{Orbit: Orbit{ + A: a, + Period: PeriodFor(a, cfg.OrbitSpeed), + M0: sr.FixedRange(0, fixed.TwoPi), + }} + return asts, st +} diff --git a/world/world_test.go b/world/world_test.go new file mode 100644 index 0000000..2a01eaa --- /dev/null +++ b/world/world_test.go @@ -0,0 +1,78 @@ +package world + +import ( + "math" + "testing" + + "wh/config" + "wh/fixed" +) + +func TestOrbitsAreCirclesAtCommonSpeed(t *testing.T) { + cfg := config.Default() + asts, _ := Generate(cfg) + want := cfg.OrbitSpeed.Float64() + for _, a := range asts { + o := a.Orbit + for _, ts := range []int64{0, 100_000, 777_777, o.Period / 3, o.Period - 1} { + p, v := o.State(ts) + if r := p.Len().Float64(); math.Abs(r-o.A.Float64()) > 1e-3 { + t.Fatalf("asteroid %d t=%d: radius %g, want %g", a.ID, ts, r, o.A.Float64()) + } + if s := v.Len().Float64(); math.Abs(s-want)/want > 1e-5 { + t.Fatalf("asteroid %d t=%d: speed %g, want %g", a.ID, ts, s, want) + } + if d := p.Dot(v).Float64() / (p.Len().Float64() * want); math.Abs(d) > 1e-4 { + t.Fatalf("asteroid %d: velocity not tangent (cos=%g)", a.ID, d) + } + // Inclination bounds the height above the ecliptic. + if z := p.Z.Abs().Float64(); z > o.A.Float64()*math.Sin(o.Inc.Float64())*1.001+1e-3 { + t.Fatalf("asteroid %d: |z|=%g exceeds inclination bound", a.ID, z) + } + } + // Periodic. + p1, _ := o.State(12345) + p2, _ := o.State(12345 + o.Period) + if d := p1.Sub(p2).Len().Float64(); d > 1 { + t.Fatalf("asteroid %d not periodic, off by %g km", a.ID, d) + } + } +} + +func TestOrbitPlaneMatchesInclination(t *testing.T) { + o := Orbit{A: fixed.FromInt(2_000_000), Inc: fixed.FromRatio(3, 10), Node: fixed.FromInt(2)} + o.Period = PeriodFor(o.A, fixed.FromInt(3)) + // The orbit normal is Rz(Node)Rx(Inc) applied to +Z; positions must be + // perpendicular to it, and the maximum height is A*sin(Inc). + n := fixed.Vec{Z: fixed.One}.RotateX(o.Inc).RotateZ(o.Node) + var maxZ float64 + for k := int64(0); k < 50; k++ { + p, _ := o.State(k * o.Period / 50) + if d := p.Dot(n).Float64(); math.Abs(d) > 1e-2 { + t.Fatalf("position not in the orbital plane: p.n = %g", d) + } + maxZ = math.Max(maxZ, p.Z.Float64()) + } + if want := o.A.Float64() * math.Sin(0.3); math.Abs(maxZ-want)/want > 0.01 { + t.Fatalf("max z = %g, want %g", maxZ, want) + } +} + +func TestGenerateDeterministic(t *testing.T) { + cfg := config.Default() + a1, s1 := Generate(cfg) + a2, s2 := Generate(cfg) + if len(a1) != cfg.AsteroidCount || s1 != s2 { + t.Fatal("mismatch") + } + for i := range a1 { + if a1[i] != a2[i] { + t.Fatalf("asteroid %d differs", i) + } + } + cfg.Seed = 2 + a3, _ := Generate(cfg) + if a3[0] == a1[0] { + t.Fatal("different seeds should differ") + } +}