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.
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).
Open questions¶
- 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.