diff --git a/NDS/.gitignore b/NDS/.gitignore new file mode 100644 index 0000000..93f542f --- /dev/null +++ b/NDS/.gitignore @@ -0,0 +1,4 @@ +build/ +*.elf +*.nds +*.map diff --git a/NDS/Makefile b/NDS/Makefile new file mode 100644 index 0000000..79fb9d7 --- /dev/null +++ b/NDS/Makefile @@ -0,0 +1,175 @@ +#--------------------------------------------------------------------------------- +# NDS/ -- Nintendo DS port of the Fueling app, built with Embedded Swift. +# +# Build system copied from ClassicUI's ports/NDS (branch feature/nds), itself +# modeled on MillerTechnologyPeru/swift-embedded-nds: everything is compiled +# whole-module for armv5te-none-none-eabi (an exact match for the DS's +# ARM946E-S), linked against calico-based libnds, and packaged with ndstool +# using calico's prebuilt ARM7. +# +# The port reuses the real model stack as Embedded Swift modules, compiled in +# dependency order: FoundationEmbedded (PureSwift/swift-embedded-foundation) +# -> CoreModel (in-memory store + FetchRequest evaluation engine) +# -> CoreFueling (the shared domain entities and search predicates from this +# repository) -> the port sources in source/. +# +# Screen layout (the inverse of ClassicUI's port): the *top* screen hosts the +# UI through the software rasterizer in source/Renderer.swift, drawn into a +# double-buffered 16bpp bitmap background on the main engine; the *bottom* +# (touch) screen hosts libnds' on-screen keyboard on the sub engine, used to +# type into the locations search filter. +# +# Prerequisites: +# - devkitPro with devkitARM, libnds, calico, ndstool +# (export DEVKITPRO=/opt/devkitpro, DEVKITARM=$DEVKITPRO/devkitARM) +# - A Swift toolchain whose Embedded stdlib ships an armv5te-none-none-eabi +# slice. Release toolchains only ship armv4t; a recent `main` development +# snapshot is needed. Override with `make SWIFTC=/path/to/swiftc`. +# - python3 with Pillow (tools/gen_font.py rasterizes the UI font from the +# host's Helvetica at build time) +#--------------------------------------------------------------------------------- +.SUFFIXES: + +ifeq ($(strip $(DEVKITPRO)),) +$(error "Please set DEVKITPRO in your environment. export DEVKITPRO=devkitpro") +endif +ifeq ($(strip $(DEVKITARM)),) +$(error "Please set DEVKITARM in your environment. export DEVKITARM=$$DEVKITPRO/devkitARM") +endif + +SWIFTC ?= swiftc + +# Dependency source checkouts (Embedded modules built from source; override +# for a different layout). CoreModel needs the embedded-no-concurrency +# support (PureSwift/CoreModel PR #29). +FOUNDATION_EMBEDDED_DIR ?= $(HOME)/Developer/swift-embedded-foundation +COREMODEL_DIR ?= $(HOME)/Developer/CoreModel +COREFUELING_DIR := ../Sources/CoreFueling + +TARGET := Fueling +NDS_TITLE := Fueling +NDS_SUBTITLE := Fueling locations +BUILD := build +COMMON := common/ + +LIBNDS := $(DEVKITPRO)/libnds +CALICO := $(DEVKITPRO)/calico + +PREFIX := $(DEVKITARM)/bin/arm-none-eabi- +CC := $(PREFIX)gcc +LD := $(PREFIX)gcc + +#--------------------------------------------------------------------------------- +# code generation -- ARM946E-S / armv5te, soft float, no FPU. +#--------------------------------------------------------------------------------- +ARCH := -march=armv5te -mtune=arm946e-s -mthumb + +NDSDEFS := -DARM9 -D__NDS__ -I$(COMMON) \ + -I$(LIBNDS)/include -I$(CALICO)/include + +CFLAGS := -g -Wall -O2 -ffunction-sections -fdata-sections $(ARCH) \ + $(NDSDEFS) + +LDFLAGS := -specs=$(CALICO)/share/ds9.specs -g $(ARCH) \ + -Wl,-Map,$(TARGET).map -Wl,--gc-sections \ + -L$(LIBNDS)/lib -L$(CALICO)/lib + +# dswifi must precede libnds. +LIBS := -ldswifi9 -lnds9 -lcalico_ds9 -lm + +#--------------------------------------------------------------------------------- +# Embedded Swift flags, targeting armv5te-none-none-eabi. +#--------------------------------------------------------------------------------- +SWIFTFLAGS := -target armv5te-none-none-eabi \ + -enable-experimental-feature Embedded \ + -wmo -Osize \ + -module-name Fueling \ + -Xcc -DARM9 -Xcc -D__NDS__ \ + -Xcc -march=armv5te -Xcc -mfloat-abi=soft \ + -Xcc -isystem -Xcc $(DEVKITARM)/arm-none-eabi/include \ + -Xcc -I$(COMMON) \ + -Xcc -I$(LIBNDS)/include \ + -Xcc -I$(CALICO)/include \ + -Xcc -fmodule-map-file=$(COMMON)module.modulemap + +PORT_SWIFT := $(wildcard source/*.swift) +GEN_SWIFT := $(BUILD)/FontAssets.swift $(BUILD)/ServerConfig.swift + +FOUNDATION_EMBEDDED_SRC := $(wildcard $(FOUNDATION_EMBEDDED_DIR)/Sources/FoundationEmbedded/*.swift) +COREMODEL_SRC := $(shell find $(COREMODEL_DIR)/Sources/CoreModel -name '*.swift') +COREFUELING_SRC := $(wildcard $(COREFUELING_DIR)/*.swift) + +# Swift string operations (search predicates, sorting) need the Embedded +# stdlib's Unicode data tables at link time. +TOOLCHAIN_LIB := $(dir $(shell which $(SWIFTC)))../lib/swift/embedded/armv5te-none-none-eabi +LDFLAGS += -L$(TOOLCHAIN_LIB) +LIBS += -lswiftUnicodeDataTables + +OFILES := $(BUILD)/fueling.swift.o $(BUILD)/CoreFueling.o $(BUILD)/CoreModel.o \ + $(BUILD)/FoundationEmbedded.o $(BUILD)/shim.o + +ARM7_ELF := $(CALICO)/bin/ds7_maine.elf + +#--------------------------------------------------------------------------------- +.PHONY: all clean + +all: $(TARGET).nds + +$(BUILD): + @mkdir -p $@ + +# Font conversion: the host's Helvetica at 13 px -> antialiased +# 8-bit-coverage glyph table (same face and size ClassicUI resolves). +$(BUILD)/FontAssets.swift: tools/gen_font.py | $(BUILD) + @echo generating font assets + python3 tools/gen_font.py $(BUILD) + +# Injected server URL (FUELING_SERVER_URL, default http://10.0.2.2:8080). +# Re-evaluated every build; the file is only rewritten when the URL changes. +$(BUILD)/ServerConfig.swift: tools/gen_server.py FORCE | $(BUILD) + @python3 tools/gen_server.py $(BUILD) + +FORCE: + +# Model-layer modules, compiled in dependency order (each emits its +# .swiftmodule into $(BUILD) for the next stage's -I $(BUILD)). +$(BUILD)/FoundationEmbedded.o: $(FOUNDATION_EMBEDDED_SRC) | $(BUILD) + @echo compiling FoundationEmbedded + $(SWIFTC) $(SWIFTFLAGS) -module-name FoundationEmbedded -parse-as-library \ + -emit-module -emit-module-path $(BUILD)/FoundationEmbedded.swiftmodule \ + -c $(FOUNDATION_EMBEDDED_SRC) -o $@ + +$(BUILD)/CoreModel.o: $(COREMODEL_SRC) $(BUILD)/FoundationEmbedded.o | $(BUILD) + @echo compiling CoreModel + $(SWIFTC) $(SWIFTFLAGS) -module-name CoreModel -parse-as-library -I $(BUILD) \ + -emit-module -emit-module-path $(BUILD)/CoreModel.swiftmodule \ + -c $(COREMODEL_SRC) -o $@ + +$(BUILD)/CoreFueling.o: $(COREFUELING_SRC) $(BUILD)/CoreModel.o | $(BUILD) + @echo compiling CoreFueling + $(SWIFTC) $(SWIFTFLAGS) -module-name CoreFueling -parse-as-library -I $(BUILD) \ + -emit-module -emit-module-path $(BUILD)/CoreFueling.swiftmodule \ + -c $(COREFUELING_SRC) -o $@ + +# All port Swift (+ generated font) -> one object, whole-module. +$(BUILD)/fueling.swift.o: $(PORT_SWIFT) $(GEN_SWIFT) $(BUILD)/CoreFueling.o \ + $(COMMON)module.modulemap $(COMMON)nds_umbrella.h $(COMMON)shim.h | $(BUILD) + @echo compiling Swift + $(SWIFTC) $(SWIFTFLAGS) -I $(BUILD) -c $(PORT_SWIFT) $(GEN_SWIFT) -o $@ + +$(BUILD)/shim.o: $(COMMON)shim.c $(COMMON)shim.h | $(BUILD) + @echo $(notdir $<) + $(CC) $(CFLAGS) -c $< -o $@ + +$(TARGET).elf: $(OFILES) + @echo linking $(notdir $@) + $(LD) $(LDFLAGS) $(OFILES) $(LIBS) -o $@ + +$(TARGET).nds: $(TARGET).elf + @echo packaging $(notdir $@) + ndstool -c $@ -9 $(TARGET).elf -7 $(ARM7_ELF) \ + -b $(CALICO)/share/nds-icon.bmp "$(NDS_TITLE);$(NDS_SUBTITLE);swift-nds" + +clean: + @echo clean ... + @rm -fr $(BUILD) $(TARGET).elf $(TARGET).nds $(TARGET).map diff --git a/NDS/common/module.modulemap b/NDS/common/module.modulemap new file mode 100644 index 0000000..ab8b219 --- /dev/null +++ b/NDS/common/module.modulemap @@ -0,0 +1,5 @@ +// Exposes libnds (nds.h) plus our shim to Swift as `import NDS`. +module NDS { + header "nds_umbrella.h" + export * +} diff --git a/NDS/common/nds_umbrella.h b/NDS/common/nds_umbrella.h new file mode 100644 index 0000000..64bada0 --- /dev/null +++ b/NDS/common/nds_umbrella.h @@ -0,0 +1,20 @@ +//--------------------------------------------------------------------------------- +// nds_umbrella.h -- single header exposed to Swift as the `NDS` module. +// +// Trimmed from MillerTechnologyPeru/swift-embedded-nds's umbrella: Fueling +// only needs core libnds (video/backgrounds/input) -- the UI is a +// software rasterizer into a 16bpp bitmap background (source/Renderer.swift), +// same shape as junkbot-swift's ports/NDS. +//--------------------------------------------------------------------------------- +#ifndef FUELING_NDS_UMBRELLA_H +#define FUELING_NDS_UMBRELLA_H + +#include +#include +#include // Wifi_InitDefault / Wifi_GetIPInfo +#include // struct in_addr +#include // socket / connect / send / recv +#include // gethostbyname, struct hostent +#include "shim.h" + +#endif // FUELING_NDS_UMBRELLA_H diff --git a/NDS/common/shim.c b/NDS/common/shim.c new file mode 100644 index 0000000..ca7c052 --- /dev/null +++ b/NDS/common/shim.c @@ -0,0 +1,132 @@ +//--------------------------------------------------------------------------------- +// shim.c -- C support for the Fueling NDS port (see shim.h). +// +// Runtime helpers mirror junkbot-swift's ports/NDS shim (itself derived from +// MillerTechnologyPeru/swift-embedded-nds). +//--------------------------------------------------------------------------------- +#include +#include +#include +#include +#include +#include + +#include "shim.h" + +void nds_puts(const char *s) { + iprintf("%s", s); +} + +//--------------------------------------------------------------------------------- +// Runtime support the Embedded Swift object needs but devkitARM's libc/libgcc +// do not provide for this target. +//--------------------------------------------------------------------------------- + +// Embedded Swift's runtime can reference arc4random_buf (e.g. for the system +// RNG). The NDS has no entropy source and newlib's getentropy fallback is not +// wired up here, so provide a small xorshift PRNG. NOT cryptographically +// secure -- nothing in this port relies on randomness at all. +void arc4random_buf(void *buf, size_t n) { + static uint32_t s = 0x2545F491u; + uint8_t *p = (uint8_t *)buf; + for (size_t i = 0; i < n; i++) { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + p[i] = (uint8_t)s; + } +} + +// The Embedded stdlib's Double(String)/Float(String) parsing calls these +// libc-locale-pinned wrappers; newlib's plain strtod/strtof are already +// locale-free here. +double _swift_stdlib_strtod_clocale(const char *nptr, char **outEnd) { + return strtod(nptr, outEnd); +} + +float _swift_stdlib_strtof_clocale(const char *nptr, char **outEnd) { + return strtof(nptr, outEnd); +} + +// Swift's allocator calls posix_memalign; newlib only ships memalign. +int posix_memalign(void **memptr, size_t alignment, size_t size) { + void *p = memalign(alignment, size); + if (!p) return ENOMEM; + *memptr = p; + return 0; +} + +// ARMv5TE has no atomic instructions, so LLVM emits __atomic_* libcalls. +// The Swift code runs only on the single ARM9 core, so a short interrupt +// lock makes each operation atomic with respect to IRQ handlers. + +uint16_t __atomic_load_2(const volatile void *ptr, int memorder) { + (void)memorder; + ArmIrqState st = armIrqLockByPsr(); + uint16_t v = *(const volatile uint16_t *)ptr; + armIrqUnlockByPsr(st); + return v; +} + +void __atomic_store_2(volatile void *ptr, uint16_t val, int memorder) { + (void)memorder; + ArmIrqState st = armIrqLockByPsr(); + *(volatile uint16_t *)ptr = val; + armIrqUnlockByPsr(st); +} + +uint32_t __atomic_load_4(const volatile void *ptr, int memorder) { + (void)memorder; + ArmIrqState st = armIrqLockByPsr(); + uint32_t v = *(const volatile uint32_t *)ptr; + armIrqUnlockByPsr(st); + return v; +} + +void __atomic_store_4(volatile void *ptr, uint32_t val, int memorder) { + (void)memorder; + ArmIrqState st = armIrqLockByPsr(); + *(volatile uint32_t *)ptr = val; + armIrqUnlockByPsr(st); +} + +uint32_t __atomic_fetch_add_4(volatile void *ptr, uint32_t val, int memorder) { + (void)memorder; + ArmIrqState st = armIrqLockByPsr(); + volatile uint32_t *p = ptr; + uint32_t old = *p; + *p = old + val; + armIrqUnlockByPsr(st); + return old; +} + +uint32_t __atomic_fetch_sub_4(volatile void *ptr, uint32_t val, int memorder) { + (void)memorder; + ArmIrqState st = armIrqLockByPsr(); + volatile uint32_t *p = ptr; + uint32_t old = *p; + *p = old - val; + armIrqUnlockByPsr(st); + return old; +} + +_Bool __atomic_compare_exchange_4(volatile void *ptr, void *expected, + uint32_t desired, _Bool weak, + int success, int failure) { + (void)weak; (void)success; (void)failure; + ArmIrqState st = armIrqLockByPsr(); + volatile uint32_t *p = ptr; + uint32_t *exp = expected; + _Bool ok = (*p == *exp); + if (ok) { + *p = desired; + } else { + *exp = *p; + } + armIrqUnlockByPsr(st); + return ok; +} + +int nds_errno(void) { + return errno; +} diff --git a/NDS/common/shim.h b/NDS/common/shim.h new file mode 100644 index 0000000..32cac1e --- /dev/null +++ b/NDS/common/shim.h @@ -0,0 +1,21 @@ +//--------------------------------------------------------------------------------- +// shim.h -- C support for the Fueling NDS port. +// +// The Swift-visible surface is tiny (a fixed-arity print wrapper around +// libnds' variadic iprintf, which Embedded Swift can import but not call). +// The bulk of shim.c is runtime support referenced only by the linker: +// posix_memalign, arc4random_buf, the _swift_stdlib_strto* wrappers, and +// the __atomic_* outline helpers ARMv5TE needs (no atomic instructions on +// the ARM946E-S; see junkbot-swift's ports/NDS and +// MillerTechnologyPeru/swift-embedded-nds). +//--------------------------------------------------------------------------------- +#ifndef FUELING_NDS_SHIM_H +#define FUELING_NDS_SHIM_H + +// Print an already-formatted string (no varargs). +void nds_puts(const char *s); + +#endif // FUELING_NDS_SHIM_H + +// Current errno value (errno is a macro over __errno(), which doesn't import). +int nds_errno(void); diff --git a/NDS/source/Core.swift b/NDS/source/Core.swift new file mode 100644 index 0000000..e316ceb --- /dev/null +++ b/NDS/source/Core.swift @@ -0,0 +1,138 @@ +//--------------------------------------------------------------------------------- +// +// Core.swift -- embedded mini-core of the Fueling DS UI. +// +// Copied from ClassicUI's ports/NDS ClassicCore.swift (branch feature/nds): +// Embedded Swift has no Mirror, no untyped existentials, no Foundation and +// no Observation, so neither the desktop SwiftUI-subset resolver nor +// FuelingModel's Store/view models can compile for this target. The port +// carries its own small screen model that reproduces the same semantics -- +// menu rows with right-aligned values, and stack navigation with per-screen +// selection restore and scroll windowing. +// +// Differences from the ClassicUI original: `Screen` gains `showsSearch` +// (the locations list draws the search field fed by the bottom-screen +// keyboard, and lays rows out below it), and titles/details are runtime +// byte arrays rather than StaticString -- location names and fuel prices +// arrive over wifi at runtime, not at compile time. +// +//--------------------------------------------------------------------------------- + +/// One selectable menu row. +struct MenuItem { + + enum Action { + /// Non-interactive row. + case none + /// Pushes a screen built on demand. + case push(() -> Screen) + /// Runs an action on select. + case run(() -> Void) + } + + var title: [UInt8] + var action: Action + /// Right-aligned value bytes, recomputed on every redraw. + var detail: (() -> [UInt8])? + + var isNavigation: Bool { + if case .push = action { return true } + return false + } + + init( + _ title: [UInt8], action: Action = .none, + detail: (() -> [UInt8])? = nil + ) { + self.title = title + self.action = action + self.detail = detail + } + + init( + _ title: StaticString, action: Action = .none, + detail: (() -> [UInt8])? = nil + ) { + self.init(staticBytes(title), action: action, detail: detail) + } +} + +/// A full screen: a menu of rows, optionally headed by the search field. +final class Screen { + + enum Content { + case menu([MenuItem]) + } + + var title: [UInt8] + var content: Content + var showsSearch: Bool + var selection: Int32 = 0 + var scrollOffset: Int32 = 0 + + init(title: [UInt8], content: Content, showsSearch: Bool = false) { + self.title = title + self.content = content + self.showsSearch = showsSearch + } + + convenience init(title: StaticString, content: Content, showsSearch: Bool = false) { + self.init(title: staticBytes(title), content: content, showsSearch: showsSearch) + } + + var visibleRowCount: Int32 { + showsSearch ? visibleRowsWithSearch : visibleRows + } +} + +/// The navigation stack: select pushes, B pops, and each screen's +/// selection and scroll position are restored when navigating back. +final class Navigator { + + private(set) var stack: [Screen] + + init(root: Screen) { + stack = [root] + } + + var top: Screen { stack[stack.count - 1] } + + /// Scroll window so the selection is always visible and the offset never + /// leaves blank space at the bottom (ClassicUI's NavigationModel). + static func scrollOffset(selection: Int32, current: Int32, rowCount: Int32, visibleRows: Int32) + -> Int32 + { + var offset = current + if selection < offset { + offset = selection + } + if selection >= offset + visibleRows { + offset = selection - visibleRows + 1 + } + let maxOffset = rowCount > visibleRows ? rowCount - visibleRows : 0 + return min(max(offset, 0), maxOffset) + } + + /// Moves the selection by `delta` rows (positive = down). + func moveSelection(by delta: Int32, rowCount: Int32, visibleRows: Int32) { + guard rowCount > 0 else { return } + let screen = top + screen.selection = min(max(screen.selection + delta, 0), rowCount - 1) + screen.scrollOffset = Self.scrollOffset( + selection: screen.selection, + current: screen.scrollOffset, + rowCount: rowCount, + visibleRows: visibleRows) + } + + func push(_ screen: Screen) { + stack.append(screen) + } + + /// Pops the top screen. Returns `false` when already at the root. + func pop() -> Bool { + guard stack.count > 1 else { return false } + stack.removeLast() + return true + } +} diff --git a/NDS/source/Network.swift b/NDS/source/Network.swift new file mode 100644 index 0000000..9febfb9 --- /dev/null +++ b/NDS/source/Network.swift @@ -0,0 +1,563 @@ +//--------------------------------------------------------------------------------- +// +// Network.swift -- dswifi networking for the DS port. +// +// Socket flow copied from swift-embedded-nds's wifi_httpget example: +// associate via the firmware WFC settings (melonDS's firmware points at its +// emulated "melonAP" access point), open a TCP socket, send a raw HTTP GET, +// and read until the server closes the connection. +// +// The server is injected at build time via the FUELING_SERVER_URL +// environment variable (see tools/gen_server.py), the same variable the +// playground app and Android port use, defaulting to 10.0.2.2:8080 -- the +// emulator-NAT alias for the host machine, where Scripts/test-server.py +// listens. Hostnames are resolved on-device with DNS; nothing is hardcoded +// anywhere in the sources. +// +// The JSON walker below is a minimal byte scanner for the API envelope +// {"status": ..., "message": ..., "data": [...]} in the exact wire format +// FuelingAPI's GetLocation/FuelPrice DTOs decode on the other platforms -- +// Embedded Swift has no Foundation, so no JSONDecoder. Parsed objects are +// mapped into `CoreModel.ModelData` mirroring FuelingAPI's ModelData +// mapping key for key, then inserted into the shared `store` -- with every +// schema attribute set explicitly (`.null` when absent), since the +// in-memory store returns records verbatim. +// +//--------------------------------------------------------------------------------- + +import NDS +import CoreModel +import CoreFueling + +// MARK: - Server address +// (serverHost / serverPort / serverHostHeader come from the generated +// ServerConfig.swift -- see tools/gen_server.py.) + +/// Resolves the injected host (DNS name or dotted-quad) to an IPv4 address +/// in network byte order, or nil on failure. +func resolveServer() -> UInt32? { + var hostBytes = staticBytes(serverHost) + hostBytes.append(0) // C string terminator + let resolved: UInt32? = hostBytes.withUnsafeBufferPointer { buffer in + buffer.baseAddress!.withMemoryRebound(to: CChar.self, capacity: buffer.count) { name in + guard let host = gethostbyname(name), let first = host.pointee.h_addr_list[0] else { + return nil + } + return first.withMemoryRebound(to: in_addr_t.self, capacity: 1) { UInt32($0.pointee) } + } + } + return resolved +} + +// MARK: - Wifi + +var wifiConnected = false + +/// Associate using the firmware WFC settings. Blocks until associated or +/// the attempt fails. +func connectWifi() -> Bool { + if wifiConnected { return true } + wifiConnected = Wifi_InitDefault(true) // WFC_CONNECT + return wifiConnected +} + +// MARK: - HTTP + +/// host (DS little-endian) -> network byte order. (Macro, doesn't import.) +@inline(__always) func htons(_ x: UInt16) -> UInt16 { x.byteSwapped } + +/// Which step of the last HTTP request failed (1 DNS, 2 socket, 3 connect, +/// 4 send, 5 closed-without-data, 6 malformed response) plus the errno and +/// HTTP status -- surfaced on the error screen for debugging. +var lastHTTPStep: Int32 = 0 +var lastErrno: Int32 = 0 +var lastHTTPStatus: Int32 = 0 + +/// Raw HTTP GET; returns the response body, or nil on any socket/protocol +/// failure. +func httpGet(path: StaticString) -> [UInt8]? { + guard let address = resolveServer() else { + lastHTTPStep = 1 + lastErrno = nds_errno() + return nil + } + + let sock = socket(AF_INET, SOCK_STREAM, 0) + guard sock >= 0 else { + lastHTTPStep = 2 + lastErrno = nds_errno() + return nil + } + defer { _ = closesocket(sock) } + + var sain = sockaddr_in() + sain.sin_family = sa_family_t(AF_INET) + sain.sin_port = htons(serverPort) + sain.sin_addr.s_addr = address + + let connected = withUnsafePointer(to: &sain) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sp in + connect(sock, sp, socklen_t(MemoryLayout.size)) + } + } + guard connected >= 0 else { + lastHTTPStep = 3 + lastErrno = nds_errno() + return nil + } + + var request = [UInt8]() + request.append(contentsOf: staticBytes("GET ")) + request.append(contentsOf: staticBytes(path)) + request.append(contentsOf: staticBytes(" HTTP/1.1\r\nHost: ")) + request.append(contentsOf: staticBytes(serverHostHeader)) + request.append(contentsOf: staticBytes("\r\nDevice-ID: 0\r\nConnection: close\r\n\r\n")) + let sent = request.withUnsafeBytes { send(sock, $0.baseAddress, request.count, 0) } + guard sent == request.count else { + lastHTTPStep = 4 + lastErrno = nds_errno() + return nil + } + + // Read until the server closes the connection. + var response = [UInt8]() + var buffer = [UInt8](repeating: 0, count: 1024) + while true { + let received = buffer.withUnsafeMutableBytes { recv(sock, $0.baseAddress, 1024, 0) } + if received <= 0 { break } + response.append(contentsOf: buffer[0.. 12, response[0] == 72 { // 'H' + var i = 0 + while i < response.count, response[i] != 32 { i += 1 } // first space + var status: Int32 = 0 + i += 1 + while i < response.count, response[i] >= 48, response[i] <= 57 { + status = status &* 10 &+ Int32(response[i] - 48) + i += 1 + } + lastHTTPStatus = status + guard status >= 200, status < 300 else { + lastHTTPStep = 6 + lastErrno = 0 + return nil + } + } + + // Split headers from body at the first blank line. + var i = 0 + while i + 3 < response.count { + if response[i] == 13, response[i + 1] == 10, response[i + 2] == 13, response[i + 3] == 10 { + return Array(response[(i + 4)...]) + } + i += 1 + } + lastHTTPStep = 6 + lastErrno = 0 + return nil +} + +// MARK: - JSON scanner + +/// Minimal JSON walker over ASCII bytes: strings, numbers, objects, arrays, +/// true/false/null. No Unicode escapes (the wire data is plain ASCII). +struct JSONScanner { + let bytes: [UInt8] + var pos = 0 + + init(_ bytes: [UInt8]) { + self.bytes = bytes + } + + var atEnd: Bool { pos >= bytes.count } + + @inline(__always) var current: UInt8 { bytes[pos] } + + mutating func skipWhitespace() { + while !atEnd, current == 32 || current == 9 || current == 10 || current == 13 { + pos += 1 + } + } + + /// Consumes `byte` (after whitespace); false if the next byte differs. + mutating func consume(_ byte: UInt8) -> Bool { + skipWhitespace() + guard !atEnd, current == byte else { return false } + pos += 1 + return true + } + + /// Peeks (after whitespace) without consuming. + mutating func peek() -> UInt8? { + skipWhitespace() + return atEnd ? nil : current + } + + /// Parses a quoted string; assumes the next non-space byte is '"'. + mutating func parseString() -> [UInt8]? { + guard consume(34) else { return nil } // '"' + var out = [UInt8]() + while !atEnd { + let byte = current + pos += 1 + if byte == 34 { return out } // closing '"' + if byte == 92, !atEnd { // '\' + let escaped = current + pos += 1 + switch escaped { + case 110: out.append(10) // \n + case 116: out.append(9) // \t + case 114: out.append(13) // \r + default: out.append(escaped) // \" \\ \/ (and anything exotic, verbatim) + } + } else { + out.append(byte) + } + } + return nil + } + + /// Parses a number (integer or decimal fraction; no exponents in the wire + /// data) as a Double. + mutating func parseDouble() -> Double? { + skipWhitespace() + var negative = false + if !atEnd, current == 45 { // '-' + negative = true + pos += 1 + } + var whole: Int64 = 0 + var sawDigit = false + while !atEnd, current >= 48, current <= 57 { + whole = whole &* 10 &+ Int64(current - 48) + sawDigit = true + pos += 1 + } + guard sawDigit else { return nil } + var fraction: Int64 = 0 + var scale: Int64 = 1 + if !atEnd, current == 46 { // '.' + pos += 1 + while !atEnd, current >= 48, current <= 57 { + if scale < 1_000_000 { + fraction = fraction &* 10 &+ Int64(current - 48) + scale &*= 10 + } + pos += 1 + } + } + let value = Double(whole) + Double(fraction) / Double(scale) + return negative ? -value : value + } + + /// Skips any single value (string, number, object, array, literal). + mutating func skipValue() { + skipWhitespace() + guard !atEnd else { return } + switch current { + case 34: // string + _ = parseString() + case 123, 91: // '{' or '[' + var depth = 0 + repeat { + guard !atEnd else { return } + let byte = current + if byte == 34 { + _ = parseString() + continue + } + pos += 1 + if byte == 123 || byte == 91 { depth += 1 } + if byte == 125 || byte == 93 { depth -= 1 } + } while depth > 0 + default: // number / true / false / null + while !atEnd, current != 44, current != 125, current != 93 { // , } ] + pos += 1 + } + } + } + + /// Parses an array of strings (the `fueling_options` list). + mutating func parseStringArray() -> [[UInt8]] { + var result = [[UInt8]]() + guard consume(91) else { return result } // '[' + if peek() == 93 { + _ = consume(93) + return result + } + repeat { + if peek() == 34, let value = parseString() { + result.append(value) + } else { + skipValue() + } + } while consume(44) // ',' + _ = consume(93) // ']' + return result + } +} + +/// Byte-array key comparison against a fixed name. +func isKey(_ key: [UInt8], _ name: StaticString) -> Bool { + name.withUTF8Buffer { buffer -> Bool in + guard buffer.count == key.count else { return false } + var i = 0 + while i < key.count { + if key[i] != buffer[i] { return false } + i += 1 + } + return true + } +} + +// MARK: - Wire parsing + +/// Walks the `{"status", "message", "data": [...]}` envelope; calls +/// `element` positioned at each element of the `data` array. +private func parseEnvelope(_ body: [UInt8], element: (inout JSONScanner) -> Void) -> Bool { + var scanner = JSONScanner(body) + guard scanner.consume(123) else { return false } // '{' + while true { + guard let key = scanner.parseString() else { return false } + guard scanner.consume(58) else { return false } // ':' + if isKey(key, "data") { + guard scanner.consume(91) else { return false } // '[' + if scanner.peek() == 93 { // empty array + _ = scanner.consume(93) + } else { + repeat { + element(&scanner) + } while scanner.consume(44) // ',' + guard scanner.consume(93) else { return false } // ']' + } + } else { + scanner.skipValue() + } + if !scanner.consume(44) { break } // ',' + } + return true +} + +/// A parsed location: its entity data plus the join key for fuel prices. +private struct LocationRecord { + var id: Location.ID + var data: ModelData + var fuelProductIDs: [ObjectID] = [] +} + +/// The moment data was cached. The DS port has no calendar clock wired up; +/// the epoch marks every record as stale, which is harmless offline. +private var cachedDate: AttributeValue { .date(Date(timeIntervalSince1970: 0)) } + +/// GET /v1/locations -> location + fuel option records, mirroring +/// FuelingAPI's `ModelData(location:)` mapping (every schema attribute is +/// set explicitly -- see the header note). +private func parseLocations(_ body: [UInt8]) -> (locations: [LocationRecord], options: [ModelData])? { + var records = [LocationRecord]() + var options = [ModelData]() + let ok = parseEnvelope(body) { scanner in + var id: Location.ID? + var name = "", address = "", city = "", state = "", zipCode = "", phone = "" + var brand: String?, directions: String? + var latitude = 0.0, longitude = 0.0 + var fuelLanes: Int16 = 0, parking: Int16 = 0, showers: Int16 = 0 + var fuelingOptions = [[UInt8]]() + + guard scanner.consume(123) else { return } // '{' + while true { + guard let key = scanner.parseString(), scanner.consume(58) else { break } + if isKey(key, "site_id") { + let raw = scanner.parseString() ?? [] + id = Location.ID.Prefixed(rawValue: String(decoding: raw, as: UTF8.self)).map { Location.ID($0) } + } else if isKey(key, "location_name") { + name = String(decoding: scanner.parseString() ?? [], as: UTF8.self) + } else if isKey(key, "address_line_1") { + address = String(decoding: scanner.parseString() ?? [], as: UTF8.self) + } else if isKey(key, "city") { + city = String(decoding: scanner.parseString() ?? [], as: UTF8.self) + } else if isKey(key, "state") { + state = String(decoding: scanner.parseString() ?? [], as: UTF8.self) + } else if isKey(key, "zip_code") { + zipCode = String(decoding: scanner.parseString() ?? [], as: UTF8.self) + } else if isKey(key, "store_brand") { + if scanner.peek() == 34 { + brand = String(decoding: scanner.parseString() ?? [], as: UTF8.self) + } else { + scanner.skipValue() + } + } else if isKey(key, "directions") { + if scanner.peek() == 34 { + directions = String(decoding: scanner.parseString() ?? [], as: UTF8.self) + } else { + scanner.skipValue() + } + } else if isKey(key, "latitude") { + latitude = scanner.parseDouble() ?? 0 + } else if isKey(key, "longitude") { + longitude = scanner.parseDouble() ?? 0 + } else if isKey(key, "diesel_dispenser_lanes") { + fuelLanes = Int16(scanner.parseDouble() ?? 0) + } else if isKey(key, "truck_parking_spaces") { + parking = Int16(scanner.parseDouble() ?? 0) + } else if isKey(key, "private_showers") { + showers = Int16(scanner.parseDouble() ?? 0) + } else if isKey(key, "fueling_options") { + fuelingOptions = scanner.parseStringArray() + } else if isKey(key, "phone_numbers") { + // nested object; pull the primary number + if scanner.consume(123) { + while true { + guard let inner = scanner.parseString(), scanner.consume(58) else { break } + if isKey(inner, "primary_phone_number") { + phone = String(decoding: scanner.parseString() ?? [], as: UTF8.self) + } else { + scanner.skipValue() + } + if !scanner.consume(44) { break } + } + _ = scanner.consume(125) // '}' + } + } else { + scanner.skipValue() + } + if !scanner.consume(44) { break } + } + _ = scanner.consume(125) // '}' + guard let id else { return } + + // fuel options -> entities + relationship ids + var optionIDs = [ObjectID]() + for optionName in fuelingOptions { + let text = String(decoding: optionName, as: UTF8.self) + guard let optionID = FuelOption.ID(name: text) else { continue } + optionIDs.append(ObjectID(optionID)) + var option = ModelData(entity: FuelOption.entityName, id: ObjectID(optionID)) + option.attributes[.init(FuelOption.CodingKeys.name)] = .string(text) + option.relationships[.init(FuelOption.CodingKeys.locations)] = .toMany([]) + options.append(option) + } + + var data = ModelData(entity: Location.entityName, id: ObjectID(id)) + data.attributes[.init(Location.CodingKeys.name)] = .string(name) + data.attributes[.init(Location.CodingKeys.brand)] = brand.map { .string($0) } ?? .null + data.attributes[.init(Location.CodingKeys.address)] = .string(address) + data.attributes[.init(Location.CodingKeys.city)] = .string(city) + data.attributes[.init(Location.CodingKeys.state)] = .string(state) + data.attributes[.init(Location.CodingKeys.zipCode)] = .string(zipCode) + data.attributes[.init(Location.CodingKeys.phone)] = .string(phone) + data.attributes[.init(Location.CodingKeys.directions)] = directions.map { .string($0) } ?? .null + data.attributes[.init(Location.CodingKeys.latitude)] = .double(latitude) + data.attributes[.init(Location.CodingKeys.longitude)] = .double(longitude) + data.attributes[.init(Location.CodingKeys.fuelLanes)] = .int16(fuelLanes) + data.attributes[.init(Location.CodingKeys.truckParkingSpaces)] = .int16(parking) + data.attributes[.init(Location.CodingKeys.showers)] = .int16(showers) + data.attributes[.init(Location.CodingKeys.lastCached)] = cachedDate + data.attributes[.init(Location.CodingKeys.lastViewed)] = .null + data.relationships[.init(Location.CodingKeys.fuelOptions)] = .toMany(optionIDs) + records.append(LocationRecord(id: id, data: data)) + } + return ok ? (records, options) : nil +} + +/// GET /v1/fuelprice -> fuel product records, mirroring FuelingAPI's +/// `ModelData(fuelPrice:)` mapping, appended to the owning location's +/// `fuelProducts` join list. +private func parseFuelPrices(_ body: [UInt8], into records: inout [LocationRecord]) -> [ModelData] { + var products = [ModelData]() + _ = parseEnvelope(body) { scanner in + var locationID: Location.ID? + var fuelCode = "" + var description = "" + var price = 0.0 + guard scanner.consume(123) else { return } // '{' + while true { + guard let key = scanner.parseString(), scanner.consume(58) else { break } + if isKey(key, "siteID") { + let raw = scanner.parseString() ?? [] + locationID = Location.ID.Prefixed(rawValue: String(decoding: raw, as: UTF8.self)).map { Location.ID($0) } + } else if isKey(key, "fuelCode") { + fuelCode = String(decoding: scanner.parseString() ?? [], as: UTF8.self) + } else if isKey(key, "productDescription") { + description = String(decoding: scanner.parseString() ?? [], as: UTF8.self) + } else if isKey(key, "price") { + price = scanner.parseDouble() ?? 0 + } else { + scanner.skipValue() + } + if !scanner.consume(44) { break } + } + _ = scanner.consume(125) // '}' + guard let locationID, !description.isEmpty else { return } + + let productID = FuelProduct.ID.fuelPrice(fuelCode, location: locationID) + var data = ModelData(entity: FuelProduct.entityName, id: ObjectID(productID)) + data.attributes[.init(FuelProduct.CodingKeys.updated)] = cachedDate + data.attributes[.init(FuelProduct.CodingKeys.price)] = .double(price) + data.attributes[.init(FuelProduct.CodingKeys.descriptionText)] = .string(description) + data.attributes[.init(FuelProduct.CodingKeys.lastCached)] = cachedDate + data.relationships[.init(FuelProduct.CodingKeys.location)] = .toOne(ObjectID(locationID)) + products.append(data) + + var index = 0 + while index < records.count { + if records[index].id == locationID { + records[index].fuelProductIDs.append(ObjectID(productID)) + break + } + index += 1 + } + } + return products +} + +// MARK: - Fetch flow + +enum FetchResult { + case success + case wifiFailed + case requestFailed + case parseFailed + case storeFailed +} + +/// Connects (once), downloads locations + fuel prices, and populates the +/// shared CoreModel store. +func fetchAll() -> FetchResult { + guard connectWifi() else { return .wifiFailed } + guard let locationsBody = httpGet(path: "/v1/locations") else { return .requestFailed } + guard var (records, options) = parseLocations(locationsBody), !records.isEmpty else { + return .parseFailed + } + // Prices are optional -- show locations even if this request fails, + // like the other ports do. + var products = [ModelData]() + if let pricesBody = httpGet(path: "/v1/fuelprice") { + products = parseFuelPrices(pricesBody, into: &records) + } + do { + for option in options { + try store.insert(option) + } + for product in products { + try store.insert(product) + } + for var record in records { + // The in-memory store returns records verbatim (no inverse + // relationship maintenance), so the join list is set explicitly. + record.data.relationships[.init(Location.CodingKeys.fuelProducts)] = .toMany(record.fuelProductIDs) + try store.insert(record.data) + } + } catch { + return .storeFailed + } + return .success +} diff --git a/NDS/source/Renderer.swift b/NDS/source/Renderer.swift new file mode 100644 index 0000000..3e1f091 --- /dev/null +++ b/NDS/source/Renderer.swift @@ -0,0 +1,462 @@ +//--------------------------------------------------------------------------------- +// +// Renderer.swift -- software rasterizer for the Fueling DS screen. +// +// Copied from ClassicUI's ports/NDS renderer (branch feature/nds) and +// trimmed to what this app draws: status bar, search bar, menu rows with +// right-aligned detail values, selection gradient, chevrons, scrollbar, +// and the push/pop slide composite. Draws into a 16bpp ARGB1555 row-major +// canvas (bit 15 set = opaque, the DS's bitmap-background format); +// main.swift copies the canvas into the 16bpp bitmap background's VRAM +// on the *top* screen (the bottom screen hosts the keyboard). +// +// Deliberately imports nothing (no NDS): the same file compiles on the +// host for snapshot verification. +// +//--------------------------------------------------------------------------------- + +// MARK: - Canvas + +struct Canvas { + var pixels: UnsafeMutablePointer + var width: Int32 + var height: Int32 + + @inline(__always) + func set(_ x: Int32, _ y: Int32, _ color: UInt16) { + guard x >= 0, x < width, y >= 0, y < height else { return } + pixels[Int(y &* width &+ x)] = color + } +} + +// MARK: - Colors (ARGB1555: red in the low bits, bit 15 = opaque) + +@inline(__always) +func rgb15(_ r: Int32, _ g: Int32, _ b: Int32) -> UInt16 { + let r5 = UInt16((r &* 31) / 255) & 0x1F + let g5 = UInt16((g &* 31) / 255) & 0x1F + let b5 = UInt16((b &* 31) / 255) & 0x1F + return 0x8000 | (b5 << 10) | (g5 << 5) | r5 +} + +// Theme colors (ClassicUI's classic look). +let colorBackground = rgb15(255, 255, 255) +let colorText = rgb15(0, 0, 0) +let colorSelectedText = rgb15(255, 255, 255) +let colorSeparator = rgb15(115, 115, 120) +let colorDetailText = rgb15(115, 115, 120) +let colorScrollTrack = rgb15(217, 217, 222) +let colorScrollThumb = rgb15(115, 115, 128) +let colorBatteryGreen = rgb15(89, 199, 71) +let colorSearchField = rgb15(240, 240, 244) +let statusGradientTop: (Int32, Int32, Int32) = (250, 250, 250) +let statusGradientBottom: (Int32, Int32, Int32) = (191, 191, 196) +let selectionGradientTop: (Int32, Int32, Int32) = (107, 173, 242) +let selectionGradientBottom: (Int32, Int32, Int32) = (13, 89, 217) + +// Metrics (ClassicUI's Theme, adjusted for the DS's 256x192 panel). +let statusBarHeight: Int32 = 20 +let searchBarHeight: Int32 = 20 +let rowHeight: Int32 = 24 +let horizontalPadding: Int32 = 6 +let screenWidth: Int32 = 256 +let screenHeight: Int32 = 192 +/// Menu rows visible without a search bar. +let visibleRows: Int32 = (screenHeight - statusBarHeight) / rowHeight +/// Menu rows visible below the search bar. +let visibleRowsWithSearch: Int32 = (screenHeight - statusBarHeight - searchBarHeight) / rowHeight + +// MARK: - Primitives + +func fillRect(_ canvas: Canvas, x: Int32, y: Int32, width: Int32, height: Int32, color: UInt16) { + let x0 = max(0, x), y0 = max(0, y) + let x1 = min(canvas.width, x &+ width), y1 = min(canvas.height, y &+ height) + guard x0 < x1, y0 < y1 else { return } + var dy = y0 + while dy < y1 { + let row = canvas.pixels + Int(dy &* canvas.width) + var dx = x0 + while dx < x1 { + row[Int(dx)] = color + dx &+= 1 + } + dy &+= 1 + } +} + +/// Vertical gradient with the classic glossy top half (lightened toward +/// white), one band per scanline. +func fillVerticalGradient( + _ canvas: Canvas, x: Int32, y: Int32, width: Int32, height: Int32, + top: (Int32, Int32, Int32), bottom: (Int32, Int32, Int32), gloss: Bool +) { + guard height > 0 else { return } + var row: Int32 = 0 + while row < height { + let t = height > 1 ? (row &* 64) / (height &- 1) : 0 + var r = top.0 &+ ((bottom.0 &- top.0) &* t) / 64 + var g = top.1 &+ ((bottom.1 &- top.1) &* t) / 64 + var b = top.2 &+ ((bottom.2 &- top.2) &* t) / 64 + if gloss, row < height / 2 { + // ~20% toward white, like the desktop renderer's gloss overlay + r = r &+ (255 &- r) / 5 + g = g &+ (255 &- g) / 5 + b = b &+ (255 &- b) / 5 + } + fillRect(canvas, x: x, y: y &+ row, width: width, height: 1, color: rgb15(r, g, b)) + row &+= 1 + } +} + +// MARK: - Text (see tools/gen_font.py) + +/// Alpha-blends `color` over `dst` at 8-bit `coverage` (ARGB1555 channels). +@inline(__always) +func blend555(_ dst: UInt16, _ color: UInt16, _ coverage: Int32) -> UInt16 { + let dr = Int32(dst & 0x1F) + let dg = Int32((dst >> 5) & 0x1F) + let db = Int32((dst >> 10) & 0x1F) + let sr = Int32(color & 0x1F) + let sg = Int32((color >> 5) & 0x1F) + let sb = Int32((color >> 10) & 0x1F) + let r = dr &+ ((sr &- dr) &* coverage) / 255 + let g = dg &+ ((sg &- dg) &* coverage) / 255 + let b = db &+ ((sb &- db) &* coverage) / 255 + return 0x8000 | (UInt16(b) << 10) | (UInt16(g) << 5) | UInt16(r) +} + +/// Draws one run of ASCII bytes; returns the x after the last glyph. +@discardableResult +func drawBytes( + _ canvas: Canvas, _ bytes: UnsafePointer, _ count: Int32, + x: Int32, y: Int32, color: UInt16, maxX: Int32 = Int32.max +) -> Int32 { + var penX = x + var i: Int32 = 0 + while i < count { + var code = Int32(bytes[Int(i)]) + if code < fontFirstCode || code > fontLastCode { code = 63 } // '?' + let glyph = Int32(code - fontFirstCode) + let width = fontGlyphWidths[Int(glyph)] + if penX &+ width > maxX { break } + let glyphBase = glyph &* fontGlyphHeight &* fontGlyphRowStride + var gy: Int32 = 0 + while gy < fontGlyphHeight { + let destY = y &+ gy + let rowBase = Int(glyphBase &+ gy &* fontGlyphRowStride) + // draw one column past the advance so antialiased right edges + // (which spill into the next cell) aren't clipped off + let drawWidth = min(width &+ 1, fontGlyphRowStride) + var gx: Int32 = 0 + while gx < drawWidth { + let coverage = Int32(fontGlyphPixels[rowBase + Int(gx)]) + if coverage != 0 { + let destX = penX &+ gx + if destX >= 0, destX < canvas.width, destY >= 0, destY < canvas.height { + let index = Int(destY &* canvas.width &+ destX) + canvas.pixels[index] = + coverage >= 255 + ? color + : blend555(canvas.pixels[index], color, coverage) + } + } + gx &+= 1 + } + gy &+= 1 + } + penX &+= width + i &+= 1 + } + return penX +} + +@discardableResult +func drawText( + _ canvas: Canvas, _ text: StaticString, x: Int32, y: Int32, color: UInt16, + maxX: Int32 = Int32.max +) -> Int32 { + text.withUTF8Buffer { buffer in + guard let base = buffer.baseAddress else { return x } + return drawBytes(canvas, base, Int32(buffer.count), x: x, y: y, color: color, maxX: maxX) + } +} + +/// Draws a runtime byte-array string (the typed search text). +@discardableResult +func drawByteArray( + _ canvas: Canvas, _ bytes: [UInt8], x: Int32, y: Int32, color: UInt16, + maxX: Int32 = Int32.max +) -> Int32 { + bytes.withUnsafeBufferPointer { buffer in + guard let base = buffer.baseAddress, buffer.count > 0 else { return x } + return drawBytes(canvas, base, Int32(buffer.count), x: x, y: y, color: color, maxX: maxX) + } +} + +func measureBytes(_ bytes: UnsafePointer, _ count: Int32) -> Int32 { + var width: Int32 = 0 + var i: Int32 = 0 + while i < count { + var code = Int32(bytes[Int(i)]) + if code < fontFirstCode || code > fontLastCode { code = 63 } + width &+= fontGlyphWidths[Int(code - fontFirstCode)] + i &+= 1 + } + return width +} + +func measure(_ text: StaticString) -> Int32 { + text.withUTF8Buffer { buffer in + guard let base = buffer.baseAddress else { return 0 } + return measureBytes(base, Int32(buffer.count)) + } +} + +func measureByteArray(_ bytes: [UInt8]) -> Int32 { + bytes.withUnsafeBufferPointer { buffer in + guard let base = buffer.baseAddress, buffer.count > 0 else { return 0 } + return measureBytes(base, Int32(buffer.count)) + } +} + +/// Draws a decimal integer; returns the x after the last digit. +@discardableResult +func drawInt(_ canvas: Canvas, _ value: Int32, x: Int32, y: Int32, color: UInt16) -> Int32 { + var digits = [UInt8]() + var v = value + if v < 0 { + digits.append(45) // '-' + v = -v + } + var stack = [UInt8]() + repeat { + stack.append(UInt8(48 &+ v % 10)) + v /= 10 + } while v > 0 + while let d = stack.popLast() { + digits.append(d) + } + return digits.withUnsafeBufferPointer { buffer in + drawBytes(canvas, buffer.baseAddress!, Int32(buffer.count), x: x, y: y, color: color) + } +} + +/// "$D.CC" price bytes from a cents value (no Foundation formatters here). +func priceBytes(cents: Int32) -> [UInt8] { + var bytes = [UInt8]() + bytes.append(36) // '$' + var dollars = cents / 100 + let remainder = cents % 100 + var stack = [UInt8]() + repeat { + stack.append(UInt8(48 &+ dollars % 10)) + dollars /= 10 + } while dollars > 0 + while let d = stack.popLast() { bytes.append(d) } + bytes.append(46) // '.' + bytes.append(UInt8(48 &+ remainder / 10)) + bytes.append(UInt8(48 &+ remainder % 10)) + return bytes +} + +/// Text y that vertically centers the glyph box in a row of `height` at `y`. +@inline(__always) +func textTop(rowY: Int32, rowHeight height: Int32) -> Int32 { + rowY &+ (height &- fontGlyphHeight) / 2 &+ 1 +} + +// MARK: - Chrome + +func drawStatusBar(_ canvas: Canvas, title: [UInt8]) { + fillVerticalGradient( + canvas, x: 0, y: 0, width: canvas.width, height: statusBarHeight, + top: statusGradientTop, bottom: statusGradientBottom, gloss: true) + fillRect(canvas, x: 0, y: statusBarHeight - 1, width: canvas.width, height: 1, color: colorSeparator) + + let titleWidth = measureByteArray(title) + drawByteArray( + canvas, title, x: (canvas.width - titleWidth) / 2, + y: textTop(rowY: 0, rowHeight: statusBarHeight), color: colorText) + + // battery + let batteryRight = canvas.width - 4 + fillRect(canvas, x: batteryRight - 17, y: 5, width: 17, height: 9, color: colorSeparator) + fillRect(canvas, x: batteryRight - 19, y: 8, width: 2, height: 4, color: colorSeparator) + fillRect(canvas, x: batteryRight - 16, y: 6, width: 15, height: 7, color: colorBatteryGreen) +} + +/// The search field row under the status bar: typed text, or a gray +/// placeholder when empty, with a caret. Fed by the bottom-screen keyboard. +func drawSearchBar(_ canvas: Canvas, query: [UInt8]) { + let y = statusBarHeight + fillRect(canvas, x: 0, y: y, width: canvas.width, height: searchBarHeight, color: colorSearchField) + fillRect(canvas, x: 0, y: y &+ searchBarHeight - 1, width: canvas.width, height: 1, color: colorSeparator) + + let textY = textTop(rowY: y, rowHeight: searchBarHeight) + var caretX: Int32 + if query.isEmpty { + drawText(canvas, "Search", x: horizontalPadding, y: textY, color: colorDetailText) + caretX = horizontalPadding + } else { + caretX = drawByteArray( + canvas, query, x: horizontalPadding, y: textY, color: colorText, + maxX: canvas.width - horizontalPadding) + caretX &+= 1 + } + // caret + fillRect(canvas, x: caretX, y: y &+ 3, width: 1, height: searchBarHeight - 6, color: colorSeparator) +} + +func drawChevron(_ canvas: Canvas, right: Int32, centerY: Int32, color: UInt16) { + var i: Int32 = 0 + while i < 5 { + canvas.set(right - 5 &+ i, centerY - 4 &+ i, color) + canvas.set(right - 6 &+ i, centerY - 4 &+ i, color) + canvas.set(right - 5 &+ i, centerY + 4 &- i, color) + canvas.set(right - 6 &+ i, centerY + 4 &- i, color) + i &+= 1 + } +} + +func drawScrollBar( + _ canvas: Canvas, top: Int32, rowCount: Int32, visibleCount: Int32, scrollOffset: Int32 +) { + let x = canvas.width - 8 + let trackY = top + let trackHeight = canvas.height - top + fillRect(canvas, x: x, y: trackY, width: 8, height: trackHeight, color: colorScrollTrack) + fillRect(canvas, x: x, y: trackY, width: 1, height: trackHeight, color: colorSeparator) + guard rowCount > visibleCount else { return } + let usable = trackHeight - 4 + var thumbHeight = (usable &* visibleCount) / rowCount + if thumbHeight < 8 { thumbHeight = 8 } + let maxOffset = rowCount - visibleCount + let thumbY = trackY &+ 2 &+ ((usable &- thumbHeight) &* scrollOffset) / maxOffset + fillRect(canvas, x: x + 2, y: thumbY, width: 4, height: thumbHeight, color: colorScrollThumb) +} + +// MARK: - Screen rendering + +func renderScreen(_ canvas: Canvas, screen: Screen, query: [UInt8]) { + fillRect(canvas, x: 0, y: 0, width: canvas.width, height: canvas.height, color: colorBackground) + drawStatusBar(canvas, title: screen.title) + if screen.showsSearch { + drawSearchBar(canvas, query: query) + } + if case .menu(let items) = screen.content { + renderMenu(canvas, screen: screen, items: items) + } +} + +private func renderMenu(_ canvas: Canvas, screen: Screen, items: [MenuItem]) { + let listTop = statusBarHeight &+ (screen.showsSearch ? searchBarHeight : 0) + let visible = screen.showsSearch ? visibleRowsWithSearch : visibleRows + let count = Int32(items.count) + let showsScrollBar = count > visible + let rowWidth = showsScrollBar ? canvas.width - 8 : canvas.width + + if count == 0, screen.showsSearch { + drawText( + canvas, "No locations found", x: horizontalPadding, + y: textTop(rowY: listTop &+ 4, rowHeight: rowHeight), color: colorDetailText) + return + } + + var slot: Int32 = 0 + while slot < visible, screen.scrollOffset &+ slot < count { + let index = screen.scrollOffset &+ slot + let item = items[Int(index)] + let y = listTop &+ slot &* rowHeight + let selected = index == screen.selection + + if selected { + fillVerticalGradient( + canvas, x: 0, y: y, width: rowWidth, height: rowHeight, + top: selectionGradientTop, bottom: selectionGradientBottom, gloss: true) + } + + let textColor = selected ? colorSelectedText : colorText + let textY = textTop(rowY: y, rowHeight: rowHeight) + var maxX = rowWidth - horizontalPadding + if item.isNavigation { maxX -= 14 } + + if let detail = item.detail { + let value = detail() + let valueWidth = value.withUnsafeBufferPointer { buffer -> Int32 in + guard let base = buffer.baseAddress else { return 0 } + return measureBytes(base, Int32(buffer.count)) + } + drawByteArray( + canvas, value, x: maxX - valueWidth, y: textY, + color: selected ? colorSelectedText : colorDetailText) + maxX -= valueWidth &+ 8 + } + + drawByteArray(canvas, item.title, x: horizontalPadding, y: textY, color: textColor, maxX: maxX) + + if item.isNavigation { + drawChevron(canvas, right: rowWidth - horizontalPadding, centerY: y &+ rowHeight / 2, color: textColor) + } + slot &+= 1 + } + + if showsScrollBar { + drawScrollBar( + canvas, top: listTop, rowCount: count, visibleCount: visible, + scrollOffset: screen.scrollOffset) + } +} + +// MARK: - Navigation slide (ClassicUI's push/pop composite) + +/// Composites `outgoing` and `incoming` side by side at eased progress +/// `p64` (0...64) into `present`. Push slides in from the right, pop from +/// the left; the status bar stays pinned to the incoming screen. +func compositeSlide( + present: UnsafeMutablePointer, + outgoing: UnsafePointer, + incoming: UnsafePointer, + width: Int32, height: Int32, p64: Int32, push: Bool +) { + // ease-out: 64 - (64-p)^2/64 + let inverted = 64 - p64 + let eased = 64 - (inverted &* inverted) / 64 + var offset = (width &* eased) / 64 + if offset < 0 { offset = 0 } + if offset > width { offset = width } + + var y: Int32 = 0 + while y < height { + let rowStart = Int(y &* width) + if y < statusBarHeight { + // pinned status bar, always the incoming screen's + var x = 0 + while x < Int(width) { + present[rowStart + x] = incoming[rowStart + x] + x += 1 + } + } else if push { + // outgoing slides left, incoming enters from the right + var x: Int32 = 0 + while x < width { + let source = x &+ offset + present[rowStart + Int(x)] = + source < width + ? outgoing[rowStart + Int(source)] + : incoming[rowStart + Int(source &- width)] + x &+= 1 + } + } else { + // incoming enters from the left, outgoing slides right + var x: Int32 = 0 + while x < width { + present[rowStart + Int(x)] = + x < offset + ? incoming[rowStart + Int(width &- offset &+ x)] + : outgoing[rowStart + Int(x &- offset)] + x &+= 1 + } + } + y &+= 1 + } +} diff --git a/NDS/source/Store.swift b/NDS/source/Store.swift new file mode 100644 index 0000000..ad0f82e --- /dev/null +++ b/NDS/source/Store.swift @@ -0,0 +1,107 @@ +//--------------------------------------------------------------------------------- +// +// Store.swift -- the DS port's CoreModel store. +// +// The same stack the other front ends use, running as Embedded Swift on the +// ARM9: `CoreModel.InMemoryStorage` (the synchronous in-memory store, since +// bare-metal armv5te has no `_Concurrency` runtime for the actor-based +// `InMemoryModelStorage`) validated against the shared `Model.fueling` +// schema, holding real `CoreFueling.Location`/`FuelProduct` entities that +// Network.swift populates from the server. Search runs through the real +// `Location.Query.search` predicate and CoreModel's pure Swift +// `FetchRequest` evaluation engine -- not a hand-rolled filter. +// +//--------------------------------------------------------------------------------- + +import CoreModel +import CoreFueling + +/// The in-memory store, shared by the fetch flow and the UI. +let store = InMemoryStorage(model: .fueling) + +// MARK: - Queries + +/// Locations matching the search text, sorted by name -- the same +/// name/city/directions/address/zip/state predicate the other ports use. +func searchLocations(_ query: [UInt8]) -> [Location] { + let text = String(decoding: query, as: UTF8.self) + let request = FetchRequest( + entity: Location.entityName, + predicate: Location.Query.search(text)?.predicate + ) + guard let data = try? store.fetch(request) else { return [] } + var result = [Location]() + result.reserveCapacity(data.count) + for item in data { + if let location = try? Location(from: item) { + result.append(location) + } + } + result.sort { utf8Less($0.name, $1.name) } + return result +} + +/// The fuel products linked from a location, sorted by product description. +func fuelProducts(for location: Location) -> [FuelProduct] { + var result = [FuelProduct]() + for id in location.fuelProducts { + guard + let data = try? store.fetch(FuelProduct.entityName, for: ObjectID(id)), + let product = try? FuelProduct(from: data) + else { continue } + result.append(product) + } + result.sort { utf8Less($0.descriptionText, $1.descriptionText) } + return result +} + +// MARK: - Byte helpers + +/// String contents as renderer bytes. +func utf8Bytes(_ text: String) -> [UInt8] { + Array(text.utf8) +} + +/// StaticString contents as renderer bytes (fixed labels in menu rows). +func staticBytes(_ text: StaticString) -> [UInt8] { + text.withUTF8Buffer { buffer -> [UInt8] in + var bytes = [UInt8]() + bytes.reserveCapacity(buffer.count) + for byte in buffer { + bytes.append(byte) + } + return bytes + } +} + +/// Byte-wise lexicographic order (avoids the stdlib's Unicode collation). +func utf8Less(_ a: String, _ b: String) -> Bool { + var i = a.utf8.makeIterator() + var j = b.utf8.makeIterator() + while true { + switch (i.next(), j.next()) { + case (nil, nil): return false + case (nil, _): return true + case (_, nil): return false + case (let x?, let y?): + if x != y { return x < y } + } + } +} + +/// Decimal integer as bytes (parking-space counts). +func intBytes(_ value: Int32) -> [UInt8] { + var bytes = [UInt8]() + var v = value + if v < 0 { + bytes.append(45) // '-' + v = -v + } + var stack = [UInt8]() + repeat { + stack.append(UInt8(48 &+ v % 10)) + v /= 10 + } while v > 0 + while let d = stack.popLast() { bytes.append(d) } + return bytes +} diff --git a/NDS/source/main.swift b/NDS/source/main.swift new file mode 100644 index 0000000..33a57fa --- /dev/null +++ b/NDS/source/main.swift @@ -0,0 +1,350 @@ +//--------------------------------------------------------------------------------- +// +// Fueling for Nintendo DS -- Embedded Swift ARM9 binary. +// +// Bootstrap copied from ClassicUI's ports/NDS main.swift (branch +// feature/nds), with the screens swapped: the *top* screen hosts the +// Fueling UI through the software rasterizer in Renderer.swift, drawn into +// a double-buffered 16bpp bitmap background on the main engine +// (map-base flip); the *bottom* (touch) screen hosts libnds' on-screen +// keyboard on the sub engine, which types into the locations search field. +// +// On boot the app associates with the configured access point over dswifi +// (melonDS: the emulated "melonAP") and downloads the locations and fuel +// prices from the test server -- see Network.swift. Nothing is hardcoded; +// a failure shows an error screen with a Retry row. +// +// Touch keyboard type to filter the locations list +// D-pad up/down move the selection +// A open the selected location / retry +// B back, with the slide animation +// START exit +// +//--------------------------------------------------------------------------------- + +import NDS +import CoreModel +import CoreFueling + +// MARK: - Video setup + +// Main engine: 16bpp bitmap UI on the TOP screen. +// Sub engine: tiled background for the keyboard on the bottom (touch) screen. +videoSetMode(MODE_5_2D.rawValue) +videoSetModeSub(MODE_0_2D.rawValue) +lcdMainOnTop() +vramSetPrimaryBanks( + VRAM_A_MAIN_BG_0x06000000, VRAM_B_MAIN_BG_0x06020000, + VRAM_C_SUB_BG, VRAM_D_LCD) + +let bg = bgInit(3, BgType_Bmp16, BgSize_B16_256x256, 0, 0) +/// The buffer currently being drawn into (the one NOT displayed). +var backBuffer = bgGetGfxPtr(bg)! + 256 * 256 + +func flipBuffers() { + backBuffer = bgGetGfxPtr(bg)! + // Each map base is 16KB; a 256x256x16bpp screen is 128KB = 8 bases. + bgSetMapBase(bg, bgGetMapBase(bg) == 8 ? 0 : 8) +} + +let canvasPixels = Int(screenWidth * screenHeight) +let renderCanvas = Canvas( + pixels: UnsafeMutablePointer.allocate(capacity: canvasPixels), + width: screenWidth, height: screenHeight) +let outgoingBuffer = UnsafeMutablePointer.allocate(capacity: canvasPixels) +let presentBuffer = UnsafeMutablePointer.allocate(capacity: canvasPixels) + +/// Copies the composed frame into the (256-pixel-pitch) back buffer; +/// the canvas is 256 wide, so rows are contiguous halfword copies. +func uploadFrame() { + var index = 0 + while index < canvasPixels { + backBuffer[index] = presentBuffer[index] + index += 1 + } +} + +// MARK: - Bottom-screen keyboard + +keyboardDemoInit() +keyboardShow() + +// MARK: - Search state + +/// The typed search filter (ASCII bytes), rendered in the search bar and +/// matched against name/city/address/zip/state like the other platforms. +var query = [UInt8]() +let maxQueryLength = 28 + +// MARK: - Screens + +func makeDetail(_ location: Location) -> Screen { + var items = [MenuItem]() + items.append(MenuItem(utf8Bytes(location.address))) + items.append(MenuItem("City", detail: { utf8Bytes(location.city) })) + items.append(MenuItem("State", detail: { utf8Bytes(location.state) })) + items.append(MenuItem("ZIP Code", detail: { utf8Bytes(location.zipCode) })) + items.append(MenuItem("Phone", detail: { utf8Bytes(location.phone) })) + items.append(MenuItem("Truck Parking", detail: { intBytes(Int32(location.truckParkingSpaces)) })) + for product in fuelProducts(for: location) { + let cents = Int32(product.price * 100 + 0.5) + items.append( + MenuItem( + utf8Bytes(product.descriptionText), + detail: { priceBytes(cents: cents) })) + } + return Screen(title: utf8Bytes(location.name), content: .menu(items)) +} + +/// The locations list, filtered by the current query through the real +/// CoreModel predicate engine (see Store.swift). +func buildLocationItems() -> [MenuItem] { + var items = [MenuItem]() + for location in searchLocations(query) { + items.append( + MenuItem( + utf8Bytes(location.name), + action: .push { makeDetail(location) }, + detail: { utf8Bytes(location.city) })) + } + return items +} + +let rootScreen = Screen( + title: "Fueling", + content: .menu([MenuItem("Starting up...")]), + showsSearch: false) + +let navigator = Navigator(root: rootScreen) + +/// Requested by the error screen's Retry row; serviced in the main loop. +var requestFetch = true + +/// Swaps the root screen to the fetched list (search enabled) or an error +/// screen with a Retry row. +func applyFetchResult(_ result: FetchResult) { + var items = [MenuItem]() + switch result { + case .success: + rootScreen.showsSearch = true + query.removeAll() + items = buildLocationItems() + case .wifiFailed: + rootScreen.showsSearch = false + items.append(MenuItem("Wifi connection failed")) + items.append(MenuItem("Check the emulator's")) + items.append(MenuItem("internet settings")) + items.append(MenuItem("Retry", action: .run { requestFetch = true })) + case .requestFailed: + rootScreen.showsSearch = false + items.append(MenuItem("Server unreachable")) + items.append(MenuItem("Server", detail: { staticBytes(serverHostHeader) })) + items.append(MenuItem("Failed step", detail: { intBytes(lastHTTPStep) })) + items.append(MenuItem("errno", detail: { intBytes(lastErrno) })) + items.append(MenuItem("HTTP status", detail: { intBytes(lastHTTPStatus) })) + items.append(MenuItem("Retry", action: .run { requestFetch = true })) + case .parseFailed: + rootScreen.showsSearch = false + items.append(MenuItem("Unexpected response")) + items.append(MenuItem("Retry", action: .run { requestFetch = true })) + case .storeFailed: + rootScreen.showsSearch = false + items.append(MenuItem("Store rejected data")) + items.append(MenuItem("Retry", action: .run { requestFetch = true })) + } + rootScreen.content = .menu(items) + rootScreen.selection = 0 + rootScreen.scrollOffset = 0 + frameDirty = true +} + +/// Re-filters the list after a keystroke, keeping the selection in range. +func queryDidChange() { + let items = buildLocationItems() + rootScreen.content = .menu(items) + let count = Int32(items.count) + if rootScreen.selection >= count { + rootScreen.selection = count > 0 ? count - 1 : 0 + } + rootScreen.scrollOffset = Navigator.scrollOffset( + selection: rootScreen.selection, + current: rootScreen.scrollOffset, + rowCount: count, + visibleRows: rootScreen.visibleRowCount) + frameDirty = true +} + +// MARK: - Navigation slide state + +var slideProgress: Int32 = -1 // -1 = idle, else 0...64 +var slidePush = true +var frameDirty = true + +func beginSlide(push: Bool) { + // capture what is currently on screen as the outgoing frame + var i = 0 + while i < canvasPixels { + outgoingBuffer[i] = presentBuffer[i] + i += 1 + } + slidePush = push + slideProgress = 0 + frameDirty = true +} + +// MARK: - Presenting + +/// Draws the top screen synchronously (startup and the blocking fetch). +func presentImmediately() { + renderScreen(renderCanvas, screen: navigator.top, query: query) + var i = 0 + while i < canvasPixels { + presentBuffer[i] = renderCanvas.pixels[i] + i += 1 + } + uploadFrame() + flipBuffers() +} + +/// Blocking connect + download, with progress shown on the top screen. +func runFetch() { + rootScreen.showsSearch = false + rootScreen.content = .menu([ + MenuItem(wifiConnected ? "Downloading locations..." : "Connecting to wifi..."), + MenuItem("Server", detail: { staticBytes(serverHostHeader) }), + ]) + rootScreen.selection = 0 + rootScreen.scrollOffset = 0 + presentImmediately() + applyFetchResult(fetchAll()) +} + +// MARK: - Input helpers + +func scroll(_ delta: Int32) { + let screen = navigator.top + guard case .menu(let items) = screen.content else { return } + navigator.moveSelection( + by: delta, rowCount: Int32(items.count), visibleRows: screen.visibleRowCount) + frameDirty = true +} + +func select() { + guard case .menu(let items) = navigator.top.content else { return } + let index = Int(navigator.top.selection) + guard index >= 0, index < items.count else { return } + switch items[index].action { + case .none: + break + case .run(let action): + action() + frameDirty = true + case .push(let makeScreen): + beginSlide(push: true) + navigator.push(makeScreen()) + } +} + +func back() { + if navigator.pop() { + beginSlide(push: false) + } +} + +/// A key from the bottom-screen keyboard: printable ASCII appends to the +/// query, backspace deletes; only the search screen listens. +func handleKeyboard(_ key: Int32) { + guard navigator.top.showsSearch else { return } + if key == 8 { // backspace + if !query.isEmpty { + query.removeLast() + queryDidChange() + } + } else if key >= 32, key <= 126, query.count < maxQueryLength { + query.append(UInt8(key)) + queryDidChange() + } +} + +// MARK: - Main loop + +// key repeat for scrolling: 360ms delay, 60ms interval (in frames) +var heldFrames: Int32 = 0 + + +// first frame +presentImmediately() + +/// Flip on the vblank after the upload, never mid-frame. +var pendingFlip = false + +while pmMainLoop() { + threadWaitForVBlank() + if pendingFlip { + flipBuffers() + pendingFlip = false + } + + // startup fetch / Retry row (blocks the loop while downloading) + if requestFetch { + requestFetch = false + runFetch() + } + + scanKeys() + let pressed = keysDown() + let held = keysHeld() + + if pressed & KEY_START != 0 { break } + + // selection: pressed edges plus a simple hold-to-repeat + var delta: Int32 = 0 + if pressed & KEY_UP != 0 { delta = -1 } + if pressed & KEY_DOWN != 0 { delta = 1 } + if held & (KEY_UP | KEY_DOWN) != 0 { + heldFrames &+= 1 + if heldFrames > 21, heldFrames % 4 == 0 { + delta = held & KEY_UP != 0 ? -1 : 1 + } + } else { + heldFrames = 0 + } + if delta != 0 { scroll(delta) } + + if pressed & KEY_A != 0 { select() } + if pressed & KEY_B != 0 { back() } + + // bottom-screen keyboard (touch handled inside keyboardUpdate) + let key = keyboardUpdate() + if key > 0 { handleKeyboard(key) } + + // navigation slide + if slideProgress >= 0 { + slideProgress &+= 5 + frameDirty = true + if slideProgress >= 64 { + slideProgress = -1 + } + } + + if frameDirty { + frameDirty = false + renderScreen(renderCanvas, screen: navigator.top, query: query) + if slideProgress >= 0 { + compositeSlide( + present: presentBuffer, + outgoing: outgoingBuffer, + incoming: renderCanvas.pixels, + width: screenWidth, height: screenHeight, + p64: slideProgress, push: slidePush) + } else { + var pixel = 0 + while pixel < canvasPixels { + presentBuffer[pixel] = renderCanvas.pixels[pixel] + pixel += 1 + } + } + uploadFrame() + pendingFlip = true + } +} diff --git a/NDS/tools/gen_font.py b/NDS/tools/gen_font.py new file mode 100644 index 0000000..0ebb26c --- /dev/null +++ b/NDS/tools/gen_font.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""gen_font.py -- rasterize the iPod UI font for the 3DS port. + +Renders ASCII 32..126 of Helvetica at 13 px (the same face and size the +desktop Silica renderer resolves) into an antialiased 8-bit-coverage +variable-width glyph table (16 columns per row per glyph), emitted as +Swift arrays; the renderer alpha-blends each pixel's coverage against the +destination. Requires Pillow and a macOS system Helvetica; pass a .ttf +path as argv[2] to use another face. + +Usage: gen_font.py [font-path] +""" + +import sys + +from PIL import Image, ImageDraw, ImageFont + +FIRST, LAST = 32, 126 +SIZE = 13 + +CANDIDATES = [ + "/System/Library/Fonts/Helvetica.ttc", + "/System/Library/Fonts/HelveticaNeue.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", +] + + +def load_font(path=None): + for candidate in ([path] if path else []) + CANDIDATES: + try: + return ImageFont.truetype(candidate, SIZE) + except (OSError, TypeError): + continue + raise SystemExit("gen_font.py: no usable font found") + + +def main(): + build_dir = sys.argv[1] + font = load_font(sys.argv[2] if len(sys.argv) > 2 else None) + ascent, descent = font.getmetrics() + height = ascent + descent + + widths = [] + pixels = [] + for code in range(FIRST, LAST + 1): + ch = chr(code) + width = max(1, round(font.getlength(ch))) + if width > 16: + raise SystemExit(f"gen_font.py: glyph {code} wider than 16 px") + img = Image.new("L", (18, height), 0) + ImageDraw.Draw(img).text((0, 0), ch, font=font, fill=255) + for y in range(height): + for x in range(16): + pixels.append(img.getpixel((x, y))) + widths.append(width) + + with open(f"{build_dir}/FontAssets.swift", "w") as out: + out.write("// Generated by tools/gen_font.py -- do not edit.\n") + out.write(f"let fontFirstCode: Int32 = {FIRST}\n") + out.write(f"let fontLastCode: Int32 = {LAST}\n") + out.write(f"let fontAscent: Int32 = {ascent}\n") + out.write(f"let fontGlyphHeight: Int32 = {height}\n") + out.write("let fontGlyphRowStride: Int32 = 16\n") + out.write("let fontGlyphWidths: [Int32] = [\n") + for i in range(0, len(widths), 16): + out.write(" " + ", ".join(str(w) for w in widths[i:i + 16]) + ",\n") + out.write("]\n") + out.write("// 8-bit antialiased coverage, fontGlyphRowStride columns per\n") + out.write("// row, fontGlyphHeight rows per glyph, glyphs in ASCII order.\n") + out.write("let fontGlyphPixels: [UInt8] = [\n") + for i in range(0, len(pixels), 24): + out.write(" " + ",".join(str(p) for p in pixels[i:i + 24]) + ",\n") + out.write("]\n") + print(f"gen_font.py: {LAST - FIRST + 1} glyphs, {height} px tall") + + +if __name__ == "__main__": + main() diff --git a/NDS/tools/gen_server.py b/NDS/tools/gen_server.py new file mode 100644 index 0000000..74c6ce4 --- /dev/null +++ b/NDS/tools/gen_server.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""gen_server.py -- bake the injected server URL into the DS build. + +Reads FUELING_SERVER_URL (the same environment variable the playground app +and the Android port use), defaulting to the local test server through the +emulator NAT (http://10.0.2.2:8080), and emits /ServerConfig.swift +with the host, port, and Host-header constants Network.swift compiles +against. The host may be a hostname (resolved on-device via DNS) or a +dotted-quad IP. + +No URL is ever committed: the default points at the local test server, and +anything else exists only in the generated file under build/ (gitignored). + +The Nintendo DS has no TLS stack, so https URLs are rejected outright -- +point FUELING_SERVER_URL at a plain-http endpoint or a local proxy. + +Usage: gen_server.py +""" + +import os +import sys + + +def main() -> None: + build_dir = sys.argv[1] if len(sys.argv) > 1 else "build" + url = os.environ.get("FUELING_SERVER_URL", "").strip() or "http://10.0.2.2:8080" + + if url.startswith("https://"): + sys.exit( + "FUELING_SERVER_URL: https is not supported on the Nintendo DS " + "(no TLS stack); use a plain-http endpoint or a local proxy" + ) + if url.startswith("http://"): + url = url[len("http://") :] + url = url.rstrip("/") + + if "/" in url: + sys.exit("FUELING_SERVER_URL: paths are not supported, use scheme://host[:port]") + + host, _, port_text = url.partition(":") + if not host: + sys.exit("FUELING_SERVER_URL: missing host") + port = int(port_text) if port_text else 80 + if not 0 < port < 65536: + sys.exit("FUELING_SERVER_URL: invalid port") + + host_header = host if port == 80 else f"{host}:{port}" + contents = f"""// +// ServerConfig.swift -- generated by tools/gen_server.py, do not edit. +// +// Injected from FUELING_SERVER_URL at build time (default: the local test +// server through the emulator NAT). +// + +let serverHost: StaticString = "{host}" +let serverPort: UInt16 = {port} +let serverHostHeader: StaticString = "{host_header}" +""" + + path = os.path.join(build_dir, "ServerConfig.swift") + os.makedirs(build_dir, exist_ok=True) + # Only rewrite on change so make doesn't rebuild unnecessarily. + try: + with open(path) as existing: + if existing.read() == contents: + return + except FileNotFoundError: + pass + with open(path, "w") as out: + out.write(contents) + print(f"server: http://{host_header}") + + +if __name__ == "__main__": + main() diff --git a/Scripts/test-server.py b/Scripts/test-server.py index 80cb45f..820e967 100755 --- a/Scripts/test-server.py +++ b/Scripts/test-server.py @@ -85,6 +85,12 @@ def log_message(self, format: str, *args) -> None: # noqa: A002 (matches base s def main() -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"port to listen on (default: {DEFAULT_PORT})") + parser.add_argument( + "--bind", + default="127.0.0.1", + help="interface to listen on (default loopback; use 0.0.0.0 to serve " + "emulators that reach the host over NAT, e.g. the DS port)", + ) parser.add_argument( "--data", type=Path, @@ -98,9 +104,9 @@ def main() -> None: raise SystemExit(1) Handler.data_path = args.data - server = HTTPServer(("127.0.0.1", args.port), Handler) + server = HTTPServer((args.bind, args.port), Handler) print(f"Fueling test server serving fake data from {args.data}") - print(f"Listening on http://127.0.0.1:{args.port} (GET /v1/locations, GET /v1/fuelprice)") + print(f"Listening on http://{args.bind}:{args.port} (GET /v1/locations, GET /v1/fuelprice)") print("Press Ctrl-C to stop.") try: server.serve_forever() diff --git a/Sources/CoreFueling/LocationCoordinate.swift b/Sources/CoreFueling/LocationCoordinate.swift index ba5b30e..7ed3119 100644 --- a/Sources/CoreFueling/LocationCoordinate.swift +++ b/Sources/CoreFueling/LocationCoordinate.swift @@ -22,6 +22,13 @@ import WASILibc import Glibc #elseif canImport(ucrt) import ucrt +#elseif hasFeature(Embedded) +// Bare-metal Embedded targets (e.g. the Nintendo DS port) have no importable +// libc module at all; bind the libm symbols directly — the platform C library +// (newlib on devkitARM, linked via `-lm`) provides them at link time. +@_silgen_name("sin") private func sin(_ x: Double) -> Double +@_silgen_name("cos") private func cos(_ x: Double) -> Double +@_silgen_name("atan2") private func atan2(_ y: Double, _ x: Double) -> Double #endif /// Geographic coordinate (latitude / longitude in degrees). diff --git a/Sources/CoreFueling/Model.swift b/Sources/CoreFueling/Model.swift index d459b92..6b3304a 100644 --- a/Sources/CoreFueling/Model.swift +++ b/Sources/CoreFueling/Model.swift @@ -5,13 +5,6 @@ import CoreModel -// Not available under Embedded Swift: `EntityDescription(entity:)` and the -// `Relationship(id:entity:destination:type:inverseRelationship:)` convenience -// initializer it relies on both require `T: CoreModel.Entity`, and the -// Embedded-only declarations of `Location`/`FuelProduct`/`FuelOption` (see the -// note atop Location.swift) don't conform — nothing under Embedded consumes a -// `Model` schema yet, since CoreModel's generic `Entity`-based `ModelStorage` -// helpers are themselves unavailable there. #if !hasFeature(Embedded) public extension Model { @@ -30,4 +23,37 @@ public extension Model { ) } } +#else +public extension Model { + + /// CoreModel schema for the fueling domain. + /// + /// The Embedded declarations of `Location`/`FuelProduct`/`FuelOption` don't + /// conform to `Entity` (see the note atop Location.swift), so + /// `EntityDescription(entity:)` is unavailable — the descriptions are built + /// directly from each entity's own attribute/relationship tables, which the + /// Embedded branches expose as plain dictionaries. Consumed by + /// `CoreModel.InMemoryStorage` (e.g. the Nintendo DS port's store). + static var fueling: Model { + Model( + entities: [ + EntityDescription( + id: Location.entityName, + attributes: Location.attributes.map { Attribute(id: PropertyKey($0.key), type: $0.value) }, + relationships: Array(Location.relationships.values) + ), + EntityDescription( + id: FuelProduct.entityName, + attributes: FuelProduct.attributes.map { Attribute(id: PropertyKey($0.key), type: $0.value) }, + relationships: Array(FuelProduct.relationships.values) + ), + EntityDescription( + id: FuelOption.entityName, + attributes: FuelOption.attributes.map { Attribute(id: PropertyKey($0.key), type: $0.value) }, + relationships: Array(FuelOption.relationships.values) + ) + ] + ) + } +} #endif diff --git a/Web/.gitignore b/Web/.gitignore index ebdee3c..431d746 100644 --- a/Web/.gitignore +++ b/Web/.gitignore @@ -5,3 +5,6 @@ node_modules/ dist/ *.log + +# Vite dependency-optimizer cache +.vite/