M2 complete: disk saving works via MSXgl's tool/disk_save module

Replaces the hand-rolled PHYDIO/DSKIO sector writer, which never worked,
with engine/src/tool/disk_save.h — added in MSXgl v1.3.0 for exactly this
("save to disk from a ROM application"). Saves are now a real FAT file,
KISSAT00.SAV, on the disk in drive A.

Four non-obvious requirements, all documented in CLAUDE.md:
- LibModules needs both "tool/disk_save" and "dos"
- ROMDelayBoot = true, or the Disk ROM's INIT never runs
- boot then takes 40-90s of emulated time (use `set throttle off`)
- DiskSave_Check() returns SAVEDATA_UNSIGNED for good files: with APPSIGN
  it wants the first 4 bytes to be g_AppSignature, but DiskSave_Save()
  writes the payload raw and never adds it

Also fixes a save-timing bug: the write now happens in AdvanceDay() after
the day increments, so the file describes the morning the player wakes to.
Saving during the evening made a reload replay that day and double-count
its cups.

Verified: play a full day, cold boot, resume at day 2 with served intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 22:17:49 +02:00
co-authored by Claude Opus 5
parent 45749775fa
commit cbde457da5
5 changed files with 105 additions and 228 deletions
+53 -148
View File
@@ -7,6 +7,7 @@
// INCLUDES
//=============================================================================
#include "msxgl.h"
#include "tool/disk_save.h"
// Fonts data
#include "font/font_mgl_sample6.h"
@@ -177,8 +178,7 @@ u8 g_SpacePrev = 0;
u8 g_Phase = PHASE_MORNING;
u8 g_DayServed = 0; // customers served so far today
u8 g_DayQuality = 0; // summed brew quality today, for the journal line
u8 g_DiskErr = 0; // last disk error code (0 = fine)
bool g_HasDisk = FALSE; // a usable save disk was found at boot
bool g_SaveOk = TRUE; // did the last write succeed (shown next morning)
//=============================================================================
// SAVE STATE (DESIGN.md §6)
@@ -213,134 +213,26 @@ typedef struct
SaveState g_Save;
// One 512-byte sector staging buffer. Must live in RAM (page 3) — PHYDIO
// writes straight into it, and pages 1-2 are ROM on a cartridge target.
u8 g_Sector[512];
// Sector holding the save, derived from the disk's own BPB at boot rather
// than hardcoded, so a 360K disk works as well as the 720K one.
u16 g_SaveSector = 14;
//=============================================================================
// DISK ACCESS
// DISK SAVE
//=============================================================================
// Number of disk drives, maintained by the Disk ROM at boot. Zero means there
// is no disk interface at all — and on such a machine (C-BIOS, for one) the
// PHYDIO hook is not patched and calling it is not safe.
#define M_NMBDRV_ADDR 0xF347
#define M_MASTER_ADDR 0xF348 // main Disk ROM slot, 0 if there is none
// PHYDIO trampoline, executed from RAM.
//
// PHYDIO (Main-ROM 0144h) reaches the Disk ROM through an inter-slot call, and
// the Disk ROM lives at 4000h — the same page as this cartridge. For the
// duration of the call our ROM is switched out of page 1, so the instruction
// making the call must not itself be in page 1. Copying these twelve bytes to
// RAM and calling *them* keeps the call site alive across the switch.
u8 g_DiskStub[21];
// Uses MSXgl's tool/disk_save module (added in v1.3.0, "save to disk from a
// ROM application"). It writes a real named file — KISSAT0.SAV — through the
// Disk ROM, so the save disk stays a normal, inspectable FAT disk.
//
// This replaced a hand-rolled PHYDIO/DSKIO sector writer that never worked:
// both entry points returned carry-clear without transferring anything. Do
// not go back to raw sectors; use this module.
//
// Requirements, both non-obvious:
// - LibModules must include BOTH "tool/disk_save" and "dos".
// - ROMDelayBoot = true, or the Disk ROM's INIT never runs (see CLAUDE.md).
void Disk_InitStub()
{
// Calls DSKIO (Disk ROM 4010h) through CALSLT rather than the Main ROM's
// PHYDIO wrapper at 0144h: PHYDIO jumps to a RAM hook that this machine
// leaves unpatched, so it returns carry-clear — reporting success while
// never touching the buffer. Going straight at the Disk ROM avoids it.
//
// IY high byte must hold the slot ID, and MASTER lives at F348h, so a
// word load from F347h lands it in IYh exactly as CALSLT wants.
static const u8 code[21] =
{
0xF3, // di
0xFD, 0x2A, 0x47, 0xF3, // ld iy, (0xF347) ; IYh = MASTER
0xDD, 0x21, 0x10, 0x40, // ld ix, #0x4010 ; DSKIO
0xCD, 0x1C, 0x00, // call 0x001C ; CALSLT
0x38, 0x04, // jr c, err
0x2E, 0x00, // ld l, #0 ; success
0xFB, // ei
0xC9, // ret
0x6F, // err: ld l, a ; BIOS error code
0xFB, // ei
0xC9, // ret
};
Mem_Copy(code, g_DiskStub, sizeof(code));
}
// Save slot. Multiple save files would be entries 1, 2, ... on the same disk.
#define SAVE_ENTRY 0
//-----------------------------------------------------------------------------
// Absolute sector read/write into g_Sector.
// carry clear = read, carry set = write
// A=drive B=sector count C=media ID DE=first sector HL=buffer
// Returns the BIOS error code, or 0 on success.
u8 Disk_Access(u16 sector, u8 write) __NAKED
{
sector; // on stack
write;
__asm
push ix
ld ix, #0
add ix, sp
ld e, 4(ix) // sector low
ld d, 5(ix) // sector high
ld a, 6(ix) // write flag
ld hl, #_g_Sector
ld b, #1 // one sector
ld c, #0xF9 // media ID: 720K DS/DD
or a // test flag; also clears carry (= read)
jr z, phydio_go
scf // carry set = write
phydio_go:
ld a, #0 // drive A:. 'ld a,n' preserves flags
call _g_DiskStub // -> PHYDIO, from RAM. Returns code in L
pop ix
ret
__endasm;
}
//-----------------------------------------------------------------------------
// Read the BIOS Parameter Block and work out where the data area starts.
// Returns FALSE if no disk responded at all.
bool Disk_Probe()
{
u8 drives = *(const u8*)M_NMBDRV_ADDR;
// Both of these are set by the Disk ROM's INIT. Without them the entry
// points below are not safe to call — on C-BIOS this work area is never
// initialised at all, and calling anyway black-screens the machine.
if (drives == 0 || drives > 8)
return FALSE;
if (*(const u8*)M_MASTER_ADDR == 0)
return FALSE;
Disk_InitStub();
// Sentinel: the disk entry points can return carry-clear ("success")
// without having touched the buffer, so a clean error code is not on its
// own proof that a read happened. Verify the buffer actually changed.
Mem_Set(0xAA, g_Sector, 16);
g_DiskErr = Disk_Access(0, 0);
if (g_DiskErr != 0)
return FALSE;
if (g_Sector[0] == 0xAA && g_Sector[11] == 0xAA)
return FALSE; // nothing was read; treat the disk as unusable
u16 bytesPerSec = g_Sector[11] | ((u16)g_Sector[12] << 8);
u16 reserved = g_Sector[14] | ((u16)g_Sector[15] << 8);
u8 numFATs = g_Sector[16];
u16 rootEntries = g_Sector[17] | ((u16)g_Sector[18] << 8);
u16 secPerFAT = g_Sector[22] | ((u16)g_Sector[23] << 8);
if (bytesPerSec != 512) // anything else is not a disk we made
return FALSE;
// Root directory occupies ceil(entries * 32 / 512) sectors
u16 rootSecs = (u16)((rootEntries + 15) >> 4);
g_SaveSector = reserved + (u16)numFATs * secPerFAT + rootSecs;
return TRUE;
}
bool g_HasDisk = FALSE; // a usable save disk was found at boot
//-----------------------------------------------------------------------------
u8 Save_Checksum(const SaveState* s)
@@ -352,25 +244,42 @@ u8 Save_Checksum(const SaveState* s)
return (u8)(0 - sum);
}
//-----------------------------------------------------------------------------
// A missing drive or a blank disk is not an error the player has to deal
// with — it just means nothing will persist.
void Disk_Init()
{
DiskSave_SetName("KISSAT"); // 6 chars max
DiskSave_SetExtension("SAV"); // 3 chars max
g_HasDisk = (DiskSave_Initialize() == SAVEDATA_VALID);
}
//-----------------------------------------------------------------------------
// TRUE if a valid save was loaded; FALSE means "start a new game", which is
// also what a blank disk gives us — no error path for the player to hit.
// also what a blank disk gives us.
bool Save_Load()
{
if (!g_HasDisk)
return FALSE;
g_DiskErr = Disk_Access(g_SaveSector, 0);
if (g_DiskErr != 0)
// SAVEDATA_UNSIGNED is expected, not an error: with AppSignature on,
// DiskSave_Check() wants the file's first 4 bytes to be g_AppSignature,
// but DiskSave_Save() writes the payload raw and never adds it — so a
// perfectly good file always reports UNSIGNED. Only a genuinely absent or
// unreadable file is a reason to bail; the magic and checksum below are a
// stronger check than the signature would have been anyway.
u8 status = DiskSave_Check(SAVE_ENTRY);
if ((status != SAVEDATA_VALID) && (status != SAVEDATA_UNSIGNED))
return FALSE;
SaveState* s = (SaveState*)g_Sector;
if (s->magic != SAVE_MAGIC || s->version != SAVE_VERSION)
return FALSE;
if (s->checksum != Save_Checksum(s))
if (!DiskSave_Load(SAVE_ENTRY, (u8*)&g_Save, sizeof(SaveState)))
return FALSE;
Mem_Copy(s, &g_Save, sizeof(SaveState));
// Trust nothing that came off a disk
if (g_Save.magic != SAVE_MAGIC || g_Save.version != SAVE_VERSION)
return FALSE;
if (g_Save.checksum != Save_Checksum(&g_Save))
return FALSE;
return TRUE;
}
@@ -384,11 +293,7 @@ bool Save_Write()
g_Save.version = SAVE_VERSION;
g_Save.checksum = Save_Checksum(&g_Save);
Mem_Set(0, g_Sector, sizeof(g_Sector));
Mem_Copy(&g_Save, g_Sector, sizeof(SaveState));
g_DiskErr = Disk_Access(g_SaveSector, 1);
return (g_DiskErr == 0);
return DiskSave_Save(SAVE_ENTRY, (const u8*)&g_Save, sizeof(SaveState));
}
//-----------------------------------------------------------------------------
@@ -605,6 +510,10 @@ void EnterMorning()
Dlg_Clear();
Dlg_Line(0, "Morning. The shutters go");
Dlg_Line(1, "up, the kettle goes on.");
if (!g_HasDisk)
Dlg_Line(2, "(no save disk in drive A)");
else if (!g_SaveOk)
Dlg_Line(2, "(save failed)");
Dlg_Prompt("[SPACE] open the shop");
}
@@ -630,18 +539,17 @@ void EnterEvening()
else
Dlg_Line(1, "Tomorrow, a steadier hand.");
if (Save_Write())
Dlg_Prompt("[SPACE] sleep (saved)");
else if (!g_HasDisk)
Dlg_Prompt("[SPACE] sleep (no disk)");
else
Dlg_Prompt("[SPACE] sleep (SAVE FAILED)");
Dlg_Prompt("[SPACE] sleep");
}
//-----------------------------------------------------------------------------
// Save *after* the day rolls over, so the file always describes the morning
// the player will wake up to. Saving during the evening instead would make a
// reload replay the day just finished and double-count its cups.
void AdvanceDay()
{
g_Save.day++;
g_SaveOk = Save_Write();
EnterMorning();
}
@@ -861,13 +769,10 @@ void main()
g_Frame = 0;
g_SpaceHit = FALSE;
g_SpacePrev = 1; // treat SPACE as held at boot: no phantom first edge
g_DiskErr = 0;
g_SaveSector = 14;
// Save disk. A missing or blank disk is not an error the player has to
// deal with — it just means today is day one and nothing will persist.
Save_Reset();
g_HasDisk = Disk_Probe();
Disk_Init();
Save_Load();
EnterMorning();