From da8bb414770354f4ce7174fe49d5c267fca21e56 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Sun, 26 Jul 2026 19:35:50 -0500 Subject: [PATCH] New save interface. --- README.md | 34 ++++++- examples/save/save.c | 170 ++++++++++++++++++++++++++++++++++ include/joey/file.h | 67 +++++++++++--- include/joey/platform.h | 17 ++++ make/amiga.mk | 8 +- make/atarist.mk | 8 +- make/dos.mk | 9 +- make/iigs.mk | 18 +++- scripts/check-save.sh | 25 +++++ scripts/make-iigs-disk.sh | 9 ++ scripts/verify-iigs-all.sh | 39 ++++++++ scripts/verify-iigs-save.sh | 119 ++++++++++++++++++++++++ scripts/verify-iigs.sh | 16 +++- src/amiga/save.c | 67 ++++++++++++++ src/atarist/save.c | 50 ++++++++++ src/core/port.h | 29 ++++++ src/core/save.c | 124 +++++++++++++++++++++++++ src/dos/save.c | 53 +++++++++++ src/generic/genericSave.c | 27 ++++++ src/iigs/save.c | 177 ++++++++++++++++++++++++++++++++++++ tests/host/saveHost.c | 137 ++++++++++++++++++++++++++++ 21 files changed, 1183 insertions(+), 20 deletions(-) create mode 100644 examples/save/save.c create mode 100755 scripts/check-save.sh create mode 100755 scripts/verify-iigs-all.sh create mode 100755 scripts/verify-iigs-save.sh create mode 100644 src/amiga/save.c create mode 100644 src/atarist/save.c create mode 100644 src/core/save.c create mode 100644 src/dos/save.c create mode 100644 src/generic/genericSave.c create mode 100644 src/iigs/save.c create mode 100644 tests/host/saveHost.c diff --git a/README.md b/README.md index 180bc62..a43970f 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ make ``` This builds `libjoey.a` for every target whose toolchain is installed, -plus the example programs (`hello`, `pattern`, `keys`, `serial`, `joy`, -`sprite`, `audio`) for each. +plus the example programs (`hello`, `pattern`, `keys`, `serial`, `save`, +`joy`, `sprite`, `audio`) for each. ## Building for a single target @@ -432,6 +432,36 @@ layer against a loopback mock with no hardware. Full reference, per-port behavior, and porting notes: [`docs/serial.md`](docs/serial.md). +### Files, saves & disk space (`joey/file.h`) -- opt-in + +File access, not pulled in by `` -- include ``. Two +directories sit beside the executable: read-only `DATA/` (game content) and +writable `SAVES/` (save / preference files, created on demand). Names are +relative to their directory (`"hero.sav"`, not `"SAVES/hero.sav"`). Keep save +names to 8 characters plus a short extension, letters/digits only, so they fit +ProDOS and 8.3. Every call is non-fatal: a failed save reports `false`/`0`. + +```c +FILE * jlDataOpen (const char *name, const char *mode); // DATA/ (read-only) + +FILE * jlSaveOpen (const char *name, const char *mode); // SAVES/ stream +bool jlSaveWrite (const char *name, const void *buf, uint32_t len); +uint32_t jlSaveRead (const char *name, void *buf, uint32_t max); +bool jlSaveExists(const char *name); +bool jlSaveDelete(const char *name); + +uint32_t jlDiskFree (void); // free bytes on the save volume (0 if unknown) +``` + +Directory create / delete / free-space use a per-port HAL (POSIX `mkdir` + +`remove` + INT 21h on DOS; `mkdir` + GEMDOS `Dfree` on ST; dos.library +`CreateDir`/`DeleteFile`/`Info` on Amiga; GS/OS `Create` + a +`GetDevNumber`/`DInfo`/`Volume` chain on the IIgs). `scripts/check-save.sh` +round-trips the core against real files; `make iigs-verify-save` boots the +IIgs save path under MAME. Full reference, per-port behavior, and porting +notes: [`docs/save.md`](docs/save.md). + + ### Debug logging (`joey/debug.h`) Crash-tracing logger. Writes are buffered and durable across normal diff --git a/examples/save/save.c b/examples/save/save.c new file mode 100644 index 0000000..5604cb1 --- /dev/null +++ b/examples/save/save.c @@ -0,0 +1,170 @@ +// Save-file demo: a launch counter that persists across runs, plus a free-disk +// bar -- no text assets. On start it reads SAVES/runs.dat, increments the +// count, and writes it back; the 16-bit count shows as lit/unlit cells (MSB +// left) and a lamp reports whether the save succeeded. A bar underneath is +// scaled to jlDiskFree(). SPACE erases the save (count back to 0), ESC quits. +// +// Run it twice: the count comes back one higher each launch (on the IIgs the +// SAVES/ directory is prestaged on the disk image). Files are opt-in -- reached +// through , which the umbrella does not include. + +#include + +#include +#include + +#define CELL_W 16 +#define CELL_H 36 +#define CELL_GAP 3 +#define BITS 16 +#define BITS_ORIGIN_X 12 +#define BITS_ORIGIN_Y 28 + +#define LAMP_X 12 +#define LAMP_Y 84 +#define LAMP_W 40 +#define LAMP_H 20 + +#define BAR_X 12 +#define BAR_Y 128 +#define BAR_H 18 +#define BAR_MAX_W 280 +#define BAR_BYTES_PER_PX 4096u // 1 pixel per 4 KB free + +#define COLOR_BACKGROUND 0 +#define COLOR_BIT_OFF 1 +#define COLOR_BIT_ON 2 +#define COLOR_SAVE_OK 3 +#define COLOR_SAVE_FAIL 4 +#define COLOR_BAR_BG 5 +#define COLOR_BAR_FILL 6 + +#define SAVE_NAME "runs.dat" + +static void buildPalette(jlSurfaceT *screen); +static void drawBar(jlSurfaceT *screen, uint32_t freeBytes); +static void drawBits(jlSurfaceT *screen, uint16_t value); +static void drawLamp(jlSurfaceT *screen, bool ok); +static uint16_t loadCount(void); +static bool storeCount(uint16_t count); + + +static void buildPalette(jlSurfaceT *screen) { + uint16_t colors[SURFACE_COLORS_PER_PALETTE]; + uint16_t i; + + for (i = 0; i < SURFACE_COLORS_PER_PALETTE; i++) { + colors[i] = 0x0000; + } + colors[COLOR_BACKGROUND] = 0x0000; // black + colors[COLOR_BIT_OFF] = 0x0114; // dim blue + colors[COLOR_BIT_ON] = 0x00FF; // bright cyan + colors[COLOR_SAVE_OK] = 0x00F0; // green + colors[COLOR_SAVE_FAIL] = 0x0F00; // red + colors[COLOR_BAR_BG] = 0x0222; // gray + colors[COLOR_BAR_FILL] = 0x00FA; // teal + + jlPaletteSet(screen, 0, colors); +} + + +static void drawBar(jlSurfaceT *screen, uint32_t freeBytes) { + uint32_t px; + + jlFillRect(screen, BAR_X, BAR_Y, BAR_MAX_W, BAR_H, COLOR_BAR_BG); + px = freeBytes / BAR_BYTES_PER_PX; + if (px > (uint32_t)BAR_MAX_W) { + px = (uint32_t)BAR_MAX_W; + } + if (px > 0u) { + jlFillRect(screen, BAR_X, BAR_Y, (int16_t)px, BAR_H, COLOR_BAR_FILL); + } +} + + +static void drawBits(jlSurfaceT *screen, uint16_t value) { + int16_t i; + + for (i = 0; i < BITS; i++) { + int16_t x = (int16_t)(BITS_ORIGIN_X + i * (CELL_W + CELL_GAP)); + bool set = (value & (0x8000u >> i)) != 0u; + + jlFillRect(screen, x, BITS_ORIGIN_Y, CELL_W, CELL_H, set ? COLOR_BIT_ON : COLOR_BIT_OFF); + } +} + + +static void drawLamp(jlSurfaceT *screen, bool ok) { + jlFillRect(screen, LAMP_X, LAMP_Y, LAMP_W, LAMP_H, ok ? COLOR_SAVE_OK : COLOR_SAVE_FAIL); +} + + +static uint16_t loadCount(void) { + uint16_t count; + + count = 0u; + // A short read (missing file) leaves count at 0 -- first launch. + jlSaveRead(SAVE_NAME, &count, (uint32_t)sizeof(count)); + return count; +} + + +static bool storeCount(uint16_t count) { + return jlSaveWrite(SAVE_NAME, &count, (uint32_t)sizeof(count)); +} + + +int main(void) { + jlConfigT config; + jlSurfaceT *screen; + uint16_t count; + bool saved; + + config.codegenBytes = 8 * 1024; + config.audioBytes = 64UL * 1024; + + if (!jlInit(&config)) { + fprintf(stderr, "jlInit failed: %s\n", jlLastError()); + return 1; + } + + screen = jlStageGet(); + if (screen == NULL) { + fprintf(stderr, "jlStageGet returned NULL\n"); + jlShutdown(); + return 1; + } + + // Persist: read the prior count, bump it, write it back. + count = (uint16_t)(loadCount() + 1u); + saved = storeCount(count); + + buildPalette(screen); + jlScbSetRange(screen, 0, SURFACE_HEIGHT - 1, 0); + jlSurfaceClear(screen, COLOR_BACKGROUND); + drawBits(screen, count); + drawLamp(screen, saved); + drawBar(screen, jlDiskFree()); + jlStagePresent(); + jlInputPoll(); + + for (;;) { + jlInputPoll(); + if (jlKeyPressed(KEY_ESCAPE)) { + break; + } + if (jlKeyPressed(KEY_SPACE)) { + // Erase the save and reset the counter to zero. + jlSaveDelete(SAVE_NAME); + count = 0u; + saved = storeCount(count); + drawBits(screen, count); + drawLamp(screen, saved); + drawBar(screen, jlDiskFree()); + jlStagePresent(); + } + } + + jlShutdown(); + return 0; +} diff --git a/include/joey/file.h b/include/joey/file.h index c549e5e..8048bf6 100644 --- a/include/joey/file.h +++ b/include/joey/file.h @@ -1,21 +1,66 @@ -// JoeyLib data-file access. +// JoeyLib file access: read-only game data, writable save files, and a disk- +// space query. // -// Every game's runtime data lives under a single DATA/ directory that -// sits next to the executable (staged there by the per-platform disk / -// directory packagers). jlDataOpen forces that prefix so callers pass a -// bare name relative to DATA/ and never hard-code the prefix themselves -- -// e.g. jlDataOpen("levels/title.dat", "rb") opens DATA/levels/title.dat. +// Two directories sit next to the executable, staged there by the per-platform +// disk / directory packagers: +// DATA/ -- read-only game content (levels, sprites, audio). jlDataOpen. +// SAVES/ -- writable save / preference files. jlSave*. +// Both are addressed by a bare name relative to their directory; the prefix is +// forced here so callers never hard-code "DATA/" or "SAVES/". #ifndef JOEYLIB_FILE_H #define JOEYLIB_FILE_H #include -// Open a data file by its name relative to the DATA/ directory. The -// DATA/ prefix is prepended automatically; pass "levels/title.dat", not -// "DATA/levels/title.dat". mode is a standard fopen mode string. Returns -// NULL if the resulting path would overflow the internal buffer or the -// file cannot be opened. +#include "types.h" + +// ----- Read-only game data (DATA/) ----- + +// Open a data file by its name relative to the DATA/ directory. The DATA/ +// prefix is prepended automatically; pass "levels/title.dat", not +// "DATA/levels/title.dat". mode is a standard fopen mode string. Returns NULL +// if the resulting path would overflow the internal buffer or the file cannot +// be opened. FILE *jlDataOpen(const char *name, const char *mode); +// ----- Writable save files (SAVES/) ----- +// +// Save / preference data lives under a SAVES/ directory beside the executable +// -- a writable sibling of the read-only DATA/ tree. Names are relative to +// SAVES/: pass "hero.sav", not "SAVES/hero.sav". Keep names portable across +// the retro filesystems: at most 8 characters plus a short extension, letters +// and digits only, so they fit ProDOS (15-char) and DOS 8.3. +// +// A failed open / write is non-fatal and simply reports failure (false / 0), +// so save code can degrade gracefully on a platform or volume where SAVES/ is +// unavailable. + +// Open a save file for streaming, name relative to SAVES/. mode is a standard +// fopen mode string; for write / append modes the SAVES/ directory is created +// on demand first. Returns NULL on failure. Use this for large or incremental +// saves; for a whole-file blob prefer jlSaveWrite / jlSaveRead. +FILE *jlSaveOpen(const char *name, const char *mode); + +// Write `len` bytes from `buf` to save file `name`, replacing any existing +// contents. Returns true only if all `len` bytes were written. +bool jlSaveWrite(const char *name, const void *buf, uint32_t len); + +// Read up to `max` bytes from save file `name` into `buf`. Returns the number +// of bytes read (0 if the file is missing or empty). +uint32_t jlSaveRead(const char *name, void *buf, uint32_t max); + +// True if save file `name` exists and can be opened for reading. +bool jlSaveExists(const char *name); + +// Delete save file `name`. Returns true if the file was removed. +bool jlSaveDelete(const char *name); + +// ----- Disk space ----- + +// Free space, in bytes, on the volume that holds SAVES/ (the program's +// volume). Returns 0 if it cannot be determined (e.g. the port has no query +// for it). Saturates at UINT32_MAX (4 GB - 1) on larger volumes. +uint32_t jlDiskFree(void); + #endif diff --git a/include/joey/platform.h b/include/joey/platform.h index 5d7aa04..ab3083a 100644 --- a/include/joey/platform.h +++ b/include/joey/platform.h @@ -144,6 +144,11 @@ #define JL_HAS_SERIAL_WRITE #define JL_HAS_SERIAL_AVAILABLE #define JL_HAS_SERIAL_FLUSH + // save files + disk space: delete (GS/OS Destroy), dir-ensure (GS/OS Create + // $2001, storageType 13), disk-free (GS/OS Volume $2008 via prefix 0). + #define JL_HAS_SAVE_DIR_ENSURE + #define JL_HAS_SAVE_DELETE + #define JL_HAS_DISK_FREE // millisElapsed: generic frameCount*1000/frameHz (no override) #elif defined(JOEYLIB_PLATFORM_AMIGA) #define JL_HAS_SURFACE_CLEAR // planar clear, function @@ -208,6 +213,10 @@ #define JL_HAS_SERIAL_WRITE #define JL_HAS_SERIAL_AVAILABLE #define JL_HAS_SERIAL_FLUSH + // save files + disk space (CreateDir/DeleteFile/Info via dos.library) + #define JL_HAS_SAVE_DIR_ENSURE + #define JL_HAS_SAVE_DELETE + #define JL_HAS_DISK_FREE // millisElapsed: generic frameCount*1000/frameHz (no override) #elif defined(JOEYLIB_PLATFORM_ATARIST) #define JL_HAS_SURFACE_CLEAR // planar clear, function @@ -273,6 +282,10 @@ #define JL_HAS_SERIAL_WRITE #define JL_HAS_SERIAL_AVAILABLE #define JL_HAS_SERIAL_FLUSH + // save files + disk space (POSIX mkdir/remove + GEMDOS Dfree) + #define JL_HAS_SAVE_DIR_ENSURE + #define JL_HAS_SAVE_DELETE + #define JL_HAS_DISK_FREE #elif defined(JOEYLIB_PLATFORM_DOS) // chunky generics: surface clear, draw pixel/line/circle, fill circle/rect, // tiles, surface readers, allocation -- DOS overrides the timing services @@ -317,6 +330,10 @@ #define JL_HAS_SERIAL_WRITE #define JL_HAS_SERIAL_AVAILABLE #define JL_HAS_SERIAL_FLUSH + // save files + disk space (POSIX mkdir/remove + int21h/36h disk-free) + #define JL_HAS_SAVE_DIR_ENSURE + #define JL_HAS_SAVE_DELETE + #define JL_HAS_DISK_FREE #elif defined(JOEYLIB_PLATFORM_BLANK) // Copy-to-start template. It overrides ONLY the platform SERVICES (TODO // stubs in src/blank/blank.c). Everything graphical -- draw / tile / sprite diff --git a/make/amiga.mk b/make/amiga.mk index 0a5e8d6..5eb935a 100644 --- a/make/amiga.mk +++ b/make/amiga.mk @@ -70,6 +70,8 @@ KEYS_SRC := $(EXAMPLES)/keys/keys.c KEYS_BIN := $(BINDIR)/Keys SERIAL_SRC := $(EXAMPLES)/serial/serial.c SERIAL_BIN := $(BINDIR)/Serial +SAVE_SRC := $(EXAMPLES)/save/save.c +SAVE_BIN := $(BINDIR)/Save JOY_SRC := $(EXAMPLES)/joy/joy.c JOY_BIN := $(BINDIR)/Joy SPRITE_SRC := $(EXAMPLES)/sprite/sprite.c @@ -135,7 +137,7 @@ DATA_DIR := $(BINDIR)/DATA DATA_FILES := $(DATA_DIR)/test.mod $(DATA_DIR)/test.sfx .PHONY: all amiga clean-amiga -all amiga: $(LIB) $(HELLO_BIN) $(PATTERN_BIN) $(DRAW_BIN) $(KEYS_BIN) $(SERIAL_BIN) $(JOY_BIN) $(SPRITE_BIN) $(AUDIO_BIN) $(UBER_BIN) $(ADV_BIN) $(ADV2_BIN) $(AGI_BIN) $(STAXI_BIN) $(DATA_FILES) $(STAXI_ASSET_DSTS) +all amiga: $(LIB) $(HELLO_BIN) $(PATTERN_BIN) $(DRAW_BIN) $(KEYS_BIN) $(SERIAL_BIN) $(SAVE_BIN) $(JOY_BIN) $(SPRITE_BIN) $(AUDIO_BIN) $(UBER_BIN) $(ADV_BIN) $(ADV2_BIN) $(AGI_BIN) $(STAXI_BIN) $(DATA_FILES) $(STAXI_ASSET_DSTS) $(BUILD)/obj/core/%.o: $(SRC_CORE)/%.c @mkdir -p $(dir $@) @@ -199,6 +201,10 @@ $(SERIAL_BIN): $(SERIAL_SRC) $(LIB) @mkdir -p $(dir $@) $(AMIGA_CC) $(CFLAGS) $< $(LIB) -o $@ $(LDFLAGS) +$(SAVE_BIN): $(SAVE_SRC) $(LIB) + @mkdir -p $(dir $@) + $(AMIGA_CC) $(CFLAGS) $< $(LIB) -o $@ $(LDFLAGS) + $(JOY_BIN): $(JOY_SRC) $(LIB) @mkdir -p $(dir $@) $(AMIGA_CC) $(CFLAGS) $< $(LIB) -o $@ $(LDFLAGS) diff --git a/make/atarist.mk b/make/atarist.mk index c20f868..d770004 100644 --- a/make/atarist.mk +++ b/make/atarist.mk @@ -57,6 +57,8 @@ KEYS_SRC := $(EXAMPLES)/keys/keys.c KEYS_BIN := $(BINDIR)/KEYS.PRG SERIAL_SRC := $(EXAMPLES)/serial/serial.c SERIAL_BIN := $(BINDIR)/SERIAL.PRG +SAVE_SRC := $(EXAMPLES)/save/save.c +SAVE_BIN := $(BINDIR)/SAVE.PRG JOY_SRC := $(EXAMPLES)/joy/joy.c JOY_BIN := $(BINDIR)/JOY.PRG SPRITE_SRC := $(EXAMPLES)/sprite/sprite.c @@ -115,7 +117,7 @@ DATA_DIR := $(BINDIR)/DATA DATA_FILES := $(DATA_DIR)/test.mod $(DATA_DIR)/test.sfx .PHONY: all atarist clean-atarist -all atarist: $(LIB) $(LIBXMP_AR) $(HELLO_BIN) $(PATTERN_BIN) $(DRAW_BIN) $(KEYS_BIN) $(SERIAL_BIN) $(JOY_BIN) $(SPRITE_BIN) $(AUDIO_BIN) $(UBER_BIN) $(ADV_BIN) $(ADV2_BIN) $(AGI_BIN) $(STAXI_BIN) $(DATA_FILES) $(STAXI_ASSET_DSTS) +all atarist: $(LIB) $(LIBXMP_AR) $(HELLO_BIN) $(PATTERN_BIN) $(DRAW_BIN) $(KEYS_BIN) $(SERIAL_BIN) $(SAVE_BIN) $(JOY_BIN) $(SPRITE_BIN) $(AUDIO_BIN) $(UBER_BIN) $(ADV_BIN) $(ADV2_BIN) $(AGI_BIN) $(STAXI_BIN) $(DATA_FILES) $(STAXI_ASSET_DSTS) $(BUILD)/obj/core/%.o: $(SRC_CORE)/%.c @mkdir -p $(dir $@) @@ -186,6 +188,10 @@ $(SERIAL_BIN): $(SERIAL_SRC) $(LIB) @mkdir -p $(dir $@) $(ST_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ $(LDFLAGS) +$(SAVE_BIN): $(SAVE_SRC) $(LIB) + @mkdir -p $(dir $@) + $(ST_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ $(LDFLAGS) + $(JOY_BIN): $(JOY_SRC) $(LIB) @mkdir -p $(dir $@) $(ST_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ $(LDFLAGS) diff --git a/make/dos.mk b/make/dos.mk index 350160d..d853de7 100644 --- a/make/dos.mk +++ b/make/dos.mk @@ -53,6 +53,8 @@ KEYS_SRC := $(EXAMPLES)/keys/keys.c KEYS_BIN := $(BINDIR)/KEYS.EXE SERIAL_SRC := $(EXAMPLES)/serial/serial.c SERIAL_BIN := $(BINDIR)/SERIAL.EXE +SAVE_SRC := $(EXAMPLES)/save/save.c +SAVE_BIN := $(BINDIR)/SAVE.EXE JOY_SRC := $(EXAMPLES)/joy/joy.c JOY_BIN := $(BINDIR)/JOY.EXE SPRITE_SRC := $(EXAMPLES)/sprite/sprite.c @@ -130,7 +132,7 @@ STAXI_ASSET_DSTS += $(STAXI_SPC_RUN) MKSTLEVEL_BIN := $(REPO_DIR)/build/tools/mkstlevel .PHONY: all dos clean-dos -all dos: $(LIB) $(LIBXMP_AR) $(HELLO_BIN) $(PATTERN_BIN) $(DRAW_BIN) $(KEYS_BIN) $(SERIAL_BIN) $(JOY_BIN) $(SPRITE_BIN) $(AUDIO_BIN) $(UBER_BIN) $(ADV_BIN) $(ADV2_BIN) $(AGI_BIN) $(STAXI_BIN) $(DATA_FILES) $(STAXI_ASSET_DSTS) +all dos: $(LIB) $(LIBXMP_AR) $(HELLO_BIN) $(PATTERN_BIN) $(DRAW_BIN) $(KEYS_BIN) $(SERIAL_BIN) $(SAVE_BIN) $(JOY_BIN) $(SPRITE_BIN) $(AUDIO_BIN) $(UBER_BIN) $(ADV_BIN) $(ADV2_BIN) $(AGI_BIN) $(STAXI_BIN) $(DATA_FILES) $(STAXI_ASSET_DSTS) $(BUILD)/obj/core/%.o: $(SRC_CORE)/%.c @mkdir -p $(dir $@) @@ -189,6 +191,11 @@ $(SERIAL_BIN): $(SERIAL_SRC) $(LIB) $(DOS_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ $(DOS_EMBED_DPMI) $@ +$(SAVE_BIN): $(SAVE_SRC) $(LIB) + @mkdir -p $(dir $@) + $(DOS_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ + $(DOS_EMBED_DPMI) $@ + $(JOY_BIN): $(JOY_SRC) $(LIB) @mkdir -p $(dir $@) $(DOS_CC) $(CFLAGS) $< $(LIB) $(LIBXMP_AR) -o $@ diff --git a/make/iigs.mk b/make/iigs.mk index 1cc165c..7d1fe29 100644 --- a/make/iigs.mk +++ b/make/iigs.mk @@ -47,6 +47,7 @@ PATTERN_SRC := $(EXAMPLES)/pattern/pattern.c DRAW_SRC := $(EXAMPLES)/draw/draw.c KEYS_SRC := $(EXAMPLES)/keys/keys.c SERIAL_SRC := $(EXAMPLES)/serial/serial.c +SAVE_SRC := $(EXAMPLES)/save/save.c JOY_SRC := $(EXAMPLES)/joy/joy.c SPRITE_SRC := $(EXAMPLES)/sprite/sprite.c UBER_SRC := $(EXAMPLES)/uber/uber.c @@ -66,7 +67,7 @@ NTP_BIN := $(BUILD)/audio/ntpplayer.bin NTP_ASM := $(BUILD)/audio/ntpdata.s IIGS_MERLIN := $(REPO_DIR)/toolchains/iigs/merlin32/bin/merlin32 -.PHONY: all iigs iigs-lib iigs-clang-smoke iigs-examples iigs-disk iigs-verify clean-iigs +.PHONY: all iigs iigs-lib iigs-clang-smoke iigs-examples iigs-disk iigs-verify iigs-verify-all iigs-verify-save clean-iigs # Default: compile-check the library + run the end-to-end smoke test. all iigs: iigs-lib iigs-clang-smoke @@ -78,7 +79,7 @@ all iigs: iigs-lib iigs-clang-smoke # NTP replayer from disk (see the $(BINDIR)/AUDIO rule). Leaving them # out made `make iigs` silently test 8-day-old STAXI binaries against # new assets (the 2026-07-20 gear-out/no-flame reports). -iigs-examples: $(BINDIR)/PATTERN $(BINDIR)/DRAW $(BINDIR)/KEYS $(BINDIR)/SERIAL $(BINDIR)/JOY \ +iigs-examples: $(BINDIR)/PATTERN $(BINDIR)/DRAW $(BINDIR)/KEYS $(BINDIR)/SERIAL $(BINDIR)/SAVE $(BINDIR)/JOY \ $(BINDIR)/SPRITE $(BINDIR)/UBER $(BINDIR)/ADV $(BINDIR)/ADV2 \ $(BINDIR)/AGI $(BINDIR)/STAXI $(BINDIR)/AUDIO @@ -113,6 +114,15 @@ iigs-disk: iigs-examples $(NTP_BIN) $(STAXI_IIGS_SPC) iigs-verify: iigs-disk $(REPO_DIR)/scripts/verify-iigs.sh $(or $(VERIFY_EXAMPLE),draw) +# Boot-verify every launchable example on joey.2mg (per-example thresholds). +iigs-verify-all: iigs-disk + $(REPO_DIR)/scripts/verify-iigs-all.sh + +# Runtime gate for the save HAL's GS/OS calls (Create + GetDevNumber/DInfo/ +# Volume): boots SAVE under MAME and checks the save-OK lamp + disk-free bar. +iigs-verify-save: $(BINDIR)/SAVE $(BINDIR)/DRAW + $(REPO_DIR)/scripts/verify-iigs-save.sh + $(NTP_BIN): $(NTP_SRC) $(IIGS_MERLIN) @mkdir -p $(BUILD)/audio @cp $(NTP_SRC) $(BUILD)/audio/ninjatrackerplus.s @@ -164,6 +174,10 @@ $(BINDIR)/SERIAL: $(SERIAL_SRC) $(LIB_SRCS) $(IIGS_CLANG_BUILD) @mkdir -p $(dir $@) $(DEP_DIR) $(IIGS_CLANG_BUILD) -M $(DEP_DIR)/SERIAL.d $(INCLUDES) -o $@ $(SERIAL_SRC) $(LIB_SRCS) +$(BINDIR)/SAVE: $(SAVE_SRC) $(LIB_SRCS) $(IIGS_CLANG_BUILD) + @mkdir -p $(dir $@) $(DEP_DIR) + $(IIGS_CLANG_BUILD) -M $(DEP_DIR)/SAVE.d $(INCLUDES) -o $@ $(SAVE_SRC) $(LIB_SRCS) + $(BINDIR)/JOY: $(JOY_SRC) $(LIB_SRCS) $(IIGS_CLANG_BUILD) @mkdir -p $(dir $@) $(DEP_DIR) $(IIGS_CLANG_BUILD) -M $(DEP_DIR)/JOY.d $(INCLUDES) -o $@ $(JOY_SRC) $(LIB_SRCS) diff --git a/scripts/check-save.sh b/scripts/check-save.sh new file mode 100755 index 0000000..9ab58ba --- /dev/null +++ b/scripts/check-save.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# check-save.sh - Host-cc harness for the save-file core (src/core/save.c). +# Compiles it with a POSIX save HAL (tests/host/saveHost.c) and runs real +# save/read/exists/delete/disk-free round-trips inside a throwaway temp cwd, so +# the SAVES/ directory it creates lands in that temp dir and is cleaned up. +# +# The BLANK platform block registers no JL_HAS_SAVE_*, so the save op flags are +# passed on the command line to route jlpSave*/jlpDiskFree to the host HAL +# instead of the generic no-op. Exits nonzero on any failed check (CI gate). +set -euo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cc=${CC:-cc} +work=$(mktemp -d -t joeylib-save.XXXXXX) +trap 'rm -rf "$work"' EXIT + +"$cc" -DJOEYLIB_PLATFORM_BLANK \ + -DJL_HAS_SAVE_DIR_ENSURE -DJL_HAS_SAVE_DELETE -DJL_HAS_DISK_FREE \ + -I"$repo/include" -I"$repo/include/joey" -I"$repo/src/core" -Wall -Wextra \ + "$repo/src/core/save.c" "$repo/tests/host/saveHost.c" \ + -o "$work/saveHost" + +# Run from the temp dir so the SAVES/ tree it creates is disposable. +cd "$work" +"$work/saveHost" diff --git a/scripts/make-iigs-disk.sh b/scripts/make-iigs-disk.sh index fbb4c5d..5a7bc86 100755 --- a/scripts/make-iigs-disk.sh +++ b/scripts/make-iigs-disk.sh @@ -32,6 +32,15 @@ trap 'rm -rf "$work"' EXIT rm -f "$OUT" "$CADIUS" CREATEVOLUME "$OUT" "$VOL" 800KB >/dev/null +# Writable save area for the save-file API (joey/file.h -> jlSave*). GS/OS's +# libc has no mkdir, but the IIgs save HAL creates SAVES/ at runtime via GS/OS +# Create; prestaging it here as well means saves work even before that call and +# on a first run. Set JOEY_SKIP_SAVES_PRESTAGE=1 to omit it (tests runtime +# creation in isolation). +if [ -z "${JOEY_SKIP_SAVES_PRESTAGE:-}" ]; then + "$CADIUS" CREATEFOLDER "$OUT" "/$VOL/SAVES" >/dev/null +fi + freeBlocks() { "$CADIUS" CATALOG "$OUT" 2>/dev/null \ | grep -oE 'Free : [0-9]+' | grep -oE '[0-9]+' | head -1 diff --git a/scripts/verify-iigs-all.sh b/scripts/verify-iigs-all.sh new file mode 100755 index 0000000..6d27d73 --- /dev/null +++ b/scripts/verify-iigs-all.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# verify-iigs-all.sh - Boot-verify EVERY launchable example on the built +# joey.2mg (the S16 apps cadius packed onto the volume), each via verify-iigs.sh +# with its per-example distinct-color threshold. Reports one line per example +# and exits nonzero if any example fails to render. +# +# Requires: toolchains/env.sh sourced; build/iigs/bin/joey.2mg built +# (`make iigs-disk`); apple2gs ROM + gsos-system.po (see verify-iigs.sh). +set -uo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +source "$repo/toolchains/env.sh" 2>/dev/null || true +: "${LLVM816_ROOT:?source toolchains/env.sh first}" +CADIUS="${CADIUS:-$LLVM816_ROOT/tools/cadius/cadius}" +disk="$repo/build/iigs/bin/joey.2mg" + +[ -f "$disk" ] || { echo "verify-iigs-all: $disk missing (run 'make iigs-disk')" >&2; exit 2; } +[ -x "$CADIUS" ] || { echo "verify-iigs-all: cadius not found at $CADIUS" >&2; exit 2; } + +# Launchable examples = ProDOS type S16 files in the catalog, lowercased. +apps=$("$CADIUS" CATALOG "$disk" 2>/dev/null | awk '$2 == "S16" { print tolower($1) }' | sort) +[ -n "$apps" ] || { echo "verify-iigs-all: no S16 apps found on $disk" >&2; exit 2; } + +echo "verify-iigs-all: booting $(echo "$apps" | wc -w) examples off joey.2mg" +fails=0 +for ex in $apps; do + out=$("$repo/scripts/verify-iigs.sh" "$ex" 2>&1) + rc=$? + info=$(echo "$out" | grep -oE 'distinctNibbles=[0-9]+ nonZeroBytes=[0-9]+' | tail -1) + if [ "$rc" -eq 0 ]; then + echo " PASS $ex ($info)" + else + echo " FAIL $ex ($info)" + fails=$((fails + 1)) + fi +done + +echo "verify-iigs-all: $fails failed" +[ "$fails" -eq 0 ] diff --git a/scripts/verify-iigs-save.sh b/scripts/verify-iigs-save.sh new file mode 100755 index 0000000..9df46bc --- /dev/null +++ b/scripts/verify-iigs-save.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# verify-iigs-save.sh - Runtime gate for the IIgs save-file HAL's GS/OS calls +# (Create $2001 for the SAVES/ directory, Volume $2008 for jlDiskFree). Boots +# GS/OS under MAME, launches the SAVE example off a freshly built JOEYLIB disk, +# and reads two spots out of the Super Hi-Res framebuffer: +# - the save lamp at (32,94): color 3 (COLOR_SAVE_OK) means jlSaveWrite +# succeeded, i.e. jlpSaveDirEnsure (GS/OS Create) + the write into SAVES/ +# both worked; color 4 means it failed. +# - the disk-free bar row at y=137: any COLOR_BAR_FILL (6) pixels mean +# jlDiskFree() (GS/OS Volume) returned a nonzero free-byte count. +# PASS requires lamp==3 AND barFill>0. +# +# Set JOEY_SAVE_NOPRESTAGE=1 to build the disk WITHOUT prestaging SAVES/, which +# isolates GS/OS Create: a green lamp then proves Create made the directory +# from scratch (not that a prestaged one was merely present). +# +# Requires: toolchains/env.sh sourced; build/iigs/bin/SAVE + DRAW built; the +# apple2gs ROM and gsos-system.po present (same as verify-iigs.sh). +set -euo pipefail + +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +source "$repo/toolchains/env.sh" 2>/dev/null || true +: "${LLVM816_ROOT:?source toolchains/env.sh first}" + +sys_disk=$repo/toolchains/emulators/support/gsos-system.po +rompath="${MAME_ROMPATH:-$HOME/.mame/roms}" +readFrame="${MAME_READ_FRAME:-9000}" + +[ -f "$repo/build/iigs/bin/SAVE" ] || { echo "verify-iigs-save: build/iigs/bin/SAVE missing (make iigs first)" >&2; exit 2; } +[ -f "$sys_disk" ] || { echo "verify-iigs-save: missing $sys_disk" >&2; exit 2; } + +work=$(mktemp -d -t joeylib-vsave.XXXXXX) +trap 'rm -rf "$work"' EXIT + +# Build the data disk with SAVE (DRAW keeps a known-good sibling on the volume). +# JOEY_SAVE_NOPRESTAGE=1 omits the prestaged SAVES/, isolating GS/OS Create. +prestage_env="" +if [ -n "${JOEY_SAVE_NOPRESTAGE:-}" ]; then + prestage_env="JOEY_SKIP_SAVES_PRESTAGE=1" + echo "verify-iigs-save: building WITHOUT prestaged SAVES/ (isolating GS/OS Create)" +fi +env $prestage_env JOEY_DISK_EXAMPLES="SAVE DRAW" "$repo/scripts/make-iigs-disk.sh" "$work/joey.2mg" >/dev/null +cp "$sys_disk" "$work/boot.po" + +cat > "$work/vsave.lua" <> 1)) + if (x & 1) == 0 then return (b >> 4) & 0x0F else return b & 0x0F end +end + +local function report() + local lamp = nib(32, 94) -- COLOR_SAVE_OK=3 / COLOR_SAVE_FAIL=4 + local barFill = 0 + for x = 12, 291 do -- bar row; COLOR_BAR_FILL=6 + if nib(x, 137) == 6 then barFill = barFill + 1 end + end + io.write(string.format("VERIFY-SAVE lamp=%d barFill=%d\n", lamp, barFill)) + io.flush() +end + +local steps = { + {3000, function() nat:post("J") end}, + {3120, function() press(key_cmd) end}, + {3126, function() nat:post("o") end}, + {3180, function() release(key_cmd) end}, + {3540, function() nat:post("SAVE") end}, + {3660, function() press(key_cmd) end}, + {3666, function() nat:post("o") end}, + {3720, function() release(key_cmd) end}, + {$readFrame, function() report(); manager.machine:exit() end}, +} + +emu.register_frame_done(function() + frame = frame + 1 + while idx <= #steps and frame >= steps[idx][1] do + steps[idx][2]() + idx = idx + 1 + end +end) +LUA + +cd "$work" +out=$(QT_QPA_PLATFORM=offscreen SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \ + timeout 300 mame apple2gs \ + -rompath "$rompath" \ + -flop3 "$work/boot.po" -flop4 "$work/joey.2mg" \ + -video none -sound none -nothrottle \ + -autoboot_script "$work/vsave.lua" &1) || true + +line=$(echo "$out" | grep -E '^VERIFY-SAVE ' | tail -1) +echo "$line" +if [ -z "$line" ]; then + echo "verify-iigs-save: FAIL - no report (boot/launch failed)" >&2 + echo "$out" | tail -15 >&2 + exit 1 +fi +lamp=$(echo "$line" | sed -E 's/.*lamp=([0-9]+).*/\1/') +barFill=$(echo "$line" | sed -E 's/.*barFill=([0-9]+).*/\1/') +if [ "$lamp" = "3" ] && [ "$barFill" -gt 0 ]; then + echo "verify-iigs-save: PASS (save OK lamp + disk-free bar; GS/OS Create+Volume work)" +else + echo "verify-iigs-save: FAIL (lamp=$lamp expected 3, barFill=$barFill expected >0)" >&2 + exit 1 +fi diff --git a/scripts/verify-iigs.sh b/scripts/verify-iigs.sh index 7a7efb4..31f6ae3 100755 --- a/scripts/verify-iigs.sh +++ b/scripts/verify-iigs.sh @@ -14,15 +14,27 @@ # Usage: scripts/verify-iigs.sh [example] [minDistinct] # example lowercase name (default: draw). Must be on joey.2mg (run # `make iigs-disk` first). The volume is JOEYLIB. -# minDistinct PASS threshold for distinct nibble values (default: 4). +# minDistinct PASS threshold for distinct nibble values. If omitted, a +# per-example default is used: 4 for most demos, but 3 for the +# deliberately sparse ones (keys draws a mostly-monochrome grid; +# sprite draws one small ball on a black background), which +# legitimately render only ~3 distinct colors. An explicit +# second arg always overrides. # # Requires: toolchains/env.sh sourced; build/iigs/bin/joey.2mg built. set -euo pipefail repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) example=${1:-draw} -minDistinct=${2:-4} NAME=${example^^} +if [ -n "${2:-}" ]; then + minDistinct=$2 +else + case "$example" in + keys|sprite) minDistinct=3 ;; + *) minDistinct=4 ;; + esac +fi sys_disk=$repo/toolchains/emulators/support/gsos-system.po data_disk=$repo/build/iigs/bin/joey.2mg diff --git a/src/amiga/save.c b/src/amiga/save.c new file mode 100644 index 0000000..fa40262 --- /dev/null +++ b/src/amiga/save.c @@ -0,0 +1,67 @@ +// Amiga save-file + disk-space HAL. +// +// SAVES/ is a real directory next to the executable, made with dos.library +// CreateDir; delete is DeleteFile; free space is Info() on a lock to PROGDIR: +// (the program's -- and thus SAVES/'s -- volume). All ROM-backed, so no libc +// filesystem dependency. + +#include +#include + +#include + +#include "joey/file.h" +#include "port.h" + + +#define AMIGA_MAX_U32 0xFFFFFFFFu + + +uint32_t jlpDiskFree(void) { + struct InfoData info; + BPTR lock; + uint32_t freeBlocks; + uint32_t bytesPerBlock; + + lock = Lock((CONST_STRPTR)"PROGDIR:", SHARED_LOCK); + if (lock == 0) { + return 0u; + } + if (Info(lock, &info) == 0) { + UnLock(lock); + return 0u; + } + UnLock(lock); + + freeBlocks = (info.id_NumBlocks > info.id_NumBlocksUsed) + ? (uint32_t)(info.id_NumBlocks - info.id_NumBlocksUsed) : 0u; + bytesPerBlock = (uint32_t)info.id_BytesPerBlock; + // Saturate in 32-bit (no 64-bit multiply helper on the 68000). + if (bytesPerBlock != 0u && freeBlocks > AMIGA_MAX_U32 / bytesPerBlock) { + return AMIGA_MAX_U32; + } + return freeBlocks * bytesPerBlock; +} + + +bool jlpSaveDelete(const char *path) { + return DeleteFile((CONST_STRPTR)path) != 0; +} + + +bool jlpSaveDirEnsure(const char *dir) { + BPTR lock; + + lock = CreateDir((CONST_STRPTR)dir); + if (lock != 0) { + UnLock(lock); + return true; + } + // CreateDir fails if the directory already exists -- confirm via Lock. + lock = Lock((CONST_STRPTR)dir, SHARED_LOCK); + if (lock != 0) { + UnLock(lock); + return true; + } + return false; +} diff --git a/src/atarist/save.c b/src/atarist/save.c new file mode 100644 index 0000000..7cfc543 --- /dev/null +++ b/src/atarist/save.c @@ -0,0 +1,50 @@ +// Atari ST save-file + disk-space HAL. +// +// SAVES/ is a real GEMDOS subdirectory next to the .PRG, made with POSIX +// mkdir; delete is stdio remove(); free space comes from GEMDOS Dfree on the +// default drive. + +#include +#include + +#include +#include + +#include "joey/file.h" +#include "port.h" + + +#define SAVE_DIR_MODE 0777 +#define ST_MAX_U32 0xFFFFFFFFu + + +uint32_t jlpDiskFree(void) { + _DISKINFO info; + uint32_t bytesPerClus; + + // Dfree drive 0 = the current (default) GEMDOS drive; < 0 is an error. + if (Dfree(&info, 0) < 0) { + return 0u; + } + // Saturate in 32-bit (no 64-bit multiply helper on the 68000). + bytesPerClus = (uint32_t)info.b_secsiz * (uint32_t)info.b_clsiz; + if (bytesPerClus != 0u && (uint32_t)info.b_free > ST_MAX_U32 / bytesPerClus) { + return ST_MAX_U32; + } + return (uint32_t)info.b_free * bytesPerClus; +} + + +bool jlpSaveDelete(const char *path) { + return remove(path) == 0; +} + + +bool jlpSaveDirEnsure(const char *dir) { + struct stat st; + + if (mkdir(dir, SAVE_DIR_MODE) == 0) { + return true; + } + return stat(dir, &st) == 0 && S_ISDIR(st.st_mode); +} diff --git a/src/core/port.h b/src/core/port.h index 27dc42f..6ab8c35 100644 --- a/src/core/port.h +++ b/src/core/port.h @@ -995,6 +995,35 @@ void jlpGenericSerialFlush(void); #endif +// --- Save files + disk space (platform-only; blank-port stub generic + JL_HAS override) --- +// Function overrides (not IIgs asm macros), so no entry in the macro-consistency +// #error block above. Core layer is src/core/save.c; publics in joey/file.h. +bool jlpGenericSaveDirEnsure(const char *dir); +#if !defined(jlpSaveDirEnsure) + #if defined(JL_HAS_SAVE_DIR_ENSURE) + bool jlpSaveDirEnsure(const char *dir); + #else + #define jlpSaveDirEnsure(_d) jlpGenericSaveDirEnsure((_d)) + #endif +#endif +bool jlpGenericSaveDelete(const char *path); +#if !defined(jlpSaveDelete) + #if defined(JL_HAS_SAVE_DELETE) + bool jlpSaveDelete(const char *path); + #else + #define jlpSaveDelete(_p) jlpGenericSaveDelete((_p)) + #endif +#endif +uint32_t jlpGenericDiskFree(void); +#if !defined(jlpDiskFree) + #if defined(JL_HAS_DISK_FREE) + uint32_t jlpDiskFree(void); + #else + #define jlpDiskFree() jlpGenericDiskFree() + #endif +#endif + + // --- Large allocation (malloc/free generic default; IIgs Memory Manager override) --- void *jlpGenericBigAlloc(uint32_t bytes); void jlpGenericBigFree(void *p); diff --git a/src/core/save.c b/src/core/save.c new file mode 100644 index 0000000..415deda --- /dev/null +++ b/src/core/save.c @@ -0,0 +1,124 @@ +// Writable save files (SAVES/) and a disk-space query. +// +// Mirrors jlDataOpen's forced-prefix approach (see assetLoad.c) but rooted at +// the writable SAVES/ tree instead of read-only DATA/, and it creates SAVES/ +// on demand before a write open. Blob read/write/exists are plain stdio over +// jlSaveOpen; only the two operations stdio cannot do portably -- create the +// directory and delete a file -- plus the disk-free query are per-port HAL +// hooks (jlpSaveDirEnsure / jlpSaveDelete / jlpDiskFree). + +#include +#include + +#include "joey/file.h" +#include "port.h" + + +#define SAVE_DIR "SAVES" +#define SAVE_DIR_PREFIX "SAVES/" +#define SAVE_PATH_MAX 256 + + +// ----- Helpers ----- + +static bool buildSavePath(char *out, size_t cap, const char *name); + + +// Join SAVE_DIR_PREFIX + name into `out`. Returns false if it would overflow. +static bool buildSavePath(char *out, size_t cap, const char *name) { + int written; + + written = snprintf(out, cap, SAVE_DIR_PREFIX "%s", name); + return written >= 0 && (size_t)written < cap; +} + + +// ----- Public API (alphabetical) ----- + +uint32_t jlDiskFree(void) { + return jlpDiskFree(); +} + + +bool jlSaveDelete(const char *name) { + char path[SAVE_PATH_MAX]; + + if (name == NULL) { + return false; + } + if (!buildSavePath(path, sizeof(path), name)) { + return false; + } + return jlpSaveDelete(path); +} + + +bool jlSaveExists(const char *name) { + FILE *fp; + + if (name == NULL) { + return false; + } + fp = jlSaveOpen(name, "rb"); + if (fp == NULL) { + return false; + } + fclose(fp); + return true; +} + + +FILE *jlSaveOpen(const char *name, const char *mode) { + char path[SAVE_PATH_MAX]; + + if (name == NULL || mode == NULL) { + return NULL; + } + // Create SAVES/ before a write / append open. The result is advisory: if + // the directory cannot be made, fopen below simply fails and the caller + // sees NULL. + if (strchr(mode, 'w') != NULL || strchr(mode, 'a') != NULL) { + (void)jlpSaveDirEnsure(SAVE_DIR); + } + if (!buildSavePath(path, sizeof(path), name)) { + return NULL; + } + return fopen(path, mode); +} + + +uint32_t jlSaveRead(const char *name, void *buf, uint32_t max) { + FILE *fp; + size_t got; + + if (name == NULL || buf == NULL || max == 0u) { + return 0u; + } + fp = jlSaveOpen(name, "rb"); + if (fp == NULL) { + return 0u; + } + got = fread(buf, 1u, (size_t)max, fp); + fclose(fp); + return (uint32_t)got; +} + + +bool jlSaveWrite(const char *name, const void *buf, uint32_t len) { + FILE *fp; + size_t written; + + if (name == NULL) { + return false; + } + fp = jlSaveOpen(name, "wb"); + if (fp == NULL) { + return false; + } + written = 0u; + if (buf != NULL && len > 0u) { + written = fwrite(buf, 1u, (size_t)len, fp); + } + fclose(fp); + return written == (size_t)len; +} diff --git a/src/dos/save.c b/src/dos/save.c new file mode 100644 index 0000000..03f06ba --- /dev/null +++ b/src/dos/save.c @@ -0,0 +1,53 @@ +// DOS save-file + disk-space HAL. +// +// SAVES/ is a real subdirectory next to the .EXE, made with POSIX mkdir; +// delete is stdio remove(); free space comes from DOS INT 21h AH=36h (Get +// Disk Free Space) on the default drive. + +#include +#include +#include + +#include "joey/file.h" +#include "port.h" + + +// FAT ignores mode bits; 0777 is the conventional "create it" request. +#define SAVE_DIR_MODE 0777 +#define DOS_MAX_U32 0xFFFFFFFFu + + +uint32_t jlpDiskFree(void) { + __dpmi_regs r; + uint32_t bytesPerClus; + + r.h.ah = 0x36u; // Get Disk Free Space + r.h.dl = 0x00u; // 0 = default drive + __dpmi_int(0x21, &r); + if (r.x.ax == 0xFFFFu) { + return 0u; // invalid drive + } + // AX = sectors/cluster, CX = bytes/sector, BX = free clusters. Saturate + // in 32-bit rather than pull a 64-bit multiply helper onto the target. + bytesPerClus = (uint32_t)r.x.ax * (uint32_t)r.x.cx; // <= 65535*65535 < 2^32 + if (bytesPerClus != 0u && (uint32_t)r.x.bx > DOS_MAX_U32 / bytesPerClus) { + return DOS_MAX_U32; + } + return bytesPerClus * (uint32_t)r.x.bx; +} + + +bool jlpSaveDelete(const char *path) { + return remove(path) == 0; +} + + +bool jlpSaveDirEnsure(const char *dir) { + struct stat st; + + if (mkdir(dir, SAVE_DIR_MODE) == 0) { + return true; + } + // mkdir failed -- success only if it failed because a directory is there. + return stat(dir, &st) == 0 && S_ISDIR(st.st_mode); +} diff --git a/src/generic/genericSave.c b/src/generic/genericSave.c new file mode 100644 index 0000000..728c898 --- /dev/null +++ b/src/generic/genericSave.c @@ -0,0 +1,27 @@ +// Generic portable-C no-op defaults for the save-file + disk-space service. +// A port with no writable filesystem (or the BLANK template) links these: the +// SAVES/ directory cannot be made, nothing can be deleted, and free space is +// unknown -- so jlSaveWrite/Open fail cleanly and jlDiskFree returns 0. Every +// real port overrides via JL_HAS_SAVE_DIR_ENSURE / JL_HAS_SAVE_DELETE / +// JL_HAS_DISK_FREE. Always compiled in via the src/generic/*.c wildcard; +// dead-stripped where overridden. + +#include "joey/file.h" +#include "port.h" + + +uint32_t jlpGenericDiskFree(void) { + return 0u; +} + + +bool jlpGenericSaveDelete(const char *path) { + (void)path; + return false; +} + + +bool jlpGenericSaveDirEnsure(const char *dir) { + (void)dir; + return false; +} diff --git a/src/iigs/save.c b/src/iigs/save.c new file mode 100644 index 0000000..264080b --- /dev/null +++ b/src/iigs/save.c @@ -0,0 +1,177 @@ +// Apple IIgs save-file HAL. +// +// GS/OS-backed stdio (fopen/fread/fwrite -- the runtime's libc GS/OS layer) +// handles the blob + stream paths, so jlSaveOpen/Write/Read/Exists just work. +// The three ops stdio cannot express are issued as GS/OS class-1 calls through +// the runtime wrappers in : +// +// - delete: remove() (maps to GS/OS Destroy). +// - directory create: GS/OS Create ($2001) with storageType 13 (directory). +// - disk free: the volume that holds the default prefix (prefix 0, which +// unrooted paths like "SAVES/x" resolve against) does not have a name the +// Volume call accepts directly -- Volume ($2008) wants a DEVICE name. So +// the chain is GetPrefix(0) -> ":VOL:..." -> GetDevNumber(":VOL") -> devNum +// -> DInfo(devNum) -> device name ".XXX" -> Volume(".XXX") -> free blocks. +// +// Every GS/OS call is guarded by __gsosAvailable(): when only the universal- +// success stub is linked (sysless smoke harness) the wrappers are not real, so +// dir-ensure falls back to trusting a prestaged SAVES/ and disk-free reports 0. + +#include + +#include + +#include "joey/file.h" +#include "port.h" + + +// GS/OS length-prefixed input string, sized for our short save/device paths. +typedef struct { + unsigned short length; + char text[64]; +} GsInStr; + +// GS/OS ResultBuf (maxLen + length + text), sized for a prefix / name string. +typedef struct { + unsigned short maxLen; + unsigned short length; + char text[80]; +} GsResult; + +#define GSOS_ERR_DUP 0x47u // duplicate pathname (already exists) +#define GSOS_DIR_TYPE 0x0Fu // directory file type +#define GSOS_DIR_STORE 0x000Du // storageType 13 = directory +#define GSOS_FULL_ACCESS 0xC3u // read/write/rename/destroy enabled +#define IIGS_MAX_U32 0xFFFFFFFFu + + +static void gsStrSet(GsInStr *dst, const char *src, unsigned short cap); + + +// Copy a C string into a GS/OS length-prefixed string (truncating at cap). +static void gsStrSet(GsInStr *dst, const char *src, unsigned short cap) { + unsigned short i; + + i = 0u; + while (src[i] != '\0' && i < cap) { + dst->text[i] = src[i]; + i++; + } + dst->length = i; +} + + +uint32_t jlpDiskFree(void) { + // Static (not stack): keeps this leaf HAL off the shallow IIgs soft stack. + static GsResult prefix; + static GsResult devNameBuf; + static GsResult volNameOut; + static GsInStr volArg; + static GsInStr devArg; + PrefixRecGS pr; + DevNumRecGS dn; + DInfoRecGS di; + VolumeRecGS vr; + unsigned short i; + unsigned short n; + unsigned short cut; + unsigned short bytesPerBlock; + + if (!__gsosAvailable()) { + return 0u; + } + + // Prefix 0 is what unrooted paths (like "SAVES/x") resolve against, so its + // volume is the one SAVES/ lives on. It comes back in ":VOL:..." form. + prefix.maxLen = (unsigned short)(sizeof(prefix) - 2u); + pr.pCount = 2u; + pr.prefixNum = 0u; + pr.prefix = &prefix; + if (gsosGetPrefix(&pr) != 0u || prefix.length == 0u) { + return 0u; + } + + // Extract the leading volume component ":VOL" (up to the 2nd separator; + // '/' on ProDOS, ':' in GS/OS native form). + cut = prefix.length; + for (i = 1u; i < prefix.length; i++) { + if (prefix.text[i] == '/' || prefix.text[i] == ':') { + cut = i; + break; + } + } + n = 0u; + for (i = 0u; i < cut && n < (unsigned short)sizeof(volArg.text); i++) { + volArg.text[n++] = prefix.text[i]; + } + volArg.length = n; + + // Volume name -> device number -> device name (what Volume needs). + dn.pCount = 2u; + dn.devName = &volArg; + dn.devNum = 0u; + if (gsosGetDevNumber(&dn) != 0u) { + return 0u; + } + devNameBuf.maxLen = (unsigned short)(sizeof(devNameBuf) - 2u); + di.pCount = 2u; + di.devNum = dn.devNum; + di.devName = &devNameBuf; + if (gsosDInfo(&di) != 0u || devNameBuf.length == 0u) { + return 0u; + } + n = 0u; + for (i = 0u; i < devNameBuf.length && n < (unsigned short)sizeof(devArg.text); i++) { + devArg.text[n++] = devNameBuf.text[i]; + } + devArg.length = n; + + // Device name -> free blocks + block size. + volNameOut.maxLen = (unsigned short)(sizeof(volNameOut) - 2u); + vr.pCount = 6u; + vr.devName = &devArg; + vr.volName = &volNameOut; + vr.totalBlocks = 0uL; + vr.freeBlocks = 0uL; + vr.fileSysID = 0u; + vr.blockSize = 0u; + if (gsosVolume(&vr) != 0u) { + return 0u; + } + + // free bytes = freeBlocks * blockSize, saturating in 32-bit. + bytesPerBlock = vr.blockSize; + if (bytesPerBlock == 0u) { + return 0u; + } + if (vr.freeBlocks > IIGS_MAX_U32 / bytesPerBlock) { + return IIGS_MAX_U32; + } + return (uint32_t)(vr.freeBlocks * bytesPerBlock); +} + + +bool jlpSaveDelete(const char *path) { + return remove(path) == 0; +} + + +bool jlpSaveDirEnsure(const char *dir) { + GsInStr name; + CreateRecGS cr; + unsigned short err; + + if (!__gsosAvailable()) { + return true; // no real GS/OS surface -- trust a prestaged SAVES/ + } + gsStrSet(&name, dir, (unsigned short)sizeof(name.text)); + + cr.pCount = 5u; + cr.pathname = &name; + cr.access = GSOS_FULL_ACCESS; + cr.fileType = GSOS_DIR_TYPE; + cr.auxType = 0uL; + cr.storageType = GSOS_DIR_STORE; + err = gsosCreate(&cr); + return err == 0u || err == GSOS_ERR_DUP; // created, or already present +} diff --git a/tests/host/saveHost.c b/tests/host/saveHost.c new file mode 100644 index 0000000..f739182 --- /dev/null +++ b/tests/host/saveHost.c @@ -0,0 +1,137 @@ +// saveHost.c - Host-cc harness for the save-file core (src/core/save.c). +// +// Built by scripts/check-save.sh on the serialHost.c pattern: compiled with +// -DJOEYLIB_PLATFORM_BLANK plus the JL_HAS_SAVE_* flags so port.h routes +// jlpSave*/jlpDiskFree to the POSIX HAL below. Unlike the serial mock, this +// exercises the REAL core end to end -- jlSaveWrite/Read/Open/Exists/Delete do +// actual stdio into a SAVES/ directory in the current working directory, which +// check-save.sh points at a throwaway temp dir. Prints one line per check and +// a final tally; exits nonzero on any failure, so it doubles as a CI gate. + +#include +#include +#include +#include + +#include "joey/file.h" +#include "port.h" + + +// ----- POSIX save HAL (satisfies the jlpSave* dispatch under -DJL_HAS_SAVE_*) ----- + +uint32_t jlpDiskFree(void) { + struct statvfs vfs; + unsigned long long bytes; + + if (statvfs(".", &vfs) != 0) { + return 0u; + } + bytes = (unsigned long long)vfs.f_bavail * (unsigned long long)vfs.f_frsize; + if (bytes > 0xFFFFFFFFull) { + return 0xFFFFFFFFu; + } + return (uint32_t)bytes; +} + + +bool jlpSaveDelete(const char *path) { + return remove(path) == 0; +} + + +bool jlpSaveDirEnsure(const char *dir) { + struct stat st; + + if (mkdir(dir, 0777) == 0) { + return true; + } + return stat(dir, &st) == 0 && S_ISDIR(st.st_mode); +} + + +// ----- Test harness ----- + +static int gPass = 0; +static int gFail = 0; + + +static void check(const char *name, bool ok) { + if (ok) { + gPass++; + printf(" ok %s\n", name); + } else { + gFail++; + printf(" FAIL %s\n", name); + } +} + + +int main(void) { + uint8_t out[64]; + uint8_t in[64]; + uint32_t n; + FILE *fp; + int i; + + printf("save-host: core save/read/exists/delete/diskfree (real files)\n"); + + for (i = 0; i < 64; i++) { + out[i] = (uint8_t)(i * 7 + 1); + } + + // 1. Clean slate + a plausible free-space number. + check("fresh: hero.sav absent", jlSaveExists("hero.sav") == false); + check("diskFree > 0", jlDiskFree() > 0u); + + // 2. Blob write creates SAVES/ on demand and round-trips. + check("write blob (64)", jlSaveWrite("hero.sav", out, 64u) == true); + check("exists after write", jlSaveExists("hero.sav") == true); + memset(in, 0, sizeof(in)); + n = jlSaveRead("hero.sav", in, sizeof(in)); + check("read full length", n == 64u); + check("payload round-trips", memcmp(in, out, 64) == 0); + + // 3. Read honors the caller's max. + memset(in, 0, sizeof(in)); + n = jlSaveRead("hero.sav", in, 16u); + check("read capped at max", n == 16u); + check("capped payload matches", memcmp(in, out, 16) == 0); + + // 4. Streaming open for incremental writes. + fp = jlSaveOpen("stream.sav", "wb"); + check("stream open (wb)", fp != NULL); + if (fp != NULL) { + fputc('J', fp); + fputc('L', fp); + fclose(fp); + } + memset(in, 0, sizeof(in)); + n = jlSaveRead("stream.sav", in, sizeof(in)); + check("stream read length 2", n == 2u); + check("stream payload 'JL'", in[0] == (uint8_t)'J' && in[1] == (uint8_t)'L'); + + // 5. Zero-length write makes an empty file. + check("write empty", jlSaveWrite("empty.sav", NULL, 0u) == true); + check("empty exists", jlSaveExists("empty.sav") == true); + check("empty reads 0", jlSaveRead("empty.sav", in, sizeof(in)) == 0u); + + // 6. Delete removes it; deleting a missing file reports false. + check("delete hero.sav", jlSaveDelete("hero.sav") == true); + check("hero.sav gone", jlSaveExists("hero.sav") == false); + check("delete missing -> false", jlSaveDelete("nope.sav") == false); + + // 7. NULL / bad-argument guards. + check("write NULL name", jlSaveWrite(NULL, out, 4u) == false); + check("read NULL name", jlSaveRead(NULL, in, 4u) == 0u); + check("read NULL buf", jlSaveRead("empty.sav", NULL, 4u) == 0u); + check("open NULL name", jlSaveOpen(NULL, "rb") == NULL); + check("exists NULL name", jlSaveExists(NULL) == false); + check("delete NULL name", jlSaveDelete(NULL) == false); + + // Best-effort cleanup of the files this run created. + jlSaveDelete("stream.sav"); + jlSaveDelete("empty.sav"); + + printf("save-host: %d passed, %d failed\n", gPass, gFail); + return (gFail == 0) ? 0 : 1; +}