Skip to content

Add an opt-in headless fixed-step simulation mode, seeded RNG, On Ship Damage hook and batch runner - #7874

Closed
BTawaifi wants to merge 5 commits into
scp-fs2open:masterfrom
BTawaifi:feature/headless-sim-on-master
Closed

BTawaifi wants to merge 5 commits into
scp-fs2open:masterfrom
BTawaifi:feature/headless-sim-on-master

Conversation

@BTawaifi

@BTawaifi BTawaifi commented Oct 16, 2026 •

Copy link
Copy Markdown

What this adds

An opt-in headless, fixed-step simulation mode for running missions unattended and many times, plus the pieces it needs. Nothing changes unless the new flags are passed.

New command-line options:

Option Effect
-headless No window, renderer, audio, speech, joystick, HUD/radar or movies. Uses the stub renderer; model and collision data still load. Implies -unthrottled, -noninteractive, -nomovies.
-unthrottled Run frames as fast as the CPU allows, on a virtual mission clock that only advances by simulated time. Wall-clock APIs (profiling, UI deadlines) are unchanged.
-fixed-dt <s> Authoritative step, default 1/30, range [1/65536, 0.25].
-max-sim-time <s> End the run after this much simulated time (at most 32767, the fixed-point limit).
-sim-result <path> Write a JSON result (mission, seed, simulated and wall seconds, termination) at shutdown.

In a headless run -start_mission goes straight through: fiction and command briefing are skipped, red-alert missions pass through the red alert screen, the default loadout is committed through commit_pressed(), and the debriefing is left by quitting. A failed commit ends the run and the result says start_failed. Modal popups are logged and answered with the abort answer instead of waiting for input (unless a script handles On Dialog Frame).

Commits

  1. Make seeded runs reproducible across the RNG families the simulation uses. -seed previously seeded only the main RNG. It now also seeds RandomRange instances and the three std::shuffle call sites (ship firepoints, asteroid splits, random message senders), which used a fresh random_device each time. Without -seed they behave exactly as before.
  2. Add an "On Ship Damage" script hook. Reports resolved shield and hull damage with source, weapon, damage type and quadrant. With no script registered the cost is one isActive() lookup per damage event.
  3. Add a batch runner (tools/headless_batch/): a standard-library Python script that runs many headless engine processes in parallel (one workspace, seed and UDP port each) and collects the results. Includes a README and unit tests that need no game files.
  4. Add the headless, fixed-step simulation mode (the options above).
  5. Use the mission clock for afterburner cooldowns in unthrottled runs. Those cooldowns used the real clock, which an unthrottled run's simulated time does not follow. Only affects -unthrottled.

Behaviour without the new flags

I read each hunk with that question in mind; the additions are single boolean checks on the normal path (timer read, frame timing, radar_plot_object, popup()), the hook lookup above, and the unchanged random_device seeding for the shuffles. No windowed gameplay regression test was run.

Testing

  • Runner tests: python -m unittest tools/headless_batch/test_run_batch.py (16 tests).
  • Headless runs on a retail mission: a script-triggered end-mission ends the run at the debriefing (termination: mission_exit), and four runs with seeds A, A, B, B gave identical end-state fingerprints for the equal seeds and different ones for the different seeds (short, quiet mission).
  • Red-alert mission goes START_GAME -> RED_ALERT -> ENTER_GAME -> GAME_PLAY in headless mode.
  • Fuzzed the new command-line values and the runner's plan validation (invalid values are rejected without hanging).
  • Windowed A/B against current master on one retail mission: one uncapped pair gave 2.749 ms (master) vs 2.763 ms (this branch) mean frame time over 3000 frames. A longer series was frame-capped and only shows the cap, so this is not a real performance comparison.

Known limits

  • Same-seed runs of long scenarios with many AI-driven ships can still diverge; the cause is not found. The README says to treat same-seed runs as correlated, not identical.
  • -unthrottled without -headless freezes menu timers and breaks multiplayer timing; it is meant to be used with -headless.
  • In a headless run a player death does not reach the debriefing; the run continues to -max-sim-time.
  • The engine's argument parser treats a value beginning with - (for example -seed -1) as an unknown option and shows its existing message box, which blocks a headless run. Unchanged upstream behaviour; documented in the README.

…uses

-seed only seeded the main RNG. RandomRange instances (seeded from a separate
generator before tables parse) and the std::random_device shuffles of weapon
firepoints, asteroid splits and random-message senders stayed unseeded, so two
runs with the same seed could diverge from the first frame.

- Random::seed_ranges() seeds the generator that RandomRange instances draw from.
- game_init() seeds both families when -seed is given, before table parsing.
- Random::shuffle_seed() gives each std::shuffle its generator seed: a draw from
  the seeded main RNG under -seed, OS entropy otherwise (normal gameplay is
  unchanged). The ship firepoint, asteroid-split and message-sender shuffles use
  it instead of three separate std::random_device sites.
Runs after resolved damage has been applied to a ship (lethal hits run before
the destruction callback). Hook variables: Target, Source, Attacker,
WeaponClass, WeaponSubtype, SourceKind (PROJECTILE, BEAM, SHOCKWAVE, COLLISION,
OTHER), DamageType, ShieldDamage, HullDamage, Quadrant, Ballistic, BeamWeapon
and LethalHit. Conditions can filter on the target ship. Nothing is computed
unless a script has registered the hook.
tools/headless_batch/run_batch.py runs many headless engine processes in
parallel, one mission, workspace, seed and UDP port per process, and collects
each run's -sim-result file into results.jsonl and summary.json. A run counts as
ok only if the engine exited 0 and wrote a readable result; crashes, missing
results and timeouts are recorded as failures, and a timed-out process tree is
killed.

Plans are validated up front: unknown keys, out-of-range values, a missing pilot
or mod folder, a base workspace and output directory on different volumes (the
.vp packs are hardlinked), and, on Windows, an output directory so deep that the
engine could not open a staged file are all errors that say what to change.
Workspaces hardlink .vp packs and copy everything else so a run does not write
through to the base. Supports --dry-run and --resume.

README.md documents the headless options, what runs and what is skipped, the
plan format, outputs, reproducibility and known limits. test_run_batch.py covers
validation, command construction, staging, the volume and path checks, and a
stub engine (success, crash, missing result, timeout) and needs no game files.
New command-line options:
  -headless        no window, renderer, audio, speech, joystick, HUD/radar or
                   movies; implies -unthrottled, -noninteractive and -nomovies.
                   Graphics runs on the stub renderer, and model
                   geometry/collision data still load.
  -unthrottled     run frames as fast as the CPU allows, with a virtual 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 <s>    authoritative step in seconds (default 1/30, range
                   [1/65536, 0.25]).
  -max-sim-time <s>  end the run after this much simulated time (at most
                   32767; larger values overflow the fixed-point time type and
                   are rejected).
  -sim-result <path> write a JSON result (mission, seed, simulated and wall
                   seconds, why it stopped) when the engine shuts down.

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).

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). A failed commit ends the run with a warning and the result says
"start_failed". The debriefing is left by quitting, so most single-player missions can be run
without a person present.

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.
afterburners_start()/afterburners_stop() measure the afterburner cooldown
(and the player's re-engage delay) with timer_get_milliseconds(), the real
clock. An unthrottled run simulates many seconds per real second, so a
cooldown lasted far longer in simulated time and varied with machine speed
and load. Of the wall-clock uses found in the simulation sources searched
(not the whole engine), this was the one that affects gameplay, and a
possible source of run-to-run variation between same-seed runs.

Under -unthrottled these sites now use timestamp(), the virtual mission
clock. Without the flag they still call timer_get_milliseconds().
@BTawaifi
BTawaifi force-pushed the feature/headless-sim-on-master branch from 2d63ffe to 64c1d68 Compare October 16, 2026 01:48
@Goober5000

Copy link
Copy Markdown
Contributor

I expect this would need some of the bugfixes in #7834. Wait until that goes in first.

Also, @dustinspace217 has put together another FSO harness for testing. Let's see if we can reconcile the different designs.

@dustinspace217

Copy link
Copy Markdown
Contributor

Interesting. I'll see if there's anything this build does better than mine and adopt it if so. Mine has gotten quite a bit of testing and fine tuning at this point. Mine is primarily designed for agents, but can be driven fully from CLI.

@dustinspace217

dustinspace217 commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Ran a comparison between this PR and my harness (reviewed with Claude, two models independently, claims checked against the engine source).

Tl;dr: Mine is better at flying like a human does and drives the engine from the outside on stock builds. This one is better at fast, structured, repeatable runs, but needs engine changes. They sit at different layers, so I think they fit together rather than compete.

What this PR does better

  1. Speed and cost per run. My only speed control is time compression, which makes each physics step bigger (worse collisions and AI). At 800% each frame covered about 8.5x more mission time. This PR keeps a fixed step (1/30 s by default), just runs more steps, and uses no GPU. I haven't measured it against mine yet.
  2. Repeatable runs. With -seed now covering the engine's C++ random number generators, same-seed runs can come out the same. Lua's math.random is still separate, and long runs with many AI ships still drift, as the README says.
  3. Damage data. On Ship Damage reports real shield and hull damage, weapon class, attacker and lethal hits. Mine can only infer hits from collisions and hull changes.
  4. Many seeds at once. The batch runner gives each run its own folder, seed and port, can resume, and writes results.jsonl plus a summary. Mine has nothing that sweeps N seeds.

Issues I found (each checked in source)

  • No steering under -headless. The PR skips the block that calls read_player_controls, which is where Lua flight control is applied, so an agent can't fly the player ship. Whether AI flying the player still works is untested.
  • Restarts don't repeat. The engine re-seeds its main RNG every mission, but the RandomRange generators are seeded only once at startup, so a mission restarted in the same process won't repeat. One process per run, as the batch runner does, is unaffected.
  • The result file can mislead. It has no mission outcome, and a run where the player dies idles until -max-sim-time and counts as ok.
  • Afterburner cooldown. The engine times +Aburn Cooldown Time on the real clock, so under time compression the cooldown lasts longer in mission time than the table says. This PR fixes that only for -unthrottled. I think it should use the mission clock everywhere.
  • Popups: no conflict. The automatic answer steps aside when a script handles On Dialog Frame, and my bridge does.

Requests

  • Keep Lua flight control working under -headless. Then an agent could fly at a fixed 1/30 s step, which should fix the aiming trouble time compression causes for mine.
  • Add the mission outcome to the result file (goals, player alive, losses).
  • End the run when the player dies.
  • Re-seed the RandomRange generators at each mission start.

Where mine is ahead

Live control and game-state reads during a run, screenshots and renderer comparison, qtFRED, multiplayer, crash reports and validation, a Lua safety check, hard resource limits and network isolation, and an agent interface (MCP and CLI). It runs on stock release builds today.

Suggested way forward

For the maintainers to weigh in on: splitting this into separate PRs might make review easier.

  1. RNG seeding
  2. On Ship Damage hook
  3. Afterburner cooldown on the mission clock, in all modes
  4. Headless plus unthrottled, fixed step and the result file, after Make quickstart options quick, and fix several associated bugs #7834. These have to land together, since -unthrottled without -headless breaks menu timers and multiplayer timing.

Once those are in, I'll add support for them to my harness: a seed option, a headless launch mode, a batch command, and the damage hook in its telemetry.

On folding the harness into the SCP org: I think it should be its own repo, so downloading it isn't tied to the engine. It also needs Linux today (but I'm working on that), while the engine builds everywhere. The batch runner could live here in tools/ or with the harness, whichever the maintainers prefer.

@BTawaifi

Copy link
Copy Markdown
Author

This is used to accelerate the autoplay of missions and/or get more logging out of what happens in the engine.

In RTS fleet command, you have 2+ factions that coordinate the warping and complex orders and tactics of ships and sudo wings, and to tune it; it had to be replayed hundreds of times, I have tools that read the traces and find out for example did the Capital spawn in 20 mins in because the cost of the ship was too low, and how much damage it did to the base so the damage done to the base by that ship or beam is reduced or adjusted, we need to know how far the turrets shoot because you don't want a turret blowing another base thousands of space units away, so the hooks are needed for deeper tracing and error logging.

I think the layer that @dustinspace217 is building will provide much needed stability testing and agent driving can unlock stuff like skirmishes or entire generated campaigns from the player perspective that goes beyond simple mission generation and much more, having the logging and speed unlocked on demand will allow the reviewers to do much more per unit of time.

Do take what you see is most useful from the PR, Good luck O7.

@dustinspace217

Copy link
Copy Markdown
Contributor

Sorry for the confusion here @BTawaifi - I actually think you should craft the layered PRs I mentioned that will build the hooks into the engine. That part was firmly your idea and you deserve the contribution credit (and your agents have the context needed to easily make it happen). Just wait for the maintainers to approve the approach.

Once those hooks are built, I can build in ways for my harness to use them.

@BTawaifi

BTawaifi commented Oct 16, 2026 •

Copy link
Copy Markdown
Author

No worries @dustinspace217 and thank you for building out the harness!

The engine change doesn't conflict with your work; rather, it adds another layer that the harness can leverage.
The improved testing stability from your harness will be a game changer for refining the multiplayer experience.

Since I’m not currently focusing on multiplayer engine work, I’ll keep reporting any issues I spot as I test my mod.
Hopefully, you or others on the team can tackle them when ready.

@Goober5000

Copy link
Copy Markdown
Contributor

Ok, I've gotten #7834 reviewed and merged, so this PR can now build on it. I also agree with the suggestion to split this into several PRs, each focusing on a different enhancement.

@BTawaifi

Copy link
Copy Markdown
Author

Thanks both. Now that #7834 is in, I've split this into separate PRs as suggested:

  1. Make seeded runs reproducible across the RNG families the simulation uses #7893 Make seeded runs reproducible across the RNG families the simulation uses
  2. Add an "On Ship Damage" script hook #7894 Add an "On Ship Damage" script hook
  3. Measure afterburner cooldowns on the mission clock #7895 Measure afterburner cooldowns on the mission clock (all modes, as @dustinspace217 suggested)
  4. Add a headless, fixed-step simulation mode for batch experiments #7897 Add a headless, fixed-step simulation mode (best merged after 3)
  5. Add a batch runner for headless simulations #7898 Add a batch runner for headless simulations (tools/headless_batch, after 4)
  6. Don't pause on lost focus in noninteractive runs #7896 Don't pause on lost focus in noninteractive runs (found while testing windowed automation; independent)

Changes from this PR, mostly from @dustinspace217's review:

  • Lua flight control works under -headless (read_player_controls() still runs; only input reading is skipped).
  • The player's death ends a headless run (player_died); before, it ran until -max-sim-time, or forever without it.
  • The result file includes the state of each mission goal.
  • -unthrottled is gone as a separate option; the fast clock only comes with -headless.
  • The afterburner cooldown uses the mission clock in all modes.
  • Not done: re-seeding RandomRange at mission start. Each instance owns its generator, seeded when it is created (mostly at table parse), so re-seeding the shared seeder would not reach them. Reproducible runs need one process per run, which is what the batch runner does.

I'll close this PR in favour of the split ones.

@BTawaifi BTawaifi closed this Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants