Ship of Harkinian · agentic playtest · step 18

The Side-Car and the Copy

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.

Metric
17 → 18of 23 playtest steps written
Full suite
16 PASS2 FAIL (9, 20 — known), 5 not written
Sabotage
BITthe break flips the step to FAIL
Processes
3the only step that relaunches

1 · the problem

Slot 255 never saves anything

./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.

BootSequence=3the Map Select fileNum = 0xFF(255) SlotIsReal(255)= false nothing written file_select9 presses fileNum = 0Sram_InitSave Save_SaveFileOnSaveFile(0) file1.savfile1.txt

The same store, two ways in. Everything else in the suite takes the top one.

2 · the nine presses

START is the whole shortcut

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.

file selectempty slot quest menuNormal name entryone char cursor onEND file1.savexists main menuthe file Open thisfile? Yes Map Select(slot 0 only) AAA START A AA Slot 0 with DebugEnabled lands on the Map Select instead of in the world, so it costs one more A than slots 2 and 3. One more A into a game already in play is a roll.

Nine presses, all on a free-running game — the file select is not a PlayState, so nothing here is stepped.

Two CVars have to be in the config before the process starts. There is no 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

START, B, A — and the file lands a frame late

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.

worker thread(SaveManager) game thread(the frame tick) OnSaveFile(0)sFlushSlot = 0 ScriptedSave_Tickfopen + rename one frame

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.

ship.save 1 third_brother # Quest state for the Lua script 'third_brother', save file 1. # Edit by hand if you like. The last line must stay 'end', or the whole # file is ignored and the script starts again from its defaults. n flames 2 n stage 1 end
And it comes back. A second process, booted from nothing, reads the store at its declared defaults — stage 0, no flames — and it is the file open that puts two flames back. That ordering is asserted, so “the file brought it back” is a measurement rather than an assumption.

4 · the gap

There is no OnCopyFile

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.

file1.savthe save file1.txtthe quest file2.savbyte-for-byte file2.txtdoes not exist no OnCopyFile DeleteZeldaFile(to) removed it Asserted on disk and in the store: file2.sav is a byte copy of file1.sav, no file2.txt exists, health capacity and cumulative heart pieces are EQUAL across the copy, and the quest reads stage 0.

Everything except the quest copies. That is the documented behaviour; whether it looks like corruption is the next section.

5 · the judgement call

Two Poes, same graveyard

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.

step18_the_copy_forgets_he_is_dark.png On the copied file. Unlit — the 0-flame Poe, photographed from the same south-west vantage the suite's flame-colour captures use. The twenty hearts at the top of the frame came across with the copy. He opens text id 0xE000: the first meeting. (660, 181, 20) · stage 0 · 0/3 flames
step18_a_bad_edit_leaves_him_standing.png On the hand-edited file. Pale green rather than wholly dark — the same file as the two-flame save above, with 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
The question, printed rather than answered. A file that carries a finished player's hearts, medallions and rupees, and a Natural who has never met them. Is that a reset, or does it read as a corrupted save? The frames are above; the step does not decide.

6 · the correction

A reload cannot see a hand edit

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.

lua_reload(the console) declare #2Desc_Adopt live values kept file NOT read open file 1(file select) OnLoadFile(0)Attach Desc_ReadFilereset, then read the storestage = 9

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.

third_brother: 'stage' in the save is 9, which is not 0..4; using 4
The clamp is on the READ, not on the store. The store really does hold a raw 9 — 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 sayswhat 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

What the page simplified, and what is not verified

The file select is driven blind. There is no 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.
The first START after a file loads is swallowed, every time. Not a lost press: the pause menu refuses to open while 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.
One thing measured three times and still unexplained. A leftover side-car in 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.
Not driven: erasing a file in-game (the hook that does carry the side-car away), a randomizer quest made from the file select, and any slot but 1–3 of a copy whose source is file 1. The copy navigation refuses outright if asked for any other pair, rather than pressing its way somewhere it cannot describe.