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
+42 -72
View File
@@ -62,83 +62,53 @@ Read `docs/CONVERSATION.md` for the original design discussion and rationale.
- Erase blits must repaint the *actual* background at that spot — the counter
area has three bands (wainscot / counter top highlight / counter top).
## Disk saving from a cartridge ROM — status: UNRESOLVED
## Disk saving from a cartridge ROM — WORKING
M2 wants saves on a real `.dsk`. The game is a cartridge MegaROM, which makes
this awkward. What is established so far, all verified on
`Philips_NMS_8250` + `-carta` + `-diska`:
Saves go to a real file on a real disk: `KISSAT00.SAV` in the root of the
disk in drive A. The disk stays a normal FAT disk you can inspect and copy.
- **A cartridge ROM does boot fine on a disk machine.** Not a problem.
- **`ROMDelayBoot = true` is required.** A cartridge's INIT normally runs
during the BIOS slot scan, and MSXgl never returns from it — so the Disk
ROM's own INIT never happens, `NMBDRV` stays 0 and the disk hooks are never
patched. This option installs an H.STKE hook and returns to the scan
instead. **Boot becomes much slower** — the game appears at ~40-90s of
emulated time, so early screenshots will show a blue BASIC screen and look
like a hang when they are not.
- **Any call into the disk system must be made from RAM.** Both PHYDIO and
CALSLT reach the Disk ROM at 4000h — the same page as this cartridge — so
the calling instruction is switched out mid-call unless it lives elsewhere.
`Disk_InitStub()` copies a small stub to RAM for exactly this. Calling
directly from ROM black-screens the machine.
- With the above in place, `NMBDRV` reads 2 and `MASTER` is non-zero, so the
Disk ROM *has* initialised by the time the game runs.
**Use `engine/src/tool/disk_save.h`.** MSXgl v1.3.0 added this module
specifically for "save to disk from a ROM application". Do not hand-roll
sector I/O — an earlier attempt using PHYDIO (Main ROM 0144h) and a direct
CALSLT to DSKIO (4010h) had both entry points return carry-clear *while
transferring nothing*, proven with a sentinel that survived the call intact.
**The remaining blocker:** both `PHYDIO` (Main ROM 0144h) and a direct
`CALSLT` to `DSKIO` (Disk ROM 4010h) return **carry clear — "success" — while
never touching the destination buffer.** Proven with a 0xAA sentinel that
survives the call intact. Page 0 is confirmed to be the Main ROM at call time
(0x002D reads 1 = MSX2, 0x0144 holds 0xC3 = `JP`), so the entry points
themselves are addressable.
Four things are required, none of them obvious:
Because a clean error code is *not* proof that a read happened, `Disk_Probe()`
now verifies the buffer actually changed and treats "no change" as no disk.
The game degrades gracefully: no disk means day 1, nothing persists, and the
evening card says so. Nothing crashes on either C-BIOS or a real-BIOS machine.
1. **`LibModules` must include both `"tool/disk_save"` and `"dos"`.** The
module alone will not link. See `projects/samples/s_save.js` for the
reference configuration, and `s_save.c` for usage.
2. **`ROMDelayBoot = true`.** A cartridge's INIT normally runs during the BIOS
slot scan and MSXgl never returns from it, so the Disk ROM's own INIT never
happens and there is no disk system to talk to. This option installs an
H.STKE hook and returns to the scan instead.
3. **Boot becomes slow.** The game appears at roughly 40-90s of *emulated*
time. Screenshots before that show a blue BASIC screen and look exactly
like a hang. Use `set throttle off` in the test script so this costs
seconds of wall clock, not minutes.
4. **`DiskSave_Check()` returns `SAVEDATA_UNSIGNED` for perfectly good
files.** With `AppSignature = true` (`-DAPPSIGN`), `DiskSave_Check()`
requires the file's first four bytes to equal `g_AppSignature` — but
`DiskSave_Save()` writes the payload raw and never adds it. Treat
`SAVEDATA_UNSIGNED` as success; `SaveState` carries its own magic, version
and checksum, which is a stronger check regardless.
Next things to try: check whether the H.PHYD RAM hook is genuinely patched at
that moment; try a different disk machine profile; or reconsider the medium
(see PLAN.md §8 item 1).
**Save timing:** written in `AdvanceDay()` *after* the day counter increments,
so the file always describes the morning the player wakes to. Saving during
the evening instead makes a reload replay the day just finished and
double-count its cups.
**RTC CMOS is not a viable fallback**, despite `RTC_USE_SAVEDATA` being TRUE
in `msxgl_config.h`. Block 3 of the RP-5C01 is 13 nibbles, and MSXgl's
`RTC_SaveData()` (`engine/src/clock.c`) stores exactly **6 bytes** — against a
~40-byte `SaveState`. `RTC_SaveDataSigned()` is tighter still. Remaining
options are cartridge SRAM, the FM-PAC SRAM (MSXgl has a `PAC` module), or
shrinking the save to a 6-byte fingerprint — which would cost per-character
affinity and money, so it is a design decision, not just a storage one.
Reference: MSX2 Technical Handbook ch.5, CLOCK-IC section.
Absent or unusable disk is not an error the player has to handle — it just
means nothing persists, and the morning card says so.
## Architecture conventions
- VRAM page 0: visible shop scene bitmap. Page 1: asset warehouse (sprite
frames, portraits, UI tiles) blitted with VDP commands (HMMM/HMMV).
- Seasons and time-of-day are palette swaps only (32-byte tables); never
duplicate background art per season.
- Sprite mode 2, two layered 16×16 sprites per customer (outline + fill).
- Portraits are 48×48, three expressions per character max.
- Game state lives in a single `SaveState` struct (~40 bytes); see DESIGN.md.
Implemented in `kissaten.c` with a magic word, version byte and checksum;
a failed or absent load is not an error, it just means day one.
- The day is a three-phase machine (morning / open / evening) and the clock is
**event-driven** — customers are the clock. `CUSTOMERS_PER_DAY` is the
single pacing dial.
- Dialogue scenes are data, not code: trigger conditions → portrait,
expression, text, optional choice, effects.
Test:
```
openmsx -machine Philips_NMS_8250 -carta emul/rom/kissaten.rom -diska save.dsk
```
Create a blank 720K disk with `tools/build/msxtar/msxtar -cf save.dsk --dos1 --size=720K`.
## Code style
- C99, MSXgl idioms (u8/u16 types, `msxgl_` module prefixes)
- Keep ISR/VBlank work minimal; game logic in main loop
- Comment bank-switching boundaries explicitly
- Tools are Node.js (plain JS, no framework), living in `tools/`
## Scope ladder (build in this order; every stage must be playable)
1. ✅ Shop scene + one customer + brew minigame + serve loop (no story)
2. ◐ Day/night cycle + save + money — day counter, three phases and the
journal line work; **persistence is blocked** (see the disk section below).
Money deliberately deferred to stage 5, since nothing gates on it before.
3. Dialogue engine + two regulars with short arcs ← real milestone
4. Seasons/weather/palette system
5. Remaining cast, upgrades, endings
`docs/PLAN.md` expands this into ten milestones with per-stage requirements
and a decision queue. `docs/SCRIPT.md` holds the full first-draft script.
**RTC CMOS is not an alternative** for a save this size, despite
`RTC_USE_SAVEDATA` being TRUE in `msxgl_config.h`. Block 3 of the RP-5C01 is
13 nibbles and MSXgl's `RTC_SaveData()` stores exactly **6 bytes**, against a
~40-byte `SaveState`. (MSXgl has no cartridge-SRAM mapper target either; the
`PAC` module's FM-PAC SRAM, 8 x 1024 bytes, is the other real option.)
+7 -7
View File
@@ -76,10 +76,9 @@ rains" patterns.
**Produces:** `SaveState` struct, phase state machine, save/load module.
**Status:** day counter, three phases, journal line and `SaveState` are done
and playable. **Persistence is blocked** — sector I/O from a cartridge ROM
reports success without transferring data; full findings in the disk section
of `../CLAUDE.md`.
**Status: ✅ complete.** Day counter, three phases, journal line, `SaveState`
and disk persistence all working. Verified by playing a day, cold-booting, and
resuming at day 2 with the served count intact.
**Pacing dial, revisited at the end.** `CUSTOMERS_PER_DAY` in `kissaten.c` is
the only number that sets day length. Worth reconsidering at M10 as a
@@ -482,7 +481,7 @@ milestone that consumes them.
| # | Needed for | Decision | My recommendation |
|---|---|---|---|
| 1 | M2 | ~~Save medium~~**save disk (.dsk), asset supplied** | ⚠ decided, but **not working yet** — see the disk section in `../CLAUDE.md`. Sector I/O reports success without transferring. **The RTC CMOS fallback I previously suggested is not viable**: block 3 is 13 nibbles, and MSXgl's `RTC_SaveData()` stores **6 bytes**, against a ~40-byte `SaveState`. Real fallbacks are cartridge SRAM, the FM-PAC SRAM, or shrinking the save to a 6-byte fingerprint |
| 1 | M2 | ~~Save medium~~**save disk (.dsk)** | ✅ working, via MSXgl's `tool/disk_save` module. Saves `KISSAT00.SAV` to drive A. Four non-obvious requirements — see the disk section in `../CLAUDE.md` |
| 2 | M2 | ~~Day length~~**5 min / 5 customers** | ✅ done. One constant, `CUSTOMERS_PER_DAY` in `kissaten.c` |
| 3 | M2 | ~~Clock model~~**event-driven** | ✅ done. Customers are the clock |
| 4 | M3 | ~~Who writes the dialogue~~**drafted by me, you edit** | ✅ resolved |
@@ -510,8 +509,9 @@ project, and both want answering now.
`../CLAUDE.md` for everything established so far.
2. ✅ **Aki's beats as prose** — done, along with the other three arcs, in
`SCRIPT.md`. Awaiting your edit pass.
3. ⬜ **Unblock saving.** Either keep digging at the disk path, or switch
medium. Note the RTC CMOS fallback is off the table (§8 item 1).
3. ⬜ **M3 vertical slice** — Aki's first six beats, hardcoded, with a scratch
portrait. The next real milestone, and the one that decides whether the
game works (§2, M3).
4. ⬜ **Start the portrait question** (§5) — still the only item with external
lead time, and now the longest pole by some distance.
Binary file not shown.
+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();
+3 -1
View File
@@ -4,7 +4,9 @@
// ASCII-safe name: used for output filenames (ROM, symbols, intermediates)
ProjName = "kissaten";
ProjModules = [ ProjName ];
LibModules = [ "system", "bios", "vdp", "print", "input", "memory", "math" ];
// "tool/disk_save" needs "dos" alongside it — see projects/samples/s_save.js
LibModules = [ "system", "bios", "vdp", "print", "input", "memory", "math",
"tool/disk_save", "dos" ];
Machine = "2";