Simulation timing

An instant tells you when something happened. A duration tells you how much time separates two instants. Keeping those concepts distinct avoids squeezing a long-running simulation's entire history into the numeric type used for one physics step.

ChronicleTimestamp is a nonnegative instant measured from a simulation origin. ChronicleDuration is a signed interval. Both are immutable values available in the Standard and Lean packages, with no math-library or engine dependency.

Seconds and fractions

Both types store long WholeSeconds plus uint FractionalSecond. Each fractional unit is exactly 2^-32 seconds; 0x40000000 represents a quarter second, and 0x80000000 represents half a second. Fractions never change precision as the simulation ages.

Whole seconds are the mathematical floor, not truncation toward zero:

Value WholeSeconds FractionalSecond
Zero 0 0
Half a second 0 0x80000000
Negative half a second -1 0x80000000
Negative one fractional unit -1 uint.MaxValue

Every duration component pair is canonical. Timestamps reject negative whole seconds with ArgumentOutOfRangeException. The default value of either type is zero.

Subtract instants before narrowing an interval

This complete method measures a quarter second after 100 Julian years (365.25 days per year):

using Chronicler.Timing;

public static class TimingExample
{
    public static ChronicleDuration MeasureInterval()
    {
        var start = new ChronicleTimestamp(3_155_760_000, 0);
        var end = start + new ChronicleDuration(0, 0x40000000);
        return end - start; // WholeSeconds = 0; FractionalSecond = 0x40000000.
    }
}

The instants are large; their difference is small. Keep the difference wide until a consumer explicitly checks whether its narrower representation can hold it. A large interval cannot become a small numeric value without either rejecting the conversion or losing information.

Arithmetic and boundaries

Supported operations are duration addition/subtraction, timestamp plus a signed duration, and timestamp subtraction. Subtracting an earlier instant from a later one gives a positive duration; reversing them gives a negative duration. Both directions work across the complete timestamp range.

Arithmetic throws OverflowException if its final result cannot fit. It never wraps or saturates. A duration's range runs from (long.MinValue, 0) through (long.MaxValue, uint.MaxValue). A timestamp's range starts at zero and has the same upper endpoint. Moving a timestamp before its origin is also an overflow, even though negative durations themselves are valid.

Comparisons and equality use exact values. GetHashCode() uses a fixed, process-independent component mix for collections; it is not a record hash or a persisted schema.

Ownership stays with the host

These values do not read a system clock, advance a simulation, or carry a world/lifetime identifier. Compare or subtract timestamps only when the host knows they share an origin and simulation lifetime. Resetting a world does not make a timestamp retained from the old run meaningful in the new run.

The value types remain readonly and do not implement IRecordable. Their in-memory layout is not a serialization format. Use the explicit recording helper below instead. No Fixed64 conversion API is provided by Chronicler.

Applications using FixedMathSharp can use the optional FixedMathSharp.Chronicler companion. Its FixedChronicleTime widens Fixed64 seconds exactly and narrows a duration only when it fits. Subtract wide timestamps first, then use TryToFixed64 or the throwing ToFixed64 conversion. The companion also counts complete fixed-size steps using raw integer division; that is not a wait scheduler or a lookup of historical frame numbers. This dependency points toward Chronicler, never from Chronicler to the math library. See the companion guide for exact conversion examples.

Advance a clock explicitly

ChronicleClock starts at frame/time zero and advances only when its owner calls Advance(). Supply a positive step; there is no default frame rate. FrameCount is a signed long, and ElapsedTime accumulates each actual step. Changing StepDuration affects future advances, never history.

This complete example changes from half-second to quarter-second steps, saves the clock, restores a host-created shell, and continues:

using Chronicler;
using Chronicler.Serialization;
using Chronicler.Timing;

public static class ClockExample
{
    public static ChronicleClock RestoreAndAdvance()
    {
        var clock = new ChronicleClock(new ChronicleDuration(0, 0x80000000));
        clock.Advance();
        clock.SetStepDuration(new ChronicleDuration(0, 0x40000000));
        clock.Advance();

        string snapshot = JsonRecordSerializer.Serialize(clock);
        var restored = new ChronicleClock(new ChronicleDuration(1, 0));
        JsonRecordSerializer.Populate(restored, snapshot);
        restored.Advance();
        return restored; // Frame 3, exactly 1 second elapsed, quarter-second step.
    }
}

GetDeadlineFrame(offset) checked-adds a nonnegative frame offset; zero selects the current frame. It does not schedule anything. Frame offsets count advances, not seconds, so step changes do not move an existing frame deadline.

This fragment is the body of a method; the deadline is reached after two advances even though those advances use different step sizes:

var clock = new ChronicleClock(new ChronicleDuration(0, 0x80000000));
long due = clock.GetDeadlineFrame(2);
clock.Advance(); // Frame 1: not due yet; 0.5 seconds elapsed.
clock.SetStepDuration(new ChronicleDuration(0, 0x40000000));
clock.Advance(); // Frame 2: due; 0.75 seconds elapsed.
bool isDue = clock.FrameCount >= due;

Use an elapsed timestamp deadline instead when the requirement is a number of seconds rather than a number of advances. Neither form schedules work itself.

Reset() returns frame/time to zero while retaining the configured step. Advance() rejects an exhausted frame counter with InvalidOperationException or an unrepresentable timestamp with OverflowException, leaving all clock state unchanged. A deadline overflow also throws OverflowException. Zero/negative steps and negative deadline offsets throw ArgumentOutOfRangeException.

The clock is not thread-safe and does not run your simulation. A reset or restore starts a new owner-controlled lifetime: quiesce the host, invalidate old waits and pending work, and coordinate other restored state. Clock transactionality does not roll back unrelated host code or exceptions later in a simulation step.

For a coordinated restore, stop advancement first, discard old pending work, restore the clock and the state that belongs to its origin, then resume ordered input. Restoring only the clock is not a rollback of the world. A context that owns a clock may deliberately expose no live-clock population API; use that context's supported lifecycle instead of reaching into its private clock.

sequenceDiagram
    participant Host
    participant World as Runtime shell
    participant Chronicler
    Host->>Host: Stop advancement and invalidate pending work
    Host->>World: Prepare objects and stable link registrations
    Host->>Chronicler: Populate clock and recorded world state
    Chronicler->>World: Apply schemas and resolve deferred links
    Chronicler-->>Host: Return after successful population
    Host->>Host: Resume ordered input and advancement

The host coordinates these steps. A population failure can leave other world objects partly updated even though the clock's own population is transactional.

Gravitas and Trailblazer each own their clock. A host using both advances each context once per gameplay step with matching durations; the navigation adapter does not add another advance, synchronize rates, or repair a missed step. The standalone ChronicleClock example above does not replace either context's loop.

Record values and clock state

Use RecordChronicleTime inside your type's RecordData for either time value. This partial schema example assumes a ChronicleDuration field named cooldown and a ChronicleTimestamp field named startedAt:

RecordChronicleTime.Look(chronicler, ref cooldown, "Cooldown");
RecordChronicleTime.Look(chronicler, ref startedAt, "StartedAt");

Each value records SchemaVersion (int, version 1), WholeSeconds (long), and FractionalSecond (uint), in that order. Missing components default to zero; a missing nested record, missing schema, or unsupported version rejects. Negative durations are valid; negative timestamps reject. A failed helper call leaves its referenced value unchanged, not every field of the enclosing object.

The clock records version 1, FrameCount, nested ElapsedTime, then nested StepDuration. Loading stages every field before applying any clock state. Frames must be nonnegative, the step positive, and frame/time must either both be zero or both positive. A missing frame defaults to zero; both nested schemas are required. The clock does not require elapsed time to equal frame count times the current step, because step sizes can change.

JSON and MemoryPack follow this same schema; Lean uses JSON. Missing/unsupported schemas and inconsistent frame/time pairs throw InvalidOperationException; negative timestamps and nonpositive steps throw ArgumentOutOfRangeException. Malformed transport payloads can also raise their transport's parsing errors. Any failed clock population leaves frame, elapsed time, and step unchanged. ChronicleHashSerializer hashes the ordered schema, including the full-width frame and fractions, without recording host identity.