Skip to content

Calendar Time v2.1.0

Multi-clock Save Identity

How ClockGroupSerializer.todict() / fromdict() identifies a clock across save → load cycles, and why we use a stable clockid rather than array position.

Status
Current
Version
v2.1.0
Source updated
2026-08-09
Generated on
2026-09-01

How ClockGroupSerializer.to_dict() / from_dict() identifies a clock across save → load cycles, and why we use a stable clock_id rather than array position.

TL;DR

Identity is clock_id, not array index.

Saves key by clock_id from resource_path or a runtime counter.

Reordering clocks between save and load is safe.

How clock_id is derived

A GameClock exposes a clock_id getter. The resolution chain:

  • clock_id_override — durable across sessions. Designer-chosen explicit label.
  • persistent_id — durable across file renames and project restructuring. It also survives process restarts and cross-machine use. Duplicating a resource copies its UUID. Duplicate IDs are detected and can be regenerated.
  • resource_path hash — durable when the same .tres is shipped.
  • Runtime counter — not durable; session seed changes.

clock_id_override takes priority over persistent_id so that clocks with an existing explicit override keep their identity unchanged when a persistent_id is later assigned. Set persistent_id once via clock.assign_persistent_id() or the editor "Calendar Time: Assign Persistent ID" menu action. Leave it stable — changing it silently breaks the link to existing saves.

Authored persistent ID

See issue #424.

persistent_id is a @export var String on GameClock. When non-empty, it is second in the clock_id resolution chain (after clock_id_override but before resource_path and the runtime counter).

Authoring workflow

  • File-backed .tres clocks: Select the clock resource in the FileSystem dock, then run Project → Tools → Calendar Time: Assign Persistent ID. The UUID is written to the resource and saved to disk automatically.
  • Runtime clocks (created via GameClock.new()): Call clock.assign_persistent_id() in code. This generates a UUID v4-like string using CalendarTimeUtils.generate_uuid() and assigns it if the field is empty. Safe to call repeatedly — it's idempotent.
  • Regenerate: Call clock.regenerate_persistent_id() or run the Project → Tools → Calendar Time: Regenerate Persistent ID action. Regeneration replaces the existing UUID. Use it after duplicating a resource. The Assign action is idempotent. The Regenerate action always overwrites.

Save format

ClockGroupSerializer._clock_to_dict() stores persistent_id in each per-clock save entry. It stores the field when the value is non-empty. On load, the serializer tries clock_id first. If that fails, it uses a unique persistent_id from the save entry. A shared UUID is marked ambiguous. The serializer refuses fallback instead of choosing an arbitrary clock. This handles clocks whose paths changed or whose IDs were assigned after saving.

Legacy path-key migration (2.0.3)

Saves made before persistent_id existed have no persistent_id field. If a clock later receives one, its clock_id changes from the path hash to the UUID. The old save entry key no longer matches. To preserve compatibility, from_dict() builds a legacy-ID alias table with _derive_clock_id_legacy(). Legacy path-hash entries still match through this alias.

Persistence safety by identity type

Persistence by identity type:

  • persistent_id (e.g. "a1b2c3d4-...") — durable across renames, restarts, and machines. Duplicating a resource copies its UUID; duplicate IDs are detected and the regeneration workflow provides the repair path. This is the recommended identity for any clock that participates in long-lived saves.
  • clock_id_override (e.g. &"world_clock") — durable across sessions.
  • resource_path hash (e.g. path:world_clock.tres:a3f2b1c0) — durable; same file, same id. Changes on file rename/restructure.
  • Runtime counter (e.g. runtime:1a2b3c4d:1) — not durable; session seed changes.

For durable persistence of runtime clocks, set persistent_id:

func _ready()-> void:
    var clock:= GameClock.new(my_calendar)
    clock.assign_persistent_id()
    $TimeHost.clocks= [clock]

Runtime-only clocks

Clocks created at runtime via GameClock.new() that have neither a persistent_id nor a clock_id_override receive a session-local runtime:<seed>:<counter> id. These ids cannot match after a process restart — the session seed changes between processes. ClockGroupSerializer warns when a save contains anonymous runtime clocks and recommends setting persistent_id for durable persistence.

Calendar configuration compatibility

A clock's clock_id identifies which clock receives a save entry. It does not prove that the authored calendar still means the same thing. Before loading an instant into a host that may have changed its calendar resources, compare the saved calendar configuration fingerprint:

var saved_hash :String = save_data["calendar_configuration_hash"]
if not clock.calendar.is_configuration_compatible(saved_hash):
    push_error("Calendar configuration changed; refusing to restore this save")
    return
clock.from_dict(save_data["clock"])

Save the fingerprint beside the clock snapshot:

var save_data:= {
    "calendar_configuration_hash": clock.calendar.configuration_hash(),
    "clock": clock.to_dict(),
}

configuration_descriptor() is a versioned JSON-safe description containing the calendar start date, week settings, unit structure, month lengths, event-day presence, and time-of-day enter/transition timing. Display names, descriptions, icons, colors, and runtime pacing are excluded because they do not change how a saved instant is interpreted. Use the descriptor when a host needs to log or inspect the exact compatibility inputs; use configuration_hash() for a compact comparison. Check it before calling the restore API so an incompatible save cannot partially mutate live clock state.

Reordered saves restore by clock_id match.

When clocks are added or removed:

  • More clocks at load than save: new clocks keep initial values.
  • Fewer clocks at load than save: unknown saved entries are skipped.

Atomic restore of matched clocks

ClockGroupSerializer.from_dict() treats the clocks that actually match the current host as one restore transaction. It validates every matched clock's time and age payload on isolated staging clocks before changing live state.

  • If every matched entry validates, all matched clocks commit and TimeHost.load_state() publishes clock_state_loaded once for each restored clock.
  • If any matched entry rejects, none of the matched live clocks change and no state-loaded notification is published.
  • Saved entries whose IDs do not exist in the current host remain intentionally outside the transaction. They are skipped under the existing forward/backward compatibility policy rather than causing a valid current host to reject the whole save.

This prevents a single host load from leaving clocks (or a clock's time and age state) from different save eras.

Explicit host binding

GameClock instances are not registered in a process-wide static list. TimeHost drives only the clocks assigned to its clocks export array. Bind clocks explicitly:

@export var world_clock :GameClock
@export var festival_clock :GameClock

func _ready()-> void:
    var host:= TimeHost.new()
    host.clocks= [world_clock, festival_clock]
    add_child(host)

Multi-host setups (e.g. two unrelated scenes with separate clocks that happen to share a process) work correctly because each host tracks only the clocks it explicitly lists.