Ship of Harkinian · agentic playtest · step 18
A Lua script in this engine keeps its saved state in a plain text file next to the save it
belongs to — Save/scripts/third_brother/file1.txt beside
Save/file1.sav. Erasing a file in the file-select screen takes the side-car with
it. Copying one does not. The copy gets every heart and every medallion and none of the
quest.
That was written down as a known gap and had never been run, because the harness that drives this game has never had a real save file to run it on. This is the mechanism, and the nine button presses that got one.
1 · the problem
./dev drive boots with BootSequence=3 and
DebugSaveFileMode=2, which lands the game on the debug Map Select with
gSaveContext.fileNum = 0xFF. The scripting layer refuses that number
outright — ScriptedSave_SlotIsReal takes 0–2
and nothing else — so no side-car is ever written and a relaunch has nothing to read
back.
Three earlier playtest steps each end with the same paragraph: save-and-quit is not judged, and covering it “needs a harness that boots into the file select and makes a real file”. Nobody had built one, so the round trip to disk had never happened even once.
The same store, two ways in. Everything else in the suite takes the top one.
2 · the nine presses
Naming a file in Ocarina of Time means walking a cursor around a keyboard grid to the END
key. It does not have to: in CM_NAME_ENTRY, START sets
kbdY = 5 and kbdX = 4 — that is the END key
— with no navigating at all. One typed character first, because END refuses a name
that is still all 0xDF.
Nine presses, all on a free-running game — the file select is not a PlayState, so nothing here is stepped.
cvar_set in the console, and the file select reads both long before a scenario
function runs. DebugEnabled makes slot 0 a debug save rather than a child at the
intro cutscene; DpadInText makes a menu cursor move on a press edge
instead of on a stick auto-repeat, whose step count would otherwise depend on how long the
pulse happened to last. Both are written into shipofharkinian.json
for this step's process only and put back to what they were at teardown.
3 · the save
There is no save console command, and the autosave cannot make the first one
(it needs a file that already exists and a three-minute interval). So the step uses
the pause menu's own Save: B on the open menu sets pauseCtx->state = 7 with
promptChoice = 0, and A on that prompt is Play_PerformSave.
The side-car is then written on the next frame, deliberately.
OnSaveFile fires at the end of SaveFileThreaded on one of
SaveManager's worker threads, so the hook does nothing but put the slot in a
volatile int; the serialise and the fopen happen on the game
thread, on the following tick.
Which is why the step polls for the file's contents, not for its existence: making the file in the first place already wrote one, holding the defaults.
4 · the gap
Erase fires a hook and the side-car goes with the file. Copy fires the hook for the
destination — CopyZeldaFile calls
DeleteZeldaFile(to) first — and then copies the .sav with
nothing further fired at all. There is no OnCopyFile in GameInteractor's hook
table to wire up.
That is the safe direction to fail in, and it is the direction the design chose on purpose: a quest that reset is a bug report, a quest that thinks it is finished on a file that never did it is a broken game with no way back out.
Everything except the quest copies. That is the documented behaviour; whether it looks like corruption is the next section.
5 · the judgement call
The step's own words ask a question a machine cannot answer: does the copy look like corruption? So it files the frames and prints the numbers beside them. Both of these are the same actor at the same coordinates on the same night; only the store behind him is different.
0xE000: the first
meeting.
(660, 181, 20) · stage 0 · 0/3 flames
n stage 9 typed into the side-car by hand
and clamped to 4 on the read. The frame is cluttered: the harness caught it on the
Graveyard title card with his box still fading in. The point of it is that
he is there at all, and that he opens 0xE009, the after-line.
(660, 181, 20) · stage 9 → 4 · 2/3 flames
6 · the correction
The side-car is a text file the script's own comments invite a player to open, so
n stage 9 is a thing somebody will type. The playtest doc says to make that
edit and then lua_reload. That cannot work, and finding out why is the
one part of this step that changed a document rather than confirming it.
A reload re-runs every script's top level, so ship.save.declare runs again
— but the second and later declarations take the adopt branch, which carries
the live values across and then deliberately does not re-read the file. The comment
in the source says why: what is in memory includes changes made since the last save, and
re-reading would quietly undo them.
The step asserts both: after the edit a reload leaves the store at 1 / 2 and prints nothing, and the open is what brings the 9 in.
natural_stage() is where the range check lives, so nothing complains until
something asks. The step therefore asks once through quest where, then warps to
the graveyard at night where the placement gate and his own per-frame update ask hundreds of
times more, and only then counts the line. exactly 1.
| the doc says | what runs |
|---|---|
edit the side-car, then lua_reload; the clamp line appears once |
no. The reload adopts the live values and never opens the file. Store unchanged, no line. |
| — | edit, then open the file. Clamp line once, quest where reads stage 4, and the Poe is still standing. |
7 · where this drifts
probe field for its config
mode, so every hop is a press with a generous settle after it. The hops that have an
observable are gated on one — the .sav appearing on disk for a creation,
in_play and save_slot together for an open — and every
gate that times out files the frame it got stuck in. But the settles
(1.2 s inside a screen, 2.2 s
after one that animates) are generous rather than swept. If this ever turns flaky, the PNG is
the evidence, not the timing.
gSaveContext.magicState is
MAGIC_STATE_FILL, and a just-loaded debug save is exactly that for the first
second or so. The fix is press-and-check — and only re-pressing while the game is
still in the state the press was meant to leave, because START toggles the menu and B
at the Save? prompt cancels it.
Save/ does not contaminate an ordinary boot — the store still reads
stage 0 — even though the log plainly shows the file being read during the script's own
load-time self-test. Something resets it between there and the socket coming up, and this
page does not know what. The step wipes the directory at both ends rather than relying on the
behaviour it could not account for.