Skip to content

Deterministic Simulation - #291

Open
Causeless wants to merge 38 commits into
developmentfrom
deterministic-sim
Open

Causeless wants to merge 38 commits into
developmentfrom
deterministic-sim

Conversation

@Causeless

@Causeless Causeless commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

This is a hand-reviewed PR created from the output of Claude over the past day. I've looked over the code already and confirmed it makes sense, of course with a fair bit of prodding towards the correct decisions.

Main architectural things to mention:

  • This intoduces (further?) changes to LuaJIT, so that'll need to be compiled locally for replays/multiuplayer in future to work properly. These changes ensure that Lua won't have random iteration orders when doing pairs(), both in mods and an vanilla. The AI's original proposal was to just change the scripts but I don't want to cripple mods in MP.
  • UniqueIDs can be accessed from script and there's a race condition there that can affect how scripts order UniqueIDs, as we use a shared ID pool. Claude suggested a few fixes here but none were entirely suitable. Ultimately I propsed a solution based on using the upper bits of the index for a thread ID. To ensure that the total number of UniqueIDs isn't reduced from these bits, I also advised that we upgrade the UniqueID to 64 bit, however the top 11 of these bits I decided to guard against using as Lua's 64bit doubles only have 53 bits of integer precision.
  • Various changes to ensure draw/render uses a separate PRNG from sim. This is pretty standard stuff.
  • Some other fixes for uninitialised variables etc.

It's worth noting that Claude initially suggested changing many different parts of the codebase based on reading the code and effectively gut-feeling. I declined these changes as I knew some of these suggestions were not accurate, and so each individual change/fix here has been tested to ensure that they actually fix a real determinism issue.

This is only part one- further work will need to be done in order to handle user inputs and more complex scenarios, as well as tracking controller and user input for when we move AI state syncing to pure controller state syncing, but this is already pretty accurate (at least when run in a headless mode). Most scenarios in vanilla of course just go into the pre-game editor phase, but determinism in a sim without user inputs has been confirmed in Dummy Assault and Decision Day too which are a bit more complicated, and I was sure to get those simulated for 100,000 game ticks each, which is about half an hour of game-time.

Makes the simulation deterministic enough that two headless runs of an activity play out identically, and adds the tooling to check that.

TLDR: This is deterministic assuming a headless run, with a fixed number of Lua states, and with ForceImmediatePathingRequestCompletion = 1. Further steps will be handling determinism when some of these constraints don't hold. Claude notes that the async pathing may still be a concern below, but we're gonna handle that by just not running the AI on each machine independently.

Claude PR notes below here:

What's in it

Tooling

  • -headless runs the game with a hidden window, no drawing and no audio, with the sim going as fast as it can. -activity <class> <preset> <scene>, -save <name> and -sim-updates <count> launch straight into something and quit after a set number of updates.
  • DeterminismChecker: -determinism-record <file> writes checkpoints (a label plus a hash of sim state) to a trace as the sim runs, and -determinism-verify <file> checks a run against a trace, reports the first checkpoint that differs and exits with a failure code. Nothing is hashed unless one of the two is given.
  • Tools/determinism_test.py records each mission once and verifies further runs against it. It uses its own copy of Settings.ini with ForceImmediatePathingRequestCompletion and the new DisableCrashDialogs turned on.

Fixes, each for a divergence that was seen in a run first

  • Threaded scripts on a Lua state ran in pointer order. They now run in order of unique ID.
  • LuaJIT seeded its PRNG randomly, which shuffled pairs() over string keys. It's now built with a fixed seed.
  • AI code used the sim's random numbers from several threads. Each Lua state now has a generator that's only for AI.
  • Unique IDs came from one counter shared by all threads. They are now 64 bit, with the thread in the upper bits and a count per thread.
  • Drawing and audio code used the sim's random numbers. They now have a generator of their own.
  • MOs added by several threads in the same update ended up in the list in whichever order the threads got there. They are now sorted by unique ID after the threaded phases.
  • LuaJIT hashed tables, functions, userdata and threads by address, so pairs() over a table keyed by objects went in a different order every run. They are now hashed by an ID kept in the object (x64 and arm64 only).
  • SceneMan::TryPenetrate returned true without setting its retardation when the pixel was already air, and Atom::Travel and AtomGroup went on to use it. It's now set to zero there.
  • Pathfinding updates went by a real time timer. They now go by sim time.

Smaller things that came up along the way

  • Activity save and load numbers are doubles, and the Writer writes floating point numbers out exactly.
  • MuteAudioOnFocusLoss wasn't initialized.

Testing

Debug Release build, Windows x64, headless, on the last commit. Each mission was recorded once and two more runs were verified against the recording.

Mission Sim updates Result
Dummy Assault 100,000 passed
Decision Day 100,000 passed
Bunker Breach, Harvester, Maginot Defence, Massacre, One-Man Army, Signal Hunt, Skirmish Defense, Survival, Wave Defense, Tutorial Mission 5,000 each passed

Brain vs Brain was also run, but it ends after one update when launched this way, so it tested nothing.

Not covered

  • Everything above is one machine, with nobody at the controls and on one stream of random numbers.
  • Windowed runs have not been compared since the fixes went in. Headless runs don't draw or play audio at all.
  • Path requests still run on background threads. ForceImmediatePathingRequestCompletion makes the results arrive by the next update, but not at a fixed point within it.
  • LuaJIT: cdata and light userdata keys are still hashed by address, the arm64 backend change has not been run, and the meson build change for the fixed PRNG seed has not been built.
  • AI runs on the same Lua states as everything else, so an AI that behaved differently between runs would still shift things for the scripts sharing its state.

🤖 Generated with Claude Code

Causeless and others added 18 commits October 10, 2026 13:30
-headless runs the game with a hidden window, no drawing and no audio, with
the sim running as fast as it can instead of in real time.

-save <name> and -activity <type> <preset> <scene> skip the menus and launch
straight into a saved game or an Activity, and -sim-updates <count> quits
after that many sim updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sim makes checkpoints as it goes, each a label and a hash of some of its
state. -determinism-record <file> writes these to a trace file, and
-determinism-verify <file> checks a later run against that trace, reporting
the first checkpoint that doesn't match and exiting with a failure code.
Nothing gets hashed unless one of the two is given.

DeterminismCheckSimState checkpoints the RNGs, all MovableObjects, the
Activity, and every so often the terrain and unseen layers. It's done at a
few points through MovableMan::Update, to narrow down which part of the
update a divergence comes from. DeterminismCheck checkpoints any values
passed to it, for narrowing things down further.

While checking, the number of Lua states is fixed if there's no override
set, since scripted objects get spread out over them, so how many there are
can't be left up to the machine.

Runs only match with ForceImmediatePathingRequestCompletion turned on in the
settings. Pathing requests otherwise complete after however many sim updates
the pathfinding threads happen to take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runs each scenario headless to record a determinism trace, then verifies
further runs against it, optionally including a windowed one.

The runs use a copy of the game's settings that has
ForceImmediatePathingRequestCompletion turned on, so the game's own settings
don't have to be changed for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There's no one around to dismiss a message box when headless, so aborts and
failed asserts now just log and exit, and other messages go to the CLI.
Headless runs also leave the abort save alone, since they're automated and
shouldn't replace one from an actual play session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each Lua state kept the MOs registered with it in an unordered_set of
pointers, so the order their ThreadedUpdate and SyncedUpdate functions were
run in followed their addresses in memory, which are different every time
the game is run. The same MOs would be registered in two runs of the same
scenario, but run in a different order, which made the runs diverge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LuaJIT's own PRNG decides the IDs that strings get hashed by, which decides
the order pairs() goes through a table with string keys in. With the PRNG
seeded from the OS, that order would occasionally come out different from
one run of the game to the next.

Decision Day spawns its initial defenders while going through its bunker
regions with pairs(). Roughly 1 in 15 runs had the same Actors end up in
different regions from the rest, with everything else being equal. With a
fixed seed, 60 out of 60 runs started out the same.

This doesn't make the order of tables with objects as keys predictable,
since those are hashed by their address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The AI scripts of Actors get run on several threads at once, one per Lua
state, and some of what they call draws from the global random number
generator, like AHuman::LookForMOs and ACrab::LookForMOs. That made the
global generator end up in a different state from one run to the next,
depending on how the threads happened to interleave.

Each Lua state now has a second generator that is used while it's running
AI, by the Lua state itself and by the global random functions called from
the thread it's being run on. So the AI no longer touches the random numbers
that the rest of the sim gets, whatever it does and whichever thread it's on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unique IDs came from a single counter shared by all threads. The threaded
Lua states make MOs at the same time as each other, so which of those MOs
got which unique ID was down to how the threads happened to interleave. A
thread would hand out the same number of unique IDs from one run to the
next, but not the same ones.

The upper bits of a unique ID are now the number of the thread it was
handed out on, 0 for the main thread and counting on from there for the
threaded Lua states, and the rest count up separately for each thread.

Unique IDs are 64 bit now to have room for that. Scripts get them as Lua
numbers though, which hold whole numbers exactly up to 53 bits, but only
come out in full when turned into text, like with tostring(), if they are
14 digits or less. So unique IDs are kept within 46 bits, 7 for the thread
number and 39 for counting, which is about 550 billion per thread.

luabind didn't have converters for long long, which is what int64_t is on
Windows, so those are added. The number of threaded Lua states is capped
at 127, since that is how many thread numbers there are for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SaveNumber and LoadNumber kept their numbers as floats, which can't hold
whole numbers above about 16.7 million exactly. Unique IDs can be far larger
than that now, and are an obvious thing for a script to want to save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Floats and doubles were written with whatever precision the stream happened
to be set to. For files that was 6 decimals, and for the in-memory stream
that saved games are written to it was the default of 6 significant digits.
So a position of 12345.678 was saved as 12345.7, and a number value of
1234567, like a unique ID kept with SetNumberValue, as 1.23457e+06.

They are now written with as many digits as it takes to read back exactly
the same number, and no more, so 0.5 instead of 0.500000 and 1 instead of
1.000000. Loading a saved game gives back the numbers that were saved.

One visible effect is on Settings.ini files made from now on. The default
DeltaTime of 0.0166666 used to get written as 0.016667, and now stays as
it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It was left out of AudioMan::Clear, so a newly made Settings.ini got
whatever happened to be in memory written to it for the setting, like 44.
That isn't something a bool can be read from, so the next time the game
was started, reading the settings would fail at that line, and none of
the settings after it would get applied.

It now starts out as off, which is how the game behaved before there
was a setting for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With it on, the game doesn't show a dialog when it aborts, runs into an
unhandled exception or fails an assert. It logs what happened like it
otherwise would, and then quits, with a failed assert counting as an abort
since there's no asking whether to ignore it.

Headless mode already worked this way. This is for when the game is being
run with its window by something automated, where a dialog would leave it
waiting on someone to come and dismiss it. The determinism test script turns
it on for its runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
How many times things get drawn per sim update is up to the frame rate, so
drawing code that used the sim's random numbers left the sim with different
ones from one run to the next. With the aim dots in HDFirearm::DrawHUD and
the screen shake in CameraMan::Update, which FrameMan::Draw calls, runs that
only differed in how many times they drew per sim update diverged within the
first couple of updates.

Audio isn't up to the sim either. Whether there is any depends on the
machine, and when the music moves on depends on real time.

All of it now has to use g_DrawRandomGenerator: the aim dots, the screen
shake, the throwing reticle, the glow dots in post processing, randomly
animated backgrounds, the picking of sounds and music and their pitch
variation, the seed given to FMOD, and the stars and flickering in the title
screen, scenario menu and metagame menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e ID

Scripts on different Lua states add MOs at the same time in their threaded
updates, and which thread got its MOs into the list first was different from
run to run. Two incendiary bullets on different Lua states each making a
flame particle in the same update was enough: the same two particles ended
up in either order, and the runs diverged from there.

The MOs waiting to be added now get sorted by unique ID after the threaded
script update and the threaded AI update. Unique IDs already say which
thread an MO was made on and how many that thread had made before it, so
that order is the same every time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Scene::Update gets called once per sim update, but whether it then went on to
update the pathfinders was up to a real time timer, so which sim update that
happened in depended on how fast the game was running.

This is the same change as on the interpolated-render branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dress

A table with tables, functions, userdata or threads for keys got iterated in
order of where those happened to be in memory, which is different every run.
The automover controller builds the scene's no gravity area ten nodes at a
time by going through such a table, so early on that area differed from run
to run, and with it the paths the pathfinder came up with for the AI.

Each of these objects now gets an ID when it's made, from a count that its
Lua state keeps, and that is what gets hashed. The order then only depends
on what has been run in that Lua state.

With 64 bit GC references all four have four bytes of padding at the offset
where a string has its own ID, so nothing grows, and the JIT backends can
load the ID the same way they already do for strings. Only the x64 and arm64
backends have been changed and it's only turned on for those. The arm64 one
hasn't been run.

cdata and light userdata are still hashed by address. cdata gets allocated
by code that the backends emit, and a light userdata is an address to begin
with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It returns true right away when the pixel it's asked about is air, and
Atom::Travel and AtomGroup then go on to use the retardation it was supposed
to give them, which nothing had set. The object's velocity got changed by
whatever happened to be in that memory, which was different from run to run.

The terrain can be gone by then because a script's OnCollideWithTerrain gets
run between the hit being detected and the penetration being tried. This was
seen with Ground Flames hitting grass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
// The main thread is number 0, and the thread that is running a threaded Lua state has the number after that state's.
int64_t threadNumber = 0;
if (const LuaStateWrapper* luaState = g_LuaMan.GetThreadLuaStateOverride(); luaState && luaState != &g_LuaMan.GetMasterScriptState()) {
threadNumber = 1 + (luaState - g_LuaMan.GetThreadedScriptStates().data());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might ask for this to be adjusted, using .data() here as ptr arithmatic feels messy, but it's not an issue in itself.

if (IsSetToLaunchIntoEditor()) {
// Evaluate LaunchIntoEditor before LaunchIntoActivity so it takes priority when both are set, otherwise it is ignored and editor is never launched.
return SetStartEditorActivitySetToLaunchInto();
} else if (!m_SaveGameToLaunch.empty()) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This, and the other changes in this file, are just to support headless testing of activities and savegames

Destroy();
}

bool LuaStateWrapper::UniqueIDOrder::operator()(const MovableObject* lhs, const MovableObject* rhs) const {

@Causeless Causeless Oct 10, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used to ensure that scripts are run in a deterministic order.

Comment thread Source/Managers/LuaMan.h
/// Gets a list of the MOs registed as using us.
/// @return The MOs registed as using us.
const std::unordered_set<MovableObject*>& GetRegisteredMOs() const { return m_RegisteredMOs; }
const std::set<MovableObject*, UniqueIDOrder>& GetRegisteredMOs() const { return m_RegisteredMOs; }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No a huge fan of using a set here at the std impl is awful, but wasn't something I felt a need to change for now.

}

g_LuaMan.SetThreadLuaStateOverride(&g_LuaMan.GetMasterScriptState());
g_LuaMan.GetMasterScriptState().SetRunningAI(true);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When running AI, we use a different PRNG. This is because the long-term plans are to have AI not deterministic at all, and instead have the controller states synced. This means we cannot have AI usages of random values pollute the sim randoms.

}

int SettingsMan::GetNumberOfLuaStatesOverride() const {
constexpr int luaStatesWhenCheckingDeterminism = 4;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll probably ask for this to be changed into purely using the Settings.ini value. There are a few other cases where values like this were hardcoded that I asked to be removed, but this is okay for now. We'll probably settle on a fixed (but relatively high) number of lua states for determinism, as it's not a huge amount of extra cost even though machines with less cores will need to waste some time context switching.

writer.NewLine(false);
writer.NewPropertyWithValue("DisableLuaJIT", m_DisableLuaJIT);
writer.NewPropertyWithValue("EnableLuaDebugging", m_EnableLuaDebugging);
writer.NewPropertyWithValue("DisableCrashDialogs", RTEError::s_DisableCrashDialogs);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used so that headless runs can just print an error to abort log instead of locking up the tester.

} else if (m_IntroSequenceState == IntroSequence::SlideshowEnd) {
m_PreGameLogoText.Draw(g_FrameMan.GetBackBuffer32());
int blendAmount = 220 + RandomNum(-35, 35);
int blendAmount = 220 + g_DrawRandomGenerator.RandomNum(-35, 35);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes aren't strictly necessary here, but I asked for them to be done. In future we'll serialize the PRNG seed whenever starting a saved activity to ensure that prior random calls from past activities don't pollute anything.

}

bool RTEError::ShowAbortMessageBox(const std::string& message) {
if (System::IsInHeadlessMode() || s_DisableCrashDialogs) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure it makes sense to have these headless checks here when the proper Settings.ini setup will do, but again, eh

Comment thread Source/System/RTEError.h Outdated
public:
static bool s_CurrentlyAborting; //!< Flag to prevent a potential recursive fault while attempting to save the game when aborting.
static bool s_IgnoreAllAsserts; //!< Whether to skip the assert dialog and just let everything burn at whatever point that happens.
static bool s_DisableCrashDialogs; //!< Whether to not show a dialog when the game aborts, runs into an unhandled exception or fails an assert, and just log it and quit. This is the DisableCrashDialogs setting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"This is the DisableCrashDialogs setting." - hah. I'll get this pointless note removed.

Comment thread Source/System/Writer.cpp
}

auto ofStream = std::make_unique<std::ofstream>(fileName, append ? (std::ios::out | std::ios::app | std::ios::ate) : (std::ios::out | std::ios::trunc));
*ofStream << std::fixed << std::setprecision(6);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not using a fixed precision of 6 anymore, now we save the full absolute value to whatever precision is required to store it accurately. The fact that saves are zipped now makes this extra space usage less significant.

It said that this is the DisableCrashDialogs setting, which is obvious from
context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Causeless and others added 2 commits October 11, 2026 21:30
CHANGELOG.md and RTEError.cpp conflicted. The message box functions check for headless mode and DisableCrashDialogs first, as on this branch, and then for being off the main thread, as on development.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Causeless Causeless changed the title Deterministic Simulation Part One Deterministic Simulation Oct 12, 2026
Causeless and others added 17 commits October 12, 2026 03:04
Seeding LuaJIT's PRNG with a fixed value wasn't enough to keep pairs()
going through tables with string keys in the same order from run to run.
LuaJIT also draws from that PRNG to pick the addresses it tries when it
needs memory for compiled traces, and draws again for each one that is
already taken. How often that happens depends on how the process' memory
happens to be laid out, so the IDs that later strings got reseeded with
still differed between runs.

DeliveryCreationHandler picks the type of each infantry unit by going
through a table of them with pairs(). In the AI Battle on Alezer Canyon
Bunker, two runs with the same random number for the pick (35 of 48) made
a sniper in one and an engineer in the other, and so the sim started out
in one of two ways. With the IDs given out in order, 16 of 16 runs
started out the same.

The order still follows from which strings a Lua state has made so far,
and tables with objects as keys still go by their addresses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An AHuman remembers the item it found within reach as a plain pointer.
The check for whether that item was still around looked at the item
itself before asking MovableMan if it still existed, so an item that got
deleted in the meantime, such as a grenade that went off, was read from
memory that had been freed. The HUD, the pie menu and Lua's ItemInReach
used the pointer with no check at all.

What that read comes back with isn't the same from run to run, and it
decides whether the item stays in reach, which in turn decides how many
random numbers the next reach check uses. AddressSanitizer reported it
in the AI Battle on Eleann Forest, and no longer does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… request

The count is what gets waited on for the async pathing requests to be
done, but a request was only in it while its path was being calculated.
One that was still queued up for a thread wasn't counted yet, and one
that was done calculating stopped being counted before it was marked as
complete. Either way a wait could end with requests still left, and an
Actor would get its path a sim update later than it would have.

The counting is now done where the request is made and where it is
marked as complete, instead of in CalculatePath. Paths that get
calculated right away for their caller aren't counted anymore, as the
count was only ever meant for the async ones.

A wait ending early wasn't seen to happen in testing, there was just
nothing to keep it from happening.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… immediately

The Scene updates its PathFinders with the changes to the terrain every
100 ms of sim time, but puts it off for as long as any pathing request
is active. Which sim update the pathfinding threads were done by, and so
which one the update happened on, differed from run to run, and paths
requested around then came out different.

In the AI Battle on Eleann Forest that showed up as a just landed Actor
starting off with a jump in some runs and by walking in others, in 4 of
26 runs. With ForceImmediatePathingRequestCompletion on, the update now
waits for the requests instead, and 52 of 52 runs came out the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ShootArea timed its shots, and how fast its aim settled, by real time,
unlike the rest of the AI. How much real time a sim update takes isn't
the same from run to run, least of all with the sim going as fast as it
can, so Actors shooting at an area aimed and fired slightly differently
each time.

In the AI Battle on Eleann Forest, 1 of 6 runs had an Actor's aim differ
by 0.0008 radians from the others, with all else being equal up to then.
That didn't happen again in 72 runs after this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With --jobs the scenarios get run that many at a time, each still doing
its own recording run followed by its verifying runs.

The game can write to its settings file, so each scenario now gets a
copy of its own instead of all of them sharing one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-seed <number> adds the number to the seed of the sim's random number
generator, so each number has the same scenario play out differently. With
0 the sim gets its usual random numbers.

The generators are now first seeded after the early arguments are handled
instead of before, so that the Lua states, which take their seeds from the
sim's generator, get different ones too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is over

An unattended run could so far only be ended after a set number of sim
updates. With this it can instead go for as long as the Activity takes
to finish, which is what a determinism test of a whole battle needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he Activity is over

With --seeds each scenario gets run once for every seed given, each of
which has it play out differently, to cover more of what can happen in
it. With --until-over the runs go on for as long as the Activity takes
to finish instead of for a set number of sim updates, and giving
--updates along with it stops a run that hasn't got there by then.

These go with the -seed and -quit-when-over launch arguments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Headless mode left the audio system uninitialised, so whatever in the sim
goes by audio came out differently than in a normal run. A sound never
counted as being played, for example, which scripts wait on.

FMOD now gets initialised when headless as well, with its no-sound output,
which does everything it otherwise would except play the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dotted lines that get drawn at the exits of a craft with its hatch open
were laid out from the velocity that things leave through the exit at. That
has a spread to it, which is rolled with the simulation's random numbers
every time it is asked for. How the simulation went on from there depended
on how many times the HUD had been drawn, so a windowed run stopped
matching a headless one, where nothing gets drawn, as soon as a hatch
opened.

The lines only need the way the exit faces, so they go by the velocity
without its spread now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AboveHUDPos of an Actor comes from its HUD stack, which only drawing the
HUD sets. It depends on how many lines the HUD had on the screen it was
last drawn to, and is left at its starting value when nothing is drawn.

The crosses of the self heal, medic drone, medikit and medical dart
scripts were placed by it. They are particles in the simulation, and
collide with the terrain. The AA drone cast the rays that decide whether
it fires from it. So a headless run and one with a window played out
differently.

These now go by the position and height of the Actor instead, which
gives what AboveHUDPos does for an Actor whose HUD shows the health and
nothing more. Everything else that uses AboveHUDPos does so for drawing,
and is left as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the simulation

Whether a SoundContainer is being played is up to the audio system, which
plays in real time. What a script does on the back of it happens at a
different point of the simulation every time, and all the more so in
headless mode, where the simulation doesn't go by real time at all.

The Gatling Gun, Bulldog and Giga Pulsar started spinning down when they
were let go of while their ActiveSound was still playing. They now keep
track by themselves of having been activated.

The refinery drill blew up when its overload sound was done playing, and
the Gunsword didn't swing again until its swing sound was. Both now wait
for as much sim time as those sounds are long.

Scripts that only play, move or stop sounds depending on it are left as
they were, since nothing in the simulation comes of that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sound only got played when it was already being played, so it never
started. The pitch it was to be played at was also rolled with a call
that would have failed, had it ever been made.

It is now played when it isn't already playing, and gets its pitch varied
by the SoundContainer itself. A roll made in the script would use the
simulation's random numbers, and that can't depend on whether the audio
system is still playing the sound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An Atom's trail only gets drawn in the sim updates that end up on screen,
and how much of it gets drawn was rolled with the simulation's random
numbers. With more than one sim update to a frame, as when the time scale
is turned up or the frame rate drops below the simulation's, the updates
in between rolled nothing. How the simulation went on from there depended
on how many frames had been drawn, so a run at double speed stopped
matching one at normal speed within a few updates.

The trail length is rolled with the generator for drawing now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… its threaded update

The threaded updates of the lua states' scripts are run at the same time,
on a thread each. Whatever they called in the engine that uses the global
random functions got its random numbers from the simulation's one
generator, from all of those threads at once.

Each lua state now has its own generator used on its thread for as long as
its threaded update is being run, in the same way as is done for AI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant