Skip to content

SwanSong Playtesting and Debugging

Nick Hamze edited this page Jul 18, 2026 · 2 revisions

SwanSong Playtesting and Debugging

SwanSong is Story Forge's primary compiled-game progression target. Mesen and Mednafen remain useful independent emulators, but neither substitutes for a complete SwanSong route pass.

What the ship gate proves

For a game under games/<slug>/, the normal command is:

python3 scripts/ship_wscvn_game.py <slug>

The required order is:

  1. rebuild and audit the current project and assets;
  2. discover every reachable graph route and play each one in SwanSong;
  3. package the fresh ROM and evidence;
  4. verify the release ZIP against the live workspace.

--route all is the default. The route planner evaluates initial flag values, choice conditions, choice flag operations, branch conditions, investigations, hotspots, default exits, and endings. Instantaneous branch and chapter nodes remain in the graph plan but are not expected as observable runtime frames.

The playthrough report records discovered and tested route counts. Packaging fails unless those counts match.

The top-level Signal slice uses explicit paths because it predates the games/<slug>/ layout:

python3 scripts/playtest_wscvn_swansong.py \
  --name signal-before-dawn-slice \
  --project projects/signal-before-dawn-slice.wscvn.json \
  --rom runtime-local/signal-before-dawn-slice.wsc \
  --evidence-root assets/signal-before-dawn-slice/swansong-playthrough \
  --report assets/signal-before-dawn-slice/swansong-playthrough-report.json \
  --route all

Build Signal immediately before this command. A ROM created before mailbox schema 2/save schema 5 cannot provide current SwanSong evidence.

Exact SwanSong build

By default the test runner uses:

/Applications/SwanSong.app/Contents/Frameworks/libSwanAresEngine.dylib

The runner preflights that dylib before moving old route captures. It negotiates a bounded set of public ABI versions around Story Forge's current version and records the selected ABI, attempted versions, app version, dylib hash, backend, and build ID. This allows a compatible SwanSong ABI bump without mislabeling a harness startup problem as a game bug.

For an exact diagnostic override only:

SWANSONG_ENGINE_ABI=7 python3 scripts/playtest_wscvn_swansong.py \
  <slug> --route 1

If no compatible session can be created, the runner writes an explicit failed playthrough report over any prior green report. Never use old route evidence after an ABI or session-creation failure.

Bind a local or release-candidate build explicitly when needed:

python3 scripts/ship_wscvn_game.py <slug> \
  --swansong-dylib /path/to/SwanSong.app/Contents/Frameworks/libSwanAresEngine.dylib

The equivalent environment override is SWANSONG_ENGINE_DYLIB. Reports bind the exact dynamic-library hash, app version/build, engine build ID, ROM hash, and backend name.

An installed app and a newly built app can share a version number while containing different payloads. Check the actual bundle; do not infer freshness from CFBundleShortVersionString alone.

Runtime debug mailbox

The Story Forge runtime exposes a read-only WVNDBG1 mailbox in internal RAM. Schema 2 reports:

  • current phase, node, text block, choice index, and transition count;
  • observed keys, new keys, and accepted-action count;
  • investigation cursor coordinates;
  • Auto and Skip Read state;
  • text-speed mode;
  • music and SFX volume levels;
  • whether the current node has been read.

This mailbox is test instrumentation, not a substitute for input. The runner still sends real WonderSwan button masks through SwanSong's public engine ABI and verifies that confirm actions were accepted.

The project converter uses a stable topological order so explicit jumps remain forward in the ROM. Mailbox node values are indices in that compiled order, not positions in the source JSON list. The playtest runner mirrors the converter before mapping indices back to node IDs, and scripts/selftest_wscvn_swansong_node_order.py locks that behavior. A source expansion that moves a branch subtree must never make a scene look like an unplanned choice in the report.

For exhaustive coverage of finished-length games, the runner first uses the real Options UI to create a hash-recorded cartridge-RAM seed with Text Speed set to Instant. It stages that seed for route enumeration only; branching, buttons, audio, transitions, save states, and captures still execute in the compiled ROM. The independent restart-persistence test begins from factory defaults and advances through every paginated {pause} block after loading before it claims the game progressed beyond the saved node. For a complete --route all run it also quarantines exact old route-N-{ending,audio,stall}.{png,wav} artifacts before recapturing them. They move into a timestamped reports/runtime-stale/ directory; the report records both the quarantine root and moved names. The runner never deletes route evidence in place, and obsolete routes cannot survive a graph rewrite as current release evidence.

Evidence per route

scripts/playtest_wscvn_swansong.py writes:

  • the planned and observed node sequence;
  • every requested input and observed key mask;
  • accepted-action counters and a progress trace;
  • a native ending capture;
  • a native SwanSong audio sample with RMS, peak, clipping, and finite-sample checks;
  • every declared fade's native presented-raster luminance profile, including frame count, distinct levels, black hold, scene-swap spike analysis, and proof that fade-in recovers above the black basin;
  • a stall snapshot and runtime state if progress stops;
  • exact ROM and SwanSong engine facts.

Separate graph routes may legitimately converge on one final scene. Distinct final scene nodes may not converge on the same evidence: readiness rejects a matching terminal page plus visible state, and SwanSong rejects identical ending captures. Long-form scene expanders therefore keep branch-specific payoff text on the final page instead of finishing every ending with shared cadence prose.

Transition continuity

transition: fade is only project intent. The compiled runtime must traverse all 15 RGB444 brightness levels, disable both display layers while scene VRAM and palettes change, restore the runtime's known SCR1|SCR2 enable state, restore black before reenabling them, hold black for at least two presented frames, and then fade in. Never restore from display-register readback; SwanSong caught that value leaving the game black. This prevents both the one-frame full-bright target flash and a black-screen fade-in.

The route runner samples SwanSong's native raster while the debug mailbox is in node-entry phase. A fade profile fails when it is shorter than 24 presented frames, exposes fewer than six observable luma levels, lacks two dark frames, contains a bright spike inside the black scene-swap basin, or ends without recovering above that basin. The source guard still requires all 15 hardware levels even when dark artwork produces fewer visually distinct whole-frame averages.

Run the synthetic regression guard directly with:

python3 scripts/selftest_wscvn_transition_continuity.py

The first route also captures a native save state, advances the engine, restores the state, and requires an exact raster replay.

Restart persistence and player settings

After route coverage, the runner uses the real in-game menus to:

  1. enable Auto and Skip Read;
  2. change text speed;
  3. lower music and SFX volume;
  4. save into slot 1;
  5. read cartridge persistence through SwanSong's ABI;
  6. destroy the engine;
  7. create a fresh engine and stage the persisted bytes;
  8. verify settings restoration;
  9. load the saved node and continue beyond it.

The runtime save schema is version 5. It stores player preferences and read history. Schema-4 save data is intentionally rejected rather than interpreted with the wrong layout.

Player controls and options

The runtime now includes:

  • Auto advance;
  • Skip Read;
  • Story, Slow, Normal, Fast, and Instant text speeds;
  • Mute, 25%, 50%, 75%, and 100% music levels;
  • Mute, 25%, 50%, 75%, and 100% SFX levels;
  • a visible A/X>, AUTO, or SKIP continue indicator.

Preferences and read history persist in cartridge RAM.

SwanSong app-level checks

The SwanSong source repository builds and bundles SwanSongRouteRunner under:

SwanSong.app/Contents/Helpers/SwanSongRouteRunner

Verify the whole bundle, including the helper:

CONFIGURATION=debug Scripts/build-app.sh
Scripts/check-app-bundle.sh .build/app/SwanSong.app

For a real focus and keyboard-input regression test:

Scripts/check-player-input.sh /absolute/path/to/authorized-homebrew.wsc

The script launches an isolated SwanSong instance, injects a physical X key, and requires the input log to show keyboard-active effective A input plus a changed native raster fingerprint. macOS Accessibility permission is required for the terminal or Codex host that injects the event. Exit 77 means the gate was skipped and is not a pass.

Automated debug logs use schema swan-song-input-frame-log-v2; each frame may include gameRasterSHA256. SWAN_SONG_ENABLE_DEBUG_TOOLS=1, SWAN_SONG_DEBUG_LOG_PATH, and SWAN_SONG_STOP_AT_FRAME support isolated UI automation without enabling debug tools for ordinary users.

Stale-state audit

Before calling a release current:

python3 scripts/audit_wscvn_releases.py
python3 scripts/status_story_forge.py \
  --no-write \
  --check-index CURRENT_RELEASES.md
python3 scripts/check_build_wonderswan_vn_skill.py --require-installed-match
git diff --check

Also verify that new scripts are present in the Story Forge or SwanSong source repository, not only inside an installed skill, generated runtime-local/ copy, application bundle, or temporary directory. The canonical runtime change is the repository patch under runtime-patches/; generated runtime copies are not source of truth.

The Signal ship transaction may internally run the inventory and status tools with --allow-pending-signal-ship. That narrow recovery mode ignores only the previous ship report's ok/errors fields while still checking all release ZIP paths, sizes, and hashes. It exists so a failed prior ship attempt can replace its own stale report. After the ship report is green, always run the strict commands above without that flag; the final doctor and CURRENT_RELEASES.md must represent strict, non-transactional status.

The Signal ship transaction does not rebuild the finished-length game fleet. First ship each game with ship_wscvn_game.py, or run doctor_story_forge.py --build-games; then ship Signal. Signal performs a read-only repository doctor/status pass against those current game releases. Keeping the transactions independent avoids a long sequential fleet rebuild inside a reference-slice release and makes failures attributable to the title that produced them.

Clone this wiki locally