Two reloads

Playtest step 22 of Ship of Harkinian’s Lua layer — the live-reload loop, and the three things the documents said about it that turned out not to be true.
2026-09-21 · headway:personal/cheese-wool-cigar · d8c46705597a

docs/PLAYTEST.md step 22 is one paragraph and a table of dials. It is the loop every judgement call in the other twenty-two steps is supposed to be answered with — edit the quest, save, watch the change. Nobody had ever run it. Running it moved ./dev playtest --list from 20 of 23 written steps to 21, and turned up three corrections.

The mechanism: one function, one argument

There are two ways to reload the Lua runtime, and they are the same function called with different arguments. That one argument is the whole difference, and no document said so.

The two reload paths through ScriptHost_ReloadInternal The file watcher calls ScriptHost_ReloadInternal with respawn=1, which snapshots live actors before teardown and respawns them after. The console command lua_reload calls it with respawn=0, which skips both, leaving the scene empty. save the file ScriptWatch → respawn=1 type lua_reload ScriptHost_Reload → respawn=0 ScriptHost_ReloadInternal(respawn) SnapshotLive() only if respawn = 1 Close() + Open() every live scripted actor is killed — both paths RespawnSnapshot() 2 of 2 back, where they stood scene empty
The one argument. Both paths close the lua_State, which sets scriptFailed on every live scripted instance; ScriptedActor_Update kills a failed one on its next update. Only the watcher’s path notes down where they were standing first and puts them back. ScriptHost.c says this is deliberate: “typing lua_reload is asking for one.” Step 22 asserts the difference rather than filing it — and the doc now says which command does which.

Correction 1 — the doc described the watcher and named neither

Step 22’s paragraph says “the actor respawns where it stood, replays its arrival, and runs the new code.” True — of a save. A reader who reaches for lua_reload instead gets an empty graveyard and no hint why. Measured, on a fresh game at stage 1 with one flame:

after…Naturalhis handleroost Blog line
a savestanding, (660,181,20)1.1 → 3.1standing, y=385respawned 2 of 2
a typed lua_reloadgone—gone(no respawn line)

The handle change is the assertion that carries part B. A handle is the token probe hands out per live actor; a reload that really tore down and rebuilt cannot hand back the same one. Same coordinates, different handle, means it is a new instance standing in the old one’s footprints — which is exactly what “replays its arrival” should mean.

Correction 2 — a save does not move a placement

The doc’s dial table says: if the sac is out of reach, change ROOST_HANG. Step 22 turns that dial for real — 45 to 80 — and the sac you are looking at does not move. Walk out and back in and it has.

ROOST_HANG edited from 45 to 80: the sac moves only at the room load Three stages. Before the edit the sac hangs at y=385. Immediately after the save it is still at 385 although the declaration now says 420. After a room load it is at 420. 340 385 420 floor under roost B, as the room load measures it before HANG = 45 y = 385 after the save HANG = 80 y = 385 — unmoved declared here after a room load HANG = 80 y = 420
Re-declared is not re-placed. A reload re-runs every script’s top level, so the ship.actor.place call really does register the new coordinates. But the only thing that turns a declaration into an Actor_Spawn is a hook that fires once per room load, and a reload loads no room. Meanwhile the respawned instance is put back where it stood, not where the new file says it belongs. Step 22 then reverts the edit, waits for the watcher to re-read that too, and confirms the sac returns to 385 — so the step can be run twice.

Correction 3 — the cp recipe was one line short

docs/PLAYTEST.md promised that docs/QUESTS.md’s worked example — the smallest thing that is a quest — was installable with a copy and a reload:

cp .../tools/dryrun/quest_skeleton.lua build-cmake/soh/mods/scripts/errand/main.lua
# then lua_reload

Step 22 does exactly that, verbatim, and asserts it does not work. The skeleton lands in probe.disabled — “installed but not in ./mods/scripts/enabled” — because a later change made that file the list the loader reads, and this line predates it. Only then does the step add the missing line and watch the skeleton come up:

stepwhat the game says
cp + lua_reloaderrand in probe.disabled, not loaded
+ echo errand >> enabled, lua_reload4 scripts loaded (was 3), none failed
errand stage 2answers; its key reads {'stage': 2}
third_brother’s own storeunchanged: {'stage': 1, 'flames': 1}
the placement, before a room loadnothing standing — as designed
after a room loadEn_Errand at (600,181,120)

So docs/QUESTS.md’s worked example does run in the real game, which until now only the headless dry run had ever claimed.

The card’s harder half, answered

The card asked whether QUESTS.md assumes knowledge it never gives. On the install question it does not: it opens with “Read SCRIPTING.md first. Everything here assumes it,” and SCRIPTING.md §1 covers the enable list in full, down to “On is not enough either.” The gap was entirely in PLAYTEST.md’s self-contained shortcut past both documents — which is the shape doc rot usually takes: not a missing explanation, a convenience copy that stopped tracking the thing it copied.

The race that made the watcher look dead

The step’s first run reported no reload at all. The cause is a deliberate line in ScriptHost_Tick: while the watch CVar is off, every poll resets the baseline, so that turning the watcher on does not immediately reload on top of every edit made while it was off. The first poll after lua_watch on is therefore the one that decides what “unchanged” means.

The priming window after lua_watch on A timeline. An edit written before the first poll after lua_watch on is absorbed into the baseline and never fires. An edit after it is seen as a change and fires a reload. poll poll poll poll lua_watch on baseline set here a save inside this window is swallowed save change seen → reload
A person cannot lose this race. Ticking a checkbox and then reaching for an editor is seconds. A harness writing the file 40 ms after the console replies loses it every time, and the symptom is indistinguishable from a broken watcher. The step waits, and says in its own comment why.

Reported, not gated: the roost cries wolf on every save

A respawned instance re-asks the floor beneath it from mid-scene, where a room-load placement asks it at room load — and those are different questions. Roost B reads 359 on a live reload where the room load reads 340, which is enough to trip the quest’s own *** THE FLOOR IS NOT WHERE THE TABLE SAYS alarm. The sac does not actually move: it is put back at the y it had. What a person in the live-reload loop sees is a scene-integrity warning that is an artefact of the loop itself. Step 22 prints this and asserts nothing about it.

What this did not measure

The fifteen minutes. docs/PLAYTEST.md promised the skeleton was “installable in fifteen minutes.” The mechanical install here takes 0.3 s — but the doc’s claim is about a person reading QUESTS.md and deciding what to type, and nothing in a harness measures that. Reporting the harness’s seconds as an answer to it would be the flattering kind of check. What the step can say about that promise is the part that is mechanical, and it says it by assertion: the recipe as written did not work at all.

The respawn itself cannot be sabotaged. --sabotage patches Lua, and SnapshotLive/RespawnSnapshot are engine code, so no live break can reach the thing step 22 is mostly about. BREAK_STEP22 is aimed at the one consequence a Lua patch can falsify — it hardcodes the hang in the roost’s placement so ROOST_HANG is no longer what decides where the sac is declared — and the step goes red on “the dial did not take.” That is the failure the doc’s dial table promises against, and it bit. Parts B and D remain asserted but not negatively controlled.

The latency is a turbo’d clock. 0.05–0.10 s here, because the harness lifts the 20 fps throttle. The loop’s real unit is twelve frames — poll every four, quiet for eight — which is the “about half a second” lua_watch prints at shipping speed.


Where the number stands

./dev playtest --list | grep -c '\[written'
20  →  21     of 23

Remaining: step 14 (reset and do it again) and step 23 (the abuse list). Two steps also stand FAIL on real defects they found — step 9’s tear sitting 11.45 units under the stone apron it is drawn in, and step 20’s fourteen text lines wider than the box.

Step 22 is 3-for-3 PASS on three consecutive cold runs. Steps 1 and 22 together are green, so the enable list and mods/scripts are left exactly as they were found.