Files
stickman/plans/COMMAND_LINE_SCENE.md

17 KiB

Command-Line .scene Loader + PLAY-on-Launch

Status: proposed — awaiting approval before implementation.

1. Goal and boundaries

After Phase 5, a tester must be able to launch the standalone Sandbox Stage from the command line with a .scene file, have it load that scene, and (optionally) enter PLAY immediately so the scene "just plays" for automated/manual testing.

Four hard requirements:

  1. A command-line .scene loader.
  2. A flag to start "Stickman Studio" in PLAY mode.
  3. PLAY mode is invalid unless a .scene is specified.
  4. On scene-load error, show the user a dialog and default to a new (blank) scene.

This plan targets res://scenes/sandbox_stage.tscn (run standalone), its root script scripts/sandbox_stage.gd, a new pure parser, and a new headless test. It does not change the stickman editor, main.tscn, project.godot's run/main_scene, or the .scene v1.0 JSON schema.

Constraints that shape the design:

  • There is currently no command-line argument parsing anywhere in the repo.
  • SandboxStage._ready() ends with _update_scene_chrome() then _check_autosave_recovery(). Startup integration must slot in there.
  • set_mode(StageMode.PLAY) (line ~607) writes an autosave via _save_scene_to_path(AUTOSAVE_PATH, true) before _enter_play_mode(). A CLI PLAY transition must not create that autosave.
  • _load_scene_from_path(path, is_recovery=false) surfaces read failures with a toast, not a dialog (line ~1284). The CLI path needs a dialog-bearing variant.
  • StageSceneIO.read_file(path) returns {ok, data, error, warnings}; error is a String.
  • _show_missing_assets_dialog(paths) (line ~1383) is the existing AcceptDialog pattern to reuse.
  • The stage starts as a blank Untitled document (_scene_name = "Untitled", _current_scene_path = "", default camera), current_mode starts StageMode.EDIT.
  • Tests live in tests/*.gd, extend SceneTree, instantiate sandbox_stage.tscn, and drive internals directly (see tests/test_phase5_file_features.gd). No test autoload exists.

2. Command-line contract

2.1 Flags

Flag Type Meaning
--play boolean After a successful --scene load, enter StageMode.PLAY.
--scene <path> value (space-separated) The .scene file to load.
--scene=<path> value (equals form) Same, = separated.

A bare positional token that is not ---prefixed is accepted as a convenience scene path, but only when --scene is absent. Supplying both --scene and a positional path is a parse error (avoid silent ambiguity).

2.2 Godot separator mechanics

Godot consumes its own arguments up to a -- separator; everything after -- is exposed to the script via OS.get_cmdline_user_args(). To also run the sandbox stage instead of the project main scene, the stage scene is passed positionally before --:

Godot --path <project> res://scenes/sandbox_stage.tscn -- <user args>

OS.get_cmdline_user_args() then returns exactly ["--play", "--scene", "/abs/foo.scene"] (the positional scene path is not in the user-args array — it is a Godot argument).

2.3 Exact launch commands (macOS binary per project facts)

Primary tester flow — open and PLAY:

/Applications/Godot.app/Contents/MacOS/Godot \
  --path /Users/ryan/Projects/stickman \
  res://scenes/sandbox_stage.tscn \
  -- --play --scene /Users/ryan/Projects/stickman/stickmen/test.scene

Equivalent forms:

# --scene= form
.../Godot --path /Users/ryan/Projects/stickman res://scenes/sandbox_stage.tscn -- --play --scene=res://my_playground.scene

# bare positional scene path (no --scene)
.../Godot --path /Users/ryan/Projects/stickman res://scenes/sandbox_stage.tscn -- --play /Users/ryan/Projects/stickman/my_playground.scene

# load into EDIT (no PLAY)
.../Godot --path /Users/ryan/Projects/stickman res://scenes/sandbox_stage.tscn -- --scene res://my_playground.scene

Windows equivalent (same tokens, project's established console binary):

& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --path "C:\Godot4\stickman" res://scenes/sandbox_stage.tscn -- --play --scene "C:\Godot4\stickman\stickmen\test.scene"

2.4 Path handling

  • res://…, user://…, and absolute paths are passed through unchanged; StageSceneIO.read_file / FileAccess already handle all three (the stage already stores both res:// and absolute paths in recents).
  • The parser performs no scheme normalization. A bare relative path (no scheme, not absolute) is resolved by Godot against the process CWD and is therefore fragile; the documented and recommended forms are res:// (project-relative) or absolute. This keeps the parser pure and testable.

3. Files and classes

File Change Responsibility
scripts/cli_scene_launcher.gd new class_name CliSceneLauncher, extends RefCounted Pure argument parser; no scene-tree or OS access. Returns a structured result.
scripts/sandbox_stage.gd modified Startup integration, the CLI load/error-dialog path, the autosave-suppress guard, and the new error dialog in _build_ui().
tests/test_cli_scene.gd new Headless parser + startup-integration coverage.

No changes to StageSceneIO, stickman_editor.tscn, main.tscn, project.godot, or the .scene v1.0 schema. No authored .tscn node changes in sandbox_stage.tscn (the error dialog is code-built like the other file dialogs).

3.1 CliSceneLauncher API contract

class_name CliSceneLauncher
extends RefCounted

static func parse(args: Array) -> Dictionary
# -> { "scene_path": String, "play": bool, "error": String }
#    scene_path = "" when no scene requested
#    play       = false unless --play was present
#    error      = "" when valid, else a human-readable message

Parsing rules (documented so tests assert them):

  1. Scan tokens left to right.
  2. --play → play = true.
  3. --scene → consume the next token as the scene path; missing/empty value → error.
  4. --scene=<path> → scene path; empty value → error.
  5. A non----prefixed token → positional scene path; a second positional → error.
  6. Both --scene and a positional path → error ("Specify the scene with either --scene or a single positional path.").
  7. Any other ---prefixed token → error ("Unknown option: …").
  8. After the scan: play == true with scene_path == "" → error ("--play requires --scene <path>.").

The parser enforces requirement 3 (PLAY requires a scene) so the stage can treat any error uniformly.

3.2 sandbox_stage.gd additions

var _cli_error_dialog: AcceptDialog = null     # code-built in _build_ui()
var _cli_startup: bool = false                 # scoped suppress of autosave-on-Play

func _handle_cli_startup(args: Array) -> void          # entry point from _ready()
func _load_cli_scene(path: String) -> Dictionary        # -> { "ok": bool, "error": String }
func _show_cli_error_dialog(message: String) -> void

_load_cli_scene reuses StageSceneIO.read_file + _apply_scene_data(data, path, false) (the ordinary clean-load path), but returns the read error instead of toasting, so the caller can surface it in a dialog:

func _load_cli_scene(path: String) -> Dictionary:
    var read: Dictionary = STAGE_SCENE_IO.read_file(path)
    if not bool(read.get("ok", false)):
        return {"ok": false, "error": String(read.get("error", "Could not load scene."))}
    if not _apply_scene_data(read["data"], path, false):
        return {"ok": false, "error": "Could not apply scene data."}
    return {"ok": true, "error": ""}

The existing _load_scene_from_path toast path is left untouched for interactive Open.

4. Startup flow integration into _ready()

Replace the final _check_autosave_recovery() line in _ready() with a single _handle_cli_startup(OS.get_cmdline_user_args()) call (which internally owns recovery for the no---scene case):

# _ready() tail (current -> new)
_update_scene_chrome()
_check_autosave_recovery()                       # removed from _ready() body
# becomes:
_update_scene_chrome()
_handle_cli_startup(OS.get_cmdline_user_args())

_handle_cli_startup(args) flow:

result := CliSceneLauncher.parse(args)

if result.error != "":
    _show_cli_error_dialog(result.error)
    _check_autosave_recovery()          # invalid CLI -> normal blank-session recovery
    return                               # stay EDIT / blank

if result.scene_path.is_empty():
    _check_autosave_recovery()          # no --scene -> unchanged behavior
    return

# Explicit --scene: retire autosave and skip recovery (explicit file intent wins).
if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH)

load := _load_cli_scene(result.scene_path)
if not load.ok:
    _show_cli_error_dialog(load.error)
    return                               # blank/Untitled stage remains

if result.play:
    _cli_startup = true                  # suppress the autosave-on-Play write
    set_mode(StageMode.PLAY)
    _cli_startup = false

4.1 Ordering with autosave recovery (decision)

When --scene is explicitly given, _check_autosave_recovery() is skipped and the autosave file is retired (deleted) before the CLI load. Justification:

  • Explicit user intent ("open this file, and play it") must win over an offer to recover a previous interrupted session; popping the recovery dialog over a freshly CLI-loaded scene is both wrong and confusing.
  • _apply_scene_data(..., is_recovery=false) already deletes the autosave on success, but the failure path never reaches that line; deleting upfront covers both outcomes so a stale autosave cannot prompt on a later launch.
  • When no --scene is given (including the invalid --play-only case), recovery is unchanged: _check_autosave_recovery() still runs, preserving the existing crash-recovery lifecycle for ordinary launches.

4.2 set_mode autosave guard

# set_mode(), current:
if mode == StageMode.PLAY:
    if not _save_scene_to_path(AUTOSAVE_PATH, true): ...
# becomes:
if mode == StageMode.PLAY and not _cli_startup:
    if not _save_scene_to_path(AUTOSAVE_PATH, true): ...

_cli_startup is a scoped boolean set only around the CLI set_mode(StageMode.PLAY) call. This prevents the CLI PLAY transition from writing user://sandbox_autosave.scene (which would otherwise be newer than its own source file and trigger a false recovery prompt on the next launch). It does not change the interactive Play button path, and it still routes the transition through the public set_mode() so mode bookkeeping (_sync_mode_buttons, _apply_mode_frame, _refresh_mode_badge, mode_changed) runs exactly as for a manual Play press.

5. Error-dialog and fallback-to-new-scene behavior

A new AcceptDialog _cli_error_dialog is built in _build_ui() alongside _missing_assets_dialog:

SandboxStage/UI
└── CliErrorDialog (AcceptDialog)
    title: "Could Not Load Scene"
    name:  "CliErrorDialog"

_show_cli_error_dialog(message) sets dialog_text = message and calls popup_centered(), mirroring _show_missing_assets_dialog. It is a blocking dialog (not the transient toast), satisfying requirement 4.

After the dialog is dismissed, the stage is left exactly as a brand-new document: Untitled, empty World, default camera, current_mode == EDIT. No PLAY is entered. This is the "default to a new (blank) scene" guarantee.

The error dialog is also used for the --play-without---scene case (requirement 3), with the parser's error text (e.g. "--play requires --scene <path>.").

6. --play validity rules

CLI input Behavior
--scene <valid> Load the scene, remain EDIT. Title bar shows the loaded scene.
--play --scene <valid> Load the scene, then set_mode(StageMode.PLAY) (autosave suppressed).
--play (no --scene) Parser error → CliErrorDialog, remain EDIT/blank. Never auto-enter PLAY.
--scene <missing/corrupt> CliErrorDialog with the read error, remain EDIT/blank.
unknown flag / two positional paths / --scene+positional Parser error → CliErrorDialog, remain EDIT/blank.

7. Dirty / recent state

A CLI load uses the normal clean-load semantics (_apply_scene_data(..., is_recovery=false)): it adopts _current_scene_path, clears _dirty, adds the path to recents (_add_recent_scene), removes the autosave, and updates the title. This is what makes the title bar show Scene Name — <file>.scene — Stickman Studio and keeps the Open Recent menu consistent with the launched file. No special "read-only" or "dirty-on-load" CLI state is introduced.

8. Verification strategy

8.1 Headless test — tests/test_cli_scene.gd

Run with the project's established headless form (macOS):

/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/ryan/Projects/stickman --script res://tests/test_cli_scene.gd

Required assertions:

  1. Parser (pure, no scene tree): CliSceneLauncher.parse([]) → {scene_path:"", play:false, error:""}; ["--play","--scene","res://x.scene"] → {scene_path:"res://x.scene", play:true, error:""}; ["--scene=res://x.scene"] → scene path set; bare positional ["/abs/x.scene","--play"] → scene path + play; ["--play"] → error; ["--scene"] (missing value) → error; ["--bogus"] → error; two positionals → error; --scene + positional → error.
  2. Load: instantiate sandbox_stage.tscn, write a small valid .scene fixture to user://, call _handle_cli_startup(["--scene", fixture]), assert _current_scene_path == fixture, World child count matches, _dirty == false, _recent_scenes[0] == fixture, title contains the scene basename, and the autosave file is absent.
  3. PLAY: _handle_cli_startup(["--play","--scene", fixture]) → current_mode == StageMode.PLAY and user://sandbox_autosave.scene does not exist afterward (autosave-on-Play suppressed).
  4. Error → blank: _handle_cli_startup(["--scene", "user://definitely_missing_cli.scene"]) → _cli_error_dialog visible with non-empty dialog_text, World.get_child_count() == 0, current_mode == StageMode.EDIT.
  5. --play without scene: _handle_cli_startup(["--play"]) → _cli_error_dialog visible, current_mode == StageMode.EDIT, never PLAY.
  6. Recovery skipped: pre-write a valid user://sandbox_autosave.scene (autosave metadata), call _handle_cli_startup(["--scene", fixture]), assert the autosave file is removed and no AutosaveRecoveryDialog was created.

Note the repo gotcha (docs/tech_debt_and_optimizations.md #22): a newly added class_name CliSceneLauncher is not resolvable by --check-only --script until an --editor pass regenerates .godot/global_script_class_cache.cfg. Run once first:

/Applications/Godot.app/Contents/MacOS/Godot --headless --editor --path /Users/ryan/Projects/stickman --quit

Then a parse check:

/Applications/Godot.app/Contents/MacOS/Godot --headless --check-only --script res://scripts/cli_scene_launcher.gd --path /Users/ryan/Projects/stickman --quit

After the focused suite passes, re-run every existing script under tests/ using its documented headless command and report suite names/results.

8.2 Manual Godot Editor verification

Run the exact launch commands from §2.3 in a terminal (not F6) and confirm:

  • --scene res://<file>.scene opens the scene in EDIT with the correct title and Open Recent entry.
  • --play --scene res://<file>.scene opens and immediately plays; the Play mode badge shows ▶️ SIMULATING; no recovery prompt appears on the following ordinary launch.
  • --play alone shows the "Could Not Load Scene" dialog with "--play requires --scene <path>.", leaves a blank Untitled stage, and does not enter PLAY.
  • --scene /nonexistent.scene shows the dialog with the read error and leaves a blank Untitled stage.
  • A pre-existing user://sandbox_autosave.scene is removed when launching with --scene (explicit file intent overrides recovery).

9. Approval gate: decisions to accept before implementation

The architect recommends approving these as a set:

  1. Flag syntax — --play (boolean) and --scene <path> (with --scene=<path> and a single bare-positional convenience), parsed from OS.get_cmdline_user_args(); the parser enforces "PLAY requires a scene" as an error. Paths must be res:///user:///absolute (no scheme guessing).
  2. Autosave policy — an explicit --scene skips _check_autosave_recovery() and retires the autosave file; the CLI PLAY transition suppresses the autosave-on-Play write via a scoped _cli_startup guard (leaving the interactive Play button untouched).
  3. Failure semantics — on any CLI parse error or --scene load failure, show a blocking AcceptDialog (CliErrorDialog) and remain on a blank EDIT/Untitled stage; never auto-enter PLAY. A successful CLI load uses normal clean-load semantics (adopts path, clears dirty, updates recents/title).

10. Out of scope

  • Changes to main.tscn, stickman_editor.tscn, project.godot run/main_scene, or the .scene v1.0 JSON schema.
  • Reusing the CLI loader to launch the stickman editor (.stk) — this targets the Sandbox Stage only.
  • --headless-only automation mode, output/logging flags, or a --quit-after flag.
  • Argument path normalization (scheme guessing), environment-variable overrides, or multiple-scene batch loading.