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.
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.
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.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… | Natural | his handle | roost B | log line |
|---|---|---|---|---|
| a save | standing, (660,181,20) | 1.1 → 3.1 | standing, y=385 | respawned 2 of 2 |
| a typed lua_reload | gone | — | 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.
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.
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.cp recipe was one line shortdocs/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:
| step | what the game says |
|---|---|
cp + lua_reload | errand in probe.disabled, not loaded |
+ echo errand >> enabled, lua_reload | 4 scripts loaded (was 3), none failed |
errand stage 2 | answers; its key reads {'stage': 2} |
| third_brother’s own store | unchanged: {'stage': 1, 'flames': 1} |
| the placement, before a room load | nothing standing — as designed |
| after a room load | En_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 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 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.
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.
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.
./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.