New save interface.

This commit is contained in:
Scott Duensing 2026-07-26 19:35:50 -05:00
parent a0d3cdb876
commit da8bb41477
21 changed files with 1183 additions and 20 deletions

View file

@ -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 `<joey/joey.h>` -- include `<joey/file.h>`. 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

170
examples/save/save.c Normal file
View file

@ -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 <joey/file.h>, which the umbrella <joey/joey.h> does not include.
#include <stdio.h>
#include <joey/joey.h>
#include <joey/file.h>
#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;
}

View file

@ -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 <stdio.h>
// 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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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 $@

View file

@ -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)

25
scripts/check-save.sh Executable file
View file

@ -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"

View file

@ -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

39
scripts/verify-iigs-all.sh Executable file
View file

@ -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 ]

119
scripts/verify-iigs-save.sh Executable file
View file

@ -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" <<LUA
local cpu = manager.machine.devices[":maincpu"]
local mem = cpu.spaces["program"]
local nat = manager.machine.natkeyboard
local frame = 0
local idx = 1
local function field(port, name)
local p = manager.machine.ioport.ports[port]
if p == nil then return nil end
return p.fields[name]
end
local key_cmd = field(":macadb:KEY3", "Command / Open Apple")
local function press(f) if f then f:set_value(1) end end
local function release(f) if f then f:set_value(0) end end
local function nib(x, y)
local b = mem:read_u8(0xE12000 + y * 160 + (x >> 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" </dev/null 2>&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

View file

@ -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

67
src/amiga/save.c Normal file
View file

@ -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 <exec/types.h>
#include <dos/dos.h>
#include <proto/dos.h>
#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;
}

50
src/atarist/save.c Normal file
View file

@ -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 <stdio.h>
#include <sys/stat.h>
#include <mint/osbind.h>
#include <mint/ostruct.h>
#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);
}

View file

@ -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);

124
src/core/save.c Normal file
View file

@ -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 <stdio.h>
#include <string.h>
#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;
}

53
src/dos/save.c Normal file
View file

@ -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 <dpmi.h>
#include <stdio.h>
#include <sys/stat.h>
#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);
}

27
src/generic/genericSave.c Normal file
View file

@ -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;
}

177
src/iigs/save.c Normal file
View file

@ -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 <iigs/gsos.h>:
//
// - 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 <stdio.h>
#include <iigs/gsos.h>
#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
}

137
tests/host/saveHost.c Normal file
View file

@ -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 <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#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;
}