OrbitalSimulation¶
OrbitalSimulation is the authority for the sun direction and the day/night cycle. It is a plain C# class (not a MonoBehaviour) exposing a static singleton OrbitalSimulation.System. There is no stored "sun angle" scalar: the sun is a derived unit direction vector OrbitalSimulation.WorldSunVector, recomputed every frame from an orbital/rotational simulation driven by a TimeScale-scaled clock. Everything sun-facing (solar panels, daylight sensors, atmosphere temperature curve, eclipse, ambient light) reads WorldSunVector.
This page covers the runtime mechanism, the freeze and set-time levers, and the save serialization. Solar-panel power math lives in SolarPanel.md; per-body solar irradiance lives in WeatherEvent.md; the time-skip recipe lives in TimeSkipWorldManipulation.md.
Core members and the per-frame advance¶
OrbitalSimulation (declared line 56302) singleton: public static OrbitalSimulation System { get; } = new OrbitalSimulation("Sol", ...); (line 56386). The player's planet is OrbitalSimulation.System.PlayerBody (a RotatingCelestialBody, field at line 56316).
| Member | Kind | Line | Meaning |
|---|---|---|---|
OrbitalSimulation.WorldSunVector |
public static Vector3 |
56358 (set 56603) | Current sun direction, normalized. Derived each frame. There is no angle scalar. |
OrbitalSimulation.System |
public static singleton |
56386 | The "Sol" simulation instance. |
OrbitalSimulation.PlayerBody |
RotatingCelestialBody |
56316 | The player's planet body. |
OrbitalSimulation.SimulationTimeSeconds |
public double |
56336 | Absolute simulation clock in seconds. Drives WorldSunVector. |
OrbitalSimulation.TotalRealTimeSeconds |
public double |
(set 56584) | Accumulated real time. Persisted as AccumulatedTime. |
OrbitalSimulation.TimeScale |
public property (private setter) |
56411 (_timeScale 56378) |
Per-frame multiplier converting real delta to simulation delta. 0 freezes the sun. |
OrbitalSimulation.TimeOfDay / _timeOfDay |
public static float property |
56390 / 56318 | Derived 0..1 day-fraction = Abs(AccumulatedAngle/360) % 1. Not centered at 0.5 for noon. |
OrbitalSimulation.SolarIntensity |
public static float |
56366 (set 56779) | Directional light brightness, Clamp(WorldSunVector.y / 0.01, 0, MaxSunIntensity). |
OrbitalSimulation.DayLengthSeconds |
auto-property, default 1200 |
56448 | Real-time seconds per in-game day. DEFAULT_DAY_SECONDS = 1200 (line 56348). |
RotatingCelestialBody._baseRotationSpeed |
public double, default 15.0 |
53600 (seed 56190) | Raw axial spin rate (deg per orbit unit). Lowest-level rotation knob. |
RotatingCelestialBody.CurrentAngle |
float |
53647 | Planet rotation angle, derived 0..360 degrees. |
Driver chain, frame by frame:
WorldManager.ManagerUpdate() (line 59773) calls OrbitalSimulation.UpdateEachFrame() only while GameManager.IsRunning:
public override void ManagerUpdate() // line 59773
{
base.ManagerUpdate();
if (GameManager.IsRunning)
OrbitalSimulation.UpdateEachFrame(); // line 59778
}
UpdateEachFrame() (line 56706):
public static void UpdateEachFrame() // line 56706
{
if (System != null && IsValid)
{
System.UpdateAllBodies(Time.deltaTime); // line 56710 advances the sun
CameraController.SetCameraPosition();
HandleUpdate(); // line 56712 recomputes _timeOfDay, sun transform
SetSunState(Time.unscaledDeltaTime); // line 56713 eclipse + light intensity
}
}
UpdateAllBodies(double deltaRealTime) (line 56582) is where the clock advances, scaled by TimeScale:
private void UpdateAllBodies(double deltaRealTime) // line 56582
{
TotalRealTimeSeconds += deltaRealTime;
double num = deltaRealTime * TimeScale; // line 56585 <-- the freeze point
SetAllBodies(SimulationTimeSeconds + num); // line 56586
}
SetAllBodies(double simulationTime) (line 56594) rebuilds the sun vector:
private void SetAllBodies(double simulationTime) // line 56594
{
if (GameManager.IsRunning && OrbitalDebugger)
HandleDebugInput();
SimulationTimeSeconds = simulationTime; // line 56600
PrimaryBody.Set(simulationTime);
PrimaryBody.SetPlayerVectorTo(PlayerBody);
WorldSunVector = PrimaryBody.WorldVector.normalized; // line 56603
...
}
The single line that, neutralized, freezes the sun: line 56585, double num = deltaRealTime * TimeScale;. With TimeScale == 0, num == 0, so SetAllBodies keeps recomputing the same SimulationTimeSeconds and WorldSunVector is constant.
There is no dedicated PauseSun / RotationEnabled / DisableOrbit boolean. The only gates are: GameManager.IsRunning (whole update, line 59776), OrbitalDebugger (debug overlay + dev scrub keys only, line 56346), GameManager.IsBatchMode (skips SetSunState visuals only, line 56735). The idiomatic sun-only freeze is TimeScale = 0.
Day length and TimeScale derivation¶
Two distinct knobs control how fast the sun advances:
OrbitalSimulation.DayLengthSeconds(line 56448, default 1200 = 20 min real-time per in-game day). Chosen at world creation fromWorldConfigurationMenu.GetDayLengthSeconds()(line 48574), passed intoOrbitalSimulation.GetSimulation(worldSetting, dayLengthSeconds)(line 57369, assigns at 57379).RotatingCelestialBody._baseRotationSpeed(line 53600, default 15.0). The raw spin;celestial <body> rotation <v>sets it directly.
DayLengthSeconds feeds CalculateTimeScale (line 56503), which sets the actual per-frame multiplier:
public static double CalculateTimeScale(OrbitalSimulation simulation) // line 56503
{
double num = Math.Abs(simulation.PlayerBody.DegreesPerPlanetYear / 360.0);
num /= 360.0 / simulation.PlayerBody.RotationalDegreesForSiderealDay();
if (simulation.PlayerBody.TidallyLocked) num = 1.0;
double num2 = num * ((double)simulation.DayLengthSeconds / 60.0) * 60.0;
return 360.0 / num2; // line 56512
}
TimeScale is set from this at world build: orbitalSimulation.TimeScale = CalculateTimeScale(orbitalSimulation); (line 57383). The setter fires OnTimeScaleChanged() for network sync (line 56515).
Related RotatingCelestialBody derived quantities: DegreesPerPlanetYear => Orbit.Period * _baseRotationSpeed (line 53633); SolDegreesPerDay => 360.0 / DaysPerPlanetYear (line 53637); GetDayLength() => new TimeLength(360.0 / Math.Abs(_baseRotationSpeed) * 86400.0) (line 53675, note the division by _baseRotationSpeed, so do not zero it).
Setting and freezing the sun (runtime APIs)¶
Static entry points:
| API | Line | Effect | Caveat |
|---|---|---|---|
OrbitalSimulation.SetTimeScale(float) |
57409 | Sets System.TimeScale, prints confirmation. 0 freezes the sun. |
Network-synced; host-only path. |
OrbitalSimulation.SetSimulationTime(double seconds, bool publish=false) |
56573 | SetAllBodies(seconds) then HandleUpdate. Sets absolute sun position directly. |
Bypasses TimeScale (uses the value as-is). |
OrbitalSimulation.SetRealTime(double realTime, bool publish=false) |
56564 | realTime += GetAdjustedOffsetTime(); SetSimulationTime(realTime * System.TimeScale, ...). |
Multiplies by TimeScale. If TimeScale == 0, simulation time becomes 0. Set time BEFORE freezing, never after. |
OrbitalSimulation.SimulateTime(double realTimeDelta) |
56555 | UpdateAllBodies(delta) then HandleUpdate. Advances by a delta. |
Same TimeScale scaling inside UpdateAllBodies. |
OrbitalSimulation.SetDayTime(float clampedSunTime) |
56527 | Scrubs _timeOfDay to a 0..1 target by stepping UpdateAllBodies(+/-0.1) in a loop until _timeOfDay reaches the target. |
Target is a day-fraction, not a sun-height. See noon note below. |
public static void SetRealTime(double realTime, bool publish = false) // line 56564
{
if (IsValid)
{
realTime += System.GetAdjustedOffsetTime();
SetSimulationTime(realTime * System.TimeScale, publish); // line 56569: scaled by TimeScale
}
}
public static void SetDayTime(float clampedSunTime) // line 56527
{
if (!IsValid) return;
if (clampedSunTime > _timeOfDay)
while (_timeOfDay < clampedSunTime) System.UpdateAllBodies(0.1);
else if (clampedSunTime < _timeOfDay) { /* step down then up to land on target */ }
HandleUpdate();
}
Ordering trap: to "set noon then freeze," position the sun first (while TimeScale != 0), then SetTimeScale(0). Freezing first makes SetRealTime zero out the clock.
Thread-safety (positioning the sun from a non-main thread, e.g. an ElectricityManager.ElectricityTick postfix on a headless server): SetAllBodies(double) (line 56594) is pure managed math: it sets SimulationTimeSeconds, recomputes WorldSunVector, and updates the celestial bodies, with no Unity scene-graph access, so it is safe to call from a worker thread. SetSimulationTime(double) (line 56573) wraps SetAllBodies but then calls HandleUpdate() (line 56717), which writes WorldSunTransform.position and .LookAt (Unity, main-thread only; guarded by (object)WorldSun != null, so it no-ops when the sun GameObject is absent on a -batchmode server). To scan for or set a sun position off the main thread, call SetAllBodies directly and skip HandleUpdate. Setting TimeScale = 0 before the scan makes any concurrent main-thread UpdateEachFrame idempotent on the SimulationTimeSeconds writes (delta * 0 == 0), so the scan cannot be raced.
What "noon" is (sun at zenith)¶
The game has no "noon = 90 degrees" constant. The highest sun is encoded as WorldSunVector pointing straight up, i.e. WorldSunVector.y at its maximum, approaching Vector3.up (0,1,0). Evidence:
SolarIntensity = Mathf.Clamp(WorldSunVector.y / 0.01f, 0f, MaxSunIntensity) * ...(line 56779): brightest whenWorldSunVector.yis largest. Night =WorldSunVector.y <= 0.- Ambient light:
Mathf.Clamp((WorldSunVector.y + 0.02f) / 0.05f, ...)(line 28340). - Atmosphere "curve time" maps the angle between
Vector3.upand the sun onto 0.25..0.75:
public static float GetWorldAtmosphereCurveTime() // line 56816
{
float value = Vector3.Angle(Vector3.up, WorldSunVector); // 0 deg = zenith, 180 deg = deepest night
return RocketMath.MapToScale(0f, 180f, 0.25f, 0.75f, value);
}
So angle(up, sun) = 0 deg is noon (scalar 0.25); 90 deg is the horizon (0.5); 180 deg is deepest night (0.75).
The TimeOfDay 0..1 scalar (_timeOfDay) is Abs(AccumulatedAngle/360) % 1 (GetTimeOfDay(), line 56811), a fractional-day position that is offset by SunriseOffset and the body's LongitudeAtEpoch. The day-fraction that produces the sun's highest point is latitude/axis dependent, so the robust noon test is WorldSunVector.y maximal (sun nearest Vector3.up), not a fixed _timeOfDay value. SetDayTime(0.5) does not reliably mean "noon".
Consumers detecting "facing the sun": SolarPanel.CalculateSolarEfficiency() (line 400354) Vector3.Dot(PanelCells.forward, WorldSunVector) > 0 plus Clamp(1 - (forward - WorldSunVector).magnitude, ...); DaylightSensor.OnThreadUpdate() (line 373335) reads _solarAngle from the spherical decomposition of WorldSunVector, default mode = Vector3.Angle(Forward, WorldSunVector).
Save serialization (world.xml <Celestial>)¶
Sun/world-time persists through OrbitSimulationSaveData, serialized under the <Celestial> element of WorldData.
WorldData.CelestialData (line 250599):
[XmlElement("Celestial")]
public OrbitSimulationSaveData CelestialData = new OrbitSimulationSaveData();
OrbitSimulationSaveData (line 53098):
public class OrbitSimulationSaveData // line 53098
{
public DoubleReference AccumulatedTime; // line 53100
public DoubleReference SimulationTime; // line 53102
public void Deserialize(OrbitalSimulation system) // line 53104
{
if (SimulationTime != null)
OrbitalSimulation.SetSimulationTime(SimulationTime.Value); // line 53108 authoritative
else if (AccumulatedTime != null)
OrbitalSimulation.SetRealTime(AccumulatedTime.Value); // line 53112 fallback
if (AccumulatedTime != null)
OrbitalSimulation.System.TotalRealTimeSeconds = AccumulatedTime.Value;
}
}
Written by OrbitalSimulation.SerializeSave(...) (line 57327):
worldData.CelestialData = new OrbitSimulationSaveData
{
SimulationTime = new DoubleReference { Value = System.SimulationTimeSeconds }, // line 57331-57334
AccumulatedTime = new DoubleReference { Value = System.TotalRealTimeSeconds } // line 57335-57338
};
Read by DeserializeSave(...) (line 57342). When CelestialData == null, the fallback is SetRealTime(DaysPast * siderealDaySeconds) (line 57346).
On-disk shape (attribute-valued DoubleReference children, direct child of root <WorldData>):
<Celestial>
<AccumulatedTime Value="407548.122450768" />
<SimulationTime Value="122597.43671865921" />
</Celestial>
Field map:
| world.xml path | Serialized member | Role |
|---|---|---|
WorldData/Celestial/SimulationTime/@Value |
OrbitSimulationSaveData.SimulationTime (DoubleReference) |
Authoritative. On load, SetSimulationTime rebuilds WorldSunVector from this. Editing it moves the sun. |
WorldData/Celestial/AccumulatedTime/@Value |
OrbitSimulationSaveData.AccumulatedTime (DoubleReference) |
Total real time; fallback time source and TotalRealTimeSeconds restore. |
WorldData/DaysPast |
WorldData.DaysPast (uint, line 250575) |
Day counter; fallback if <Celestial> absent. Also at WorldMetaData/DaysPast. |
WorldData/DateTime, WorldMetaData/DateTime |
.NET DateTime.Ticks (100ns units) |
Calendar/clock display only. Not read by OrbitalSimulation for sun position. Decoding gives the wall-clock time-of-day shown in the HUD. |
Note: a <Celestial> save edit sets sun POSITION on load but does not freeze it; the sun resumes advancing once TimeScale (restored at world build from CalculateTimeScale, line 57383) ticks. Freezing requires TimeScale = 0 at runtime.
World-definition-level offsets live in the world TEMPLATE/setting XML, not the per-save world.xml: <TimeOffset> (TimeSpanReference, line 54389, loaded into _offsetTime at line 57105), <SunriseOffset> (FloatReference, line 54477), LongitudeAtEpoch (float, line 54472). The orbital makeoffset console command (line 97593) prints a ready-to-paste <TimeOffset Days=".." Hours=".." .../> snippet of the current time.
When DeserializeSave runs on a saved-world load (0.2.6428.27798)¶
Line numbers in this subsection are the 0.2.6428.27798 client decompile. XmlSaveLoad.LoadWorld ends by calling World.OnLoadingFinished(worldData) (274445), which runs GameManager.StartGame().Forget() (330907) and only then OrbitalSimulation.DeserializeSave(worldData) (330912). One of StartGame's last statements is OrbitalSimulation.OnGameStarted() (210094), which is System.RescaleBodies() (58024-58027), a Rescale() on each celestial prefab (57443-57449).
StartGame's only await is await NetworkServer.Host(), behind Settings.CurrentData.StartLocalHost || IsBatchMode, and NetworkServer.Host (219295-219317) awaits only when NetworkManager.StartServer fails and it retries after UniTask.Delay(1000). So in the usual case the whole of StartGame, OnGameStarted included, has run before DeserializeSave restores the saved time; after a failed first StartServer attempt the tail of StartGame runs later, after it. Whether anything in Rescale() depends on the simulation time was not checked.
Every member cited is textually identical in the dedicated-server decompile, except that the server's StartGame ends with one more statement, DelayedStartupPause().Forget(). The order of the calls around World.OnLoadingFinished is in PlacementOrientation, "Where GameState becomes Running on a saved-world load".
Console commands (orbital, celestial)¶
Commands derive from CommandBase; the menu token is the lowercased class name minus Command.
OrbitalCommand (token orbital, line 97551). Subcommand list (line 97569): debug, view, celestials, simulate, set, timescale, makeoffset.
| Command | Line | Effect |
|---|---|---|
orbital timescale <float> |
97631 | Sets TimeScale via SetTimeScale. orbital timescale 0 freezes the sun. Restore with orbital timescale 1. Blocked for multiplayer clients (line 97639). |
orbital set <value> [span] |
97700 | Sets absolute world time via SetRealTime(result, publish:true) (line 97724). Moves the sun / time of day. span in {Seconds, Minutes, Hours, Days, Weeks, Months, Years} (enum SimulationSpan, line 97505; default Seconds). Blocked for clients ("Cannot set time as client", line 97704). Example: orbital set 6 Hours. |
orbital simulate <value> [span] |
97672 | Advances time by a delta via SimulateTime (line 97696). Example: orbital simulate 1 Days. |
orbital makeoffset |
97593 | Copies a <TimeOffset .../> XML snippet of current time to clipboard (template authoring). |
orbital debug |
97768 | Toggles OrbitalDebugger (ImGui overlay + =/- scrub keys). Does not freeze. |
orbital view |
97758 | Toggles the in-world orbit visualization. |
orbital (no args) |
97577 | PrintDebug(): sun vector horizontal/vertical angles, timescale, etc. |
CelestialCommand (token celestial, line 97405). Subcommand list (line 97425): eccentricity, semimajoraxisau, semimajoraxiskm, inclination, periapsis, period, ascendingnode, rotation. Syntax celestial <bodyName> <field> <value>.
| Command | Line | Effect |
|---|---|---|
celestial <body> rotation <speed> |
97490-97496 | Sets rotatingCelestialBody._baseRotationSpeed = float.Parse(args[2]). celestial <body> rotation 0 zeroes axial spin (another freeze path, but risks division-by-zero in GetDayLength). Blocked entirely in multiplayer ("Cannot change celestials in multiplayer", line 97455). |
celestial <body> period <days> |
97484 | Orbital period. |
Multiplayer: the orbital state delta-syncs to clients through SerializeDeltaState (line 57283) / DeserializeDeltaState (line 57307). TimeScale rides NetworkUpdateFlags bit 1 (set by OnTimeScaleChanged, line 56523; written at 57298). TotalRealTimeSeconds + SimulationTimeSeconds ride bit 2, which SerializeDeltaState sets on every sync (line 57290), so the sun POSITION syncs continuously, not just at join; the client applies it via System.SetAllBodies(simTime) + HandleUpdate() (lines 57321-57323). A host-side set of both SimulationTimeSeconds (via SetAllBodies) and TimeScale = 0 therefore yields a frozen-at-position sun on every connected client. orbital set/simulate/timescale are host-only (clients rejected); celestial is single-player only. A freeze/set must run host-side and broadcast.
Verification history¶
- 2026-06-25: page created from a decompile read of
Assembly-CSharp.decompiled.cs(game version 0.2.6228.27061) while researching "fix the sun to noon and freeze it" on a Luna dedicated-server save. Captures theWorldSunVectorderivation, theTimeScalefreeze point (line 56585), the day-length /CalculateTimeScalederivation, the noon =Vector3.upconvention, the<Celestial>save serialization, and theorbital/celestialconsole commands. Consistent with the existingOrbitalSimulation.SetDayTime(float)"0-1 range" note in../Workflows/TimeSkipWorldManipulation.md(no conflict). - 2026-06-25: added the runtime thread-safety split (
SetAllBodiesis worker-safe pure math;SetSimulationTime->HandleUpdatewritesWorldSunTransform, main-thread only) and the delta-state network-sync detail (SimulationTimeSeconds/TotalRealTimeSecondsrideSerializeDeltaStatebit 2, set every sync, so sun position syncs continuously). Captured while building the ScenarioRunnersun-noonfreeze for a Luna dedicated-server debug session. Sources:Assembly-CSharp.decompiled.cslines 56594 (SetAllBodies), 56717 (HandleUpdate), 57283-57325 (SerializeDeltaState/DeserializeDeltaState). - 2026-09-27: added "When DeserializeSave runs on a saved-world load (0.2.6428.27798)": on a load,
StartGame(and with itOnGameStarted/RescaleBodies) normally runs to completion beforeWorld.OnLoadingFinishedcallsDeserializeSave. Raised as a side observation by the fresh validator that settled theGameStatequestion onPlacementOrientation.mdthe same day, then re-read here in both decompiles. Additive; no existing claim changed, and the other sections were not re-read and keep their 0.2.6228.27061 stamps.
Open questions¶
- Whether
CelestialPrefab.Rescale()reads the simulation time. If it does, the load order in "When DeserializeSave runs on a saved-world load (0.2.6428.27798)" meansOnGameStartedrescales against the time beforeDeserializeSaverestores it. Not checked. - The exact
SimulationTimeSecondsvalue that placesWorldSunVectorat maximumy(true zenith) for the Lunar body is latitude/axis/offset dependent and was not solved analytically. In practice, position withorbital set ... Hours(orSetDayTime) and checkWorldSunVector.y, then freeze withorbital timescale 0.