From fb6c9ea3850c6bc7753d1d5ca5ddea42ce547d58 Mon Sep 17 00:00:00 2001 From: btawaifi Date: Sat, 17 Oct 2026 23:10:21 +0300 Subject: [PATCH 1/3] Add a headless, fixed-step simulation mode for batch experiments New command-line options: -headless no window, renderer, audio, speech, joystick, HUD/radar or movies; implies -noninteractive and -nomovies. Graphics runs on the stub renderer, and model geometry/collision data still load. Frames run as fast as the CPU allows, on a mission clock that advances only by simulated time (wall-clock APIs are unchanged for profiling and UI deadlines). The clock starts at one hour and never moves backwards, so timestamps never collide with the "never" and "immediate" values. -fixed-dt authoritative step in seconds (default 1/30, range [1/65536, 0.25]). -max-sim-time end the run after this much simulated time (at most 32767; larger values overflow the fixed-point time type and are rejected). -sim-result write a JSON result (mission, seed, simulated and wall seconds, why it stopped, and the state of each mission goal) when the engine shuts down. The unthrottled clock is not a separate option: on its own it would freeze menu timers and break multiplayer timing, so it only comes with -headless. Script frame callbacks (On Frame) normally run from gr_flip(); they are split out as gr_process_frame_callbacks() so headless runs still execute them once per simulated frame. Camera and photo-mode interpolation are skipped (presentation only). Keyboard and joystick input is not read, but read_player_controls() still runs, so scripted (Lua) flight control, player physics and the player warp-out sequence work as usual. Unattended single-player runs: -start_mission normally stops at the fiction, command briefing, red alert and briefing screens and at the debriefing, waiting for a player. Headless runs skip the fiction and command briefing, enter a red-alert mission through the red alert screen as its Continue button does, and otherwise go to the briefing state and take the default loadout through commit_pressed() (the same call the engine makes for missions flagged no-briefing). The debriefing is left by quitting. The death popup cannot be answered, so once the player's ship has blown up the run ends. The result's "termination" is time_limit, start_failed (the default loadout could not be committed), player_died, or mission_exit. Modal popups cannot be answered in a headless run, so popup() logs the text and returns the abort answer instead of waiting (unless a script handles On Dialog Frame). Without this a first launch of a mod with no settings file, or any loadout-commit failure, hangs until the caller's timeout. --- code/cmdline/cmdline.cpp | 50 ++++++++++ code/cmdline/cmdline.h | 4 + code/graphics/2d.cpp | 35 +++++-- code/graphics/2d.h | 3 + code/io/timer.cpp | 28 ++++++ code/io/timer.h | 6 ++ code/popup/popup.cpp | 13 ++- code/radar/radarsetup.cpp | 5 + freespace2/freespace.cpp | 196 +++++++++++++++++++++++++++++++------- 9 files changed, 298 insertions(+), 42 deletions(-) diff --git a/code/cmdline/cmdline.cpp b/code/cmdline/cmdline.cpp index 1351c72f1db..148f4babb64 100644 --- a/code/cmdline/cmdline.cpp +++ b/code/cmdline/cmdline.cpp @@ -523,6 +523,10 @@ cmdline_parm frame_profile_write_file("-profile_write_file", NULL, AT_NONE); // cmdline_parm no_unfocused_pause_arg("-no_unfocused_pause", NULL, AT_NONE); //Cmdline_no_unfocus_pause cmdline_parm retail_time_compression_range_arg("-orig_speedx_range", NULL, AT_NONE); //Cmdline_retail_time_compression_range cmdline_parm benchmark_mode_arg("-benchmark_mode", NULL, AT_NONE); //Cmdline_benchmark_mode +cmdline_parm headless_arg("-headless", NULL, AT_NONE); +cmdline_parm fixed_dt_arg("-fixed-dt", "Authoritative simulation step in seconds", AT_FLOAT); +cmdline_parm max_sim_time_arg("-max-sim-time", "End after this many simulated seconds", AT_FLOAT); +cmdline_parm sim_result_arg("-sim-result", "Write the simulation result JSON to this path", AT_STRING); cmdline_parm pilot_arg("-pilot", nullptr, AT_STRING); //Cmdline_pilot cmdline_parm noninteractive_arg("-noninteractive", NULL, AT_NONE); //Cmdline_noninteractive cmdline_parm json_profiling("-json_profiling", NULL, AT_NONE); //Cmdline_json_profiling @@ -565,6 +569,10 @@ bool Cmdline_profile_write_file = false; bool Cmdline_no_unfocus_pause = false; bool Cmdline_retail_time_compression_range = false; bool Cmdline_benchmark_mode = false; +bool Cmdline_headless = false; +float Cmdline_fixed_dt = 1.0f / 30.0f; +float Cmdline_max_sim_time = 0.0f; +char *Cmdline_sim_result = nullptr; const char *Cmdline_pilot = nullptr; bool Cmdline_noninteractive = false; bool Cmdline_json_profiling = false; @@ -2378,6 +2386,48 @@ bool SetCmdlineParams() Cmdline_benchmark_mode = true; } + if (headless_arg.found()) + { + Cmdline_headless = true; + Cmdline_freespace_no_sound = 1; + Cmdline_freespace_no_music = 1; + Cmdline_nomovies = 1; + // frames run as fast as the CPU allows, on a mission clock that advances by -fixed-dt per frame + Cmdline_NoFPSCap = 1; + // nobody can dismiss an error or warning dialog, so report and exit instead (as -standalone does) + Cmdline_noninteractive = true; + } + + if (fixed_dt_arg.found()) + { + const float dt = fixed_dt_arg.get_float(); + // a step below the fixed-point resolution (1/65536 s) would round to zero; written as a positive range + // test so that NaN is rejected too + if (!(dt >= 1.0f / 65536.0f && dt <= 0.25f)) + { + Error(LOCATION, "-fixed-dt must be finite and in the range [1/65536, 0.25] seconds."); + return false; + } + Cmdline_fixed_dt = dt; + } + + if (max_sim_time_arg.found()) + { + const float max_sim_time = max_sim_time_arg.get_float(); + // mission time is 16.16 fixed point, so it cannot count past 32767 seconds + if (!(max_sim_time > 0.0f && max_sim_time <= 32767.0f)) + { + Error(LOCATION, "-max-sim-time must be finite, greater than zero and at most 32767 seconds."); + return false; + } + Cmdline_max_sim_time = max_sim_time; + } + + if (sim_result_arg.found()) + { + Cmdline_sim_result = sim_result_arg.str(); + } + if (pilot_arg.found()) { Cmdline_pilot = pilot_arg.str(); diff --git a/code/cmdline/cmdline.h b/code/cmdline/cmdline.h index 43a698b6d6c..8ed3cc5f32c 100644 --- a/code/cmdline/cmdline.h +++ b/code/cmdline/cmdline.h @@ -147,6 +147,10 @@ extern bool Cmdline_profile_write_file; extern bool Cmdline_no_unfocus_pause; extern bool Cmdline_retail_time_compression_range; extern bool Cmdline_benchmark_mode; +extern bool Cmdline_headless; +extern float Cmdline_fixed_dt; +extern float Cmdline_max_sim_time; +extern char *Cmdline_sim_result; extern const char *Cmdline_pilot; extern bool Cmdline_noninteractive; extern bool Cmdline_json_profiling; diff --git a/code/graphics/2d.cpp b/code/graphics/2d.cpp index 82cc870dda4..2ebdc970b1c 100644 --- a/code/graphics/2d.cpp +++ b/code/graphics/2d.cpp @@ -2076,7 +2076,7 @@ bool gr_init(std::unique_ptr&& graphicsOps, GraphicsAPI if (d_mode == GraphicsAPI::Default) { d_mode = gr_get_configured_render_api(); } - } else if ( !Is_standalone ) { + } else if ( !Is_standalone && !Cmdline_headless ) { // We cannot continue without this, quit, but try to help the user out first ptr = os_config_read_string(nullptr, NOX("VideocardFs2open"), nullptr); @@ -2228,7 +2228,7 @@ bool gr_init(std::unique_ptr&& graphicsOps, GraphicsAPI } // if we are in standalone mode then just use special defaults - if (Is_standalone) { + if (Is_standalone || Cmdline_headless) { mode = GraphicsAPI::Stub; width = 640; height = 480; @@ -2334,17 +2334,19 @@ bool gr_init(std::unique_ptr&& graphicsOps, GraphicsAPI gr_is_capable(gr_capability::CAPABILITY_PERSISTENT_BUFFER_MAPPING) ? "Enabled" : "Disabled")); mprintf(("Checking mod required rendering features...\n")); - for (gr_capability ext : Required_render_ext) { - if (!gr_is_capable(ext)) { - Error(LOCATION, "Feature %s required by mod not supported by system.\n", - gr_capability_to_human_readable_string(ext).c_str()); + if (!Cmdline_headless) { + for (gr_capability ext : Required_render_ext) { + if (!gr_is_capable(ext)) { + Error(LOCATION, "Feature %s required by mod not supported by system.\n", + gr_capability_to_human_readable_string(ext).c_str()); + } } } mprintf((" All required features are supported.\n")); bool missing_installation = false; if (!running_unittests && Web_cursor == nullptr) { - if (Is_standalone) { + if (Is_standalone || Cmdline_headless) { // Cursors don't work in standalone mode, just check if the animation exists. auto handle = bm_load_animation("cursorweb"); if (handle < 0) { @@ -2372,7 +2374,7 @@ bool gr_init(std::unique_ptr&& graphicsOps, GraphicsAPI gr_set_shader(NULL); - if (!Is_standalone) { + if (!Is_standalone && !Cmdline_headless) { if (Using_in_game_options) { // The value should have been loaded into the variable already so we can use that here gr_set_gamma(Gr_gamma); @@ -3296,7 +3298,7 @@ void gr_imgui_begin_frame() ImGui::NewFrame(); } -void gr_flip(bool execute_scripting) +void gr_process_frame_callbacks(bool execute_scripting) { // Execute external "On Frame" work items executor::OnFrameExecutor->process(); @@ -3311,6 +3313,11 @@ void gr_flip(bool execute_scripting) OnFrameHook->run(); } } +} + +void gr_flip(bool execute_scripting) +{ + gr_process_frame_callbacks(execute_scripting); model_process_cached_ui_render_instances(); @@ -3569,6 +3576,13 @@ static graphics::util::GPUMemoryHeap* get_gpu_heap(GpuHeap heap_type) { void gr_heap_allocate(GpuHeap heap_type, size_t size, void* data, size_t& offset_out, gr_buffer_handle& handle_out) { TRACE_SCOPE(tracing::GpuHeapAllocate); + if (gr_screen.mode == GraphicsAPI::Stub) { + // The stub renderer has no GPU heaps. Model geometry is not rendered in + // this mode, while authoritative model and collision data remain loaded. + offset_out = 0; + handle_out = gr_buffer_handle::invalid(); + return; + } auto gpuHeap = get_gpu_heap(heap_type); @@ -3579,6 +3593,9 @@ void gr_heap_allocate(GpuHeap heap_type, size_t size, void* data, size_t& offset void gr_heap_deallocate(GpuHeap heap_type, size_t data_offset) { TRACE_SCOPE(tracing::GpuHeapDeallocate); + if (gr_screen.mode == GraphicsAPI::Stub) { + return; + } auto gpuHeap = get_gpu_heap(heap_type); diff --git a/code/graphics/2d.h b/code/graphics/2d.h index 437bc85368d..494712771e8 100644 --- a/code/graphics/2d.h +++ b/code/graphics/2d.h @@ -1160,6 +1160,9 @@ bool gr_is_screenshot_requested(); //#define gr_flip GR_CALL(gr_screen.gf_flip) void gr_flip(bool execute_scripting = true); +// Run per-frame scripting work without performing presentation. Headless +// simulation calls this once after each authoritative gameplay frame. +void gr_process_frame_callbacks(bool execute_scripting = true); inline void gr_setup_frame() { gr_screen.gf_setup_frame(); diff --git a/code/io/timer.cpp b/code/io/timer.cpp index d3bec6523af..e07ef39d0c2 100644 --- a/code/io/timer.cpp +++ b/code/io/timer.cpp @@ -39,6 +39,10 @@ static bool Timestamp_sudo_paused = false; static uint64_t Timestamp_microseconds_at_mission_start = 0; +static bool Simulation_clock_enabled = false; +static uint64_t Simulation_clock_microseconds = 0; +static uint64_t Simulation_clock_fraction = 0; + static uint64_t timestamp_get_raw(bool start_frame = false); @@ -137,6 +141,26 @@ std::uint64_t timer_get_nanoseconds() return static_cast(time * Timer_to_nanoseconds); } +void timer_set_simulation_clock_enabled(bool enabled) +{ + Simulation_clock_enabled = enabled; + // Start well past zero, as a real session's clock is by the time a mission starts: TIMESTAMP values 0 and 1 + // mean "never" and "immediate", and cooldowns measured from a zero-initialised time assume a running clock. + Simulation_clock_microseconds = 3600 * MICROSECONDS_PER_SECOND; + Simulation_clock_fraction = 0; +} + +void timer_advance_simulation_clock(fix delta) +{ + if (!Simulation_clock_enabled || delta <= 0) { + return; + } + + const auto fixed_microseconds = static_cast(delta) * MICROSECONDS_PER_SECOND + Simulation_clock_fraction; + Simulation_clock_microseconds += fixed_microseconds / F1_0; + Simulation_clock_fraction = fixed_microseconds % F1_0; +} + static uint64_t timestamp_get_raw(bool start_frame) { static uint64_t timestamp_raw = 0; @@ -158,6 +182,10 @@ static uint64_t timestamp_get_raw(bool start_frame) static uint64_t timestamp_get_microseconds() { + if (Simulation_clock_enabled) { + return Simulation_clock_microseconds; + } + return Timestamp_time_compression_microseconds_offset + static_cast(timestamp_get_raw() * Timer_to_microseconds * Timestamp_time_compression_multiplier); } diff --git a/code/io/timer.h b/code/io/timer.h index 0e46c02f7ff..6d40a076685 100644 --- a/code/io/timer.h +++ b/code/io/timer.h @@ -109,6 +109,12 @@ extern std::uint64_t timer_get_nanoseconds(); extern int timer_get_seconds(); // seconds since program started... not accurate, but good for long // runtimes with second-based timeouts +// Optional authoritative mission clock for headless experiment runs. Wall-clock +// timer APIs remain unchanged so profiling and UI deadlines keep real time. Like the +// real clock it only moves forward, across missions too. +void timer_set_simulation_clock_enabled(bool enabled); +void timer_advance_simulation_clock(fix delta); + //================================================================= //================================================================= diff --git a/code/popup/popup.cpp b/code/popup/popup.cpp index 68b61edb5ad..31ba06a7663 100644 --- a/code/popup/popup.cpp +++ b/code/popup/popup.cpp @@ -12,6 +12,7 @@ #include #include "anim/animplay.h" +#include "cmdline/cmdline.h" #include "freespace.h" #include "gamesequence/gamesequence.h" #include "gamesnd/gamesnd.h" @@ -1148,7 +1149,17 @@ int popup(int flags, int nchoices, ... ) vsnprintf(Popup_info.raw_text, sizeof(Popup_info.raw_text), format, args); va_end(args); Popup_info.raw_text[sizeof(Popup_info.raw_text)-1] = '\0'; - + + // A headless run has nobody to answer, so log the popup and give the abort answer, unless a script answers dialogs. + if (Cmdline_headless && !scripting::hooks::OnDialogFrame->isActive()) { + mprintf(("Headless run: skipped popup \"%s\"\n", Popup_info.raw_text)); + for (i = 0; i < nchoices; i++) { + vm_free(Popup_info.button_text[i]); + Popup_info.button_text[i] = nullptr; + } + return -1; + } + gamesnd_play_iface(InterfaceSounds::POPUP_APPEAR); // play sound when popup appears io::mouse::CursorManager::get()->pushStatus(); diff --git a/code/radar/radarsetup.cpp b/code/radar/radarsetup.cpp index 2bf2f2fa0a3..bc5144db359 100644 --- a/code/radar/radarsetup.cpp +++ b/code/radar/radarsetup.cpp @@ -10,6 +10,7 @@ #include "bmpman/bmpman.h" +#include "cmdline/cmdline.h" #include "debugconsole/console.h" #include "freespace.h" #include "gamesnd/gamesnd.h" @@ -174,6 +175,10 @@ void radar_stuff_blip_info(object *objp, int is_bright, color **blip_color, int void radar_plot_object( object *objp ) { + if (Cmdline_headless) { + return; + } + vec3d pos, tempv; float awacs_level, dist, max_radar_dist; vec3d world_pos = objp->pos; diff --git a/freespace2/freespace.cpp b/freespace2/freespace.cpp index be6ddf71d60..4eba060d392 100644 --- a/freespace2/freespace.cpp +++ b/freespace2/freespace.cpp @@ -63,6 +63,7 @@ #include "gamesnd/eventmusic.h" #include "gamesnd/gamesnd.h" #include "graphics/debug_sphere.h" +#include "graphics/2d.h" #include "graphics/font.h" #include "graphics/light.h" #include "graphics/matrix.h" @@ -210,6 +211,7 @@ #include #include +#include #include "imgui.h" @@ -353,6 +355,18 @@ int Show_area_effect = 0; object *Last_view_target = nullptr; int dogfight_blown = 0; +static uint64_t Sim_run_wall_start_us = 0; +// why the run ended, for -sim-result; null means the mission or the game ended it ("mission_exit") +static const char* Sim_run_termination = nullptr; + +// End an unattended run, keeping the first reason given. +static void sim_run_end(const char* termination) +{ + if (Sim_run_termination == nullptr) { + Sim_run_termination = termination; + gameseq_post_event(GS_EVENT_QUIT_GAME); + } +} int frame_int = -1; float frametimes[FRAME_FILTER]; @@ -1898,6 +1912,7 @@ void game_init() // Initialize the timer before the os timer_init(); + timer_set_simulation_clock_enabled(Cmdline_headless); if (LoggingEnabled) { outwnd_init(); @@ -2006,11 +2021,11 @@ void game_init() // SOUND INIT START ///////////////////////////// - if ( !Is_standalone ) { + if ( !Is_standalone && !Cmdline_headless ) { snd_init(); } - if(!fsspeech_init()) { + if (!Cmdline_headless && !fsspeech_init()) { mprintf(("Failed to init speech\n")); } @@ -2019,7 +2034,7 @@ void game_init() ///////////////////////////// std::unique_ptr sdlGraphicsOperations; - if (!Is_standalone) { + if (!Is_standalone && !Cmdline_headless) { // Standalone mode doesn't require graphics operations sdlGraphicsOperations.reset(new SDLGraphicsOperations()); } @@ -2139,7 +2154,7 @@ void game_init() log_string(LOGFILE_EVENT_LOG,"FS2_Open Mission Log - Opened \n\n", 1); // standalone's don't use the joystick and it seems to sometimes cause them to not get shutdown properly - if(!Is_standalone){ + if(!Is_standalone && !Cmdline_headless){ io::joystick::init(); } @@ -2203,7 +2218,7 @@ void game_init() pilot_load_pic_list(); pilot_load_squad_pic_list(); - if (!Is_standalone) { + if (!Is_standalone && !Cmdline_headless) { // Load the default cursor and enable it io::mouse::Cursor* cursor = io::mouse::CursorManager::get()->loadCursor("cursor", true); if (cursor) { @@ -2248,7 +2263,7 @@ void game_init() // convert old pilot files (if they need it) convert_pilot_files(); - if ( !Is_standalone ) { + if ( !Is_standalone && !Cmdline_headless ) { #ifdef WITH_FFMPEG libs::ffmpeg::initialize(); #endif @@ -3764,22 +3779,19 @@ void game_simulation_frame() { TRACE_SCOPE(tracing::Simulation); - //Do camera stuff - //This is for the warpout cam - if ( Player->control_mode != PCM_NORMAL ) - Warp_camera.do_frame(flFrametime); + if (!Cmdline_headless) { + // Camera and photo-mode interpolation are presentation-only. + if (Player->control_mode != PCM_NORMAL) + Warp_camera.do_frame(flFrametime); - //Do ingame cutscenes stuff - if(!Time_compression_locked) - { - cam_do_frame(flFrametime); - } - else - { - cam_do_frame(flRealframetime); - } + if (!Time_compression_locked) { + cam_do_frame(flFrametime); + } else { + cam_do_frame(flRealframetime); + } - photo_mode_do_frame(flRealframetime); + photo_mode_do_frame(flRealframetime); + } // blow ships up in multiplayer dogfight if( MULTIPLAYER_MASTER && (Net_player != nullptr) && (Netgame.type_flags & NG_TYPE_DOGFIGHT) && (f2fl(Missiontime) >= 2.0f) && !dogfight_blown){ @@ -4363,7 +4375,7 @@ void game_frame(bool paused) } // Note: These are done even before the player enters, else buffers can overflow. - if (! (Game_mode & GM_STANDALONE_SERVER)){ + if (! (Game_mode & GM_STANDALONE_SERVER) && !Cmdline_headless){ radar_frame_init(); } @@ -4373,7 +4385,10 @@ void game_frame(bool paused) if (! (Game_mode & GM_STANDALONE_SERVER) ) { if( (!popup_running_state()) && (!popupdead_is_active()) ){ - game_process_keys(); + // a headless run has no input, but the player's flight controls still apply scripted (Lua) control + if (!Cmdline_headless) { + game_process_keys(); + } read_player_controls( Player_obj, flFrametime); } @@ -4404,9 +4419,16 @@ void game_frame(bool paused) return; } + if (Cmdline_headless && !Pre_player_entry && !(Game_mode & GM_STANDALONE_SERVER)) { + // gr_flip normally dispatches these callbacks after simulation. Mods + // commonly run authoritative game logic (AI, economy, objectives) from + // OnFrame, so preserve that work while omitting presentation in headless runs. + gr_process_frame_callbacks(); + } + } - if (!Pre_player_entry) { + if (!Pre_player_entry && !Cmdline_headless) { if (! (Game_mode & GM_STANDALONE_SERVER)) { DEBUG_GET_TIME( clear_time1 ) if (!openxr_enabled()) { @@ -4561,6 +4583,42 @@ void game_set_frametime(int state) fix thistime; float frame_cap_diff; bool do_pre_player_skip = false; + if (Cmdline_headless && state == GS_STATE_GAME_PLAY) { + // Keep the engine's normal authoritative frame/update path, but make its + // clock independent of wall time. Gameplay timestamps advance once after + // each simulation frame in game_do_frame(). + timer_start_frame(); + + const fix real_frametime = Pre_player_entry ? (F1_0 / 4) : fl2f(Cmdline_fixed_dt); + + if (Game_time_compression != Desired_time_compression) { + if (Time_compression_change_rate == 0) { + Game_time_compression = Desired_time_compression; + } else { + const bool ascending = Desired_time_compression > Game_time_compression; + Game_time_compression += fixmul(Time_compression_change_rate, real_frametime); + if ((ascending && Game_time_compression > Desired_time_compression) + || (!ascending && Game_time_compression < Desired_time_compression)) { + Game_time_compression = Desired_time_compression; + } + } + } + + Frametime = fixmul(real_frametime, Game_time_compression); + if (Frametime <= 0) { + Frametime = 1; + } + flRealframetime = f2fl(real_frametime); + flFrametime = f2fl(Frametime); + Last_time = timer_get_fixed_seconds(); + Last_frame_timestamp = _timestamp(); + + if (FrametimeOverall > (INT_MAX - Frametime)) { + FrametimeOverall = 0; + } + FrametimeOverall += Frametime; + return; + } // sync all timestamps across the entire frame timer_start_frame(); @@ -4709,6 +4767,19 @@ void game_do_frame(bool set_frametime) last_single_step = game_single_step; game_frame(); + + if (Cmdline_headless) { + timer_advance_simulation_clock(Frametime); + + // nobody can answer the death popup (which is not even shown), so the player's death ends the run + if ((Game_mode & GM_DEAD_BLEW_UP) && !(Game_mode & GM_MULTIPLAYER)) { + sim_run_end("player_died"); + } + } + + if (Cmdline_max_sim_time > 0.0f && timestamp_get_mission_time() >= fl2f(Cmdline_max_sim_time)) { + sim_run_end("time_limit"); + } } void multi_maybe_do_frame() @@ -6010,9 +6081,10 @@ void game_enter_state( int old_state, int new_state ) mission_campaign_maybe_play_movie(CAMPAIGN_MOVIE_PRE_MISSION); // determine where to go next - if (mission_has_fiction()) { + // a headless run has nobody to read the fiction or command briefing + if (!Cmdline_headless && mission_has_fiction()) { gameseq_post_event(GS_EVENT_FICTION_VIEWER); - } else if (mission_has_cmd_brief()) { + } else if (!Cmdline_headless && mission_has_cmd_brief()) { gameseq_post_event(GS_EVENT_CMD_BRIEF); } else if (red_alert_mission()) { gameseq_post_event(GS_EVENT_RED_ALERT); @@ -6155,13 +6227,13 @@ void game_enter_state( int old_state, int new_state ) // Goober5000 - people may not have realized that pausing causes this state to be re-entered if ((old_state != GS_STATE_GAME_PAUSED) && (old_state != GS_STATE_MULTI_PAUSED) && (old_state != GS_STATE_MAIN_MENU)) { - if ( !Is_standalone ) + if ( !Is_standalone && !Cmdline_headless ) radar_mission_init(); //Set the current hud set_current_hud(); - if ( !Is_standalone ) { + if ( !Is_standalone && !Cmdline_headless ) { ship_init_cockpit_displays(Player_ship); } } @@ -6613,11 +6685,26 @@ void game_do_state(int state) case GS_STATE_BRIEFING: game_set_frametime(GS_STATE_BRIEFING); + if (Cmdline_headless && !(Game_mode & GM_MULTIPLAYER)) { + // Same as the engine does for missions flagged no-briefing: take the default loadout and enter. + const auto commit_status = commit_pressed(true); + if (commit_status != commit_pressed_status::SUCCESS) { + Warning(LOCATION, "Headless run could not commit the default loadout (status %d); ending the run.", + static_cast(commit_status)); + sim_run_end("start_failed"); + } + break; + } brief_do_frame(flFrametime); break; case GS_STATE_DEBRIEF: game_set_frametime(GS_STATE_DEBRIEF); + if (Cmdline_headless && !(Game_mode & GM_MULTIPLAYER)) { + // The mission is over and nobody will dismiss the debriefing; the run ends with its result. + gameseq_post_event(GS_EVENT_QUIT_GAME); + break; + } debrief_do_frame(flFrametime); break; @@ -6696,6 +6783,11 @@ void game_do_state(int state) case GS_STATE_RED_ALERT: game_set_frametime(GS_STATE_RED_ALERT); + if (Cmdline_headless && !(Game_mode & GM_MULTIPLAYER)) { + // What the screen's Continue button does in single player: enter the mission. + gameseq_post_event(GS_EVENT_ENTER_GAME); + break; + } red_alert_do_frame(flFrametime); break; @@ -7027,7 +7119,7 @@ int game_main(int argc, char *argv[]) return 1; } - if (!Is_standalone && !headtracking::init()) + if (!Is_standalone && !Cmdline_headless && !headtracking::init()) { mprintf(("Headtracking is not enabled...\n")); } @@ -7060,11 +7152,12 @@ int game_main(int argc, char *argv[]) scripting::hooks::OnIntroAboutToPlay->run(); } - if (!Is_standalone && !skip_intro && !player_select_auto_select_requested()) { + if (!Is_standalone && !Cmdline_headless && !skip_intro && !player_select_auto_select_requested()) { movie::play("intro.mve"); } game_start_time(); + Sim_run_wall_start_us = timer_get_microseconds(); if (Is_standalone) { gameseq_post_event(GS_EVENT_STANDALONE_MAIN); @@ -7098,6 +7191,45 @@ int game_main(int argc, char *argv[]) // void game_shutdown(void) { + if (Cmdline_sim_result != nullptr) { + const auto sim_time = f2fl(timestamp_get_mission_time()); + const auto wall_time = Sim_run_wall_start_us > 0 + ? (timer_get_microseconds() - Sim_run_wall_start_us) / static_cast(MICROSECONDS_PER_SECOND) + : 0.0; + const char* termination = Sim_run_termination != nullptr ? Sim_run_termination : "mission_exit"; + json_t* result = json_pack("{s:i,s:s,s:i,s:f,s:f,s:f,s:b,s:s}", + "schema", 1, + "mission", Game_current_mission_filename, + "seed", static_cast(Cmdline_rng_seed), + "fixed_dt", Cmdline_fixed_dt, + "max_sim_time", Cmdline_max_sim_time, + "simulated_seconds", sim_time, + "time_limit_reached", !strcmp(termination, "time_limit") ? 1 : 0, + "termination", termination); + if (result != nullptr) { + json_object_set_new(result, "wall_seconds", json_real(wall_time)); + + // the outcome: goal states as the debriefing would show them (goals are kept until the next mission loads) + json_t* goals = json_array(); + for (const auto& goal : Mission_goals) { + if (goal.type & INVALID_GOAL) { + continue; + } + const int type = goal.type & GOAL_TYPE_MASK; + json_array_append_new(goals, json_pack("{s:s,s:s,s:s}", + "name", goal.name.c_str(), + "type", type == PRIMARY_GOAL ? "primary" : type == SECONDARY_GOAL ? "secondary" : "bonus", + "status", goal.satisfied == GOAL_COMPLETE ? "complete" : goal.satisfied == GOAL_FAILED ? "failed" : "incomplete")); + } + json_object_set_new(result, "goals", goals); + + if (json_dump_file(result, Cmdline_sim_result, JSON_INDENT(2) | JSON_SORT_KEYS) != 0) { + Warning(LOCATION, "Failed to write simulation result to '%s'.", Cmdline_sim_result); + } + json_decref(result); + } + } + events::EngineShutdown(); headtracking::shutdown(); @@ -7111,7 +7243,7 @@ void game_shutdown(void) #endif // don't ever flip a page on the standalone! - if(!(Game_mode & GM_STANDALONE_SERVER)){ + if(!(Game_mode & GM_STANDALONE_SERVER) && !Cmdline_headless){ gr_reset_clip(); gr_clear(); gr_flip(); @@ -7924,7 +8056,7 @@ void game_title_screen_display() { // anything that shares code between standalone and normal cannot make // gr_* calls in standalone mode because all gr_ calls are NULL pointers - if (Is_standalone) { + if (Is_standalone || Cmdline_headless) { return; } @@ -8003,7 +8135,7 @@ void game_title_screen_close() { // anything that shares code between standalone and normal cannot make // gr_* calls in standalone mode because all gr_ calls are NULL pointers - if (Is_standalone) { + if (Is_standalone || Cmdline_headless) { return; } From 93e0c72e6263f5742725413ade4f265614e0abeb Mon Sep 17 00:00:00 2001 From: btawaifi Date: Sat, 17 Oct 2026 23:49:28 +0300 Subject: [PATCH 2/3] Print Lua errors to stderr in noninteractive mode With -noninteractive, Error() and Warning() already print their text to stderr before they abort or return, so the reason is visible without a dialog. A Lua error only threw LuaErrorException, which ends the process as an unhandled exception, and release builds write no log, so an unattended run that hit a Lua error (for example a syntax error in a script table) gave no indication why. The Lua error text and traceback now go to stderr first, as for Error(). --- code/osapi/dialogs.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/code/osapi/dialogs.cpp b/code/osapi/dialogs.cpp index efef9866bab..5842079b32d 100644 --- a/code/osapi/dialogs.cpp +++ b/code/osapi/dialogs.cpp @@ -201,6 +201,9 @@ namespace os } if (Cmdline_noninteractive) { + // as Error() does, so the message is not lost when no log is written (e.g. release builds) + fprintf(stderr, "\n%s\n", msgStream.str().c_str()); + fflush(stderr); throw LuaErrorException(msgStream.str()); return; } From b15438e81e34ca5e91285c770865b9fa1f019b25 Mon Sep 17 00:00:00 2001 From: btawaifi Date: Sun, 18 Oct 2026 02:41:45 +0300 Subject: [PATCH 3/3] Address clang-tidy findings in the headless options Use nullptr for the new -headless parameter, and mark the two range checks that are written as !(in range) on purpose: the suggested De Morgan form (< min || > max) would accept NaN. --- code/cmdline/cmdline.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/code/cmdline/cmdline.cpp b/code/cmdline/cmdline.cpp index 148f4babb64..e35e8506c6d 100644 --- a/code/cmdline/cmdline.cpp +++ b/code/cmdline/cmdline.cpp @@ -523,7 +523,7 @@ cmdline_parm frame_profile_write_file("-profile_write_file", NULL, AT_NONE); // cmdline_parm no_unfocused_pause_arg("-no_unfocused_pause", NULL, AT_NONE); //Cmdline_no_unfocus_pause cmdline_parm retail_time_compression_range_arg("-orig_speedx_range", NULL, AT_NONE); //Cmdline_retail_time_compression_range cmdline_parm benchmark_mode_arg("-benchmark_mode", NULL, AT_NONE); //Cmdline_benchmark_mode -cmdline_parm headless_arg("-headless", NULL, AT_NONE); +cmdline_parm headless_arg("-headless", nullptr, AT_NONE); cmdline_parm fixed_dt_arg("-fixed-dt", "Authoritative simulation step in seconds", AT_FLOAT); cmdline_parm max_sim_time_arg("-max-sim-time", "End after this many simulated seconds", AT_FLOAT); cmdline_parm sim_result_arg("-sim-result", "Write the simulation result JSON to this path", AT_STRING); @@ -2403,7 +2403,7 @@ bool SetCmdlineParams() const float dt = fixed_dt_arg.get_float(); // a step below the fixed-point resolution (1/65536 s) would round to zero; written as a positive range // test so that NaN is rejected too - if (!(dt >= 1.0f / 65536.0f && dt <= 0.25f)) + if (!(dt >= 1.0f / 65536.0f && dt <= 0.25f)) // NOLINT(readability-simplify-boolean-expr) { Error(LOCATION, "-fixed-dt must be finite and in the range [1/65536, 0.25] seconds."); return false; @@ -2415,7 +2415,7 @@ bool SetCmdlineParams() { const float max_sim_time = max_sim_time_arg.get_float(); // mission time is 16.16 fixed point, so it cannot count past 32767 seconds - if (!(max_sim_time > 0.0f && max_sim_time <= 32767.0f)) + if (!(max_sim_time > 0.0f && max_sim_time <= 32767.0f)) // NOLINT(readability-simplify-boolean-expr) { Error(LOCATION, "-max-sim-time must be finite, greater than zero and at most 32767 seconds."); return false;