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:
- A command-line
.sceneloader. - A flag to start "Stickman Studio" in PLAY mode.
- PLAY mode is invalid unless a
.sceneis specified. - 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};erroris aString._show_missing_assets_dialog(paths)(line ~1383) is the existingAcceptDialogpattern to reuse.- The stage starts as a blank Untitled document (
_scene_name = "Untitled",_current_scene_path = "", default camera),current_modestartsStageMode.EDIT. - Tests live in
tests/*.gd, extendSceneTree, instantiatesandbox_stage.tscn, and drive internals directly (seetests/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/FileAccessalready handle all three (the stage already stores bothres://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):
- Scan tokens left to right.
--play→play = true.--scene→ consume the next token as the scene path; missing/empty value → error.--scene=<path>→ scene path; empty value → error.- A non-
---prefixed token → positional scene path; a second positional → error. - Both
--sceneand a positional path → error ("Specify the scene with either --scene or a single positional path."). - Any other
---prefixed token → error ("Unknown option: …"). - After the scan:
play == truewithscene_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
--sceneis 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:
- 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. - Load: instantiate
sandbox_stage.tscn, write a small valid.scenefixture touser://, call_handle_cli_startup(["--scene", fixture]), assert_current_scene_path == fixture,Worldchild count matches,_dirty == false,_recent_scenes[0] == fixture, title contains the scene basename, and the autosave file is absent. - PLAY:
_handle_cli_startup(["--play","--scene", fixture])→current_mode == StageMode.PLAYanduser://sandbox_autosave.scenedoes not exist afterward (autosave-on-Play suppressed). - Error → blank:
_handle_cli_startup(["--scene", "user://definitely_missing_cli.scene"])→_cli_error_dialogvisible with non-emptydialog_text,World.get_child_count() == 0,current_mode == StageMode.EDIT. --playwithout scene:_handle_cli_startup(["--play"])→_cli_error_dialogvisible,current_mode == StageMode.EDIT, never PLAY.- 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 noAutosaveRecoveryDialogwas 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>.sceneopens the scene in EDIT with the correct title and Open Recent entry.--play --scene res://<file>.sceneopens and immediately plays; the Play mode badge shows▶️ SIMULATING; no recovery prompt appears on the following ordinary launch.--playalone shows the "Could Not Load Scene" dialog with"--play requires --scene <path>.", leaves a blank Untitled stage, and does not enter PLAY.--scene /nonexistent.sceneshows the dialog with the read error and leaves a blank Untitled stage.- A pre-existing
user://sandbox_autosave.sceneis 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:
- Flag syntax —
--play(boolean) and--scene <path>(with--scene=<path>and a single bare-positional convenience), parsed fromOS.get_cmdline_user_args(); the parser enforces "PLAY requires a scene" as an error. Paths must beres:///user:///absolute (no scheme guessing). - Autosave policy — an explicit
--sceneskips_check_autosave_recovery()and retires the autosave file; the CLI PLAY transition suppresses the autosave-on-Play write via a scoped_cli_startupguard (leaving the interactive Play button untouched). - Failure semantics — on any CLI parse error or
--sceneload failure, show a blockingAcceptDialog(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.godotrun/main_scene, or the.scenev1.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-afterflag.- Argument path normalization (scheme guessing), environment-variable overrides, or multiple-scene batch loading.