diff --git a/.dockerignore b/.dockerignore index faef1e6..fa4a3bc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,4 @@ Dockerfile .dockerignore web/e2e node_modules +**/__pycache__ diff --git a/Dockerfile b/Dockerfile index 8112687..017ed82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,19 @@ COPY . . RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ go vet ./... && go test ./... +# ---- manual: an on-demand renderer for the PDF manual ----------------------- +# Not part of the app image, so deploying the app never waits for it. Running +# this image renders docs/manual.md to /out/manual.pdf (typeset with WeasyPrint, +# then coffee-stained, hole-punched and photocopied; see docs/pdf/). The file is +# replaced atomically. Use scripts/render-manual.sh (compose volume) or +# scripts/manual-pdf.sh (a local file). +FROM alpine:3.20 AS manual +RUN apk add --no-cache weasyprint py3-markdown py3-pillow py3-numpy font-liberation poppler-utils +WORKDIR /manual +COPY docs/pdf ./pdf +COPY docs/manual.md . +ENTRYPOINT ["python3", "pdf/render.py", "manual.md", "/out/manual.pdf"] + # ---- build: static binaries (SQLite and Postgres drivers are pure Go) -------- FROM deps AS build COPY . . @@ -21,23 +34,27 @@ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache # ---- 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 +RUN addgroup -S -g 10001 halcyon && adduser -S -u 10001 -G halcyon halcyon \ + && mkdir /data && chown halcyon:halcyon /data COPY --from=build /out/server /out/daily /out/asm /usr/local/bin/ -USER wh +USER halcyon 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 +ENV DATABASE_URL=sqlite:/data/halcyon.db + +# The rendered PDF manual is read from here on every request. Compose mounts the +# volume the manual renderer writes to; if the file is absent /manual.pdf is a 404. +ENV MANUAL_PDF=/manual/manual.pdf 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: +# Default: the API and website. Run one day of the belt with: # docker run --rm -e DATABASE_URL=... daily CMD ["server"] diff --git a/README.md b/README.md index c4e4b7a..8ea79d2 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,22 @@ -# Halcyon belt +# Halcyon -A once-a-day asteroid-belt simulator. Players cannot fly their ships: they write a +A once-a-day asteroid-belt game. 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. +it over a "wormhole" link. The belt is run once a day, deterministically. -- `docs/manual.txt` -- the HC-33 flight computer programmer's manual (also served at `/manual.txt`) +- `docs/manual.md` -- the HC-33 flight computer programmer's manual (source; served at `/manual.md`) +- `/manual.pdf` -- the same, typeset separately (see below) - `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) +go run ./cmd/server -db sqlite:halcyon.db # API + website on :8080 +go run ./cmd/daily -db sqlite:halcyon.db # run one day of the belt (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 +`-ticks-per-day`, `-asteroids`, ...) or their `HALCYON_*` 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; @@ -26,27 +27,47 @@ it is your login), write a program, seal it into your ship, launch it, then run ```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 run --rm -v whdata:/data halcyon daily # run one day of the belt +docker run -d -p 8080:8080 -e DATABASE_URL=postgres://user:pw@db/halcyon halcyon # or Postgres docker build --target test . # run the tests +scripts/render-manual.sh # (re)render /manual.pdf, separately ``` 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: +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 +scripts/daily.sh # one day's run 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 +The world settings are environment variables (`HALCYON_SEED`, `HALCYON_TICKS_PER_DAY`, `HALCYON_CYCLES_PER_TICK`, +`HALCYON_PROGRAM_BYTES`, `HALCYON_RAM_BYTES`, `HALCYON_COMM_BYTES`, `HALCYON_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`. +`sqlite:/data/halcyon.db`); the `-db` flag overrides it. The image also contains `asm`. + +## The manual PDF + +`docs/manual.md` is rendered to a PDF: typeset with WeasyPrint, then made to look like a well-used ring-binder +copy -- coffee rings, three binder holes, and several generations of photocopying. The PDF keeps a live text +layer, bookmarks and links. The look is generated by `docs/pdf/render.py` and is deterministic; +`--intensity` (default 0.5) sets how worn the copies look, and `docs/pdf/check_legibility.py` OCRs every page +against the text layer to check the result. + +Rendering takes a couple of minutes, so it is **not** part of the app image: deploys stay fast, and the PDF is +rendered separately into a volume that the server reads on every request (a new render is live at once, no restart): + +```sh +docker compose up -d --build # deploy the app (fast) +scripts/render-manual.sh > /tmp/render-manual.log 2>&1 & # then re-render the PDF in the background +scripts/manual-pdf.sh # or: render to docs/generated/ for a locally run server +``` + +Until it has been rendered once, `/manual.pdf` is a 404 that says so. The Markdown source is always at `/manual.md`. ## Layout @@ -54,12 +75,12 @@ server. Change them only before a world has run. |---|---| | `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 | +| `world`, `sim`, `market` | belt generation, the daily run, 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 +`go test ./...` covers everything; set `HALCYON_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 index 5ecba01..af8a9b5 100644 --- a/api/api.go +++ b/api/api.go @@ -11,12 +11,12 @@ import ( "strconv" "strings" - "wh/comms" - "wh/config" - "wh/runner" - "wh/store" - "wh/vm" - "wh/world" + "halcyon/comms" + "halcyon/config" + "halcyon/runner" + "halcyon/store" + "halcyon/vm" + "halcyon/world" ) type Server struct { @@ -33,7 +33,9 @@ type Server struct { // 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) +// POST /ships/{id}/launch {"slot": n} (optional) (enters the belt on the next run; with a slot, that +// slot's code is assembled and sealed in first) +// GET /slots, GET/PUT/DELETE /slots/{n} the sixteen code slots (1-3 are read-only examples) // PUT /ships/{id}/uplink raw bytes (<= comm size) (replaces any queued uplink) // GET /ships/{id}/downlink raw bytes, X-Downlink-Day header // @@ -49,6 +51,10 @@ func New(st *store.Store, cfg config.Config) *http.ServeMux { 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("GET /slots", s.auth(s.listSlots)) + mux.Handle("GET /slots/{n}", s.auth(s.getSlot)) + mux.Handle("PUT /slots/{n}", s.auth(s.putSlot)) + mux.Handle("DELETE /slots/{n}", s.auth(s.deleteSlot)) mux.Handle("PUT /ships/{id}/uplink", s.auth(s.uplink)) mux.Handle("GET /ships/{id}/downlink", s.auth(s.downlink)) return mux @@ -148,9 +154,9 @@ func (s *Server) info(w http.ResponseWriter, r *http.Request) { } 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, + "turns_per_day": s.cfg.TicksPerDay, + "turn_seconds": s.cfg.TickSeconds(), + "cycles_per_turn": s.cfg.CyclesPerTick, "program_bytes": s.cfg.ProgramBytes, "ram_bytes": s.cfg.RAMBytes, "comm_bytes": s.cfg.CommBytes, @@ -242,6 +248,25 @@ func (s *Server) launch(w http.ResponseWriter, r *http.Request, p store.Player) if !ok { return } + // An optional {"slot": n} chooses which code slot the ship flies. Without it + // the ship launches with the program already sealed in it (PUT .../program). + body, ok := readBody(w, r, 1<<10) + if !ok { + return + } + if strings.TrimSpace(string(body)) != "" { + var req struct { + Slot *int `json:"slot"` + } + if err := json.Unmarshal(body, &req); err != nil { + httpError(w, http.StatusBadRequest, `body must be {"slot": <1-16>} or empty`) + return + } + if req.Slot != nil { + s.launchFromSlot(w, r, p, id, *req.Slot) + return + } + } if err := s.st.Launch(r.Context(), p.ID, id); err != nil { fail(w, err) return diff --git a/api/api_test.go b/api/api_test.go index 5e0d30c..0b4702f 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -12,12 +12,12 @@ import ( "path/filepath" "testing" - "wh/api" - "wh/config" - "wh/runner" - "wh/sim" - "wh/store" - "wh/vm" + "halcyon/api" + "halcyon/config" + "halcyon/runner" + "halcyon/sim" + "halcyon/store" + "halcyon/vm" ) func TestEndToEnd(t *testing.T) { @@ -25,10 +25,10 @@ func TestEndToEnd(t *testing.T) { cfg := config.Default() cfg.AsteroidCount = 20 - // Set WH_TEST_PG to a postgres:// URL to run this against PostgreSQL + // Set HALCYON_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 != "" { + if pg := os.Getenv("HALCYON_TEST_PG"); pg != "" { db, err := sql.Open("pgx", pg) if err != nil { t.Fatal(err) diff --git a/api/site_test.go b/api/site_test.go index b1e9df1..0c71698 100644 --- a/api/site_test.go +++ b/api/site_test.go @@ -2,26 +2,46 @@ package api_test import ( "context" + "database/sql" "encoding/base64" "encoding/json" "io" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" - "wh/api" - "wh/config" - "wh/store" - "wh/web" + "halcyon/api" + "halcyon/config" + "halcyon/store" + "halcyon/web" ) // newSite starts the API with the web front end mounted, as cmd/server does. -func newSite(t *testing.T) (*httptest.Server, config.Config) { +func newSite(t *testing.T, manualPDF string) (*httptest.Server, config.Config) { + srv, cfg, _ := newSiteStore(t, manualPDF) + return srv, cfg +} + +// newSiteStore is newSite, also returning the database so tests can run a simulated day. +func newSiteStore(t *testing.T, manualPDF string) (*httptest.Server, config.Config, *store.Store) { t.Helper() cfg := config.Default() - st, err := store.Open("sqlite:" + filepath.Join(t.TempDir(), "t.db")) + dsn := "sqlite:" + filepath.Join(t.TempDir(), "t.db") + if pg := os.Getenv("HALCYON_TEST_PG"); pg != "" { // run against PostgreSQL, wiping its public schema first + 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) } @@ -30,10 +50,10 @@ func newSite(t *testing.T) (*httptest.Server, config.Config) { t.Fatal(err) } mux := api.New(st, cfg) - mux.Handle("/", web.Handler()) + mux.Handle("/", web.Handler(manualPDF)) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - return srv, cfg + return srv, cfg, st } func get(t *testing.T, url string) (*http.Response, string) { @@ -48,7 +68,7 @@ func get(t *testing.T, url string) (*http.Response, string) { } func TestInfoAndAssemble(t *testing.T) { - srv, cfg := newSite(t) + srv, cfg := newSite(t, "") _, body := get(t, srv.URL+"/info") var info struct { @@ -92,10 +112,10 @@ func TestInfoAndAssemble(t *testing.T) { } func TestWebAssets(t *testing.T) { - srv, _ := newSite(t) + srv, _ := newSite(t, "") for path, want := range map[string]string{ "/": "", - "/manual.txt": "PROGRAMMER'S REFERENCE MANUAL", + "/manual.md": "Programmer's Reference Manual", "/examples/echo.s": "ECHO", "/examples/index.json": `"pursue"`, } { @@ -108,8 +128,57 @@ func TestWebAssets(t *testing.T) { if !strings.Contains(resp.Header.Get("Content-Security-Policy"), "default-src 'self'") { t.Error("missing CSP header") } + // The PDF is rendered separately; until it exists /manual.pdf explains how. + presp, pbody := get(t, srv.URL+"/manual.pdf") + if presp.StatusCode != 404 || !strings.Contains(pbody, "render-manual.sh") { + t.Errorf("/manual.pdf before any render: status %d: %s", presp.StatusCode, pbody) + } // 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")) } } + +func TestManualPDFServedFromFileAndPickedUpLive(t *testing.T) { + path := filepath.Join(t.TempDir(), "manual.pdf") + srv, _ := newSite(t, path) + + if resp, _ := get(t, srv.URL+"/manual.pdf"); resp.StatusCode != 404 { + t.Fatalf("before the file exists: %d", resp.StatusCode) + } + write := func(content string) { + // Like the renderer: write beside the target, then rename into place. + if err := os.WriteFile(path+".tmp", []byte(content), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Rename(path+".tmp", path); err != nil { + t.Fatal(err) + } + } + write("%PDF-1.7 first render") + resp, body := get(t, srv.URL+"/manual.pdf") + if resp.StatusCode != 200 || body != "%PDF-1.7 first render" || resp.Header.Get("Content-Type") != "application/pdf" { + t.Fatalf("first render: %d %q %q", resp.StatusCode, body, resp.Header.Get("Content-Type")) + } + if resp.Header.Get("Cache-Control") != "no-cache" || resp.Header.Get("Content-Security-Policy") != "" { + t.Errorf("headers: cache=%q csp=%q", resp.Header.Get("Cache-Control"), resp.Header.Get("Content-Security-Policy")) + } + + // A re-render is served immediately, with no restart. + write("%PDF-1.7 second render, longer") + if _, body := get(t, srv.URL+"/manual.pdf"); body != "%PDF-1.7 second render, longer" { + t.Fatalf("new render not picked up: %q", body) + } + + // Range requests work (PDF viewers rely on them). + req, _ := http.NewRequest("GET", srv.URL+"/manual.pdf", nil) + req.Header.Set("Range", "bytes=0-4") + rr, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer rr.Body.Close() + if part, _ := io.ReadAll(rr.Body); rr.StatusCode != 206 || string(part) != "%PDF-" { + t.Fatalf("range: %d %q", rr.StatusCode, part) + } +} diff --git a/api/slots.go b/api/slots.go new file mode 100644 index 0000000..e54bfcc --- /dev/null +++ b/api/slots.go @@ -0,0 +1,177 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "halcyon/comms" + "halcyon/slots" + "halcyon/store" + "halcyon/vm" +) + +const ( + maxSourceBytes = 64 << 10 + maxSlotName = 40 +) + +var ( + errBadSlot = errors.New("slot must be a number from 1 to 16") + errEmptySlot = errors.New("that slot is empty") +) + +type slotView struct { + Slot int `json:"slot"` + Name string `json:"name"` + Builtin bool `json:"builtin"` + Empty bool `json:"empty"` +} + +func slotNumber(w http.ResponseWriter, r *http.Request) (int, bool) { + n, err := strconv.Atoi(r.PathValue("n")) + if err != nil || !slots.Valid(n) { + httpError(w, http.StatusBadRequest, errBadSlot.Error()) + return 0, false + } + return n, true +} + +// slotContent returns what is in slot n for this player. An empty slot has an +// empty source. The examples in slots 1-3 are the same for everyone. +func (s *Server) slotContent(r *http.Request, owner int64, n int) (slots.Slot, error) { + if !slots.Valid(n) { + return slots.Slot{}, errBadSlot + } + if b, ok := slots.Builtin(n); ok { + return b, nil + } + cs, err := s.st.GetCodeSlot(r.Context(), owner, n) + if errors.Is(err, store.ErrNotFound) { + return slots.Slot{Number: n}, nil + } + if err != nil { + return slots.Slot{}, err + } + return slots.Slot{Number: n, Name: cs.Name, Source: cs.Source}, nil +} + +// GET /slots: the sixteen slots, without their source. +func (s *Server) listSlots(w http.ResponseWriter, r *http.Request, p store.Player) { + saved, err := s.st.ListCodeSlots(r.Context(), p.ID) + if err != nil { + fail(w, err) + return + } + out := make([]slotView, 0, slots.Count) + for n := 1; n <= slots.Count; n++ { + if b, ok := slots.Builtin(n); ok { + out = append(out, slotView{Slot: n, Name: b.Name, Builtin: true}) + continue + } + name, has := saved[n] + out = append(out, slotView{Slot: n, Name: name, Empty: !has}) + } + writeJSON(w, http.StatusOK, out) +} + +// GET /slots/{n}: one slot with its source. +func (s *Server) getSlot(w http.ResponseWriter, r *http.Request, p store.Player) { + n, ok := slotNumber(w, r) + if !ok { + return + } + sl, err := s.slotContent(r, p.ID, n) + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "slot": n, "name": sl.Name, "builtin": sl.Builtin, "empty": sl.Source == "" && sl.Name == "", "source": sl.Source, + }) +} + +// PUT /slots/{n}: save source in one of slots 4-16. +func (s *Server) putSlot(w http.ResponseWriter, r *http.Request, p store.Player) { + n, ok := slotNumber(w, r) + if !ok { + return + } + if _, builtin := slots.Builtin(n); builtin { + httpError(w, http.StatusForbidden, "slots 1 to "+strconv.Itoa(slots.FirstUser-1)+" hold the examples and are read-only; save your copy in slot "+strconv.Itoa(slots.FirstUser)+" or higher") + return + } + body, ok := readBody(w, r, maxSourceBytes+4<<10) + if !ok { + return + } + var req struct { + Name string `json:"name"` + Source string `json:"source"` + } + if err := json.Unmarshal(body, &req); err != nil { + httpError(w, http.StatusBadRequest, `body must be {"name": "...", "source": "..."}`) + return + } + req.Name = strings.TrimSpace(req.Name) + switch { + case req.Name == "" || len([]rune(req.Name)) > maxSlotName: + httpError(w, http.StatusBadRequest, "name must be 1 to "+strconv.Itoa(maxSlotName)+" characters") + return + case len(req.Source) > maxSourceBytes: + httpError(w, http.StatusRequestEntityTooLarge, "source is larger than "+strconv.Itoa(maxSourceBytes)+" bytes") + return + } + if err := s.st.PutCodeSlot(r.Context(), p.ID, n, req.Name, req.Source, time.Now().UTC().Format(time.RFC3339)); err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, slotView{Slot: n, Name: req.Name}) +} + +// DELETE /slots/{n}: empty one of slots 4-16. +func (s *Server) deleteSlot(w http.ResponseWriter, r *http.Request, p store.Player) { + n, ok := slotNumber(w, r) + if !ok { + return + } + if _, builtin := slots.Builtin(n); builtin { + httpError(w, http.StatusForbidden, "the example slots cannot be cleared") + return + } + if err := s.st.DeleteCodeSlot(r.Context(), p.ID, n); err != nil { + fail(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// launchFromSlot assembles the chosen slot and seals it into the ship as it launches. +func (s *Server) launchFromSlot(w http.ResponseWriter, r *http.Request, p store.Player, ship int64, n int) { + sl, err := s.slotContent(r, p.ID, n) + if err != nil { + httpError(w, http.StatusBadRequest, err.Error()) + return + } + if strings.TrimSpace(sl.Source) == "" { + httpError(w, http.StatusBadRequest, "slot "+strconv.Itoa(n)+" is empty") + return + } + prog, err := vm.Assemble(sl.Source) + if err != nil { + httpError(w, http.StatusBadRequest, "slot "+strconv.Itoa(n)+" does not assemble: "+err.Error()) + return + } + if err := comms.CheckProgram(prog, s.cfg.ProgramBytes); err != nil { + httpError(w, http.StatusBadRequest, "slot "+strconv.Itoa(n)+": "+err.Error()) + return + } + if err := s.st.LaunchWithProgram(r.Context(), p.ID, ship, prog); err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusAccepted, map[string]any{"slot": n, "program_bytes": len(prog)}) +} diff --git a/api/slots_test.go b/api/slots_test.go new file mode 100644 index 0000000..dfd5fd9 --- /dev/null +++ b/api/slots_test.go @@ -0,0 +1,247 @@ +package api_test + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "halcyon/runner" +) + +// player is a registered account talking to the API. +type player struct { + t *testing.T + base string + key string + ship int64 +} + +func newPlayer(t *testing.T, base, name string) *player { + t.Helper() + resp, err := http.Post(base+"/register", "application/json", strings.NewReader(`{"name":"`+name+`"}`)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var r struct { + APIKey string `json:"api_key"` + ShipID int64 `json:"ship_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil || r.APIKey == "" { + t.Fatalf("register %s: %v", name, err) + } + return &player{t: t, base: base, key: r.APIKey, ship: r.ShipID} +} + +// do sends a request and returns the body, failing unless the status is want. +func (p *player) do(method, path string, body any, want int) []byte { + p.t.Helper() + var rdr io.Reader + if body != nil { + if s, ok := body.(string); ok { + rdr = strings.NewReader(s) + } else { + b, _ := json.Marshal(body) + rdr = bytes.NewReader(b) + } + } + req, _ := http.NewRequest(method, p.base+path, rdr) + if p.key != "" { + req.Header.Set("Authorization", "Bearer "+p.key) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + p.t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode != want { + p.t.Fatalf("%s %s: status %d want %d: %s", method, path, resp.StatusCode, want, b) + } + return b +} + +type slotRow struct { + Slot int `json:"slot"` + Name string `json:"name"` + Builtin bool `json:"builtin"` + Empty bool `json:"empty"` + Source string `json:"source"` +} + +func (p *player) slots() []slotRow { + var rows []slotRow + json.Unmarshal(p.do("GET", "/slots", nil, 200), &rows) + return rows +} + +func (p *player) slot(n int) slotRow { + var r slotRow + json.Unmarshal(p.do("GET", "/slots/"+itoa(int64(n)), nil, 200), &r) + return r +} + +func (p *player) shipStatus() (status string, programBytes int) { + var ships []struct { + Status string `json:"status"` + ProgramBytes int `json:"program_bytes"` + } + json.Unmarshal(p.do("GET", "/ships", nil, 200), &ships) + return ships[0].Status, ships[0].ProgramBytes +} + +func TestSlotsListAndExamples(t *testing.T) { + srv, _ := newSite(t, "") + a := newPlayer(t, srv.URL, "ann") + + rows := a.slots() + if len(rows) != 16 { + t.Fatalf("%d slots, want 16", len(rows)) + } + for i, want := range []string{"ECHO", "TELEMETRY", "PURSUE"} { + if r := rows[i]; r.Slot != i+1 || r.Name != want || !r.Builtin || r.Empty { + t.Errorf("slot %d = %+v, want the %s example", i+1, r, want) + } + } + for _, r := range rows[3:] { + if r.Builtin || !r.Empty || r.Name != "" { + t.Errorf("slot %d should start empty: %+v", r.Slot, r) + } + } + if s := a.slot(2); !s.Builtin || !strings.Contains(s.Source, "TELEMETRY") { + t.Errorf("slot 2 = %+v", s) + } + if s := a.slot(9); !s.Empty || s.Source != "" { + t.Errorf("empty slot 9 = %+v", s) + } +} + +func TestSlotsSaveLoadClearAndIsolation(t *testing.T) { + srv, _ := newSite(t, "") + a, b := newPlayer(t, srv.URL, "ann"), newPlayer(t, srv.URL, "bob") + + a.do("PUT", "/slots/5", map[string]string{"name": "MINER", "source": "ldi r0, 1\nhalt"}, 200) + if s := a.slot(5); s.Name != "MINER" || s.Source != "ldi r0, 1\nhalt" || s.Empty || s.Builtin { + t.Fatalf("saved slot = %+v", s) + } + if r := a.slots()[4]; r.Name != "MINER" || r.Empty { + t.Fatalf("listing = %+v", r) + } + // Saving again replaces it, including the name. + a.do("PUT", "/slots/5", map[string]string{"name": "MINER 2", "source": "halt"}, 200) + if s := a.slot(5); s.Name != "MINER 2" || s.Source != "halt" { + t.Fatalf("overwritten slot = %+v", s) + } + // Slots are private to their owner. + if s := b.slot(5); !s.Empty { + t.Fatalf("bob can see ann's slot: %+v", s) + } + + // The examples cannot be changed or cleared. + for _, n := range []string{"1", "2", "3"} { + a.do("PUT", "/slots/"+n, map[string]string{"name": "X", "source": "halt"}, 403) + a.do("DELETE", "/slots/"+n, nil, 403) + } + // Bad slot numbers and bodies. + for _, n := range []string{"0", "17", "abc", "-1"} { + a.do("GET", "/slots/"+n, nil, 400) + a.do("PUT", "/slots/"+n, map[string]string{"name": "X", "source": "halt"}, 400) + } + a.do("PUT", "/slots/6", map[string]string{"name": " ", "source": "halt"}, 400) + a.do("PUT", "/slots/6", map[string]string{"name": strings.Repeat("x", 41), "source": "halt"}, 400) + a.do("PUT", "/slots/6", "not json", 400) + a.do("PUT", "/slots/6", map[string]string{"name": "BIG", "source": strings.Repeat("x", 64<<10+1)}, 413) + a.do("PUT", "/slots/6", map[string]string{"name": "OK", "source": strings.Repeat("x", 64<<10)}, 200) + + // Clearing. + a.do("DELETE", "/slots/5", nil, 204) + a.do("DELETE", "/slots/5", nil, 204) // clearing an empty slot is fine + if s := a.slot(5); !s.Empty { + t.Fatalf("cleared slot = %+v", s) + } + + // Everything needs a key. + anon := &player{t: t, base: srv.URL} + anon.do("GET", "/slots", nil, 401) + anon.do("PUT", "/slots/5", map[string]string{"name": "X", "source": "halt"}, 401) +} + +func TestLaunchFromSlot(t *testing.T) { + srv, cfg := newSite(t, "") + launch := func(p *player, slot any, want int) []byte { + return p.do("POST", "/ships/"+itoa(p.ship)+"/launch", map[string]any{"slot": slot}, want) + } + + // A saved slot: assembled, sealed into the ship, and launched in one step. + a := newPlayer(t, srv.URL, "ann") + a.do("PUT", "/slots/6", map[string]string{"name": "TINY", "source": "ldi r0, 1\nhalt"}, 200) + var res struct { + Slot int `json:"slot"` + ProgramBytes int `json:"program_bytes"` + } + json.Unmarshal(launch(a, 6, 202), &res) + if res.Slot != 6 || res.ProgramBytes != 8 { + t.Fatalf("launch reply = %+v", res) + } + if st, n := a.shipStatus(); st != "launching" || n != 8 { + t.Fatalf("ship is %s with %d program bytes", st, n) + } + launch(a, 6, 409) // already launching + + // Things that must be refused, leaving the ship docked and its program untouched. + b := newPlayer(t, srv.URL, "bob") + b.do("PUT", "/slots/7", map[string]string{"name": "BROKEN", "source": "ldi r1, 5\nbogus r1"}, 200) + b.do("PUT", "/slots/8", map[string]string{"name": "HUGE", "source": strings.Repeat("nop\n", cfg.ProgramBytes/4+1)}, 200) + b.do("PUT", "/slots/10", map[string]string{"name": "BLANK", "source": " \n"}, 200) + for name, c := range map[string]struct { + slot any + msg string + }{ + "empty slot": {9, "empty"}, + "blank source": {10, "empty"}, + "does not compile": {7, "line 2"}, + "too large": {8, "limit"}, + "out of range": {99, "1 to 16"}, + "zero": {0, "1 to 16"}, + } { + if body := launch(b, c.slot, 400); !strings.Contains(string(body), c.msg) { + t.Errorf("%s: error %q should mention %q", name, body, c.msg) + } + } + if st, n := b.shipStatus(); st != "inventory" || n != 0 { + t.Fatalf("a refused launch changed the ship: %s, %d bytes", st, n) + } + b.do("POST", "/ships/"+itoa(b.ship)+"/launch", "{not json", 400) + b.do("POST", "/ships/"+itoa(b.ship)+"/launch", nil, 409) // no slot and no sealed program: as before + // Slots are per player: ann's saved slot 6 is empty for bob. + launch(b, 6, 400) + // Ann cannot launch bob's ship with her slot. + a.do("POST", "/ships/"+itoa(b.ship)+"/launch", map[string]int{"slot": 6}, 404) + + // An example slot works for anyone. + launch(b, 2, 202) + if st, n := b.shipStatus(); st != "launching" || n == 0 { + t.Fatalf("after launching the telemetry example: %s, %d bytes", st, n) + } +} + +// A ship launched from a slot really flies that slot's program. +func TestSlotProgramRunsInTheBelt(t *testing.T) { + srv, cfg, st := newSiteStore(t, "") + cfg.AsteroidCount = 20 + p := newPlayer(t, srv.URL, "ann") + p.do("POST", "/ships/"+itoa(p.ship)+"/launch", map[string]int{"slot": 2}, 202) // TELEMETRY + + if _, err := runner.RunNextDay(context.Background(), st, cfg); err != nil { + t.Fatal(err) + } + down := p.do("GET", "/ships/"+itoa(p.ship)+"/downlink", nil, 200) + if tick := binary.LittleEndian.Uint32(down[0:4]); tick != uint32(cfg.TicksPerDay-1) { + t.Fatalf("telemetry downlink reports tick %d, want the last tick %d", tick, cfg.TicksPerDay-1) + } +} diff --git a/cmd/asm/main.go b/cmd/asm/main.go index fcbdc08..5493fd8 100644 --- a/cmd/asm/main.go +++ b/cmd/asm/main.go @@ -6,7 +6,7 @@ import ( "fmt" "os" - "wh/vm" + "halcyon/vm" ) func main() { diff --git a/cmd/daily/main.go b/cmd/daily/main.go index c14e0b1..510ae30 100644 --- a/cmd/daily/main.go +++ b/cmd/daily/main.go @@ -1,4 +1,4 @@ -// Command daily runs one simulated day against the database. Schedule it +// Command daily runs one day of the belt against the database. Schedule it // once a day (cron/systemd timer). package main @@ -8,9 +8,9 @@ import ( "fmt" "log" - "wh/config" - "wh/runner" - "wh/store" + "halcyon/config" + "halcyon/runner" + "halcyon/store" ) func main() { diff --git a/cmd/server/main.go b/cmd/server/main.go index 7b0a16a..6140e41 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -6,16 +6,18 @@ import ( "flag" "log" "net/http" + "os" - "wh/api" - "wh/config" - "wh/store" - "wh/web" + "halcyon/api" + "halcyon/config" + "halcyon/store" + "halcyon/web" ) func main() { dsn := flag.String("db", config.DatabaseURL(), "sqlite:<path> or postgres:// URL (default: $DATABASE_URL)") addr := flag.String("addr", ":8080", "listen address") + manual := flag.String("manual-pdf", envOr("MANUAL_PDF", "docs/generated/manual.pdf"), "rendered PDF manual to serve (default: $MANUAL_PDF)") cfg, err := config.Bind(flag.CommandLine) if err != nil { log.Fatal(err) @@ -34,7 +36,14 @@ func main() { log.Fatal(err) } mux := api.New(st, *cfg) - mux.Handle("/", web.Handler()) // the site; API routes are more specific and win + mux.Handle("/", web.Handler(*manual)) // the site; API routes are more specific and win log.Printf("listening on %s", *addr) log.Fatal(http.ListenAndServe(*addr, mux)) } + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/config/config.go b/config/config.go index 37da5fd..662b211 100644 --- a/config/config.go +++ b/config/config.go @@ -10,7 +10,7 @@ import ( "os" "strconv" - "wh/fixed" + "halcyon/fixed" ) const SecondsPerDay = 86400 @@ -85,20 +85,20 @@ func Bind(fs *flag.FlagSet) (*Config, error) { 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)") + fs.Uint64Var(&c.Seed, "seed", c.Seed, "world seed (env HALCYON_SEED)") + fs.IntVar(&c.TicksPerDay, "ticks-per-day", c.TicksPerDay, "simulation ticks per day (env HALCYON_TICKS_PER_DAY)") + fs.IntVar(&c.CyclesPerTick, "cycles-per-tick", c.CyclesPerTick, "VM cycles per ship per tick (env HALCYON_CYCLES_PER_TICK)") + fs.IntVar(&c.ProgramBytes, "program-bytes", c.ProgramBytes, "max ship program size (env HALCYON_PROGRAM_BYTES)") + fs.IntVar(&c.RAMBytes, "ram-bytes", c.RAMBytes, "ship RAM size (env HALCYON_RAM_BYTES)") + fs.IntVar(&c.CommBytes, "comm-bytes", c.CommBytes, "uplink/downlink buffer size (env HALCYON_COMM_BYTES)") + fs.IntVar(&c.AsteroidCount, "asteroids", c.AsteroidCount, "number of asteroids (env HALCYON_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", + "HALCYON_SEED", "HALCYON_TICKS_PER_DAY", "HALCYON_CYCLES_PER_TICK", "HALCYON_PROGRAM_BYTES", + "HALCYON_RAM_BYTES", "HALCYON_COMM_BYTES", "HALCYON_ASTEROIDS", } func (c *Config) applyEnv(get func(string) string) error { @@ -124,13 +124,13 @@ func (c *Config) applyEnv(get func(string) string) error { 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) + num("HALCYON_SEED", &c.Seed) + num("HALCYON_TICKS_PER_DAY", &c.TicksPerDay) + num("HALCYON_CYCLES_PER_TICK", &c.CyclesPerTick) + num("HALCYON_PROGRAM_BYTES", &c.ProgramBytes) + num("HALCYON_RAM_BYTES", &c.RAMBytes) + num("HALCYON_COMM_BYTES", &c.CommBytes) + num("HALCYON_ASTEROIDS", &c.AsteroidCount) return err } @@ -141,5 +141,5 @@ func DatabaseURL() string { if v := os.Getenv("DATABASE_URL"); v != "" { return v } - return "sqlite:wh.db" + return "sqlite:halcyon.db" } diff --git a/config/config_test.go b/config/config_test.go index 81e080a..ecf54df 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -26,9 +26,9 @@ func TestBindDefaults(t *testing.T) { } func TestBindEnvAndFlagPrecedence(t *testing.T) { - t.Setenv("WH_SEED", "42") - t.Setenv("WH_TICKS_PER_DAY", "720") - t.Setenv("WH_ASTEROIDS", "60") + t.Setenv("HALCYON_SEED", "42") + t.Setenv("HALCYON_TICKS_PER_DAY", "720") + t.Setenv("HALCYON_ASTEROIDS", "60") c, err := bind(t) if err != nil { @@ -49,21 +49,21 @@ func TestBindEnvAndFlagPrecedence(t *testing.T) { func TestBindRejectsBadEnv(t *testing.T) { for _, v := range []string{"lots", "-3", "1.5"} { - t.Setenv("WH_SEED", v) + t.Setenv("HALCYON_SEED", v) if _, err := bind(t); err == nil { - t.Errorf("WH_SEED=%q should be rejected", v) + t.Errorf("HALCYON_SEED=%q should be rejected", v) } } - t.Setenv("WH_SEED", "") - t.Setenv("WH_COMM_BYTES", "big") + t.Setenv("HALCYON_SEED", "") + t.Setenv("HALCYON_COMM_BYTES", "big") if _, err := bind(t); err == nil { - t.Error("WH_COMM_BYTES=big should be rejected") + t.Error("HALCYON_COMM_BYTES=big should be rejected") } } func TestDatabaseURL(t *testing.T) { t.Setenv("DATABASE_URL", "") - if DatabaseURL() != "sqlite:wh.db" { + if DatabaseURL() != "sqlite:halcyon.db" { t.Fatal("default") } t.Setenv("DATABASE_URL", "postgres://x") diff --git a/docker-compose.yml b/docker-compose.yml index df592d5..ceed531 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,7 @@ +# The project name is fixed (not taken from the directory), so volume and network +# names stay stable: halcyon_data, halcyon_manual, halcyon_caddy-data, ... +name: halcyon + services: web: build: . @@ -9,21 +13,33 @@ services: # Local only: the public entrance is Caddy, which terminates TLS. - "127.0.0.1:8070:8080" environment: - DATABASE_URL: sqlite:/data/wh.db + DATABASE_URL: sqlite:/data/halcyon.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} + HALCYON_SEED: ${HALCYON_SEED:-1} + HALCYON_TICKS_PER_DAY: ${HALCYON_TICKS_PER_DAY:-1440} + HALCYON_CYCLES_PER_TICK: ${HALCYON_CYCLES_PER_TICK:-2000} + HALCYON_PROGRAM_BYTES: ${HALCYON_PROGRAM_BYTES:-4096} + HALCYON_RAM_BYTES: ${HALCYON_RAM_BYTES:-8192} + HALCYON_COMM_BYTES: ${HALCYON_COMM_BYTES:-1024} + HALCYON_ASTEROIDS: ${HALCYON_ASTEROIDS:-500} volumes: - - halcyon-data:/data + - data:/data + - manual:/manual:ro # the PDF manual, rendered separately (see below) command: ["server"] + # Renders the PDF manual into the volume the web service reads. Not started by + # `docker compose up` (that stays fast); run it with scripts/render-manual.sh. + manual: + build: + context: . + target: manual + image: halcyon-manual:latest + profiles: ["manual"] + volumes: + - manual:/out + caddy: image: caddy:2-alpine container_name: halcyon-caddy @@ -40,6 +56,7 @@ services: - caddy-config:/config volumes: - halcyon-data: + data: + manual: caddy-data: caddy-config: diff --git a/docs/docs.go b/docs/docs.go index 49e9ac2..f800eb8 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1,7 +1,13 @@ -// Package docs embeds the HC-33 programmer's manual. +// Package docs embeds the source of the HC-33 programmer's manual. +// +// The PDF is not embedded: it is rendered separately (see the manual stage of +// the Dockerfile and scripts/render-manual.sh), so that deploying the app never +// waits for it, and is served from a file by package web. package docs import _ "embed" -//go:embed manual.txt -var Manual []byte +// Markdown is the manual's source, docs/manual.md. +// +//go:embed manual.md +var Markdown []byte diff --git a/docs/docs_test.go b/docs/docs_test.go new file mode 100644 index 0000000..829ac12 --- /dev/null +++ b/docs/docs_test.go @@ -0,0 +1,107 @@ +package docs_test + +import ( + "os" + "regexp" + "strconv" + "strings" + "testing" + + "halcyon/docs" + "halcyon/sim" +) + +// portValues maps the names used in the manual and in examples/programs/ports.s +// to the simulator's real port numbers, so the documentation cannot drift. +var portValues = map[string]int{ + "TURN": sim.PortTurn, "DAY": sim.PortDay, "TURNS": sim.PortTurns, + "POSX": sim.PortPosX, "POSY": sim.PortPosY, "POSZ": sim.PortPosZ, + "VELX": sim.PortVelX, "VELY": sim.PortVelY, "VELZ": sim.PortVelZ, + "THROTTLE": sim.PortThrottle, "AZIMUTH": sim.PortAzimuth, "PITCH": sim.PortPitch, + "FUEL": sim.PortFuel, "MASS": sim.PortMass, + "SELECT": sim.PortScanSelect, "NEAREST": sim.PortScanNearest, + "RELX": sim.PortScanRelX, "RELY": sim.PortScanRelY, "RELZ": sim.PortScanRelZ, + "RELVX": sim.PortScanRelVX, "RELVY": sim.PortScanRelVY, "RELVZ": sim.PortScanRelVZ, + "DIST": sim.PortScanDist, + "ORE0": sim.PortScanOre, "ORE1": sim.PortScanOre + 1, "ORE2": sim.PortScanOre + 2, "ORE3": sim.PortScanOre + 3, + "MINE": sim.PortMine, "CARGO": sim.PortCargo, "CARGOCAP": sim.PortCargoCap, + "CARGO0": sim.PortCargoOre, "CARGO1": sim.PortCargoOre + 1, "CARGO2": sim.PortCargoOre + 2, "CARGO3": sim.PortCargoOre + 3, + "STNX": sim.PortStnRelX, "STNY": sim.PortStnRelY, "STNZ": sim.PortStnRelZ, + "STNVX": sim.PortStnRelVX, "STNVY": sim.PortStnRelVY, "STNVZ": sim.PortStnRelVZ, + "SELL": sim.PortSell, "EARNED": sim.PortCredits, + "UPNEW": sim.PortUplinkNew, "UPLEN": sim.PortUplinkLen, + "MATHX": sim.PortMathX, "MATHY": sim.PortMathY, "MATHZ": sim.PortMathZ, + "ATAN2": sim.PortMathAtan2, "HYPOT": sim.PortMathHypot, "NORM3": sim.PortMathNorm3, +} + +func section(t *testing.T, md, from, to string) string { + t.Helper() + a := strings.Index(md, from) + if a < 0 { + t.Fatalf("manual has no %q", from) + } + b := len(md) + if to != "" { + b = a + strings.Index(md[a:], to) + } + return md[a:b] +} + +func TestAppendixBMatchesSimulator(t *testing.T) { + md := string(docs.Markdown) + rows := regexp.MustCompile(`(?m)^0x([0-9A-F]{2})\s+(\w+)\s`).FindAllStringSubmatch( + section(t, md, "# Appendix B", "# Appendix C"), -1) + seen := map[string]bool{} + for _, r := range rows { + n, _ := strconv.ParseInt(r[1], 16, 32) + want, ok := portValues[r[2]] + if !ok || int(n) != want { + t.Errorf("Appendix B: %s is 0x%s, simulator says %#x (known=%v)", r[2], r[1], want, ok) + } + seen[r[2]] = true + } + for name := range portValues { + if !seen[name] { + t.Errorf("Appendix B does not document port %s", name) + } + } +} + +func readExample(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile("../examples/programs/" + name) + if err != nil { + t.Fatal(err) + } + return strings.TrimRight(string(b), "\n") +} + +func TestEquateFileMatchesSimulatorAndManual(t *testing.T) { + ports := readExample(t, "ports.s") + seen := map[string]bool{} + for _, m := range regexp.MustCompile(`(?m)^\s*\.equ\s+P_(\w+)\s+0x([0-9A-F]{2})\b`).FindAllStringSubmatch(ports, -1) { + n, _ := strconv.ParseInt(m[2], 16, 32) + if want, ok := portValues[m[1]]; !ok || int(n) != want { + t.Errorf("ports.s: P_%s = 0x%s, simulator says %#x (known=%v)", m[1], m[2], want, ok) + } + seen[m[1]] = true + } + for name := range portValues { + if !seen[name] { + t.Errorf("ports.s lacks P_%s", name) + } + } + // Appendix C prints that file. + if !strings.Contains(string(docs.Markdown), "```asm\n"+ports+"\n```") { + t.Error("Appendix C is out of date with examples/programs/ports.s") + } +} + +func TestSampleProgramsMatchExamples(t *testing.T) { + md := string(docs.Markdown) + for _, name := range []string{"echo", "telemetry", "pursue"} { + if !strings.Contains(md, "```asm\n"+readExample(t, name+".s")+"\n```") { + t.Errorf("Chapter 9 listing of %s is out of date with examples/programs/%s.s", name, name) + } + } +} diff --git a/docs/manual.md b/docs/manual.md new file mode 100644 index 0000000..e11fdc5 --- /dev/null +++ b/docs/manual.md @@ -0,0 +1,1480 @@ +<div class="cover"> +<p class="company">H A L C Y O N   I N S T R U M E N T   &   C O N T R O L</p> +<h1 class="cover-title">Model HC-33<br>Flight Computer</h1> +<p class="cover-sub">Programmer's Reference Manual</p> +<p class="cover-sub2">Including the Ship Peripheral Interface</p> +<div class="cover-box"> +<p>32-BIT  *  16 REGISTERS</p> +<p>35 INSTRUCTIONS  *  8 KB RAM</p> +<p>1 KB DAILY WORMHOLE LINK</p> +</div> +<p class="pubno">Publication No. HIC-0033-A<br>First Edition</p> +<p class="cover-for">For use with command ships of the Halcyon Prospector class and compatible hulls.</p> +<p class="cover-warn">RETAIN THIS MANUAL WITH THE SHIP RECORDS.<br>THE COMPUTER CANNOT BE REPROGRAMMED AFTER LAUNCH.<br>READ BEFORE YOU FLY.</p> +<p class="cover-notice"><b>NOTICE</b> — 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.</p> +</div> + +<h1 class="front" id="contents">Contents</h1> + +[TOC] + +# 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: + +- PERIPHERAL PORTS, which connect it to the engine, scanner, mining laser + and the rest of the ship. (Chapter 5.) + +- 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 turn, standard +Turns per day ........................ 1440 standard (one turn = 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 turn 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 run 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 turns. On each turn, 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 turn: the engine fires, the Star pulls, the ship moves, the mining + laser works. +4. After the last turn 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 turn 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, Turns and the Cycle Budget + +Time on the HC-33 is measured in TURNS. On each turn the computer is given a +CYCLE BUDGET, standard 2000 cycles, and runs until one of these happens: + +- the program executes YIELD; +- the budget is used up; +- 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 +turn 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 turn it carries on from the very next +instruction. A long calculation is therefore spread across as many turns 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 turn 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 turn with YIELD. Resumes next turn. +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 turn. Execution resumes with the next instruction on the next +turn. + +### 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 +``` + +- Case does not matter in mnemonics and register names. It DOES matter in + labels and constant names. +- Comments start with a semicolon (;) or a hash (#) and run to the end of + the line. +- Labels end with a colon and may stand alone on a line. A label names + the position of the next instruction. +- Operands are separated by commas. +- Memory operands are written [rb], [rb+off] or [rb-off]. There must be + no arithmetic beyond a single register and a single offset. +- 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. + +### Labels and Immediates + +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: + +- An INPUT port can be read. Writing to it has no effect. +- 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 ......................... turns, 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 TURN input Turn number within the current day, 0 upwards. +0x01 DAY input Day number. The first day of the belt is day 0. +0x02 TURNS input Turns per day (standard 1440). +``` + +The ship does not have a wristwatch. These three ports are how a program +knows when it is. Turn length is 86400 divided by TURNS 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 turn, 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 turn length: + +``` +Full thrust ................ 6000 newtons +Exhaust velocity ........... 30,000 m/s +Fuel used at full throttle . 12 kg per turn (thrust x turn / 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 turn 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 turn in which ALL of the following are true, it moves ore from the +asteroid into the hold: + +- an asteroid is tracked and still has ore; +- the asteroid is within 5 km of the ship; +- the ship's velocity relative to the asteroid is at most 100 m/s; +- the hold is not full. + +The Prospector mines 10 kg per turn. 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 turn, after the ship has moved. + +To fill an empty hold takes 600 turns, 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): + +- the ship is within 20 km of the Station; +- its velocity relative to the Station is at most 100 m/s; +- 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 turn 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 turn 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 launched. +LAUNCHING Cleared to launch. It will enter the belt on the next daily run. + Its program is sealed and 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, and +sixteen CODE SLOTS. Slots 1 to 3 hold the example programs of Chapter 9 and +are read-only. Slots 4 to 16 are yours: each holds the source text of one +program and a name of your choosing. Slots are kept on the belt operator's +computer, so they follow you from one terminal to the next. + +1. Write a program and save it in one of your slots. Assemble it as often + as you like to check it. Keep as many versions as you have slots. +2. Launch the ship and CHOOSE THE SLOT it will fly. The slot is assembled + at that moment; if it does not assemble, or is too large, or is empty, the + ship is not launched. Otherwise the assembled program is sealed into the + ship. There is no going back. +3. 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. + +Later changes to a slot do not affect a ship that has already launched from +it: the ship carries its own sealed copy. + +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 run once per day, in a single pass over 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 turn 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: an assembled program, raw bytes, for + programs built outside the slots (for example with asm). Allowed only + while the ship is in INVENTORY. Between 4 and 4096 bytes, a multiple + of four. + +POST /ships/{n}/launch Launch the ship on the next run. The ship + must be in INVENTORY. Body {"slot": 1..16}: assemble that code slot, + seal it into the ship and launch it, in one step (400 with the + assembler's message if it is empty or does not assemble). With no + body, the ship launches with the program already uploaded to it. + +GET /slots Your sixteen code slots: number, name, and + whether each is an example or empty. + +GET /slots/{n} One slot with its source text. + +PUT /slots/{n} Body {"name": "...", "source": "..."}. Save + source in slot 4 to 16 (403 for the read-only + examples in slots 1 to 3). + +DELETE /slots/{n} Empty slot 4 to 16. + +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: + +``` +# save source in slot 4, then launch ship 1 flying it +curl -X PUT -H "Authorization: Bearer $KEY" \ + -d '{"name":"MINER","source":"..."}' http://belt.example/slots/4 +curl -X POST -H "Authorization: Bearer $KEY" \ + -d '{"slot":4}' 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 turn +``` + +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 rotate. + +## 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 harder to reverse. The +engine at full power uses the whole tank in 333 turns, 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 turn allows 2000 cycles. + A loop that copies the full kilobyte uplink a byte at a time uses over + 5000, and needs three turns. 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 turn, + all day. Its main loop will read the sensors dozens of times per turn 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 turn. + +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 turns, 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 TURNS.** Whatever you write to the engine takes + effect when the computer stops for the turn 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 trial run of + the belt on the same 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 +turns to copy at standard settings; the computer simply carries on where it +left off (section 2.5). + +```asm +; 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 turn 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 turn, what you receive is the ship's state as +sampled at the start of the last turn of the day. + +```asm +; TELEMETRY -- every turn, write turn number, fuel and position to the +; downlink buffer as five 32-bit words. +; + .equ P_TURN 0x00 + .equ P_POSX 0x10 + .equ P_POSY 0x11 + .equ P_POSZ 0x12 + .equ P_FUEL 0x23 + .equ T_TURN 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_TURN + stw r1, [r0+T_TURN] + 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 turn 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), point the engine along +the difference, and throttle back as the distance closes. It must also +watch its fuel. That is left as an exercise. + +```asm +; 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 turn +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 TURN in turn within the day turns +0x01 DAY in day number days +0x02 TURNS in turns per day turns + +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. + +```asm +; 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_TURN 0x00 + .equ P_DAY 0x01 + .equ P_TURNS 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/turn | +| UNITS km m/s milliradians kg READINGS ROUND DOWN | ++------------------------------------------------------------------------------+ +| 00 turn 01 day 02 turns/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/turn | +| 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. | ++------------------------------------------------------------------------------+ +``` + + +<p class="end">* * *   END OF MANUAL   * * *</p> + +<p class="colophon">HALCYON INSTRUMENT & CONTROL  ·  Publication HIC-0033-A  ·  First Edition<br>Printed in the Outer System.</p> diff --git a/docs/manual.txt b/docs/manual.txt deleted file mode 100644 index 19ce531..0000000 --- a/docs/manual.txt +++ /dev/null @@ -1,1556 +0,0 @@ - - - - -================================================================================ - - 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/docs/pdf/check_legibility.py b/docs/pdf/check_legibility.py new file mode 100644 index 0000000..7d898d0 --- /dev/null +++ b/docs/pdf/check_legibility.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Measure how legible each page of the photocopied manual is. + + check_legibility.py copied.pdf [crisp.pdf] + +OCRs every page (tesseract) and scores it against the PDF's own invisible text +layer, which is the known-correct text: the score is the share of true words +that OCR recovers. If a crisp render is given, its score is the baseline (OCR +is imperfect on monospace tables), and a page is judged by how much the copying +cost it. Needs tesseract and poppler's pdftotext/pdftoppm. Development tool; +the build does not run it. +""" +import difflib +import os +import re +import subprocess +import sys +import tempfile +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + + +def words(text): + return re.findall(r"[A-Za-z0-9][A-Za-z0-9'.\-]*", text) + + +def ocr_scores(pdf, dpi=130): + """Return {page: (recall, truth_word_count)} for each page of pdf.""" + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + subprocess.run(["pdftoppm", "-r", str(dpi), "-gray", "-png", pdf, str(tmp / "p")], check=True) + images = sorted(tmp.glob("p-*.png")) + + def score(img): + n = int(img.stem.split("-")[1]) + truth = words(subprocess.run(["pdftotext", "-f", str(n), "-l", str(n), pdf, "-"], + capture_output=True, text=True, check=True).stdout) + got = words(subprocess.run(["tesseract", str(img), "-", "--psm", "6"], + capture_output=True, text=True, env={**os.environ, "OMP_THREAD_LIMIT": "1"}).stdout) + m = difflib.SequenceMatcher(None, truth, got, autojunk=False) + hit = sum(b.size for b in m.get_matching_blocks()) + return n, (hit / max(1, len(truth)), len(truth)) + + with ThreadPoolExecutor(4) as pool: + return dict(pool.map(score, images)) + + +if __name__ == "__main__": + copied = ocr_scores(sys.argv[1]) + crisp = ocr_scores(sys.argv[2]) if len(sys.argv) > 2 else {} + print("page words crisp copied loss") + losses = [] + for n in sorted(copied): + c, w = copied[n] + b = crisp.get(n, (1.0, w))[0] + losses.append(b - c) + flag = " <-- check" if b - c > 0.10 else "" + print(f"{n:4d} {w:6d} {b:5.0%} {c:5.0%} {b - c:+5.0%}{flag}") + tot = sum(w for _, w in copied.values()) + print(f"\nmean copied recall {sum(c * w for c, w in copied.values()) / tot:.1%}" + + (f", mean crisp {sum(c * w for c, w in crisp.values()) / tot:.1%}" if crisp else "")) diff --git a/docs/pdf/manual.css b/docs/pdf/manual.css new file mode 100644 index 0000000..f12c2cc --- /dev/null +++ b/docs/pdf/manual.css @@ -0,0 +1,128 @@ +/* Print stylesheet for the HC-33 manual: a typewritten reference, Letter size, + with room on the left for the binder holes drawn by render.py. */ + +@page { + size: Letter; + margin: 0.95in 0.7in 0.9in 1.3in; + + @top-left { + content: "HC-33 FLIGHT COMPUTER"; + font: 7.5pt "Liberation Sans", sans-serif; letter-spacing: 0.18em; color: #4a3a26; + border-bottom: 0.6pt solid #8a7650; padding-bottom: 3pt; vertical-align: bottom; + } + @top-right { + content: string(chapter); + font: 7.5pt "Liberation Sans", sans-serif; letter-spacing: 0.12em; text-transform: uppercase; color: #4a3a26; + border-bottom: 0.6pt solid #8a7650; padding-bottom: 3pt; vertical-align: bottom; + } + @bottom-left { + content: "HIC-0033-A"; + font: 7.5pt "Liberation Sans", sans-serif; letter-spacing: 0.18em; color: #4a3a26; vertical-align: top; padding-top: 6pt; + } + @bottom-right { + content: "PAGE " counter(page); + font: 7.5pt "Liberation Sans", sans-serif; letter-spacing: 0.18em; color: #4a3a26; vertical-align: top; padding-top: 6pt; + } +} + +@page :first { + margin: 0.8in 0.75in 0.7in 1.3in; + @top-left { content: none; border: 0; } + @top-right { content: none; border: 0; } + @bottom-left { content: none; } + @bottom-right { content: none; } +} + +html { font: 9.6pt/1.42 "Liberation Mono", "Courier New", monospace; color: #211b14; } +body { margin: 0; } + +p { margin: 0.55em 0; orphans: 2; widows: 2; } +strong { font-weight: bold; } +a { color: inherit; text-decoration: none; } + +/* ---- headings ---------------------------------------------------------- */ + +h1 { + font: bold 21pt/1.15 "Liberation Sans", sans-serif; text-transform: uppercase; letter-spacing: 0.05em; + margin: 0 0 0.9em; padding: 0.3em 0; + border-top: 3pt double #211b14; border-bottom: 3pt double #211b14; + page-break-before: always; page-break-after: avoid; string-set: chapter content(); +} +h1.front { page-break-before: auto; string-set: chapter "Contents"; } + +h2 { + font: bold 11.5pt/1.25 "Liberation Sans", sans-serif; text-transform: uppercase; letter-spacing: 0.1em; + margin: 1.5em 0 0.55em; padding-bottom: 0.15em; border-bottom: 0.8pt dashed #6b5a3a; + page-break-after: avoid; +} + +h3 { + font: bold 9.8pt/1.3 "Liberation Mono", monospace; margin: 1.05em 0 0.15em; padding-left: 0.6em; + border-left: 3pt solid #8a2a17; page-break-after: avoid; +} + +/* ---- lists, code, notes ------------------------------------------------ */ + +ul, ol { margin: 0.5em 0; padding-left: 1.7em; } +li { margin: 0.3em 0; } +li p { margin: 0.2em 0; } + +code { font-family: "Liberation Mono", monospace; } +p > code, li > code { background: rgba(120, 90, 40, 0.10); padding: 0 0.2em; } + +pre { + font: 8.6pt/1.3 "Liberation Mono", monospace; white-space: pre; + margin: 0.8em 0; padding: 0.55em 0.8em; + background: rgba(255, 251, 236, 0.5); border-left: 2.5pt solid #8b5a2b; + page-break-inside: avoid; +} +pre code { background: none; padding: 0; } + +blockquote { + margin: 1em 0; padding: 0.5em 0.9em; + border: 1.4pt solid #211b14; background: rgba(255, 255, 255, 0.32); + page-break-inside: avoid; +} +blockquote p { margin: 0.2em 0; } +blockquote strong:first-child { text-transform: uppercase; letter-spacing: 0.14em; color: #8a1f11; } + +/* ---- cover ------------------------------------------------------------- */ + +.cover { text-align: center; page-break-after: always; } +.cover p { margin: 0; } +.company { + font: bold 9.5pt "Liberation Sans", sans-serif; color: #3a2a1a; + border-top: 3pt double #3a2a1a; border-bottom: 3pt double #3a2a1a; padding: 0.55em 0; +} +.cover .cover-title { + font: bold italic 35pt/1.12 "Liberation Sans", sans-serif; text-transform: uppercase; letter-spacing: 0.04em; + border: 0; margin: 1.35in 0 0.2in; padding: 0; page-break-before: auto; string-set: none; bookmark-level: none; +} +.cover-sub { font: bold 15pt "Liberation Sans", sans-serif; letter-spacing: 0.08em; text-transform: uppercase; margin-top: 0.3in !important; } +.cover-sub2 { font-size: 10pt; margin-top: 0.1in !important; } +.cover-box { + width: 3.1in; margin: 0.6in auto 0.5in; padding: 0.15in 0.1in; + border: 2.5pt solid #211b14; outline: 0.7pt solid #211b14; outline-offset: 3pt; + font-weight: bold; font-size: 9pt; +} +.cover-box p { margin: 0.25em 0; } +.pubno { font-size: 9.5pt; letter-spacing: 0.06em; } +.cover-for { font-size: 8.6pt; margin: 0.35in 1.1in 0 !important; } +.cover-warn { + margin: 0.4in 0.5in 0 !important; padding: 0.14in 0; font-weight: bold; font-size: 8.4pt; letter-spacing: 0.05em; + border-top: 0.8pt dashed #211b14; border-bottom: 0.8pt dashed #211b14; line-height: 1.6; +} +.cover-notice { margin: 0.32in 0 0 !important; font-size: 6.6pt; line-height: 1.35; text-align: justify; color: #3a2f22; } + +/* ---- contents ---------------------------------------------------------- */ + +.toc ul { list-style: none; margin: 0; padding: 0; } +.toc > ul > li { margin-top: 0.7em; font-weight: bold; font-size: 9.4pt; } +.toc ul ul { margin-top: 0.2em; padding-left: 1.6em; } +.toc ul ul li { font-weight: normal; font-size: 8.8pt; margin: 0.1em 0; } +.toc a::after { content: leader(".") target-counter(attr(href), page); font-weight: normal; } + +/* ---- end matter -------------------------------------------------------- */ + +.end { text-align: center; margin-top: 2.2em; letter-spacing: 0.2em; font-weight: bold; page-break-inside: avoid; } +.colophon { text-align: center; font-size: 7.8pt; color: #4a3a26; letter-spacing: 0.06em; } diff --git a/docs/pdf/photocopy.py b/docs/pdf/photocopy.py new file mode 100644 index 0000000..28f639d --- /dev/null +++ b/docs/pdf/photocopy.py @@ -0,0 +1,126 @@ +"""Make a page image look like a photocopy of a photocopy. + +`intensity` scales the whole effect: 1.0 is a badly worn copy of a copy, 0.5 a +tired one, 0 leaves the image alone. + +Each "generation" re-scans the previous copy: a little skew and shift, blur from +the optics, a steeper toner curve, uneven toner, speckle, dust streaks down the +page and dark shadows where the lid did not sit flat. Colour survives only as +faint warmth, so coffee stains stay recognisable but lose their punch. +""" +import numpy as np +from PIL import Image, ImageDraw, ImageFilter + + +def _lowfreq(rng, w, h, cells_x, cells_y, amp): + """Smooth random field in [-amp, amp], roughly cells_x by cells_y blobs.""" + small = rng.normal(0, 1, (cells_y + 2, cells_x + 2)).astype(np.float32) + small = (small - small.min()) / (np.ptp(small) + 1e-6) * 2 - 1 + img = Image.fromarray(small, mode="F").resize((w, h), Image.BICUBIC) + return np.asarray(img, dtype=np.float32) * amp + + +def _speckles(rng, w, h, count, rmin, rmax): + mask = Image.new("L", (w, h), 0) + d = ImageDraw.Draw(mask) + count = max(0, count) + for _ in range(count): + x, y = rng.uniform(0, w), rng.uniform(0, h) + r = rng.uniform(rmin, rmax) + d.ellipse((x - r, y - r, x + r, y + r), fill=int(rng.uniform(120, 255))) + return np.asarray(mask.filter(ImageFilter.GaussianBlur(0.5)), dtype=np.float32) / 255.0 + + +def _streaks(rng, arr, gen, t): + h, w = arr.shape + rows = np.arange(h) + for _ in range(int(rng.integers(2, 5 + gen))): + x0 = rng.uniform(0, w) + xs = x0 + np.cumsum(rng.normal(0, 0.12, h)) # the streak wanders a little + width = int(rng.integers(1, 4)) + strength = rng.uniform(0.03, 0.13) * t + # dust comes and goes along the page + fade = 0.55 + 0.45 * np.sin(rows / rng.uniform(60, 400) + rng.uniform(0, 6.28)) + fade *= np.clip(_1d(rng, h) + 0.6, 0, 1) + for dx in range(width): + cols = np.clip(xs.astype(int) + dx, 0, w - 1) + arr[rows, cols] -= strength * fade * (1.0 if dx == 0 else 0.55) + # one broad, faint band where the roller was tired + x0 = int(rng.uniform(0.1, 0.9) * w) + arr[:, x0:x0 + int(rng.uniform(20, 60))] *= 1 - (1 - rng.uniform(0.985, 0.997)) * t + + +def _1d(rng, n): + pts = rng.normal(0, 0.5, n // 40 + 3).astype(np.float32) + return np.interp(np.arange(n), np.linspace(0, n - 1, len(pts)), pts) + + +def _edge_shadows(rng, arr, t): + h, w = arr.shape + for edge in rng.permutation(4)[: int(rng.integers(1, 3))]: + depth = int(rng.uniform(8, 26)) + strength = rng.uniform(0.18, 0.42) * t + ramp = np.linspace(strength, 0, depth, dtype=np.float32) ** 1.6 + jag = 1 + 0.35 * _1d(rng, max(h, w)) + if edge == 0: + arr[:, :depth] -= ramp[None, :] * jag[:h, None] + elif edge == 1: + arr[:, w - depth:] -= ramp[::-1][None, :] * jag[:h, None] + elif edge == 2: + arr[:depth, :] -= ramp[:, None] * jag[None, :w] + else: + arr[h - depth:, :] -= ramp[::-1][:, None] * jag[None, :w] + + +def _generation(rng, luma, gen, t): + """One trip through the copier. luma is a float array in [0, 1].""" + h, w = luma.shape + im = Image.fromarray((np.clip(luma, 0, 1) * 255).astype(np.uint8), "L") + + # placed slightly crooked on the glass; the lid backing shows in the wedges + im = im.rotate(rng.uniform(-0.28, 0.28) * t, resample=Image.BICUBIC, fillcolor=70, + translate=(rng.uniform(-4, 4) * t, rng.uniform(-4, 4) * t)) + # toner spreads: thin strokes fatten a little on every pass + fat = np.asarray(im.filter(ImageFilter.MinFilter(3)), dtype=np.float32) / 255.0 + thin = np.asarray(im, dtype=np.float32) / 255.0 + im = Image.fromarray((np.clip((1 - 0.14 * t) * thin + 0.14 * t * fat, 0, 1) * 255).astype(np.uint8), "L") + if t > 0: + im = im.filter(ImageFilter.GaussianBlur(t * (0.55 + 0.05 * gen))) # optics + a = np.asarray(im, dtype=np.float32) / 255.0 + + # a faint ghost from the previous pass being a hair out of register + dy, dx = int(rng.integers(-2, 3)), int(rng.integers(-2, 3)) + if dx or dy: + a = (1 - 0.12 * t) * a + 0.12 * t * np.roll(a, (dy, dx), axis=(0, 1)) + + # toner curve: blacker blacks, paler paper, but keep mid-tones for stains + k, c = 4.6 + 0.4 * gen, 0.50 + s = lambda x: 1 / (1 + np.exp(-k * (x - c))) + a = (1 - t) * a + t * (s(a) - s(0.0)) / (s(1.0) - s(0.0)) + + # uneven toner and grain + a *= 1 + _lowfreq(rng, w, h, 5, 7, 0.022 * t) + _lowfreq(rng, w, h, w // 45, h // 45, 0.014 * t) + a += rng.normal(0, 0.018 * t, a.shape).astype(np.float32) + + # dust and toner specks; a few white dropouts inside the ink + a = np.minimum(a, 1 - 0.65 * _speckles(rng, w, h, int((150 + 90 * gen) * t), 0.5, 1.5)) + a = np.maximum(a, 0.85 * _speckles(rng, w, h, int(110 * t), 0.5, 1.3) * (a < 0.35)) + + _streaks(rng, a, gen, t) + _edge_shadows(rng, a, t) + return np.clip(a, 0, 1) + + +def photocopy(img, seed, copies=3, intensity=1.0): + """Return a degraded RGB copy of `img` (an RGB PIL image).""" + rng = np.random.default_rng(seed) + ycc = img.convert("YCbCr") + luma = np.asarray(ycc.split()[0], dtype=np.float32) / 255.0 + for g in range(copies): + luma = _generation(rng, luma, g, intensity) + + # keep only a whisper of the original colour, smeared + _, cb, cr = ycc.split() + soften = lambda ch: ch.filter(ImageFilter.GaussianBlur(5)).point(lambda v: 128 + (v - 128) * (1 - 0.15 * intensity)) + y = Image.fromarray((luma * 255).astype(np.uint8), "L") + return Image.merge("YCbCr", (y, soften(cb), soften(cr))).convert("RGB") diff --git a/docs/pdf/render.py b/docs/pdf/render.py new file mode 100644 index 0000000..1cda76c --- /dev/null +++ b/docs/pdf/render.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Render the HC-33 manual (Markdown) to a PDF that looks like a well-used +copy from a ring binder: yellowed paper, three punched holes and coffee rings. + + render.py manual.md manual.pdf [--copies 3] [--intensity 0.5] [--dpi 130] + +The look is generated (no image assets) and deterministic: the same input +always produces the same stains and dust. Needs the `markdown`, `weasyprint`, +`pillow` and `numpy` packages, poppler's pdftoppm, and the Liberation fonts. + +--copies N photocopies the pages N times over. The pages are rasterised, run +through the copier model in photocopy.py, and used as page backgrounds +under invisible live text, so search, selection, links and bookmarks still work. +--copies 0 leaves the crisp vector pages. --intensity scales how worn each copy +looks (1.0 = badly, 0.5 = default, 0 = clean scans). +""" +import argparse +import concurrent.futures +import math +import os +import pathlib +import random +import subprocess +import tempfile + +import markdown +from weasyprint import CSS, HTML + +import photocopy + +HERE = pathlib.Path(__file__).resolve().parent +W, H = 816, 1056 # US Letter at 96 dpi + + +# ---- page backgrounds -------------------------------------------------------- + +def ring_path(cx, cy, r, rng, wobble=0.05): + """A slightly irregular closed outline around (cx, cy).""" + phases = [rng.uniform(0, 2 * math.pi) for _ in range(4)] + amps = [rng.uniform(0.01, wobble) for _ in range(4)] + pts = [] + for i in range(80): + t = 2 * math.pi * i / 80 + rr = r * (1 + sum(a * math.sin((k + 2) * t + p) for k, (a, p) in enumerate(zip(amps, phases)))) + pts.append((cx + rr * math.cos(t), cy + rr * math.sin(t) * 0.97)) + return "M" + " L".join(f"{x:.1f},{y:.1f}" for x, y in pts) + " Z" + + +def coffee_ring(uid, cx, cy, r, rng, strength=1.0): + """A cup ring: a pale wash, a dark uneven rim, a halo, and a few splashes.""" + out = [] + a = strength + out.append( + f'<radialGradient id="wash{uid}" cx="50%" cy="50%" r="50%">' + f'<stop offset="0" stop-color="#a8712f" stop-opacity="{0.02 * a:.3f}"/>' + f'<stop offset="0.75" stop-color="#a8712f" stop-opacity="{0.07 * a:.3f}"/>' + f'<stop offset="1" stop-color="#8c5a22" stop-opacity="{0.16 * a:.3f}"/></radialGradient>' + ) + body = [] + outline = ring_path(cx, cy, r, rng) + body.append(f'<path d="{outline}" fill="url(#wash{uid})"/>') + # soft halo just outside the rim + body.append(f'<path d="{ring_path(cx, cy, r * 1.03, rng)}" fill="none" stroke="#a8742f" stroke-width="9" opacity="{0.05 * a:.3f}"/>') + body.append(f'<path d="{ring_path(cx, cy, r * 1.01, rng)}" fill="none" stroke="#a8742f" stroke-width="5" opacity="{0.09 * a:.3f}"/>') + # the rim itself: thin all round, thicker and darker in places + body.append(f'<path d="{outline}" fill="none" stroke="#7b4a1c" stroke-width="2.2" opacity="{0.34 * a:.3f}"/>') + dash = " ".join(f"{rng.uniform(60, 240):.0f} {rng.uniform(30, 120):.0f}" for _ in range(3)) + body.append(f'<path d="{outline}" fill="none" stroke="#5f3510" stroke-width="3.6" stroke-dasharray="{dash}" opacity="{0.36 * a:.3f}"/>') + # splashes + for _ in range(rng.randint(4, 10)): + ang = rng.uniform(0, 2 * math.pi) + dist = r * rng.uniform(1.06, 1.45) + body.append( + f'<circle cx="{cx + dist * math.cos(ang):.1f}" cy="{cy + dist * math.sin(ang):.1f}" ' + f'r="{rng.uniform(0.8, 4.2):.1f}" fill="#6b3f18" opacity="{rng.uniform(0.12, 0.34) * a:.3f}"/>' + ) + return "".join(out), "".join(body) + + +def hole(cx, cy, reinforced, cracked, rng): + parts = [] + if reinforced: # a stick-on paper reinforcement ring + parts.append(f'<circle cx="{cx}" cy="{cy}" r="20.5" fill="none" stroke="#000" stroke-width="10" opacity="0.06"/>') + parts.append(f'<circle cx="{cx}" cy="{cy}" r="19" fill="none" stroke="#f8f2df" stroke-width="10"/>') + parts.append(f'<circle cx="{cx}" cy="{cy}" r="24" fill="none" stroke="#cdbf90" stroke-width="0.8" opacity="0.8"/>') + parts.append(f'<circle cx="{cx + 1}" cy="{cy + 1.4}" r="15.5" fill="#000" opacity="0.10"/>') # soft shadow + parts.append(f'<circle cx="{cx}" cy="{cy}" r="13.6" fill="none" stroke="#fffaf0" stroke-width="1.6" opacity="0.85"/>') # cut edge + parts.append(f'<circle cx="{cx}" cy="{cy}" r="12.4" fill="#2a1f14"/>') + parts.append(f'<circle cx="{cx + 1.6}" cy="{cy + 1.8}" r="9.6" fill="#4a3a29" opacity="0.75"/>') # lit far wall + parts.append(f'<circle cx="{cx}" cy="{cy}" r="12.4" fill="none" stroke="#000" stroke-width="1.4" opacity="0.45"/>') + if cracked: # a small tear running out from the hole + ang = rng.uniform(-0.5, 0.5) + ln = rng.uniform(9, 20) + x0, y0 = cx + 13 * math.cos(ang), cy + 13 * math.sin(ang) + parts.append(f'<path d="M{x0:.1f},{y0:.1f} l{ln * math.cos(ang):.1f},{ln * math.sin(ang) + rng.uniform(-2, 2):.1f}" stroke="#6b5a3a" stroke-width="0.9" opacity="0.55" fill="none"/>') + return "".join(parts) + + +STAIN_ANCHORS = [(705, 925), (690, 130), (170, 985), (430, 1030), (790, 520), (640, 640), (300, 60)] + + +def page_svg(n, cover=False, boost=1.0): + rng = random.Random(4242 + n * 7919) + defs = [] + layers = [f'<rect width="{W}" height="{H}" fill="#f2e9cf"/>'] + + # broad yellowing blotches, built from stacked translucent discs + for _ in range(5): + cx, cy, r = rng.uniform(0, W), rng.uniform(0, H), rng.uniform(150, 340) + for k in range(18): + layers.append(f'<circle cx="{cx:.0f}" cy="{cy:.0f}" r="{r * (1 - k / 18):.0f}" fill="#d9c17a" opacity="0.016"/>') + + # aged edges: nested frames, darker towards the sheet edge + for k in range(48): + inset = k * 3.6 + 1.8 + layers.append(f'<rect x="{inset}" y="{inset}" width="{W - 2 * inset}" height="{H - 2 * inset}" fill="none" ' + f'stroke="#b08840" stroke-width="3.6" opacity="{0.20 * (1 - k / 48) ** 2.6:.3f}"/>') + + # paper fibres and foxing + fib = [] + for _ in range(260): + x, y, a, ln = rng.uniform(0, W), rng.uniform(0, H), rng.uniform(0, math.pi), rng.uniform(5, 22) + fib.append(f'M{x:.0f},{y:.0f}l{ln * math.cos(a):.1f},{ln * math.sin(a):.1f}') + layers.append(f'<path d="{"".join(fib)}" stroke="#8a7448" stroke-width="0.6" opacity="0.10" fill="none"/>') + for _ in range(170): + layers.append(f'<circle cx="{rng.uniform(0, W):.0f}" cy="{rng.uniform(0, H):.0f}" r="{rng.uniform(0.4, 1.9):.1f}" fill="#7a5426" opacity="{rng.uniform(0.04, 0.2):.2f}"/>') + + for k in range(40): # binder-side shading fading over 80 px + layers.append(f'<rect x="{k * 2}" width="2" height="{H}" fill="#5b4526" opacity="{0.17 * (1 - k / 40) ** 1.6:.3f}"/>') + + # coffee + if cover: + # The cover is mostly text, so the cups go in the empty corners: one off the + # bottom-right edge, a small one beside the binder holes, a stray at top right. + stains = [(700, 1052, 92, 1.15), (100, 655, 50, 0.9), (745, 150, 44, 0.8)] + else: + stains = [] + if rng.random() < 0.38: + ax, ay = rng.choice(STAIN_ANCHORS) + stains.append((ax + rng.uniform(-40, 40), ay + rng.uniform(-40, 40), rng.uniform(52, 98), rng.uniform(0.7, 1.15))) + if rng.random() < 0.3: # the cup was put down twice + stains.append((stains[0][0] + rng.uniform(-40, 40), stains[0][1] + rng.uniform(25, 50), stains[0][2] * 0.75, 0.75)) + for i, (cx, cy, r, s) in enumerate(stains): + d, body = coffee_ring(f"{n}_{i}", cx, cy, r, rng, s * boost) + defs.append(d) + layers.append(body) + + # binder holes, US three-hole spacing (4.25 in), centred 0.5 in from the edge + for cy in (120, 528, 936): + layers.append(hole(48, cy, reinforced=(n % 3 == 0), cracked=(rng.random() < 0.12), rng=rng)) + + return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">' + f'<defs>{"".join(defs)}</defs>{"".join(layers)}</svg>') + + +# ---- document ---------------------------------------------------------------- + +def build_html(md_text): + body = markdown.markdown( + md_text, + extensions=["toc", "fenced_code", "md_in_html", "sane_lists"], + extension_configs={"toc": {"toc_depth": "1-2"}}, + ) + return ( + '<!doctype html><html lang="en"><head><meta charset="utf-8">' + "<title>HC-33 Flight Computer: Programmer's Reference Manual" + '' + '' + f"{body}" + ) + + +def _copy_page(job): + from PIL import Image + src, dst, seed, copies, dpi, intensity = job + photocopy.photocopy(Image.open(src).convert("RGB"), seed=seed, copies=copies, intensity=intensity).save( + dst, quality=58, optimize=True, progressive=True, dpi=(dpi, dpi)) + + +INVISIBLE = """ +html *, html *::before, html *::after { + color: rgba(0, 0, 0, 0.01) !important; background: none !important; + border-color: transparent !important; outline: 0 !important; text-shadow: none !important; +} +@page { + @top-left { color: rgba(0, 0, 0, 0.01); border-color: transparent; } + @top-right { color: rgba(0, 0, 0, 0.01); border-color: transparent; } + @bottom-left { color: rgba(0, 0, 0, 0.01); } + @bottom-right { color: rgba(0, 0, 0, 0.01); } +} +""" + + +def page_rules(image_for, pages): + rules = [] + for n in range(1, pages + 1): + # WeasyPrint positions a page background against the content box, so + # shift it back by that page's margins to cover the whole sheet. + top = "-0.8in" if n == 1 else "-0.95in" + rules.append(f"@page :nth({n}) {{ background: #f2e9cf url({image_for(n)}) no-repeat -1.3in {top} / 8.5in 11in; }}") + return "\n".join(rules) + + +def render(md_path, pdf_path, copies=3, dpi=130, intensity=0.5): + html = build_html(pathlib.Path(md_path).read_text(encoding="utf-8")) + base_css = CSS(filename=str(HERE / "manual.css")) + with tempfile.TemporaryDirectory() as tmp: + tmp = pathlib.Path(tmp) + doc = HTML(string=html, base_url=str(tmp)) + pages = len(doc.render(stylesheets=[base_css]).pages) # backgrounds do not affect layout + for n in range(1, pages + 1): + (tmp / f"bg-{n}.svg").write_text(page_svg(n, cover=(n == 1), boost=1.0 if copies <= 0 else 1.0 + 1.4 * intensity), encoding="utf-8") + bg_css = CSS(string=page_rules(lambda n: f"bg-{n}.svg", pages), base_url=str(tmp)) + + out_tmp = f"{pdf_path}.tmp" # written beside the target, then renamed into place + + def publish(): + os.replace(out_tmp, pdf_path) + + if copies <= 0: + doc.render(stylesheets=[base_css, bg_css]).write_pdf(out_tmp) + publish() + return pages + + # 1. the crisp original, 2. rasterise it, 3. photocopy the rasters + doc.render(stylesheets=[base_css, bg_css]).write_pdf(tmp / "crisp.pdf") + subprocess.run(["pdftoppm", "-r", str(dpi), "-png", str(tmp / "crisp.pdf"), str(tmp / "page")], check=True) + width = len(str(pages)) + jobs = [(tmp / f"page-{n:0{width}d}.png", tmp / f"scan-{n}.jpg", 9000 + n, copies, dpi, intensity) for n in range(1, pages + 1)] + with concurrent.futures.ProcessPoolExecutor() as pool: # pages are independent + list(pool.map(_copy_page, jobs)) + + # 4. the same document again, but the copies are the paper and the live text is invisible + scan_css = CSS(string=page_rules(lambda n: f"scan-{n}.jpg", pages) + INVISIBLE, base_url=str(tmp)) + doc.render(stylesheets=[base_css, scan_css]).write_pdf(out_tmp) + publish() + return pages + + +if __name__ == "__main__": + ap = argparse.ArgumentParser(description="Render the HC-33 manual to a battered PDF.") + ap.add_argument("markdown") + ap.add_argument("pdf") + ap.add_argument("--copies", type=int, default=3, help="photocopy generations (0 = crisp)") + ap.add_argument("--intensity", type=float, default=0.5, help="how worn the copies look (1.0 = heavy)") + ap.add_argument("--dpi", type=int, default=130, help="resolution of the copied pages") + args = ap.parse_args() + print(f"rendered {render(args.markdown, args.pdf, args.copies, args.dpi, args.intensity)} pages -> {args.pdf}") diff --git a/examples/examples_test.go b/examples/examples_test.go index 9f6fcc7..f162e9e 100644 --- a/examples/examples_test.go +++ b/examples/examples_test.go @@ -6,10 +6,10 @@ import ( "os" "testing" - "wh/config" - "wh/fixed" - "wh/sim" - "wh/vm" + "halcyon/config" + "halcyon/fixed" + "halcyon/sim" + "halcyon/vm" ) func asm(t *testing.T, name string) []byte { diff --git a/examples/programs/ports.s b/examples/programs/ports.s index 67d6f27..1ff6518 100644 --- a/examples/programs/ports.s +++ b/examples/programs/ports.s @@ -5,9 +5,9 @@ .equ TX 1024 ; downlink buffer .equ RAMTOP 8192 ; initial stack pointer - .equ P_TICK 0x00 + .equ P_TURN 0x00 .equ P_DAY 0x01 - .equ P_TICKS 0x02 + .equ P_TURNS 0x02 .equ P_POSX 0x10 .equ P_POSY 0x11 .equ P_POSZ 0x12 diff --git a/examples/programs/telemetry.s b/examples/programs/telemetry.s index dc5cb3f..3430dcf 100644 --- a/examples/programs/telemetry.s +++ b/examples/programs/telemetry.s @@ -1,20 +1,20 @@ -; TELEMETRY -- every tick, write tick number, fuel and position to the +; TELEMETRY -- every turn, write turn number, fuel and position to the ; downlink buffer as five 32-bit words. ; - .equ P_TICK 0x00 + .equ P_TURN 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_TURN 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] +loop: in r1, P_TURN + stw r1, [r0+T_TURN] in r1, P_FUEL stw r1, [r0+T_FUEL] in r1, P_POSX diff --git a/go.mod b/go.mod index 9561f29..f5c136a 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module wh +module halcyon go 1.25.0 diff --git a/market/market.go b/market/market.go index d9a9b76..4183315 100644 --- a/market/market.go +++ b/market/market.go @@ -2,7 +2,7 @@ // (credits per kg) that fall as supply accumulates and recover over time. package market -import "wh/world" +import "halcyon/world" var basePrice = [world.NumOre]int64{world.Iron: 2, world.Nickel: 6, world.Ice: 3, world.Platinum: 300} diff --git a/rng/rng.go b/rng/rng.go index 14a5037..2f701c0 100644 --- a/rng/rng.go +++ b/rng/rng.go @@ -7,7 +7,7 @@ package rng import ( "math/bits" - "wh/fixed" + "halcyon/fixed" ) type R struct{ s [4]uint64 } diff --git a/runner/runner.go b/runner/runner.go index 8c0a81f..30ceb78 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -7,9 +7,9 @@ import ( "fmt" "time" - "wh/config" - "wh/sim" - "wh/store" + "halcyon/config" + "halcyon/sim" + "halcyon/store" ) const ( diff --git a/scripts/daily.sh b/scripts/daily.sh index 968c769..d197247 100755 --- a/scripts/daily.sh +++ b/scripts/daily.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Run one simulated day inside the running container. +# Run one day of the belt inside the running container. # # scripts/daily.sh [daily flags...] # @@ -9,7 +9,7 @@ # Environment: # HALCYON_CONTAINER container name (default: halcyon, as in docker-compose.yml) # -# The world settings (WH_SEED, WH_ASTEROIDS, ...) come from the container's +# The world settings (HALCYON_SEED, HALCYON_ASTEROIDS, ...) come from the container's # environment, so the daily run always matches the server. set -euo pipefail diff --git a/scripts/manual-pdf.sh b/scripts/manual-pdf.sh new file mode 100755 index 0000000..64bd16e --- /dev/null +++ b/scripts/manual-pdf.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Render docs/manual.md to docs/generated/manual.pdf, for a locally run server +# (go run ./cmd/server serves that path at /manual.pdf by default). Needs Docker. +# The compose deployment uses scripts/render-manual.sh instead. +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p docs/generated +docker build --quiet --target manual -t halcyon-manual . +docker run --rm -v "$PWD/docs/generated:/out" halcyon-manual +echo "wrote docs/generated/manual.pdf" diff --git a/scripts/render-manual.sh b/scripts/render-manual.sh new file mode 100755 index 0000000..7982f3a --- /dev/null +++ b/scripts/render-manual.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Render docs/manual.md to the PDF served at /manual.pdf, without touching the +# running app: the renderer writes into the volume the web container reads, and +# the new file is picked up on the next request (no restart). +# +# Takes a couple of minutes; run it in the background after a deploy: +# scripts/render-manual.sh > /tmp/render-manual.log 2>&1 & +set -euo pipefail +cd "$(dirname "$0")/.." +echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) rendering the manual..." +docker compose --profile manual build --quiet manual +docker compose --profile manual run --rm manual +echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) done: /manual.pdf now serves the new render" diff --git a/sim/bus.go b/sim/bus.go index d41e088..80cf767 100644 --- a/sim/bus.go +++ b/sim/bus.go @@ -1,9 +1,9 @@ package sim import ( - "wh/config" - "wh/fixed" - "wh/world" + "halcyon/config" + "halcyon/fixed" + "halcyon/world" ) // tickCtx is per-tick shared context; asteroid positions are computed lazily @@ -81,11 +81,11 @@ func axis(v fixed.Vec, port, base uint16, conv func(fixed.F) int32) int32 { func (b *shipBus) In(port uint16) int32 { sh, tc := b.sh, b.tc switch { - case port == PortTick: + case port == PortTurn: return int32(tc.tick) case port == PortDay: return sat32(tc.s.Day) - case port == PortTicks: + case port == PortTurns: return int32(tc.cfg.TicksPerDay) case port >= PortPosX && port <= PortPosZ: return axis(sh.Pos, port, PortPosX, kmInt) diff --git a/sim/ports.go b/sim/ports.go index 363910a..cc6fe5b 100644 --- a/sim/ports.go +++ b/sim/ports.go @@ -6,9 +6,9 @@ package sim // +X towards +Y and elevation (pitch) up from the XY plane towards +Z. const ( // System. - PortTick = 0x00 // in: tick within the current day + PortTurn = 0x00 // in: turn within the current day PortDay = 0x01 // in: day number - PortTicks = 0x02 // in: ticks per day + PortTurns = 0x02 // in: turns per day // Navigation (relative to the star). PortPosX = 0x10 diff --git a/sim/sim_test.go b/sim/sim_test.go index c5eb1aa..b4d02c4 100644 --- a/sim/sim_test.go +++ b/sim/sim_test.go @@ -4,9 +4,9 @@ import ( "math" "testing" - "wh/config" - "wh/fixed" - "wh/vm" + "halcyon/config" + "halcyon/fixed" + "halcyon/vm" ) func testCfg() config.Config { diff --git a/sim/state.go b/sim/state.go index 185454d..13a751a 100644 --- a/sim/state.go +++ b/sim/state.go @@ -5,11 +5,11 @@ package sim import ( "fmt" - "wh/config" - "wh/fixed" - "wh/market" - "wh/vm" - "wh/world" + "halcyon/config" + "halcyon/fixed" + "halcyon/market" + "halcyon/vm" + "halcyon/world" ) // Hull describes the physical properties of a ship design. diff --git a/sim/step.go b/sim/step.go index 2b3219f..81c23bf 100644 --- a/sim/step.go +++ b/sim/step.go @@ -4,10 +4,10 @@ import ( "fmt" "sort" - "wh/config" - "wh/fixed" - "wh/vm" - "wh/world" + "halcyon/config" + "halcyon/fixed" + "halcyon/vm" + "halcyon/world" ) // RunDay simulates one full day, mutating the state, and returns the result. diff --git a/slots/slots.go b/slots/slots.go new file mode 100644 index 0000000..9137533 --- /dev/null +++ b/slots/slots.go @@ -0,0 +1,41 @@ +// Package slots defines the sixteen code slots each player has. Slots 1-3 hold +// the built-in example programs and are read-only; slots 4-16 belong to the +// player, who saves source code in them and picks one when launching a ship. +package slots + +import ( + "io/fs" + "strings" + + "halcyon/examples" +) + +const Count = 16 + +// builtin lists the example programs, in slot order (slot 1 is the first). +var builtin = []string{"echo", "telemetry", "pursue"} + +// FirstUser is the first slot a player can write to. +var FirstUser = len(builtin) + 1 + +type Slot struct { + Number int + Name string + Source string + Builtin bool +} + +// Valid reports whether n is one of the sixteen slots. +func Valid(n int) bool { return n >= 1 && n <= Count } + +// Builtin returns the example that occupies slot n, if it is one of the first three. +func Builtin(n int) (Slot, bool) { + if n < 1 || n > len(builtin) { + return Slot{}, false + } + src, err := fs.ReadFile(examples.FS, builtin[n-1]+".s") + if err != nil { + return Slot{}, false + } + return Slot{Number: n, Name: strings.ToUpper(builtin[n-1]), Source: string(src), Builtin: true}, true +} diff --git a/store/entities.go b/store/entities.go index 5427b75..9b7ea2c 100644 --- a/store/entities.go +++ b/store/entities.go @@ -11,7 +11,7 @@ import ( "errors" "fmt" - "wh/sim" + "halcyon/sim" ) // Ship statuses. diff --git a/store/slots.go b/store/slots.go new file mode 100644 index 0000000..12bebc4 --- /dev/null +++ b/store/slots.go @@ -0,0 +1,67 @@ +package store + +import ( + "context" + "database/sql" +) + +// CodeSlot is a player's saved source code. +type CodeSlot struct { + Slot int + Name string + Source string +} + +// ListCodeSlots returns the names of a player's saved slots, keyed by slot number. +func (q *Q) ListCodeSlots(ctx context.Context, owner int64) (map[int]string, error) { + rows, err := q.query(ctx, `SELECT slot, name FROM code_slots WHERE player_id = ?`, owner) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int]string{} + for rows.Next() { + var n int + var name string + if err := rows.Scan(&n, &name); err != nil { + return nil, err + } + out[n] = name + } + return out, rows.Err() +} + +// GetCodeSlot returns one saved slot, or ErrNotFound if it is empty. +func (q *Q) GetCodeSlot(ctx context.Context, owner int64, slot int) (CodeSlot, error) { + cs := CodeSlot{Slot: slot} + err := q.row(ctx, `SELECT name, source FROM code_slots WHERE player_id = ? AND slot = ?`, owner, slot).Scan(&cs.Name, &cs.Source) + if err == sql.ErrNoRows { + return cs, ErrNotFound + } + return cs, err +} + +// PutCodeSlot saves source in a slot, replacing whatever was there. +func (q *Q) PutCodeSlot(ctx context.Context, owner int64, slot int, name, source, at string) error { + _, err := q.exec(ctx, `INSERT INTO code_slots (player_id, slot, name, source, updated_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (player_id, slot) DO UPDATE SET name = excluded.name, source = excluded.source, updated_at = excluded.updated_at`, + owner, slot, name, source, at) + return err +} + +// DeleteCodeSlot empties a slot. Emptying an empty slot is not an error. +func (q *Q) DeleteCodeSlot(ctx context.Context, owner int64, slot int) error { + _, err := q.exec(ctx, `DELETE FROM code_slots WHERE player_id = ? AND slot = ?`, owner, slot) + return err +} + +// LaunchWithProgram seals prog into a docked ship and queues it for launch in +// one step, replacing any program it already had. +func (q *Q) LaunchWithProgram(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 = ?, status = ? WHERE id = ? AND owner_id = ? AND status = ?`, + prog, StatusLaunching, ship, owner, StatusInventory) + return q.requireOneRow(res, err) +} diff --git a/store/store.go b/store/store.go index 50d7e93..5228b21 100644 --- a/store/store.go +++ b/store/store.go @@ -123,6 +123,10 @@ var schema = []string{ 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 TABLE IF NOT EXISTS code_slots ( + player_id BIGINT NOT NULL REFERENCES players(id), slot INTEGER NOT NULL, + name TEXT NOT NULL, source TEXT NOT NULL, updated_at TEXT NOT NULL, + PRIMARY KEY (player_id, slot))`, `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 ( diff --git a/web/static/api.js b/web/static/api.js index 34bde4e..ab1e6f1 100644 --- a/web/static/api.js +++ b/web/static/api.js @@ -1,5 +1,20 @@ // Client for the belt's HTTP API. +/** + * The project used to be called "wh". Carry over anything an earlier visit stored + * under the old prefix (the access key, drafts, choices), so nobody is logged out. + */ +export function migrateStorage() { + try { + for (const k of Object.keys(localStorage)) { + if (!k.startsWith('wh.')) continue; + const next = `halcyon.${k.slice(3)}`; + if (localStorage.getItem(next) === null) localStorage.setItem(next, localStorage.getItem(k)); + localStorage.removeItem(k); + } + } catch { /* storage unavailable: nothing to carry over */ } +} + export class ApiError extends Error { constructor(status, message) { super(message); @@ -7,7 +22,7 @@ export class ApiError extends Error { } } -const KEY = 'wh.key'; +const KEY = 'halcyon.key'; export const keyStore = { get() { try { return localStorage.getItem(KEY); } catch { return null; } @@ -64,8 +79,12 @@ export const api = { 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`), + // Launch a docked ship, flying the program in code slot `slot`. + launch: (id, slot) => call('POST', `/ships/${id}/launch`, { json: { slot } }), + slots: () => call('GET', '/slots'), + slot: (n) => call('GET', `/slots/${n}`), + saveSlot: (n, name, source) => call('PUT', `/slots/${n}`, { json: { name, source } }), + deleteSlot: (n) => call('DELETE', `/slots/${n}`), 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 }); @@ -79,10 +98,6 @@ export const api = { 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) { diff --git a/web/static/app.js b/web/static/app.js index e63e78f..c9e62d2 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -1,8 +1,8 @@ // Halcyon Flight Control: application shell and routing. -import { api, keyStore, onUnauthorized } from './api.js'; +import { api, keyStore, migrateStorage, onUnauthorized } from './api.js'; import { h, clear, toast } from './dom.js'; -import { startStarfield } from './starfield.js'; +import { startBackdrop } from './backdrop.js'; import { statusOf } from './status.js'; import { authView } from './view-auth.js'; import { fleetView } from './view-fleet.js'; @@ -18,9 +18,8 @@ const TABS = [ ['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' }; +const state = { info: null, me: null, ships: [], selected: null, tab: 'fleet' }; let shell = null; // { top, nav, select, main } const tabFromHash = () => { @@ -36,7 +35,7 @@ const ctx = { }, selectShip(id, tab) { state.selected = id; - try { localStorage.setItem('wh.ship', String(id)); } catch { /* ignore */ } + try { localStorage.setItem('halcyon.ship', String(id)); } catch { /* ignore */ } renderNav(); if (tab) ctx.go(tab); else renderMain(); }, @@ -49,9 +48,9 @@ const ctx = { }; 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')); + const [me, ships, info] = await Promise.all([api.me(), api.ships(), api.info().catch(() => state.info)]); + Object.assign(state, { me, ships, info }); + const remembered = Number(safeGet('halcyon.ship')); if (!ships.some((s) => s.id === state.selected)) { state.selected = ships.some((s) => s.id === remembered) ? remembered : (ships[0]?.id ?? null); } @@ -98,22 +97,18 @@ function buildShell() { 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'), + h('footer', { class: 'foot' }, 'HALCYON INSTRUMENT & CONTROL'), ); } 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')]; + const { me, info } = state; 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() ?? ''), @@ -134,7 +129,7 @@ function renderNav() { 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('a', { class: 'tab', href: '/manual.pdf', target: '_blank', rel: 'noopener' }, 'MANUAL'), ), h('label', { class: 'shipsel' }, 'ACTIVE SHIP', select), ); @@ -160,7 +155,8 @@ document.addEventListener('visibilitychange', () => { // ---- boot ------------------------------------------------------------------- -startStarfield(document.getElementById('stars')); +migrateStorage(); // before anything reads the key +startBackdrop(document.getElementById('backdrop')); 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/backdrop.js b/web/static/backdrop.js new file mode 100644 index 0000000..0d02101 --- /dev/null +++ b/web/static/backdrop.js @@ -0,0 +1,263 @@ +// The animated backdrop, all on one canvas: drifting, twinkling stars with the +// odd shooting star, the striped sun, and a perspective grid floor sliding +// towards you. One canvas is one composited layer: no CSS 3D transforms, masks or +// animations, which is what made the old CSS floor flicker in mobile Safari. +// +// Built to survive mobile browsers: star positions are stored as fractions of +// the viewport so a resize (iOS fires them constantly as the toolbar collapses) +// never re-scatters them; a frame that throws cannot kill the loop; and the loop +// is restarted if it is ever found dead (dropped frame, back/forward cache, +// throttled tab), so the animation cannot silently stop. + +const STAR_COUNT = 320; +const TINTS = ['#ffffff', '#ffffff', '#ffffff', '#9ff8ff', '#ffb3f0']; + +export function startBackdrop(canvas) { + const ctx = canvas.getContext('2d'); + const reduce = window.matchMedia('(prefers-reduced-motion: reduce)'); + + // Positions are fractions of the viewport (nx, ny in [0, 1)). + const stars = Array.from({ length: STAR_COUNT }, () => ({ + nx: Math.random(), + ny: Math.random(), + 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)], + })); + let shooters = []; + let nextShot = 4000; + + let w = 0; + let h = 0; + let dpr = 1; + let horizonY = 0; // where the floor starts + let floorH = 0; + let staticLayer = null; // sun + floor base, painted once per size + let staticTop = 0; // the layer is empty above this row, so only the part below it is drawn + let fade = null; // gradient that dissolves the grid into the horizon + let shown = 0; // how many stars fit the current viewport + let raf = 0; + let last = performance.now(); + let lastFrame = performance.now(); + let resizeTimer = 0; + + function resize() { + // The canvas is a fixed box of 100vh anchored to the bottom (see the CSS), so + // it does not change size when a phone's toolbar slides in and out. + const nw = canvas.clientWidth; + const nh = canvas.clientHeight; + if (!nw || !nh) return; + dpr = Math.min(window.devicePixelRatio || 1, 2); + if (nw !== w || nh !== h || canvas.width !== Math.round(nw * dpr)) { + w = nw; + h = nh; + canvas.width = Math.round(w * dpr); + canvas.height = Math.round(h * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + floorH = Math.round(h * 0.17); + horizonY = h - floorH; + staticLayer = buildStatic(); + fade = ctx.createLinearGradient(0, horizonY, 0, horizonY + floorH * 0.55); + fade.addColorStop(0, 'rgba(34,9,63,1)'); + fade.addColorStop(1, 'rgba(34,9,63,0)'); + } + shown = Math.min(STAR_COUNT, Math.round((w * h) / 5500)); + draw(performance.now()); + } + + /** The parts that never move, painted once: the striped sun and the floor's base. */ + function buildStatic() { + const c = document.createElement('canvas'); + c.width = canvas.width; + c.height = canvas.height; + const g = c.getContext('2d'); + g.setTransform(dpr, 0, 0, dpr, 0, 0); + + const S = Math.max(180, Math.min(400, 0.34 * Math.min(w, h))); + const R = S / 2; + const cx = w / 2; + const cy = horizonY - 0.16 * S; // its lower third is hidden behind the floor + staticTop = Math.max(0, Math.floor(cy - R - 60)); // top of the sun's halo + + const halo = g.createRadialGradient(cx, cy, R, cx, cy, R + 60); + halo.addColorStop(0, 'rgba(255,58,216,0.30)'); + halo.addColorStop(1, 'rgba(255,58,216,0)'); + g.fillStyle = halo; + g.beginPath(); + g.arc(cx, cy, R + 60, 0, Math.PI * 2); + g.fill(); + + const t = document.createElement('canvas'); // the disc, with its stripes cut out + t.width = t.height = Math.ceil(S * dpr); + const tg = t.getContext('2d'); + tg.scale(dpr, dpr); + const sun = tg.createLinearGradient(0, 0, 0, S); + sun.addColorStop(0, '#fff27a'); + sun.addColorStop(0.32, '#ffb62e'); + sun.addColorStop(0.72, '#ff3ad8'); + sun.addColorStop(1, '#8a2cff'); + tg.fillStyle = sun; + tg.beginPath(); + tg.arc(R, R, R, 0, Math.PI * 2); + tg.fill(); + tg.globalCompositeOperation = 'destination-out'; + for (const [from, to] of [[0.46, 0.49], [0.56, 0.6], [0.67, 0.72], [0.79, 0.85], [0.91, 0.98]]) { + tg.fillRect(0, from * S, S, (to - from) * S); + } + g.drawImage(t, cx - R, cy - R, S, S); + + const base = g.createLinearGradient(0, horizonY, 0, h); + base.addColorStop(0, '#22093f'); + base.addColorStop(1, '#0c0420'); + g.fillStyle = base; + g.fillRect(0, horizonY, w, floorH); + const glow = g.createLinearGradient(0, horizonY - 28, 0, horizonY); + glow.addColorStop(0, 'rgba(40,245,255,0)'); + glow.addColorStop(1, 'rgba(40,245,255,0.45)'); + g.fillStyle = glow; + g.fillRect(0, horizonY - 28, w, 28); + g.fillStyle = '#28f5ff'; + g.fillRect(0, horizonY - 1, w, 2); + return c; + } + + /** The grid: lines at even steps in depth, projected onto the floor. */ + function drawGrid(now) { + const Z_FAR = 26; + const phase = reduce.matches ? 0 : ((now / 1000) * 0.42) % 1; // depth steps per second + ctx.save(); + ctx.beginPath(); + ctx.rect(0, horizonY, w, floorH); + ctx.clip(); + ctx.strokeStyle = '#ff3ad8'; + + // lines across, sliding towards the viewer + for (let j = 0; j < Z_FAR; j++) { + const z = 1 + j - phase; + const y = horizonY + floorH / z; + ctx.globalAlpha = 0.85; + ctx.lineWidth = 0.7 + 1.5 / Math.max(z, 1); + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + + // lines running into the distance, meeting at the vanishing point + const spacing = floorH * 0.55; + const n = Math.ceil((w / 2 / spacing) * 6); + const cx = w / 2; + ctx.globalAlpha = 0.7; + ctx.lineWidth = 1.5; + ctx.beginPath(); + for (let i = -n; i <= n; i++) { + ctx.moveTo(cx + (i * spacing) / Z_FAR, horizonY + floorH / Z_FAR); + ctx.lineTo(cx + (i * spacing) / 0.4, horizonY + floorH / 0.4); + } + ctx.stroke(); + + // dissolve everything into the horizon so distant lines cannot shimmer + ctx.globalAlpha = 1; + ctx.fillStyle = fade; + ctx.fillRect(0, horizonY, w, floorH * 0.55); + ctx.restore(); + } + + function draw(now) { + if (!staticLayer) return; + ctx.clearRect(0, 0, w, h); + for (let i = 0; i < shown; i++) { + const s = stars[i]; + 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.nx * w, s.ny * h, 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.max(0, 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; + // sun and floor over the stars; only the part of the layer that has anything in it + ctx.drawImage(staticLayer, 0, staticTop * dpr, canvas.width, canvas.height - staticTop * dpr, 0, staticTop, w, h - staticTop); + drawGrid(now); + } + + function update(dt) { + for (let i = 0; i < shown; i++) { + const s = stars[i]; + s.nx -= (s.z * 0.012 * dt) / w; + if (s.nx < 0) { + s.nx += 1; + s.ny = Math.random(); + } + } + 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); + } + + function frame(now) { + raf = 0; + try { + const dt = Math.max(0, Math.min(64, now - last)); + last = now; + update(dt); + draw(now); + } catch (e) { + console.error('starfield frame failed; continuing', e); + } + lastFrame = performance.now(); + schedule(); + } + + function schedule() { + if (raf || reduce.matches || document.visibilityState !== 'visible') return; + raf = requestAnimationFrame(frame); + } + + /** (Re)start the loop from a clean state. Safe to call at any time. */ + function restart() { + cancelAnimationFrame(raf); + raf = 0; + last = lastFrame = performance.now(); + if (reduce.matches) draw(last); + else schedule(); + } + + window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(resize, 120); + }); + window.addEventListener('orientationchange', () => setTimeout(resize, 250)); + document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') restart(); }); + window.addEventListener('pageshow', restart); // back/forward cache restore + window.addEventListener('focus', restart); + reduce.addEventListener('change', restart); + + // Watchdog: a visible page whose loop has gone quiet gets restarted. + setInterval(() => { + if (!reduce.matches && document.visibilityState === 'visible' && performance.now() - lastFrame > 1500) restart(); + }, 1000); + + resize(); + restart(); +} diff --git a/web/static/dom.js b/web/static/dom.js index a7e4393..7b68469 100644 --- a/web/static/dom.js +++ b/web/static/dom.js @@ -52,6 +52,33 @@ export function confirmDialog({ title, body, confirmText = 'CONFIRM', cancelText }); } +/** + * A modal dialog around caller-built content (form fields and so on). Returns + * { result, confirm, close }: `result` resolves true on the confirm button and + * false on cancel or Escape; the caller can enable/disable `confirm` as it + * validates, and reads its own fields after `result` resolves. + */ +export function customDialog({ title, content, confirmText = 'OK', cancelText = 'CANCEL', danger = false }) { + const dlg = document.getElementById('dialog'); + let resolve; + const result = new Promise((r) => { resolve = r; }); + const finish = (v) => { + if (dlg.open) dlg.close(); + resolve(v); + }; + const confirm = h('button', { class: `btn ${danger ? 'danger' : 'primary'}`, type: 'button', onclick: () => finish(true) }, confirmText); + clear(dlg).append( + h('div', { class: `panel dialog ${danger ? 'danger' : ''}` }, + h('div', { class: 'panel-title' }, title), + h('div', { class: 'panel-body stack' }, ...content), + h('div', { class: 'actions' }, h('button', { class: 'btn', type: 'button', onclick: () => finish(false) }, cancelText), confirm), + ), + ); + dlg.oncancel = () => resolve(false); + dlg.showModal(); + return { result, confirm, close: finish }; +} + 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 }); diff --git a/web/static/hexview.js b/web/static/hexview.js index 027915d..1af1134 100644 --- a/web/static/hexview.js +++ b/web/static/hexview.js @@ -31,50 +31,89 @@ export function describe(bytes, i) { return parts.join(' | '); } +// Row layout, in character cells: offset, gap, hex, gap, ASCII. +const BYTES_PER_ROW = [16, 8, 4]; // widest first +const GAP = 1.2; +const rowChars = (n) => 4 + GAP * 2 + n * 3 + (n === 16 ? 1 : 0) + n; + /** - * Render a hex dump. Returns { el, inspector } where inspector is the element - * that shows the decoded value of the hovered/selected byte. + * Render a hex dump that always fits its container: it picks 16, 8 or 4 bytes + * per row and sizes the font so a whole row fits without sideways scrolling, and + * re-fits whenever the container changes size (rotation, window resize). + * Returns { el, inspector }; the inspector shows the decoded hovered/selected byte. + * + * Options: maxBytes (show only the first N bytes), maxFont / minFont (px). */ -export function hexView(bytes, { rows: maxRows = Infinity } = {}) { +export function hexView(bytes, { maxBytes = Infinity, maxFont = 15, minFont = 9.5 } = {}) { 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' }); + const total = Math.min(bytes.length, maxBytes); let pinned = null; - const cells = new Map(); + let cells = new Map(); + let perRow = 0; + let ratio = 0; // width of one character as a fraction of the font size - 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; + function build(n) { + perRow = n; + cells = new Map(); + const rows = []; + for (let start = 0; start < total; start += n) { + const hexCells = []; + const ascCells = []; + for (let k = 0; k < n; k++) { + const i = start + k; + if (i >= total) { + 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 (n === 16 && k === 7) hexCells.push(h('span', { class: 'hx-gap' }, ' ')); } - 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' }, ' ')); + rows.push( + 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), + ), + ); } - 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), - ), - ); + body.replaceChildren(...rows); + if (pinned !== null) cells.get(pinned)?.forEach((c) => c.classList.add('sel')); + } + + function measure() { + if (ratio) return ratio; + const probe = h('span', { class: 'hx-probe' }, 'M'.repeat(20)); + body.append(probe); + ratio = probe.getBoundingClientRect().width / 20 / 100; + probe.remove(); + return ratio || 0.6; + } + + function layout() { + const cs = getComputedStyle(body); + const avail = body.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); + if (!(avail > 0)) return; // not laid out yet + const r = measure(); + const fontFor = (n) => avail / (rowChars(n) * r); + // The widest layout that still gives a readable font; keep the current one a + // little longer than that, so a scrollbar appearing cannot make it flap. + let n = BYTES_PER_ROW.find((c) => fontFor(c) >= minFont - (c === perRow ? 0.6 : 0)) ?? 4; + body.style.fontSize = `${Math.min(maxFont, fontFor(n)).toFixed(2)}px`; + if (n !== perRow) build(n); } 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')); + 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); @@ -87,6 +126,10 @@ export function hexView(bytes, { rows: maxRows = Infinity } = {}) { body.addEventListener('mouseleave', () => { if (pinned === null) select(null, false); }); body.addEventListener('click', (e) => { const i = target(e); if (i !== null) select(i, true); }); + build(16); + if (typeof ResizeObserver !== 'undefined') new ResizeObserver(layout).observe(body); // fires once attached, before first paint + else window.addEventListener('resize', layout); + return { el: h('div', { class: 'hex' }, body, inspector), inspector }; } diff --git a/web/static/index.html b/web/static/index.html index 3aaa991..fe29184 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -11,11 +11,7 @@ - - +
diff --git a/web/static/starfield.js b/web/static/starfield.js deleted file mode 100644 index f1eb917..0000000 --- a/web/static/starfield.js +++ /dev/null @@ -1,90 +0,0 @@ -// 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/style.css b/web/static/style.css index 34bfdf3..f1134e6 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -26,7 +26,7 @@ body { margin: 0; min-height: 100vh; color: var(--ink); - background: linear-gradient(180deg, #07030f 0%, #0f0626 40%, #2a0a4d 72%, #6a1268 100%) fixed; + background: #07030f; font-family: var(--mono); font-size: 17px; line-height: 1.5; @@ -35,50 +35,21 @@ body { /* ---- 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; +/* The whole backdrop is one canvas. It is a fixed box of 100vh anchored to the + bottom of the screen, so it never resizes when a phone's toolbar comes and goes + and the floor always sits at the visible bottom. */ +#backdrop { + position: fixed; left: 0; bottom: 0; width: 100%; height: 100vh; height: 100lvh; z-index: 0; + background: linear-gradient(180deg, #07030f 0%, #0f0626 40%, #2a0a4d 72%, #6a1268 100%); } -.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; } } - +/* Static scanlines and vignette. (They used to flicker; that was the flicker.) */ .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; } @@ -120,20 +91,23 @@ a:hover { color: var(--mag); } 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); + background: rgba(7, 3, 15, 0.96); 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); } +/* Callsigns can be 40 characters: truncate them, never the buttons beside them. */ +.top-user { min-width: 0; max-width: 100%; } +.callsign { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.top-user .btn { flex: none; } .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; } +.tabs { display: flex; flex-wrap: nowrap; gap: 0.3rem; min-width: 0; } .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); @@ -233,9 +207,28 @@ textarea { resize: vertical; } .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; } + +/* Button bars in the code editor: one control height and style throughout. */ +.bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 1rem; margin: 0.8rem 0; } +.bar-label { flex: none; width: 4.2rem; font-size: 0.72rem; letter-spacing: 0.24em; color: var(--dim); } +.bar-buttons { display: flex; flex: 1; flex-wrap: wrap; gap: 0.5rem; min-width: 0; } +.bar .btn, .bar select { + height: 2.5rem; margin: 0; padding: 0 1.1rem; font-size: 0.82rem; line-height: 1; white-space: nowrap; + display: inline-flex; align-items: center; justify-content: center; +} +.bar .push { margin-left: auto; } +.bar select { min-width: 0; max-width: 100%; text-overflow: ellipsis; } +.stack-lg > * { min-width: 0; } +/* The example chooser looks like the buttons beside it. */ +.bar select { + -webkit-appearance: none; appearance: none; width: auto; cursor: pointer; + font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--cyan); + background: rgba(40, 245, 255, 0.06) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%2328f5ff' stroke-width='1.6'/%3E%3C/svg%3E") no-repeat right 0.9rem center / 0.65rem; + border: 1px solid var(--cyan); box-shadow: 0 0 10px rgba(40, 245, 255, 0.25); padding-right: 2.4rem; +} +.bar select:hover { background-color: rgba(40, 245, 255, 0.16); } +.nowrap { white-space: nowrap; } +.panel-title .dim { min-width: 0; text-align: right; overflow-wrap: anywhere; } .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); } @@ -315,15 +308,13 @@ textarea { resize: vertical; } .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-row { display: flex; gap: 1.2ch; 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; } @@ -333,6 +324,7 @@ textarea { resize: vertical; } .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-probe { position: absolute; visibility: hidden; font-size: 100px; white-space: pre; } .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; } @@ -360,6 +352,12 @@ 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 select, .dialog input[type="text"] { width: 100%; } +.dialog select { color: var(--cyan); border-color: var(--line); } +.launch-status { min-height: 1.5em; letter-spacing: 0.04em; } +.launch-status.ok { color: var(--lime); } +.launch-status.bad { color: var(--red); } +.slot-status { margin: 0.2rem 0 0.7rem; } .dialog .actions { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0 1rem 1rem; } /* ---- small screens ------------------------------------------------------- */ @@ -372,8 +370,15 @@ dialog::backdrop { background: rgba(3, 1, 10, 0.78); backdrop-filter: blur(3px); 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; } + /* phones: a tidy two-column grid; the chooser and the primary action span the row */ + .bar { flex-direction: column; align-items: stretch; gap: 0.3rem; } + .bar-label { width: auto; } + .bar-buttons { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } + .bar select { width: 100%; } + .bar .span2 { grid-column: 1 / -1; } + .bar .push { margin-left: 0; } + .bar .btn, .bar select { padding: 0 0.6rem; } + .panel-title { letter-spacing: 0.16em; flex-wrap: wrap; row-gap: 0.15rem; } .auth { padding-bottom: 10rem; } .top-user { margin-left: 0; } .manifest thead { display: none; } @@ -382,10 +387,19 @@ dialog::backdrop { background: rgba(3, 1, 10, 0.78); backdrop-filter: blur(3px); .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; } + .navbar { flex-direction: column; align-items: stretch; gap: 0.6rem; } + .tabs { width: 100%; gap: 0.2rem; } + .tab { flex: 1 1 auto; min-width: 0; padding: 0.55rem 0.2rem; font-size: 0.68rem; letter-spacing: 0.05em; text-align: center; white-space: nowrap; } + .shipsel { justify-content: space-between; } + .hx-body { padding: 0.6rem 0.5rem; } .editor { height: 52vh; } } +@media (max-width: 360px) { + .bar .btn, .bar select { font-size: 0.7rem; letter-spacing: 0.05em; padding: 0 0.4rem; } + .tab { font-size: 0.62rem; letter-spacing: 0.02em; padding-left: 0.1rem; padding-right: 0.1rem; } +} + @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } diff --git a/web/static/view-code.js b/web/static/view-code.js index 062eaea..746dc61 100644 --- a/web/static/view-code.js +++ b/web/static/view-code.js @@ -1,27 +1,37 @@ -// Code editor: write assembly, check it, download it, seal it into a ship. +// Code editor with sixteen saved slots. Slots 1-3 are read-only examples; 4-16 +// are the player's own. Launching a ship (Fleet tab) picks which slot it flies. import { api, b64ToBytes } from './api.js'; -import { h, clear, toast, confirmDialog, download } from './dom.js'; +import { h, clear, toast, confirmDialog, customDialog, download, pad } 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 */ } }; +const DEFAULT_SLOT = 2; // the telemetry example, a working program to start from + +const readJSON = (k) => { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } }; +const writeJSON = (k, v) => { try { localStorage.setItem(k, JSON.stringify(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'; + // The unsaved working copy survives reloads, per account and per browser. + const draftKey = `halcyon.editor.${ctx.state.me?.name ?? ''}`; - const cursor = h('span', { class: 'dim' }, 'LN 1 COL 1'); + let slots = []; // [{ slot, name, builtin, empty }] from the server + let current = { slot: DEFAULT_SLOT, name: '', builtin: true, saved: '' }; // what the editor is bound to + let ready = false; let timer = 0; + + const info = (n) => slots.find((s) => s.slot === n) ?? { slot: n, name: '', builtin: false, empty: true }; + const slotLabel = (s) => `${pad(s.slot, 2)} ${s.builtin ? `${s.name} (EXAMPLE)` : s.empty ? '(EMPTY)' : s.name}`; + + const cursor = h('span', { class: 'dim nowrap' }, 'LN 1 COL 1'); const editor = createEditor({ - value: load(key) ?? '', - onInput: (v) => { clearTimeout(timer); timer = setTimeout(() => save(key, v), 300); }, + value: '', + onInput: (v) => { + clearTimeout(timer); + timer = setTimeout(() => writeJSON(draftKey, { slot: current.slot, text: v }), 150); + renderStatus(); + }, 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' }, @@ -33,14 +43,13 @@ export function codeView(ctx) { try { const r = await api.assemble(editor.value); editor.setError(null); - const bytes = b64ToBytes(r.program); - assembled = { bytes, ...r }; + assembled = { bytes: b64ToBytes(r.program), ...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) })), + h('div', { class: 'fill' })), ); output.querySelector('.fill')?.style.setProperty('width', `${pct}%`); return assembled; @@ -52,50 +61,138 @@ export function codeView(ctx) { 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' }); + // ---- slots ------------------------------------------------------------- + const btn = (label, onclick, opts = {}) => + h('button', { class: `btn ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label); - const toolbar = h('div', { class: 'toolbar' }, - exampleSel, + const slotSel = h('select', { id: 'slot', class: 'span2', 'aria-label': 'Code slot', disabled: true }); + const status = h('p', { class: 'hint slot-status', 'aria-live': 'polite' }, 'LOADING YOUR SLOTS...'); + const saveBtn = btn('SAVE', () => save(), { disabled: true }); + const saveAsBtn = btn('SAVE AS...', () => saveAs(), { disabled: true }); + const clearBtn = btn('CLEAR SLOT', () => clearSlot(), { cls: 'span2', disabled: true }); + + const dirty = () => editor.value !== current.saved; + + function renderSelect() { + slotSel.replaceChildren(...slots.map((s) => h('option', { value: s.slot, selected: s.slot === current.slot }, slotLabel(s)))); + slotSel.value = String(current.slot); + } + + function renderStatus() { + if (!ready) return; + const s = info(current.slot); + status.textContent = `SLOT ${current.slot}: ${s.builtin ? `${s.name} (EXAMPLE, READ-ONLY)` : s.empty ? 'EMPTY' : s.name}${dirty() ? ' // UNSAVED CHANGES' : ''}`; + status.classList.toggle('warn', dirty()); + saveBtn.disabled = current.builtin; + saveBtn.title = current.builtin ? 'The examples are read-only: use SAVE AS to keep a copy in your own slot' : `Save in slot ${current.slot}`; + saveAsBtn.disabled = false; + clearBtn.disabled = current.builtin || s.empty; + } + + async function openSlot(n, { keepText = null } = {}) { + const s = await api.slot(n); + current = { slot: n, name: s.name, builtin: s.builtin, saved: s.source }; + editor.value = keepText ?? s.source; + writeJSON(draftKey, { slot: n, text: editor.value }); + renderSelect(); + renderStatus(); + } + + slotSel.addEventListener('change', async () => { + const n = Number(slotSel.value); + if (dirty() && !(await confirmDialog({ + title: 'DISCARD UNSAVED CHANGES?', + body: `YOU HAVE CHANGES IN SLOT ${current.slot} THAT ARE NOT SAVED. OPENING SLOT ${n} REPLACES THEM.`, + confirmText: 'DISCARD', danger: true, + }))) { slotSel.value = String(current.slot); return; } + try { await openSlot(n); say(h('p', { class: 'dim' }, `OPENED SLOT ${n}.`)); } catch (e) { toast(e.message, 'err'); slotSel.value = String(current.slot); } + }); + + async function put(n, name) { + try { + await api.saveSlot(n, name, editor.value); + slots = slots.map((s) => (s.slot === n ? { slot: n, name, builtin: false, empty: false } : s)); + current = { slot: n, name, builtin: false, saved: editor.value }; + writeJSON(draftKey, { slot: n, text: editor.value }); + renderSelect(); + renderStatus(); + toast(`SAVED IN SLOT ${n}: ${name}`); + return true; + } catch (e) { + toast(`SAVE FAILED: ${e.message}`, 'err'); + return false; + } + } + + async function save() { + if (current.builtin) return saveAs(); + if (!current.name) return saveAs(); + return put(current.slot, current.name); + } + + async function saveAs() { + const writable = slots.filter((s) => !s.builtin); + const firstFree = writable.find((s) => s.empty); + const target = current.builtin ? (firstFree ?? writable[0]).slot : current.slot; + const sel = h('select', { id: 'sa-slot' }, writable.map((s) => h('option', { value: s.slot, selected: s.slot === target }, slotLabel(s)))); + const name = h('input', { id: 'sa-name', type: 'text', maxlength: '40', value: current.name || 'MY PROGRAM', spellcheck: 'false' }); + const warn = h('p', { class: 'warn', 'aria-live': 'polite' }); + const dlg = customDialog({ + title: 'SAVE IN A SLOT', + content: [h('label', { for: 'sa-slot' }, 'SLOT'), sel, h('label', { for: 'sa-name' }, 'NAME'), name, warn], + confirmText: 'SAVE', + }); + const check = () => { + const s = info(Number(sel.value)); + warn.textContent = s.empty || s.slot === current.slot ? '' : `THIS REPLACES "${s.name}".`; + dlg.confirm.disabled = !name.value.trim(); + }; + sel.addEventListener('change', check); + name.addEventListener('input', check); + name.addEventListener('keydown', (e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); // otherwise the same keystroke lands on the SAVE AS button that regains focus, and reopens this dialog + if (!dlg.confirm.disabled) dlg.close(true); + }); + check(); + name.select(); + if (await dlg.result) await put(Number(sel.value), name.value.trim()); + } + + async function clearSlot() { + const s = info(current.slot); + if (!(await confirmDialog({ title: `CLEAR SLOT ${s.slot}?`, body: [`"${s.name}" WILL BE DELETED FROM THE SLOT.`, 'THE TEXT NOW IN THE EDITOR STAYS UNTIL YOU CHANGE SLOT.'], confirmText: 'CLEAR SLOT', danger: true }))) return; + try { + await api.deleteSlot(s.slot); + slots = slots.map((x) => (x.slot === s.slot ? { slot: s.slot, name: '', builtin: false, empty: true } : x)); + current = { slot: s.slot, name: '', builtin: false, saved: '' }; + renderSelect(); + renderStatus(); + toast(`SLOT ${s.slot} CLEARED`); + } catch (e) { toast(`CLEAR FAILED: ${e.message}`, 'err'); } + } + + // ---- bars -------------------------------------------------------------- + const bar = (label, ...buttons) => + h('div', { class: 'bar', role: 'group', 'aria-label': label }, h('span', { class: 'bar-label' }, label), h('div', { class: 'bar-buttons' }, ...buttons)); + + const slotBar = bar('SLOT', slotSel, saveBtn, saveAsBtn, clearBtn); + const buildBar = bar('BUILD', + btn('ASSEMBLE', assemble), + btn('DOWNLOAD .BIN', async () => { const r = await bytesOrNull(); if (r) download(`slot-${current.slot}-program.bin`, r.bytes); }), + ); + const fileBar = bar('FILE', 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')), + btn('DOWNLOAD .S', () => download(`slot-${current.slot}.s`, editor.value, 'text/plain')), ); // ---- reference --------------------------------------------------------- @@ -104,36 +201,49 @@ export function codeView(ctx) { 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(); + // Appendix E of the manual source is one fenced block: the quick reference card. + const txt = await (await fetch('/manual.md')).text(); + const open = txt.indexOf('```', txt.indexOf('# Appendix E')); + const close = txt.indexOf('```', open + 3); + card.textContent = txt.slice(txt.indexOf('\n', open) + 1, close).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')), + h('p', { class: 'hint pad' }, h('a', { href: '/manual.pdf', target: '_blank', rel: 'noopener' }, 'OPEN THE FULL MANUAL (PDF)')), ); - 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, + h('p', { class: 'hint' }, 'YOU HAVE 16 CODE SLOTS: 1-3 HOLD EXAMPLES, 4-16 ARE YOURS. TO FLY A PROGRAM, LAUNCH A SHIP FROM THE FLEET TAB AND CHOOSE ITS SLOT.'), + slotBar, + status, 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.'), + h('p', { class: 'hint' }, 'YOUR UNSAVED TEXT IS KEPT IN THIS BROWSER. TAB INDENTS; ESC THEN TAB LEAVES THE EDITOR.'), + buildBar, + fileBar, output, ), ), ref, ); + + (async () => { + try { + slots = await api.slots(); + const draft = readJSON(draftKey); + const start = draft && slots.some((s) => s.slot === draft.slot) ? draft.slot : DEFAULT_SLOT; + ready = true; + slotSel.disabled = false; + await openSlot(start, { keepText: draft && draft.slot === start && typeof draft.text === 'string' ? draft.text : null }); + } catch (e) { + status.textContent = `COULD NOT LOAD YOUR SLOTS: ${e.message}`; + status.classList.add('bad'); + } + })(); + queueMicrotask(() => editor.render()); return view; } diff --git a/web/static/view-fleet.js b/web/static/view-fleet.js index 34ffc79..5ed1c36 100644 --- a/web/static/view-fleet.js +++ b/web/static/view-fleet.js @@ -1,26 +1,70 @@ // Fleet manifest with launch controls. import { api } from './api.js'; -import { h, toast, confirmDialog } from './dom.js'; +import { h, toast, customDialog, pad } 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.', + let slots; + try { slots = await api.slots(); } catch (e) { toast(`COULD NOT LOAD YOUR CODE SLOTS: ${e.message}`, 'err'); return; } + const slotLabel = (s) => `${pad(s.slot, 2)} ${s.builtin ? `${s.name} (EXAMPLE)` : s.empty ? '(EMPTY)' : s.name}`; + let remembered = 0; + try { remembered = Number(localStorage.getItem('halcyon.launchSlot')); } catch { /* ignore */ } + const usable = slots.filter((s) => !s.empty); + const first = usable.find((s) => s.slot === remembered) ?? usable.find((s) => !s.builtin) ?? usable[0]; + + const sel = h('select', { id: 'launch-slot' }, + slots.map((s) => h('option', { value: s.slot, disabled: s.empty, selected: s.slot === first.slot }, slotLabel(s)))); + const status = h('p', { class: 'launch-status', role: 'status', 'aria-live': 'polite' }, 'CHECKING...'); + const dlg = customDialog({ + title: `LAUNCH SHIP #${ship.id}`, + content: [ + h('p', null, 'CHOOSE THE CODE SLOT THIS SHIP WILL FLY. IT IS ASSEMBLED NOW AND SEALED INTO THE SHIP.'), + h('label', { for: 'launch-slot' }, 'CODE SLOT'), + sel, + status, + h('p', { class: 'warn' }, 'AT LAUNCH THE PROGRAM IS FIXED. IT CANNOT BE CHANGED, RECALLED OR RESTARTED.'), ], confirmText: 'LAUNCH', danger: true, }); - if (!ok) return; + + // Assemble the chosen slot as soon as it is chosen, so problems show before launch. + let seq = 0; + const check = async () => { + const mine = ++seq; + const n = Number(sel.value); + dlg.confirm.disabled = true; + status.className = 'launch-status'; + status.textContent = 'ASSEMBLING...'; + let text; + let good = false; + try { + const src = await api.slot(n); + const r = await api.assemble(src.source); + if (r.size === 0) text = 'THIS SLOT HAS NO CODE.'; + else if (!r.fits) text = `TOO LARGE: ${r.size} BYTES, THE LIMIT IS ${r.limit}.`; + else { good = true; text = `${r.size} BYTES / ${r.instructions} INSTRUCTIONS: READY TO LAUNCH.`; } + } catch (e) { + text = `DOES NOT ASSEMBLE: ${e.message}`; + } + if (mine !== seq) return; // a newer choice superseded this one + status.className = `launch-status ${good ? 'ok' : 'bad'}`; + status.textContent = text; + dlg.confirm.disabled = !good; + }; + sel.addEventListener('change', check); + check(); + + if (!(await dlg.result)) return; + const n = Number(sel.value); try { - await api.launch(ship.id); - toast(`SHIP #${ship.id} CLEARED FOR LAUNCH. IT FLIES ON THE NEXT DAILY RUN.`); + const r = await api.launch(ship.id, n); + try { localStorage.setItem('halcyon.launchSlot', String(n)); } catch { /* ignore */ } + toast(`SHIP #${ship.id} CLEARED FOR LAUNCH WITH SLOT ${n} (${r?.program_bytes ?? '?'} BYTES). IT FLIES ON THE NEXT DAILY RUN.`); await ctx.refresh(); } catch (e) { toast(`LAUNCH FAILED: ${e.message}`, 'err'); @@ -34,11 +78,7 @@ export function fleetView(ctx) { 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', - })); + out.push(btn('LAUNCH', () => launch(s), { cls: 'primary', title: 'Choose a code slot and 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'))); @@ -72,13 +112,13 @@ export function fleetView(ctx) { 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('1', 'WRITE', 'a flight program in the CODE editor and save it in one of your 16 code slots. Your ship cannot be flown by hand.'), + step('2', 'TEST', 'it: assemble it in the editor, and improve it in as many slots as you like.'), + step('3', 'LAUNCH', 'the ship and choose which slot it flies. 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')), + h('p', { class: 'hint pad' }, 'The belt runs once a day. Ore delivered to the station earns credits.'), + h('p', { class: 'pad' }, h('a', { class: 'btn small', href: '/manual.pdf', target: '_blank', rel: 'noopener' }, 'OPEN THE HC-33 MANUAL (PDF)')), ), ); } diff --git a/web/static/view-uplink.js b/web/static/view-uplink.js index 8bd4243..e1561f0 100644 --- a/web/static/view-uplink.js +++ b/web/static/view-uplink.js @@ -32,7 +32,7 @@ export function uplinkView(ctx) { send.disabled = !open || over || b.length === 0; clear(preview); if (b.length) { - preview.append(hexView(b, { rows: 8 }).el); + preview.append(hexView(b, { maxBytes: 128, maxFont: 13 }).el); if (b.length > 128) preview.append(h('p', { class: 'hint' }, `SHOWING THE FIRST 128 OF ${b.length} BYTES.`)); } } @@ -94,7 +94,7 @@ export function uplinkView(ctx) { 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.'), + 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) AT THE START OF EACH DAY.'), ), ), ); diff --git a/web/web.go b/web/web.go index 32df320..9fedcd2 100644 --- a/web/web.go +++ b/web/web.go @@ -6,11 +6,12 @@ import ( "encoding/json" "io/fs" "net/http" + "os" "sort" "strings" - "wh/docs" - "wh/examples" + "halcyon/docs" + "halcyon/examples" ) //go:embed static @@ -18,15 +19,18 @@ 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 { +// +// manualPath is the rendered PDF manual; it is opened on every request, so a +// new render is served as soon as it is renamed into place, without a restart. +func Handler(manualPath string) 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 /manual.md", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + w.Write(docs.Markdown) }) mux.HandleFunc("GET /examples/index.json", func(w http.ResponseWriter, r *http.Request) { entries, _ := fs.ReadDir(examples.FS, ".") @@ -41,7 +45,34 @@ func Handler() http.Handler { json.NewEncoder(w).Encode(names) }) mux.Handle("GET /examples/", http.StripPrefix("/examples/", http.FileServerFS(examples.FS))) - return secure(mux) + + top := http.NewServeMux() + top.HandleFunc("GET /manual.pdf", manualPDF(manualPath)) // not under the site's CSP: PDF viewers need their own resources + top.Handle("/", secure(mux)) + return top +} + +// manualPDF serves the manual rendered separately from the app. +func manualPDF(path string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + f, err := os.Open(path) + if err != nil { + http.Error(w, "The PDF manual has not been rendered yet (run scripts/render-manual.sh). "+ + "The Markdown source is at /manual.md.", http.StatusNotFound) + return + } + defer f.Close() + st, err := f.Stat() + if err != nil { + http.Error(w, "cannot read the PDF manual", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", `inline; filename="halcyon-hc33-manual.pdf"`) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "no-cache") // revalidate, so a fresh render shows up + http.ServeContent(w, r, "manual.pdf", st.ModTime(), f) // ranges and If-Modified-Since + } } // secure adds headers appropriate to a page that loads only its own assets. diff --git a/world/orbit.go b/world/orbit.go index 6b7eb55..113cdb1 100644 --- a/world/orbit.go +++ b/world/orbit.go @@ -6,7 +6,7 @@ // on a circle traversed at speed v; only the period (2*pi*r/v) varies. package world -import "wh/fixed" +import "halcyon/fixed" // Orbit is a circular orbit around the origin (the star). type Orbit struct { diff --git a/world/world.go b/world/world.go index 6f2a3be..3cc4fe7 100644 --- a/world/world.go +++ b/world/world.go @@ -1,9 +1,9 @@ package world import ( - "wh/config" - "wh/fixed" - "wh/rng" + "halcyon/config" + "halcyon/fixed" + "halcyon/rng" ) // Ore identifies a raw material. diff --git a/world/world_test.go b/world/world_test.go index 2a01eaa..bdf9eb9 100644 --- a/world/world_test.go +++ b/world/world_test.go @@ -4,8 +4,8 @@ import ( "math" "testing" - "wh/config" - "wh/fixed" + "halcyon/config" + "halcyon/fixed" ) func TestOrbitsAreCirclesAtCommonSpeed(t *testing.T) {