extends Node
class_name LevelPersistence
## Durable, replicated, rollback-able level edits for DCLevel.
##
## THE JOURNAL IS THE STATE. This node owns the durable record set; DCLevel is a
## derived cache of it:
##
## brush set = base(imported .MAP) (+) fold(live records, by (seq, author))
##
## The fold itself is in dc-terrain's libdc (dc_level_journal.h) so it is shared
## with any other consumer — blender-bridge next — rather than living in whichever
## UI happened to need it first. This file is the transport: it turns records into
## GameState keys and back, and asks DCLevel to re-fold.
##
## UNDO IS A TOMBSTONE VALUE PLUS A RE-FOLD, not an inverse op. Two reasons, and
## the second is a bug that shipped:
##
## 1. Mechanism (historical): GameState.erase_state() used to clear the local
## mirrors only, so an erased record returned on the next load. It deletes on the
## daemon since hyper-games #88 Tier 1 #3, but a tombstone is still the right
## shape here: redo needs the record back, and a delete cannot be undone.
## 2. Correctness: the previous scheme stored the plane each edit replaced
## (`prev_n`/`prev_d`) and re-applied it. That is only right for a linear history
## of one plane — nudge a face twice and undo the FIRST, and record 1's `prev`
## overwrites record 2's value, leaving the plane where neither op asked for it.
## Measured in dc-terrain tests/test_journal_fold.cpp. There is nothing to
## invert in a fold.
##
## KEY SHAPE: world:level:<memberId>:op:<seq>
## ONE namespace and ONE counter, because the fold needs one ordered stream.
## Dispatching on a per-kind suffix (:carve:, :face:) would need six of them and
## invites a counter each, which puts the ordering ambiguity straight back.
## The memberId segment is load-bearing: GameState._author_of takes the segment
## right after the prefix, and can_write treats an author-less key as shared content
## anyone may overwrite. `world:level:` is registered in both MAP_SCOPED_PREFIXES
## and AUTHOR_SCOPED_PREFIXES, and (since 0.843.0) actually routed to DURABLE.
##
## A RECORD'S OWN DOT NAMES WHAT IT CREATES; its payload names what it TARGETS. So
## a carve, a created brush and an added face are all called "<author>:<seq>" — no
## separate id field, because the state key already IS a (replica, counter) dot.
## Local ids are never durable: they come from per-peer counters, so a record keyed
## by one names different geometry on every peer (hyper-games #22 §5 items 1 and 4).
##
## VALUES ARE JSON-SAFE — plain float arrays, never Vector3 or Packed*Array. What
## keet-daemon does to a Variant on the way to the autobase is not this file's
## business to assume, and every pre-existing record here already used arrays.
## Emitted after every fold, because geometry can change with no keypress involved —
## a peer's edit arriving, or a room load. Anything derived from the level (the
## wireframe) has to hear about those too, not only about the edits made here.
signal folded(stats: Dictionary)
## Parametric shapes: shape.* records are expanded into brush.create records before
## DCLevel folds them (docs/parametric-shapes-design.md §2).
const ShapeFold := preload("res://scenes/maps/shapes/param/shape_fold.gd")
## Live cuts: op.* records become brush.cut records folded AFTER everything else
## (docs/cutting-and-operations.md §9a).
const OpFold := preload("res://scenes/maps/op_fold.gd")
## Modifiers (#120 F2): copies of a shape's pieces, between the generator and the cuts.
const ModFold := preload("res://scenes/maps/mod_fold.gd")
## Collections (#120 F1): organisation, folded apart from the level (no brush re-fold).
const CollectionFold := preload("res://scenes/maps/collection_fold.gd")
## Curves (#120 F5): no brushes either, so folded apart from the level too.
const CurveFold := preload("res://scenes/maps/curves/curve_fold.gd")
## Placed assets (#120 L4 §15.2): asset.* records expand FIRST into synthetic records of
## every other kind, from the blobs put_asset_blob holds.
const AssetFold := preload("res://scenes/maps/asset_fold.gd")
const AssetDoc := preload("res://scenes/maps/asset_doc.gd")
const PREFIX := "world:level:"
const OP_INFIX := ":op:"
## Record format version. Bumped when a payload shape changes; a peer that does not
## understand a version skips the record rather than guessing at it.
const RECORD_V := 2
var level: Node = null # DCLevel
## The SceneObjects manager, when the map has one. Object records are folded
## through it; brush records through `level`. Both ride the same journal because
## they are the same durable state — one map's authored content.
var objects: Node = null
var _next_seq: int = 0
## Re-fold is DEBOUNCED to one per frame. A bulk room-state load emits
## state_changed once per key, and folding + rebuilding the whole level per key
## would be minutes of Dual Contouring for one arrival.
var _dirty: bool = false
var _folding: bool = false
var _last_fold: Dictionary = {}
func _ready() -> void:
_connect_state()
_ensure_index()
func _connect_state() -> void:
if GameState == null or GameState.state_changed.is_connected(_on_state_changed):
return
GameState.state_changed.connect(_on_state_changed)
# A map change drops GameState's mirror (no per-key emits); the index is rebuilt
# from whatever it holds next, and the reload's state_changed refolds this map's
# journal onto the fresh level.
GameState.map_changing.connect(func(_id: String) -> void:
_ix_ready = false
_dirty = true)
func _author() -> String:
var a: String = KeetService.current_member_id if KeetService else ""
return a if a != "" else "p%d" % multiplayer.get_unique_id()
func _op_key(author: String, seq: int) -> String:
return "%s%s%s%d" % [PREFIX, author, OP_INFIX, seq]
func _source_key() -> String:
return "%s%s:source" % [PREFIX, _author()]
# ---- writing records --------------------------------------------------------
## Append a record and return its DOT ("<author>:<seq>") — the durable name of
## whatever it creates. The caller applies the edit locally with that same dot, so
## the optimistic local state and the fold agree on identity from the start.
func record(kind: String, payload: Dictionary) -> String:
var dot := reserve_dot()
return dot if record_at(dot, kind, payload) else ""
## TWO-PHASE, for an op the store may REFUSE. A create needs its durable name
## before it can be applied (the name is what the brush is called), but a plane set
## that encloses nothing is rejected — and a record for a brush that does not exist
## would be replayed by every peer forever. So: reserve the dot, try the edit, and
## only write the record if it took.
func reserve_seq() -> int:
if _next_seq == 0:
# Unix-seeded so (seq, author) approximates causality across peers and
# across restarts. It does not have to be exact: the author dot breaks ties,
# and the order only has to be TOTAL and identical everywhere.
#
# BUT NEVER BELOW A SEQ I HAVE ALREADY USED. The counter runs ahead of the
# clock — 100 edits in one session put it 100 seconds into the "future" —
# so a restart within that window would re-mint seqs that already name
# records and OVERWRITE MY OWN EARLIER EDITS, silently, because the durable
# view is last-write-wins per key. Two instances in one process collide the
# same way. Caught by the universal-undo gate, which counted 3 records where
# it had written 4.
#
# The journal knows what I have used, so ask it rather than hoping the clock
# has caught up.
_next_seq = maxi(int(Time.get_unix_time_from_system()), _max_seq_seen())
_next_seq += 1
return _next_seq
## The dot for a new record: my author plus the next seq.
func reserve_dot() -> String:
return "%s:%d" % [_author(), reserve_seq()]
## The highest seq ANY author has written in this room and map, or 0.
##
## Was "the highest seq I have ever written" — hyper-games #42 P1. Scanning only
## my own keys makes this a per-author counter, and (seq, author) then orders by
## whoever's CLOCK ran ahead rather than by what actually happened. Scanning every
## author makes it a Lamport clock: my next seq is above everything I have seen, so
## a record I wrote after seeing yours sorts after yours, whatever our clocks say.
##
## The overwrite guard the caller depends on is UNAFFECTED and strictly stronger —
## the maximum over all authors is never below the maximum over mine.
##
## The cost is that one peer with a badly wrong clock drags everyone's seqs up to
## it permanently. That is inherent to Lamport, it does not break the order (still
## total, still identical everywhere), and it is a better failure than silently
## reordering causally-related edits.
func _max_seq_seen() -> int:
if GameState == null:
return 0
# The INDEX's running maximum (max_seq_in's rule, kept as each key arrives). It never
# goes down — a flatten erasing records must not let a later seq re-mint one.
_ensure_index()
return _ix_max_seq
## The scan itself, over key strings and nothing else, so it can be asserted
## without a room, a peer or a GameState.
##
## A --script gate reaches this with a runtime load(), NOT a preload: preload is a
## const and forces compilation while `GameState` is still not an identifier, which
## is why the existing journal gates cannot load headless. load() from _initialize()
## compiles lazily and works. The pure static is a CHOICE — it keeps the rule
## assertable on its own terms — not a workaround for that.
static func max_seq_in(keys: Array[String]) -> int:
var top := 0
for key: String in keys:
var at: int = key.find(OP_INFIX)
if at < 0:
continue
top = maxi(top, int(key.substr(at + OP_INFIX.length())))
return top
func record_at(dot: String, kind: String, payload: Dictionary) -> bool:
if GameState == null or dot == "":
return false
var cut: int = dot.rfind(":")
if cut < 0:
return false
var value: Dictionary = payload.duplicate()
value["v"] = RECORD_V
value["kind"] = kind
value["undone"] = false
# THE GESTURE (editor redesign E5): every record names the gesture it belongs to, so
# one Ctrl+Z takes back a whole stairs (7 records) or a whole group rotate. The id is
# the dot of the gesture's FIRST record — no counter, and a record written outside any
# gesture is a gesture of one (its own dot), which is also what a legacy record reads as.
if _gesture_depth > 0:
if _gesture == "":
_gesture = dot
value["g"] = _gesture
if _gesture_label != "":
value["label"] = _gesture_label
else:
value["g"] = dot
_last_gesture = String(value["g"])
# The level-source hash doing a second job: a brush name only means anything
# relative to the .MAP the base was imported from, so a peer can refuse an edit
# authored against a different level instead of moving the wrong wall.
value["src"] = String(find_source().get("hash", ""))
var key := _op_key(dot.substr(0, cut), int(dot.substr(cut + 1)))
GameState.set_state(key, value)
# VERIFY IT LANDED. set_state DROPS a durable write whose can_write gate fails
# — it returns before writing the local mirror — and this returned true
# regardless, so a refused record read as a successful one. The caller then
# applied the edit and told the player "Z to bring it back" for a record that
# was never written. Reporting false here is what makes that a visible failure
# rather than an unrecoverable delete.
if not GameState.has_state(key):
push_error("[LevelPersistence] record %s REFUSED by GameState (%s) — the edit " % [dot, kind] +
"is applied but NOT journalled, so it cannot be undone or replicated.")
return false
return true
# ---- gestures (editor redesign E5, docs/editor-redesign.md §8b) ---------------
#
# A GESTURE IS WHAT THE PLAYER DID ONCE: one Enter, one key, one click. It may write many
# records (a stairs is a create per step plus its clearance; a group rotate is one record
# per brush). Undo used to take back ONE RECORD per press, so a 6-step stairs took 7
# presses. Records written between begin_gesture and end_gesture share `g`; undo and
# redo act on all of a gesture's records at once, with one undone_at stamp.
#
# NESTING: the outer gesture wins — the map wraps every key, and the operator confirm
# inside it is the same gesture. An inner begin may still NAME it if the outer had no
# label (the operator knows "Stairs", the key handler does not).
var _gesture_depth := 0
var _gesture := ""
var _gesture_label := ""
var _last_gesture := ""
func begin_gesture(label: String = "") -> void:
if _gesture_depth == 0:
_gesture = ""
_gesture_label = label
elif _gesture_label == "" and label != "":
_gesture_label = label
_gesture_depth += 1
func end_gesture() -> void:
_gesture_depth = maxi(0, _gesture_depth - 1)
if _gesture_depth == 0:
_gesture = ""
_gesture_label = ""
## The gesture id of the newest record written by this node ("" if none yet).
func last_gesture() -> String:
return _last_gesture
## Fallback names for a gesture written without a label, by its records' kind.
const KIND_LABELS := {
"brush.create": "Create", "brush.remove": "Delete", "brush.translate": "Move",
"brush.rotate": "Rotate", "brush.scale": "Scale", "brush.split": "Cut",
"brush.merge": "Merge", "brush.reshape": "Reshape", "brush.regroup": "Regroup", "face.set": "Face",
"face.add": "Add face", "face.remove": "Remove face", "face.tex": "Texture",
"carve": "Carve", "object.create": "Add object", "object.transform": "Move object",
"object.set_prop": "Edit object", "object.remove": "Delete object",
"object.parent": "Parent object",
# Parametric shapes (docs/parametric-shapes-design.md).
"shape.create": "Add shape", "shape.set": "Edit shape", "shape.xform": "Move shape",
"shape.size": "Resize shape", "shape.detach": "Make real", "shape.remove": "Delete shape",
# Live cuts (docs/cutting-and-operations.md §9a).
"op.create": "Live cut", "op.set": "Edit cut", "op.remove": "Remove cut", "op.apply": "Make real",
# Collections (#120 F1).
"col.create": "New collection", "col.set": "Edit collection", "col.remove": "Delete collection",
"col.link": "Link to collection", "col.unlink": "Remove from collection",
# Curves (#120 F5).
"curve.create": "Add curve", "curve.set": "Edit curve", "curve.remove": "Delete curve",
# Modifiers (#120 F2).
"mod.create": "Add modifier", "mod.set": "Edit modifier", "mod.remove": "Remove modifier",
# Placed assets (#120 L4).
"asset.place": "Place asset", "asset.xform": "Move asset", "asset.set": "Update asset",
"asset.remove": "Delete asset",
}
## My records, each {dot, key, seq, g, undone, stamp, kind, label}. Read from the
## index's per-author key set (no scan of the room, no re-parse); everything
## gesture-shaped below is a query over it.
##
## A FLATTENED record (flatten_map, `base: true`) is the map's floor, not an edit: the
## index never puts one in an author's set, so it is never in History, never undone —
## one Ctrl+Z would otherwise take the whole map away after its old records were erased.
func _my_records() -> Array:
var out: Array = []
# Not yet in the tree: no multiplayer, so no author to ask about (History refreshes at
# setup, which can run before the map has parented this node).
if GameState == null or not is_inside_tree():
return out
_ensure_index()
var me := _author()
for key: String in (_ix_by_author.get(me, {}) as Dictionary):
var m: Dictionary = _ix_meta[key]
var dot := "%s:%d" % [me, int(m["seq"])]
out.append({"dot": dot, "key": key, "seq": int(m["seq"]), "g": String(m["g"]),
"undone": bool(m["undone"]), "stamp": int(m["stamp"]),
"kind": String(m["kind"]), "label": String(m["label"])})
return out
static func _gesture_seq(g: String) -> int:
return int(g.substr(g.rfind(":") + 1))
## My gestures, NEWEST FIRST: [{g, label, count, undone, stamp, kinds}]. A gesture counts as
## undone when all its records are (a partially undone one — a peer's mod-level edit — reads
## live).
##
## A NEW EDIT DISCARDS THE REDO BRANCH, as in every editor: an undone gesture is DISCARDED
## once I have made any gesture after undoing it (a gesture seq above its undone_at stamp —
## both are the same Lamport counter). Without this, seeking forward or Ctrl+Shift+Z after an
## Adjust Last brought the superseded stairs back on top of the adjusted one. Discarded
## gestures stay in the journal (nothing is erased) and are simply not offered.
func gestures(include_discarded: bool = false) -> Array:
# CACHED until the journal changes: one fold asks several times (History refresh,
# Adjust Last's validity, undo's newest-live lookup), and each answer is identical.
if GameState == null or not is_inside_tree():
return []
_ensure_index()
var ck := "%d|%s|%s" % [_ix_ver, _author(), include_discarded]
if _gest_cache.has(ck):
return (_gest_cache[ck] as Array).duplicate()
var out := _compute_gestures(include_discarded)
if _gest_cache.size() > 4:
_gest_cache.clear()
_gest_cache[ck] = out
return out.duplicate()
var _gest_cache: Dictionary = {}
## Over the index's GESTURE TABLE: each gesture's summary is kept and recomputed only
## when one of its own records changes, so this is O(gestures), not O(records) — the
## 2000-record Subtract is summarised once, not on every fold.
func _compute_gestures(include_discarded: bool) -> Array:
var mine: Dictionary = _ix_gest.get(_author(), {})
var newest := 0
for g: String in mine:
newest = maxi(newest, _gesture_seq(g))
var out: Array = []
for g: String in mine:
var e: Dictionary = _gesture_summary(g, mine[g] as Dictionary)
if not include_discarded and bool(e["undone"]) and newest > int(e["stamp"]):
continue
out.append(e)
out.sort_custom(_newer_first)
return out
## {g, label, count, undone, stamp, kinds} for one gesture, cached on its table entry.
func _gesture_summary(g: String, entry: Dictionary) -> Dictionary:
if not (entry["sum"] as Dictionary).is_empty():
return entry["sum"]
var e := {"g": g, "label": "", "count": 0, "undone": true, "stamp": 0, "kinds": {}}
for key: String in (entry["keys"] as Dictionary):
var m: Dictionary = _ix_meta[key]
e["count"] = int(e["count"]) + 1
e["undone"] = bool(e["undone"]) and bool(m["undone"])
e["stamp"] = maxi(int(e["stamp"]), int(m["stamp"]))
(e["kinds"] as Dictionary)[m["kind"]] = true
if String(e["label"]) == "" and String(m["label"]) != "":
e["label"] = m["label"]
if String(e["label"]) == "":
var kinds: Array = (e["kinds"] as Dictionary).keys()
e["label"] = String(KIND_LABELS.get(String(kinds[0]), String(kinds[0]))) if kinds.size() == 1 else "Edit"
entry["sum"] = e
return e
static func _newer_first(a: Dictionary, b: Dictionary) -> bool:
return _gesture_seq(String(a["g"])) > _gesture_seq(String(b["g"]))
## Tombstone (or restore) every record of one of my gestures, with ONE undone_at stamp so
## redo brings the whole gesture back together. Returns how many records changed.
func undo_gesture(g: String) -> int:
return _set_gesture(g, true)
func redo_gesture(g: String) -> int:
return _set_gesture(g, false)
func _set_gesture(g: String, undone: bool) -> int:
var stamp := reserve_seq() if undone else 0
var n := 0
if GameState == null or not is_inside_tree():
return 0
_ensure_index()
var me := _author()
var entry: Variant = (_ix_gest.get(me, {}) as Dictionary).get(g, null)
if entry == null:
return 0
# A COPY of the key set: each _set_undone re-indexes its record (and so this entry).
for key: String in ((entry as Dictionary)["keys"] as Dictionary).keys():
var m: Dictionary = _ix_meta[key]
if bool(m["undone"]) == undone:
continue
if _set_undone("%s:%d" % [me, int(m["seq"])], undone, stamp):
n += 1
return n
## My newest gesture that is still live, or "".
func newest_live_gesture() -> String:
for e: Dictionary in gestures():
if not bool(e["undone"]):
return String(e["g"])
return ""
## HISTORY SEEK (Godot's History dock): make `g` the newest applied gesture. If it is live,
## undo every live gesture newer than it; if it is undone, redo every undone gesture up to
## and including it, oldest first. Returns how many gestures changed.
func seek(g: String) -> int:
var all := gestures()
var target := -1
for i in all.size():
if String(all[i]["g"]) == g:
target = i
if target < 0:
return 0
var n := 0
if not bool(all[target]["undone"]):
for i in target: # newer ones are before it
if not bool(all[i]["undone"]) and undo_gesture(String(all[i]["g"])) > 0:
n += 1
else:
for i in range(all.size() - 1, target - 1, -1): # oldest first, up to it
if bool(all[i]["undone"]) and redo_gesture(String(all[i]["g"])) > 0:
n += 1
return n
## Roll a record back. A tombstone VALUE — see this file's header for why an erase
## cannot work. Author-scoped by the same gate as every other write: you undo your
## own ops, and overriding another peer's needs mod+ (GameState.can_write).
func undo_op(dot: String) -> bool:
return _set_undone(dot, true)
func redo_op(dot: String) -> bool:
return _set_undone(dot, false)
func _set_undone(dot: String, undone: bool, stamp: int = 0) -> bool:
if GameState == null or dot == "":
return false
var cut: int = dot.rfind(":")
if cut < 0:
return false
var key := _op_key(dot.substr(0, cut), int(dot.substr(cut + 1)))
var v: Variant = GameState.get_state(key)
if not (v is Dictionary):
# Not ours to guess at: no record, nothing to tombstone. Silence here would
# read as a successful undo that did nothing.
print("[LevelPersistence] no record %s to %s" % [dot, "undo" if undone else "redo"])
return false
if bool((v as Dictionary).get("base", false)):
# The flattened floor (flatten_map): not undoable by any path.
print("[LevelPersistence] %s is a flattened base record — not undoable" % dot)
return false
var d: Dictionary = (v as Dictionary).duplicate()
d["undone"] = undone
if undone:
# WHEN it was undone, so redo can be the exact inverse of a run of undos.
#
# Without this, redo has to pick by seq, and "the lowest-seq undone record"
# resurrects whatever you tombstoned LONGEST AGO rather than what you just
# undid — measured in the gate: three undos, then redo handed back an edit
# undone much earlier in the session. Nobody means that by Ctrl+Shift+Z.
#
# A durable stamp rather than an undo STACK, because the view is
# last-write-wins per key and a stack would be a second thing to keep
# consistent — the mistake this whole design exists to avoid. The record is
# being rewritten anyway, so the ordering rides along for free and survives
# a restart with it.
# A gesture's records share ONE stamp (E5), so redo brings them back together.
d["undone_at"] = stamp if stamp > 0 else reserve_seq()
GameState.set_state(key, d)
return true
# ---- the level source (unchanged mechanism) ---------------------------------
## Announce WHERE the level came from, so a peer without the .MAP can get it.
##
## The hash, not the bytes. StateBase replicates every value in full to everyone
## with no dedup, so putting a 669 KB map through it costs every peer the whole file
## whether or not it can already read it off disk. The BYTES move content-addressed
## over the mesh (ContentOverlay); the durable state keeps one small record naming a
## hash, and the journal folds on top.
func persist_source(content_hash: String, display_name: String, size_bytes: int) -> void:
if GameState == null or content_hash.length() != 64:
return
GameState.set_state(_source_key(), {
"hash": content_hash,
"name": display_name,
"size": size_bytes,
})
## The level source announced for this map by ANY peer, or {} if none yet.
##
## First match wins rather than newest: the value is content-addressed, so two
## honest peers announcing the same map announce the identical hash, and a
## disagreement is a genuinely different level rather than a race to resolve here.
##
## From the index's source keys — record_at asks once PER RECORD, and this used to
## scan the whole journal each time (a 2000-record gesture = 2000 full scans).
func find_source() -> Dictionary:
if GameState == null:
return {}
_ensure_index()
for k: String in _ix_sources:
var v: Variant = _ix_sources[k]
if v is Dictionary and String((v as Dictionary).get("hash", "")).length() == 64:
return v
return {}
# ---- universal undo / redo --------------------------------------------------
#
# UNDO IS A QUERY OVER THE JOURNAL, NOT A STACK IN MEMORY. The first version kept
# the dots of the carves made this session in an Array and popped it, which meant
# undo could only reach THIS session's carves and only carves — reload the map and
# your own edits became unreachable, which is not an undo at all.
#
# Nothing needed adding to do it properly: the journal already records every edit
# of every kind with its author and its seq, so "what did I do last" is a query.
# That is the payoff of the journal being the state rather than a log beside it —
# undo across restarts is not a feature, it is the absence of a cache.
#
# THE ORDER RULE, which needs care because there is no durable undo STACK (the
# view is last-write-wins per key, so a stack would be another thing to keep
# consistent):
#
# undo -> the HIGHEST-seq live record of mine
# redo -> the LOWEST-seq undone record of mine
#
# Those are exact inverses over a run of undos. Undo 5, undo 4, and redo brings
# back 4 then 5 — last-undone-first, which is what a stack would do, derived from
# the records themselves instead of stored. If a record was tombstoned some other
# way (a peer with mod+, a future editor UI), redo picks it up first; that is
# explainable rather than surprising.
#
# ONLY YOUR OWN EDITS. Author-scoped keys mean a peer can only write records it
# authored (GameState.can_write), so undoing someone else's would be rejected at
# the gate anyway — better to never offer it than to fail silently.
## Tombstone my newest live record. Returns its dot, or "" if I have nothing left
## to undo. Works on records from any previous session, because it reads the
## journal rather than anything this node remembers.
##
## A WHOLE GESTURE (E5): the newest live gesture of mine, every record of it. Returns the
## gesture id (the dot of its first record).
func undo_last() -> String:
var g := newest_live_gesture()
if g == "":
return ""
return g if undo_gesture(g) > 0 else ""
## Bring back the record my LAST undo removed — the exact inverse of undo_last.
##
## Chosen by `undone_at`, the stamp written when it was tombstoned, so a run of
## undos redoes in reverse. A record undone without a stamp (legacy, or by another
## peer with mod+) sorts last, so it is only reached once there is nothing newer —
## a fallback rather than a surprise.
func redo_last() -> String:
# The most recently undone gesture that is still on the redo branch (E5): a new edit
# since its undo discards it, as in every editor.
var g := ""
var best_stamp := -1
for e: Dictionary in gestures():
if bool(e["undone"]) and int(e["stamp"]) > best_stamp:
best_stamp = int(e["stamp"])
g = String(e["g"])
if g == "":
return ""
return g if redo_gesture(g) > 0 else ""
func _last_undone_of_mine() -> String:
var best := ""
var best_stamp := -1
for r: Dictionary in _my_records():
if bool(r["undone"]) and int(r["stamp"]) > best_stamp:
best_stamp = int(r["stamp"])
best = String(r["dot"])
return best
## How many of my records are live / undone, for a UI that wants to grey out.
func undo_depth() -> int:
return _count_of_mine(false)
func redo_depth() -> int:
return _count_of_mine(true)
## The dot of my highest- or lowest-seq record in the given state, or "".
## Legacy records (no :op:) are never in my set: readable, but not undoable.
func _extreme_of_mine(want_undone: bool, want_highest: bool) -> String:
var best := ""
var best_seq := -1
for r: Dictionary in _my_records():
if bool(r["undone"]) != want_undone:
continue
var seq := int(r["seq"])
if best_seq < 0 or (seq > best_seq if want_highest else seq < best_seq):
best_seq = seq
best = String(r["dot"])
return best
## Kept as counters by the index (the undo key prints both after every Ctrl+Z).
func _count_of_mine(undone: bool) -> int:
if GameState == null:
return 0
_ensure_index()
var me := _author()
var u := int(_ix_undone_n.get(me, 0))
return u if undone else (_ix_by_author.get(me, {}) as Dictionary).size() - u
# ---- reading the journal ----------------------------------------------------
func _on_state_changed(key: String, _value: Variant, _channel: int) -> void:
if key.begins_with(PREFIX) and key.ends_with(":source"):
# Not a refold trigger on its own (it never was), but the index must know: the
# source hash decides which records the next fold admits.
if _ix_ready:
_ix_put(key, _value)
return
if key.begins_with(PREFIX):
# OBSERVE, because a Lamport clock is maintained and not merely seeded.
# reserve_seq() seeds once, on first use; without this a record arriving
# mid-session never raises the counter, and the next edit I make after
# seeing yours can still sort BEFORE it. Seeding alone buys causality only
# at session start, which is the half of the property nobody would notice
# was missing.
var at: int = key.find(OP_INFIX)
if at >= 0 and _next_seq > 0:
_next_seq = maxi(_next_seq, int(key.substr(at + OP_INFIX.length())))
# DIRTY ONLY ON CHANGE -- hyper-games #88 Tier 1 #1. Every record arrives
# twice: set_state emits it, then set_value's callback echoes the same value
# back. Each arrival refolded the whole level. An undo rewrites the SAME key
# with a new value, so the index compares values, not just keys.
if not _ix_ready:
_dirty = true # the rebuild will read it from GameState
return
if _ix_put(key, _value):
_dirty = true
func _process(_delta: float) -> void:
if _dirty:
refold()
## Rebuild the level from the journal. Idempotent by construction — that is the
## point of a fold — so calling it more often than needed costs time, never
## correctness.
func refold() -> Dictionary:
_dirty = false
# ⛔ THE LEVEL GUARD BELONGS ON THE BRUSH HALF, NOT IN FRONT OF BOTH. This used
# to return {} here, before `objects` was touched, which conflates "this map has
# no brush level" with "there is nothing to fold". It never showed because
# map_dc_level is the only map that instantiates SceneObjects at all, and it
# also has a DCLevel. Bring the object system to a map that has no DCLevel --
# map_flat is a ground plane -- and every object.create is journalled,
# replicated, and then silently never built, on every peer.
var has_level: bool = level != null and is_instance_valid(level) \
and level.has_method("apply_journal")
var has_objects: bool = objects != null and is_instance_valid(objects)
if not has_level and not has_objects:
return {}
_folding = true
_ensure_index()
# PARTITIONED, because `skipped` is a real signal on both sides. Handing object
# records to DCLevel would have it report every one of them as a record whose
# target is missing, which is exactly the warning that means a level has
# quietly diverged — diluted into noise on the first authored light.
#
# Brushes still need a level and are still skipped without one.
if has_level:
# SKIP THE BRUSH HALF WHEN NOTHING ABOUT THE BRUSHES CHANGED (2026-10-05: "delete
# is extremely slow", 11 s per delete). The replay + level.rebuild() re-meshes
# EVERY brush — 705 ms at 2000 — and ran for every fold, deleting a teleporter
# included. Skipped only when no brush/shape record has changed since the last
# applied fold (the index's brush VERSION — an O(1) check; this was an md5 over
# every brush record) AND the store's fingerprint is still the one this fold
# left: anything that touched the level directly changes the fingerprint, and
# then the full fold runs and puts the journal's state back (gate_refold_skip).
if _ix_brush_ver == _brush_ver_folded and level.get_instance_id() == _brush_level_id \
and int(level.fingerprint()) == _brush_fp:
_last_fold = _brush_stats.duplicate()
_last_fold["brush_fold"] = "skipped"
else:
var ver := _ix_brush_ver
# THE TAIL FOLD (dc_journal::apply_tail): when the only brush-side changes since
# the last fold are NEW records and the store is still exactly what that fold
# left (fingerprint — an op that pre-applied its edit fails this and folds in
# full), apply just them: fold(old + new) == the current store + new, when new
# sorts after old, and DCLevel refuses (store untouched) when it does not.
var tail_done := false
if _ix_tail_ok and not _ix_tail_keys.is_empty() and _last_brush_seq >= 0 \
and level.get_instance_id() == _brush_level_id \
and int(level.fingerprint()) == _brush_fp and level.has_method("apply_journal_tail"):
var src := String(find_source().get("hash", ""))
var tail: Array = []
for k: String in _ix_tail_keys:
if _ix_meta.has(k):
var tr := _ix_record(k, src)
if not tr.is_empty():
tail.append(tr)
var tres: Dictionary = level.apply_journal_tail(tail, _last_brush_author, _last_brush_seq)
if bool(tres.get("tail", false)):
tail_done = true
_last_fold = tres
_last_fold["brush_fold"] = "tail"
for tr2: Dictionary in tail:
if _sorts_after(int(tr2["seq"]), String(tr2["author"]), _last_brush_seq, _last_brush_author):
_last_brush_seq = int(tr2["seq"])
_last_brush_author = String(tr2["author"])
fold_stats["tail"] = int(fold_stats["tail"]) + 1
if not tail_done:
var brush_recs: Array = []
var shape_recs: Array = []
var op_recs: Array = []
var mod_recs: Array = []
var curve_recs: Array = []
var asset_recs: Array = []
for r: Dictionary in records(false):
var rk := String(r.get("kind", ""))
if AssetFold.handles(rk):
asset_recs.append(r)
continue
if CurveFold.handles(rk):
curve_recs.append(r) # no brushes, but a follow-curve reads them
continue
if CollectionFold.handles(rk):
continue # no brushes: never DCLevel's
if ShapeFold.handles(rk):
shape_recs.append(r)
elif OpFold.handles(rk):
op_recs.append(r)
elif ModFold.handles(rk):
mod_recs.append(r)
else:
brush_recs.append(r)
# PLACED ASSETS (#120 L4 §15.2) first: each live placement's blob becomes
# synthetic records of every kind, sorted into the same lists as journalled
# ones — so everything below treats an instance as ordinary shapes, curves,
# cuts and brushes. Its objects go to the objects half (_asset_objects).
var ex: Dictionary = AssetFold.expand(asset_recs, _asset_docs, _parse_as)
_asset_missing = ex["missing"]
_asset_inst = ex["inst"]
_asset_objects = []
for sr: Dictionary in (ex["records"] as Array):
var sk := String(sr["kind"])
if CurveFold.handles(sk):
curve_recs.append(sr)
elif ShapeFold.handles(sk):
shape_recs.append(sr)
elif OpFold.handles(sk):
op_recs.append(sr)
elif ModFold.handles(sk):
mod_recs.append(sr)
elif SceneObjects.handles(sk):
_asset_objects.append(sr)
else:
brush_recs.append(sr)
# PARAMETRIC SHAPES become ordinary brush.create records here, named
# "<author>/<i>:<seq>" at the shape's own seq, so DCLevel folds them in order with
# everything else. They never reach DCLevel as shape.* (it would count them skipped).
# A live op's cutter shape comes out as cutter pieces (wire, not solid).
# A cable (#120 L3) and a follow-curve read the room's curves.
var curves := CurveFold.live(curve_recs)
var shaped: Array = ShapeFold.expand(shape_recs, OpFold.cutter_dots(op_recs), curves)
# MODIFIERS: copies of a shape's pieces (same group), before the cuts.
var shape_states := ShapeFold.resolve_states(shape_recs, curves) if not mod_recs.is_empty() else {}
shaped.append_array(ModFold.expand(mod_recs, shaped, shape_states, curves))
brush_recs.append_array(shaped)
# LIVE CUTS: one brush.cut per live op, sorting after everything above; a
# target's copies get its cutters copied the same way (§3.3).
var cuts: Array = OpFold.expand(op_recs, shaped)
# STACK SCROLL (#120 L2 §13.5): a LOCAL preview skips the ops after row k —
# never journaled; StackOps.scroll sets it, a selection change clears it.
var off: Dictionary = get_meta("preview_off", {})
if not off.is_empty():
var kept: Array = []
for c: Dictionary in cuts:
if not off.has("%s:%d" % [String(c["author"]), int(c["seq"]) - OpFold.OP_SEQ_BASE]):
kept.append(c)
cuts = kept
ModFold.copy_cutters(mod_recs, cuts, shape_states, curves)
brush_recs.append_array(cuts)
# CHAMFERS last: they must see the cut pieces' planes (§3.3).
if not mod_recs.is_empty():
brush_recs.append_array(ModFold.chamfers(mod_recs, ShapeFold.live_dots(shape_recs)))
_last_fold = level.apply_journal(brush_recs)
_last_fold["brush_fold"] = "applied"
_last_brush_seq = -1
_last_brush_author = ""
for br: Dictionary in brush_recs:
if _last_brush_seq < 0 or _sorts_after(int(br["seq"]), String(br["author"]), _last_brush_seq, _last_brush_author):
_last_brush_seq = int(br["seq"])
_last_brush_author = String(br["author"])
fold_stats["full"] = int(fold_stats["full"]) + 1
_ix_tail_keys.clear()
_ix_tail_ok = true
level.rebuild()
_brush_stats = _last_fold.duplicate()
_brush_ver_folded = ver
_brush_level_id = level.get_instance_id()
_brush_fp = int(level.fingerprint())
else:
_last_fold = {"applied": 0, "skipped": 0, "undone": 0, "records": 0}
if has_objects:
var os: Dictionary = objects.apply_journal(_object_records() + _asset_objects)
# Merged so one `folded` payload still describes the whole journal, and
# reported separately so a skipped OBJECT (an unknown prefab id — the
# version-skew case) is not read as geometry disagreeing.
_last_fold["objects"] = os
_last_fold["records"] = int(_last_fold.get("records", 0)) + int(os.get("records", 0))
_folding = false
# `skipped` is not noise: it counts live records whose target is gone or that
# the store refused (an edit that would leave a brush enclosing nothing). It
# means the journal and this peer's geometry disagree, and swallowing that is
# how a level quietly diverges.
folded.emit(_last_fold)
if int(_last_fold.get("skipped", 0)) > 0:
push_warning("[LevelPersistence] fold skipped %d of %d records — the journal and this level disagree"
% [int(_last_fold["skipped"]), int(_last_fold.get("records", 0))])
return _last_fold
## The dots of the parametric shapes generating right now — a brush whose group is one
## of these is a shape's (read-only) piece.
##
## CACHED on the index's brush version (shape records are brush-side): the editor asks
## on every click and every Shape-panel frame, and the answer only changes with them.
func live_shapes() -> Dictionary:
_ensure_index()
if _shapes_ver != _ix_brush_ver:
_shape_states = ShapeFold.resolve_states(records(false), curve_states())
_live_shapes = {}
for d: String in _shape_states:
var s: Dictionary = _shape_states[d]
if not bool(s["detached"]) and not bool(s.get("removed", false)):
_live_shapes[d] = true
_shapes_ver = _ix_brush_ver
return _live_shapes
## One shape's resolved state ({type, gv, params, frame, size, material_id, ...}), or {}.
func shape_state(dot: String) -> Dictionary:
live_shapes()
return _shape_states.get(dot, {})
var _shapes_ver := -1
var _shape_states: Dictionary = {}
var _live_shapes: Dictionary = {}
## ---- PLACED ASSETS (#120 L4 §15.2) ------------------------------------------------
var _asset_docs: Dictionary = {} # oid -> decoded AssetDoc (put_asset_blob)
var _asset_missing: Array = [] # oids the last brush fold wanted and did not have
var _asset_inst: Dictionary = {} # synthetic dot / group -> placement dot
var _asset_objects: Array = [] # the last brush fold's synthetic object records
## Hold an asset blob for the fold. Refused unless its bytes hash to `oid` and decode
## (AssetDoc.decode trusts nothing). A new one re-folds the level: a placement waiting on
## it turns from its wire box into its pieces.
func put_asset_blob(oid: String, bytes: PackedByteArray) -> bool:
if AssetDoc.hash_of(bytes) != oid:
return false
var doc := AssetDoc.decode(bytes)
if doc.is_empty():
return false
if _asset_docs.has(oid):
return true
_asset_docs[oid] = doc
_ix_brush_ver += 1
_ix_tail_ok = false
_dirty = true
return true
func has_asset_blob(oid: String) -> bool:
return _asset_docs.has(oid)
## The oids live placements need and this peer has not got (the library fetches them).
func missing_assets() -> Array:
return _asset_missing.duplicate()
## The placement a synthetic shape / group / object belongs to, or "".
func instance_of(dot_or_group: String) -> String:
return String(_asset_inst.get(dot_or_group, ""))
## Every placement's state (AssetFold.resolve_states over the journal).
func placements() -> Dictionary:
return AssetFold.resolve_states(records(false))
## A payload parsed exactly as the journal parses one written by `author` at `seq` — how
## AssetFold makes its synthetic records.
func _parse_as(author: String, seq: int, payload: Dictionary) -> Dictionary:
return _to_record("%s%s%s%d" % [PREFIX, author, OP_INFIX, seq], payload)
## ---- FLATTEN HISTORY (2026-10-05) --------------------------------------------
##
## Replace this MAP's journal with the minimum records that rebuild what is there NOW,
## and erase every old record (user: "we need a way to purge history permanently" — the
## Home had 2460 records, 1983 from one accidental Subtract, and every fold and the
## History panel scanned them all).
##
## DELETION STORY: a backup of every old record is written FIRST (user://flatten_backups,
## _ci under CI) and a failed backup aborts. New records are written before old ones are
## erased. The erase is GameState.erase_state — out of the room's state that everything
## reads; the Autobase log underneath still holds the bytes.
##
## The new records carry `base: true`: History and every undo path skip them, so the
## flattened map is a floor an undo cannot go below (its old records are gone).
##
## What is written:
## brushes a live non-base brush (not a shape piece) -> brush.create, its current state;
## a base brush any record touched -> brush.remove (+ brush.create if live);
## an untouched base brush -> nothing
## shapes a live shape -> shape.create with its merged params, frame and size
## carves a live carve -> the same carve
## objects created -> object.create, then object.parent (remapped), then its props;
## a touched base object -> its transform / parent / props;
## a deleted base object -> object.remove
## collections (#120 F1) -> col.create (parents first) + col.link, with members whose
## identity the flatten re-mints REMAPPED: a shape, a created object and a
## curve get new dots here, so "g:<shape>" / "obj:<dot>" / "c:<curve>" would
## otherwise name nothing. A
## brush object's group is kept by its brush.create, so "g:<group>" holds.
## Returns {before, after, backup} or {error}.
func flatten_map() -> Dictionary:
if GameState == null or level == null:
return {"error": "no journal or level"}
# A flatten re-mints every shape under a new dot, which would orphan a live op's
# cutter and targets — the cuts would silently vanish. Refuse instead.
if not OpFold.live_ops(records(false)).is_empty():
return {"error": "Make Real or remove the live cuts first — nothing changed"}
refold()
var old_keys: Array[String] = []
for k: String in GameState.keys_with_prefix(PREFIX):
if not k.ends_with(":source"):
old_keys.append(k)
var backup := _write_flatten_backup(old_keys)
if backup == "":
return {"error": "backup failed — nothing changed"}
var recs: Array = records()
var touched_brush := {}
var touched_obj := {}
var shape_recs: Array = []
var carves: Array = []
for r: Dictionary in recs:
if bool(r.get("undone", false)):
continue
var kind := String(r.get("kind", ""))
if ShapeFold.handles(kind):
shape_recs.append(r)
elif SceneObjects.handles(kind):
touched_obj[String(r.get("target", ""))] = true
elif kind == "carve":
carves.append(r)
var bk := String(r.get("brush_key", ""))
if bk != "":
touched_brush[bk] = true
for k2: Variant in (r.get("brush_keys", []) as Array):
touched_brush[String(k2)] = true
var shape_dots := ShapeFold.live_dots(recs)
var out: Array = [] # [kind, payload]
# ---- brushes ----
var live := {}
for id: int in (level.get_live_brush_ids() as PackedInt32Array):
var key := String(level.get_brush_key(id))
live[key] = true
var b: Dictionary = level.get_brush(id)
if shape_dots.has(String(b.get("group", ""))):
continue # a shape's piece: the shape is written
var is_base := key.begins_with("i:")
if is_base and not touched_brush.has(key):
continue # untouched base: the scene has it
if is_base:
out.append(["brush.remove", {"brush_key": key}])
out.append(["brush.create", _brush_payload(b)])
for key: String in touched_brush:
if key.begins_with("i:") and not live.has(key):
out.append(["brush.remove", {"brush_key": key}])
# ---- curves ---- (each live one, under a new dot; a follow-curve or a cable is remapped to it)
var curve_writes: Array = []
var curve_remap := {}
var cst := curve_states()
for cdot: String in cst:
var cs: Dictionary = cst[cdot]
curve_remap[cdot] = reserve_dot()
curve_writes.append({"dot": curve_remap[cdot], "points": CurveFold.to_wire(cs["points"]),
"closed": cs["closed"], "name": cs["name"]})
# ---- shapes ---- (each under a dot reserved here, so collections can be remapped)
var states := ShapeFold.resolve_states(shape_recs, cst)
var shape_remap := {}
var shape_writes: Array = [] # [new dot, payload]
for d: String in states:
var s: Dictionary = states[d]
if bool(s["detached"]) or bool(s.get("removed", false)):
continue
var sz: Vector3 = s["size"]
shape_remap[d] = reserve_dot()
var sp: Dictionary = (s["params"] as Dictionary).duplicate(true)
for ck: String in ["curve", "profile_curve"]: # a cable's path and its profile
if sp.has(ck):
sp[ck] = String(curve_remap.get(String(sp[ck]), sp[ck]))
shape_writes.append([shape_remap[d], {"type": s["type"], "gv": s["gv"], "params": sp,
"frame": SceneObjects.xform_to(s["frame"]), "size": [sz.x, sz.y, sz.z],
"material_id": s["material_id"], "level_id": s["level_id"], "tex": s.get("tex", {}),
"input": (s.get("input", {}) as Dictionary).duplicate(true)}])
# LINKED DUPLICATES stay linked: each set (a root and its links) gets ONE new root —
# the root's new dot, or, the root deleted, the first live member's.
var set_root := {} # old root dot -> the new dot every other member links to
var order: Array = shape_remap.keys()
order.sort()
for d2: String in order:
var r0 := String(states[d2].get("link", ""))
var key := r0 if r0 != "" else d2
if not set_root.has(key):
set_root[key] = shape_remap[key] if shape_remap.has(key) else shape_remap[d2]
for w: Array in shape_writes:
for d3: String in shape_remap:
if shape_remap[d3] != w[0]:
continue
var r1 := String(states[d3].get("link", ""))
var nr: String = set_root.get(r1 if r1 != "" else d3, "")
if nr != "" and nr != String(w[0]):
(w[1] as Dictionary)["link"] = nr
# ---- carves ----
for r: Dictionary in carves:
var raw: Variant = GameState.get_state(_op_key(String(r["author"]), int(r["seq"])))
if raw is Dictionary:
var p: Dictionary = (raw as Dictionary).duplicate(true)
for meta: String in ["v", "kind", "undone", "undone_at", "g", "src", "label", "base"]:
p.erase(meta)
out.append(["carve", p])
# ---- objects ----
var obj_writes: Array = [] # [kind, payload, needs_new_dot_for(old dot) or ""]
var remap := {} # created object's old dot -> new dot
if objects != null and is_instance_valid(objects):
var present := {}
var created: Array = []
for e: Dictionary in (objects.entries() as Array):
present[String(e["dot"])] = true
if not objects.is_base(String(e["dot"])):
created.append(e)
for e: Dictionary in created:
remap[String(e["dot"])] = reserve_dot()
for e: Dictionary in created:
var n := e["node"] as Node3D
var top: Transform3D = objects.scene_xform(n)
obj_writes.append([String(remap[e["dot"]]), "object.create",
{"type": e["type"], "xform": SceneObjects.xform_to(top)}])
for e: Dictionary in (objects.entries() as Array):
var dot := String(e["dot"])
var is_b: bool = objects.is_base(dot)
if is_b and not touched_obj.has(dot):
continue
var tgt := String(remap.get(dot, dot))
var n2 := e["node"] as Node3D
var pd := String(e.get("parent", ""))
if pd != "":
obj_writes.append(["", "object.parent", {"target": tgt, "parent": String(remap.get(pd, pd)),
"xform": SceneObjects.xform_to(n2.transform)}])
elif is_b:
obj_writes.append(["", "object.transform", {"target": tgt, "xform": SceneObjects.xform_to(n2.transform)}])
var set_here: Dictionary = (objects.get("_props") as Dictionary).get(dot, {})
if not set_here.is_empty():
obj_writes.append(["", "object.set_prop", {"target": tgt, "props": set_here.duplicate(true)}])
for bdot: String in (objects.get("_base") as Dictionary):
if not present.has(bdot):
obj_writes.append(["", "object.remove", {"target": bdot}])
# ---- modifiers ---- (targets remapped like the shapes they modify)
var mod_writes: Array = []
for m: Dictionary in ModFold.resolve_states(recs).values():
if bool(m["removed"]) or not shape_remap.has(String(m["target"])):
continue
var mp: Dictionary = (m["params"] as Dictionary).duplicate(true)
if mp.has("curve"):
mp["curve"] = String(curve_remap.get(String(mp["curve"]), mp["curve"]))
mod_writes.append({"target": shape_remap[String(m["target"])], "mod": m["kind"],
"params": mp, "seq": int(m["seq"]), "on": m["on"]})
mod_writes.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a["seq"]) < int(b["seq"]))
# ---- collections ---- (parents first; members remapped)
var col_writes: Array = [] # [new dot, kind, payload]
var ct: Dictionary = collection_tree()["cols"]
var col_remap := {}
var col_order: Array = []
var pending: Array = ct.keys()
pending.sort()
while not pending.is_empty():
var progressed := false
for d: String in pending.duplicate():
var par := String(ct[d]["parent"])
if par == "" or col_remap.has(par):
col_remap[d] = reserve_dot()
col_order.append(d)
pending.erase(d)
progressed = true
if not progressed:
break # unreachable for a folded tree
for d: String in col_order:
var par2 := String(ct[d]["parent"])
col_writes.append([col_remap[d], "col.create", {"name": ct[d]["name"],
"parent": String(col_remap.get(par2, ""))}])
var ms: Array = []
for m: String in ct[d]["members"]:
if m.begins_with("g:") and shape_remap.has(m.substr(2)):
ms.append("g:" + String(shape_remap[m.substr(2)]))
elif m.begins_with("obj:") and remap.has(m.substr(4)):
ms.append("obj:" + String(remap[m.substr(4)]))
elif m.begins_with("c:"):
# A curve is re-minted above (#120 §16.2); a gone one is dropped.
if curve_remap.has(m.substr(2)):
ms.append("c:" + String(curve_remap[m.substr(2)]))
else:
ms.append(m)
if not ms.is_empty():
col_writes.append(["", "col.link", {"target": col_remap[d], "members": ms}])
# ---- write the new floor, then erase the old ----
var written := 0
for w: Array in out:
var p2: Dictionary = (w[1] as Dictionary).duplicate(true)
p2["base"] = true
if record_at(reserve_dot(), String(w[0]), p2):
written += 1
for sw: Array in shape_writes:
var ps: Dictionary = (sw[1] as Dictionary).duplicate(true)
ps["base"] = true
if record_at(String(sw[0]), "shape.create", ps):
written += 1
for mw: Dictionary in mod_writes: # after their shapes, in stack order
var on := bool(mw["on"])
var mdot := reserve_dot()
if record_at(mdot, "mod.create", {"target": mw["target"], "mod": mw["mod"],
"params": mw["params"], "base": true}):
written += 1
if not on and record_at(reserve_dot(), "mod.set", {"target": mdot, "on": false, "base": true}):
written += 1
for w2: Array in obj_writes:
var p3: Dictionary = (w2[2] as Dictionary).duplicate(true)
p3["base"] = true
var dot2 := String(w2[0]) if String(w2[0]) != "" else reserve_dot()
if record_at(dot2, String(w2[1]), p3):
written += 1
for cv: Dictionary in curve_writes:
var pv := cv.duplicate(true)
var cdot2 := String(pv["dot"])
pv.erase("dot")
pv["base"] = true
if record_at(cdot2, "curve.create", pv):
written += 1
for cw: Array in col_writes:
var pc: Dictionary = (cw[2] as Dictionary).duplicate(true)
pc["base"] = true
if record_at(String(cw[0]) if String(cw[0]) != "" else reserve_dot(), String(cw[1]), pc):
written += 1
for k: String in old_keys:
GameState.erase_state(k)
refold()
print("[LevelPersistence] FLATTENED %s: %d records -> %d (backup %s)"
% [GameState.current_map_id, old_keys.size(), written, backup])
return {"before": old_keys.size(), "after": written, "backup": backup}
## A brush's current state as a brush.create payload (JSON-safe numbers).
func _brush_payload(b: Dictionary) -> Dictionary:
var planes: Array = []
for f: float in (b.get("planes", PackedFloat32Array()) as PackedFloat32Array):
planes.append(f)
var proj: Array = []
for i: int in (b.get("proj", PackedInt32Array()) as PackedInt32Array):
proj.append(i)
return {"planes": planes, "proj": proj, "material_id": int(b.get("material_id", 0)),
"level_id": int(b.get("level_id", 0)), "sky": bool(b.get("sky", false)),
"clearance": bool(b.get("clearance", false)), "group": String(b.get("group", "")),
"face_tex": (b.get("face_tex", []) as Array).duplicate(true)}
## Every old record of the map, raw, to a file. "" on failure (the flatten then aborts).
func _write_flatten_backup(keys: Array[String]) -> String:
var dir := "user://flatten_backups_ci" if OS.get_environment("CI") == "true" else "user://flatten_backups"
DirAccess.make_dir_recursive_absolute(dir)
var room := String(KeetService.get_current_room()) if KeetService != null else ""
var path := "%s/%s-%d.json" % [dir, GameState.current_map_id if GameState.current_map_id != "" else "map",
int(Time.get_unix_time_from_system())]
var entries := {}
for k: String in keys:
entries[k] = var_to_str(GameState.get_state(k))
var f := FileAccess.open(path, FileAccess.WRITE)
if f == null:
return ""
f.store_string(JSON.stringify({"format": 1, "map_id": GameState.current_map_id, "room": room,
"made_at": Time.get_datetime_string_from_system(true), "entries": entries}, " ", true))
f.close()
return path
## What the last APPLIED brush fold saw (refold's skip rule).
var _brush_ver_folded := -1
var _brush_level_id := 0
var _brush_fp := 0
var _brush_stats: Dictionary = {}
## Every record in the room, as the Dictionaries DCLevel.apply_journal takes — or,
## with `with_objects` false, every record but the objects' (the fold's brush half).
## Ordering is NOT done here — the fold sorts by (seq, author), because arrival
## order in a last-write-wins view means nothing.
##
## Served from the INDEX: each record was parsed once, when it arrived. The returned
## dictionaries are shared with the index — read them, never write them.
func records(with_objects: bool = true) -> Array:
var out: Array = []
if GameState == null:
return out
_ensure_index()
var have_src := String(find_source().get("hash", ""))
for key: String in _ix_meta:
if not with_objects and _ix_obj_keys.has(key):
continue
var rec := _ix_record(key, have_src)
if not rec.is_empty():
out.append(rec)
return out
## The room's collection tree (CollectionFold.tree): {"cols": {dot: {name, parent,
## children, members}}, "ignored": [...]}.
## THE STACK SCROLL PREVIEW (#120 L2 §13.5): the op dots the fold skips, LOCALLY. It
## changes no record, so the brush fold's "nothing changed" skip must be told: the next
## refold runs in full. {} ends the preview.
func set_preview_off(off: Dictionary) -> void:
if off.is_empty():
remove_meta("preview_off")
else:
set_meta("preview_off", off)
_brush_ver_folded = -1
func collection_tree() -> Dictionary:
_ensure_index()
var recs: Array = []
var have_src := String(find_source().get("hash", ""))
for key: String in _ix_col_keys:
var rec := _ix_record(key, have_src)
if not rec.is_empty():
recs.append(rec)
return CollectionFold.tree(recs)
## The room's live curves (CurveFold): {dot: {points (CurveMath's), closed, name, …}}.
## DELETE A SHAPE, inside the caller's gesture: shape.remove, and for a CABLE its curve too
## when no other live shape or modifier names it (#120 L3 §11.3 — the cable IS its curve,
## so X must not leave the wire behind). Returns the shape.remove's dot ("" if refused).
func remove_shape(dot: String) -> String:
var st: Dictionary = shape_state(dot)
var out := record("shape.remove", {"target": dot})
if out == "" or String(st.get("type", "")) != "cable":
return out
var cdot := String((st["params"] as Dictionary).get("curve", ""))
if cdot == "" or not curve_states().has(cdot) or not curve_users(cdot, dot).is_empty():
return out
record("curve.remove", {"target": cdot})
return out
## What still USES curve `cdot`, by dot, sorted: the live shapes whose path or custom
## profile it is (§14.2) and the live modifiers that follow it, `except` one shape (the one
## being removed). A curve with users is not deleted (§16.7 Q2: X refuses and names them).
func curve_users(cdot: String, except: String = "") -> Array:
var out: Array = (curve_users_all().get(cdot, []) as Array).filter(func(d: String) -> bool: return d != except)
return out
## Every used curve's users in one pass: {curve dot: [shape / modifier dot, sorted]}, with
## {kind: "path" | "profile" | "mod"} per user in `roles` when given (the outliner names a
## cable's curve by what it is to the cable).
func curve_users_all(roles: Dictionary = {}) -> Dictionary:
var out := {}
var add := func(c: String, d: String, role: String) -> void:
if c == "":
return
if not out.has(c):
out[c] = []
if not (out[c] as Array).has(d):
(out[c] as Array).append(d)
roles["%s|%s" % [c, d]] = role
for d: String in (live_shapes() as Dictionary):
var dp: Dictionary = shape_state(d)["params"]
add.call(String(dp.get("curve", "")), d, "path")
add.call(String(dp.get("profile_curve", "")), d, "profile")
for m: Dictionary in ModFold.resolve_states(records(false)).values():
if not bool(m["removed"]):
add.call(String((m["params"] as Dictionary).get("curve", "")), String(m["dot"]), "mod")
for c2: String in out:
(out[c2] as Array).sort()
return out
func curve_states() -> Dictionary:
_ensure_index()
var recs: Array = []
var have_src := String(find_source().get("hash", ""))
for key: String in _ix_curve_keys:
var rec := _ix_record(key, have_src)
if not rec.is_empty():
recs.append(rec)
return CurveFold.live(recs)
## Object records only: the object half of a fold never has to look at a brush.
func _object_records() -> Array:
var out: Array = []
var have_src := String(find_source().get("hash", ""))
for key: String in _ix_obj_keys:
var rec := _ix_record(key, have_src)
if not rec.is_empty():
out.append(rec)
return out
## One indexed record, or {} when it is unparseable or authored against a DIFFERENT
## level. Brush names are only meaningful relative to one .MAP; replaying a foreign
## edit would move a real wall in the wrong place, quietly. Skipped rather than
## guessed — and if either side does not know its hash yet, the edit waits.
func _ix_record(key: String, have_src: String) -> Dictionary:
var m: Dictionary = _ix_meta[key]
var want_src := String(m["src"])
if want_src != "" and have_src != "" and want_src != have_src:
return {}
# A LEGACY record naming its brush by local id resolves through the level, which
# may not have been imported when it arrived: re-parsed on every read.
if bool(m["volatile"]):
return _to_record(key, _ix_raw[key])
return m["rec"]
# ---- the journal INDEX (2026-10-05) -----------------------------------------
#
# WHY: every question this node answered was a scan of the whole room — records()
# re-read and RE-PARSED every record per fold, gestures() re-read every key per History
# refresh, and record_at() scanned for the level source once PER RECORD (a 2000-record
# Subtract = 2000 full scans). Measured at 2472 records: 0.71 ms per record written,
# ~103 ms per object edit, ~150 ms per undo, all of it re-reading what had not changed.
#
# WHAT: the journal parsed ONCE, kept current from GameState.state_changed — which fires
# for every change to the mirror (set, erase, bulk load, a peer's update; read in full,
# autoloads/game_state.gd). Bulk drops (enter_map, a room switch) emit map_changing
# instead, which marks the index stale; the next read rebuilds it from GameState.
#
# It keeps: every record parsed (`rec`) with its metadata, the source keys, each
# author's undoable keys (History and undo read only those), the max seq (Lamport), and
# two VERSIONS — `_ix_ver` on any change (the gesture cache), `_ix_brush_ver` when a
# brush/shape record or a source changes (the fold's skip check).
#
# NOT a second source of truth: GameState is. gate_journal_index rebuilds from scratch
# after random sequences and compares.
var _ix_ready := false
var _ix_raw: Dictionary = {} # key -> the stored value, for change detection
var _ix_meta: Dictionary = {} # record key -> {author, seq, g, undone, stamp, kind, label, src, rec, volatile}
var _ix_sources: Dictionary = {} # ":source" key -> value
var _ix_by_author: Dictionary = {} # author -> {key: true}, undoable records only (:op:, not base)
var _ix_gest: Dictionary = {} # author -> {g -> {"keys": {key: true}, "sum": {} (empty = stale)}}
var _ix_undone_n: Dictionary = {} # author -> how many of their undoable records are undone
var _ix_obj_keys: Dictionary = {} # keys of object.* records
var _ix_col_keys: Dictionary = {} # keys of col.* records: neither objects nor brush-side
var _ix_curve_keys: Dictionary = {} # keys of curve.* records: the same (no brushes)
var _ix_followed: Dictionary = {} # curve dot -> how many mod.* records name it (follow-curve)
var _ix_max_seq := 0
var _ix_ver := 0
var _ix_brush_ver := 0
var _ix_stats := {"rebuilds": 0, "parses": 0}
## For gates and probes: how many full rebuilds and record parses have happened.
func index_stats() -> Dictionary:
var s := _ix_stats.duplicate()
s["records"] = _ix_meta.size()
s["ver"] = _ix_ver
s["brush_ver"] = _ix_brush_ver
return s
func _ensure_index() -> void:
if _ix_ready:
return
_ix_ready = true
_ix_raw.clear()
_ix_meta.clear()
_ix_sources.clear()
_ix_by_author.clear()
_ix_gest.clear()
_ix_undone_n.clear()
_ix_obj_keys.clear()
_ix_col_keys.clear()
_ix_curve_keys.clear()
_ix_followed.clear()
_ix_stats["rebuilds"] = int(_ix_stats["rebuilds"]) + 1
_ix_ver += 1
_ix_brush_ver += 1
_ix_tail_ok = false # a rebuilt index knows nothing about the store
_ix_tail_keys.clear()
if GameState == null:
return
_connect_state()
for key: String in GameState.keys_with_prefix(PREFIX):
var v: Variant = GameState.get_state(key)
if v != null:
_ix_add(key, v)
## Apply one key's new value (null = erased). Returns true if anything changed.
##
## TAIL TRACKING (the tail fold, refold): since the last brush fold, were the brush-side
## changes ONLY new records? A new brush record joins _ix_tail_keys; anything else on
## the brush side — a CHANGED record (an undo/redo rewrites one), an erase, a source
## change, any shape record (it regenerates pieces under new names) — breaks the tail.
func _ix_put(key: String, value: Variant) -> bool:
var had := _ix_raw.has(key)
var was_brush := had and (_ix_sources.has(key) or (_ix_meta.has(key) and not _ix_obj_keys.has(key)
and not _ix_col_keys.has(key) and not _ix_curve_keys.has(key)))
if value == null:
if not had:
return false
_ix_drop(key)
if was_brush:
_ix_tail_ok = false
else:
if had and _ix_raw[key] == value:
return false
if had:
_ix_drop(key)
_ix_add(key, value)
if key.ends_with(":source") or was_brush:
_ix_tail_ok = false
elif _ix_meta.has(key) and not _ix_obj_keys.has(key) and not _ix_col_keys.has(key) \
and not _ix_curve_keys.has(key):
var tk := String(_ix_meta[key]["kind"])
if ShapeFold.handles(tk) or OpFold.handles(tk) or ModFold.handles(tk) or AssetFold.handles(tk):
_ix_tail_ok = false
else:
_ix_tail_keys.append(key)
_ix_ver += 1
return true
# ---- the tail fold state ----
var _ix_tail_keys: Array[String] = [] # new brush records since the last brush fold
var _ix_tail_ok := false # ... and nothing else changed on the brush side
var _last_brush_author := "" # the greatest (seq, author) that fold applied
var _last_brush_seq := -1
var fold_stats := {"tail": 0, "full": 0}
## dc_journal::before, for the max bookkeeping: (seq, author), author by byte order.
static func _sorts_after(seq_a: int, author_a: String, seq_b: int, author_b: String) -> bool:
return seq_a > seq_b or (seq_a == seq_b and author_a > author_b)
func _ix_add(key: String, value: Variant) -> void:
_ix_raw[key] = value
if key.ends_with(":source"):
_ix_sources[key] = value
_ix_brush_ver += 1 # the source filter may now admit other records
return
if not (value is Dictionary):
return
var d: Dictionary = value
var rest := key.substr(PREFIX.length())
var at := rest.find(OP_INFIX)
var author := rest.substr(0, at) if at >= 0 else ""
var seq := int(rest.substr(at + OP_INFIX.length())) if at >= 0 else 0
if at >= 0:
_ix_max_seq = maxi(_ix_max_seq, seq)
var rec := _to_record(key, d)
_ix_stats["parses"] = int(_ix_stats["parses"]) + 1
var kind := String(rec.get("kind", d.get("kind", "")))
var dot := "%s:%d" % [author, seq]
_ix_meta[key] = {"author": author, "seq": seq, "kind": kind, "rec": rec,
"g": String(d.get("g", dot)), "undone": bool(d.get("undone", false)),
"stamp": int(d.get("undone_at", 0)), "label": String(d.get("label", "")),
"src": String(d.get("src", "")),
"volatile": d.has("brush") and String(d.get("brush_key", "")) == ""}
if at >= 0 and not bool(d.get("base", false)):
if not _ix_by_author.has(author):
_ix_by_author[author] = {}
_ix_gest[author] = {}
(_ix_by_author[author] as Dictionary)[key] = true
var gt: Dictionary = _ix_gest[author]
var g := String(_ix_meta[key]["g"])
if not gt.has(g):
gt[g] = {"keys": {}, "sum": {}}
(gt[g]["keys"] as Dictionary)[key] = true
gt[g]["sum"] = {}
if bool(_ix_meta[key]["undone"]):
_ix_undone_n[author] = int(_ix_undone_n.get(author, 0)) + 1
if SceneObjects.handles(kind):
_ix_obj_keys[key] = true
elif CollectionFold.handles(kind):
_ix_col_keys[key] = true # organisation: the level does not re-fold
elif CurveFold.handles(kind):
_ix_curve_keys[key] = true # no brushes: the level does not re-fold ...
_ix_curve_touched(rec, dot) # ... unless a follow-curve reads it
else:
for fc: String in _ix_followed_curves(rec):
_ix_followed[fc] = int(_ix_followed.get(fc, 0)) + 1
_ix_brush_ver += 1
func _ix_drop(key: String) -> void:
_ix_raw.erase(key)
if _ix_sources.has(key):
_ix_sources.erase(key)
_ix_brush_ver += 1
return
if not _ix_meta.has(key):
return
var m: Dictionary = _ix_meta[key]
_ix_meta.erase(key)
var mine: Variant = _ix_by_author.get(String(m["author"]), null)
if mine is Dictionary and (mine as Dictionary).has(key):
(mine as Dictionary).erase(key)
if bool(m["undone"]):
_ix_undone_n[String(m["author"])] = int(_ix_undone_n.get(String(m["author"]), 0)) - 1
var gt: Dictionary = _ix_gest[String(m["author"])]
var g := String(m["g"])
if gt.has(g):
(gt[g]["keys"] as Dictionary).erase(key)
if (gt[g]["keys"] as Dictionary).is_empty():
gt.erase(g)
else:
gt[g]["sum"] = {}
if _ix_obj_keys.has(key):
_ix_obj_keys.erase(key)
elif _ix_col_keys.has(key):
_ix_col_keys.erase(key)
elif _ix_curve_keys.has(key):
_ix_curve_keys.erase(key)
_ix_curve_touched(m["rec"], "%s:%d" % [String(m["author"]), int(m["seq"])])
else:
for fc: String in _ix_followed_curves(m["rec"]):
_ix_followed[fc] = int(_ix_followed.get(fc, 0)) - 1
if int(_ix_followed[fc]) <= 0:
_ix_followed.erase(fc)
_ix_brush_ver += 1
## The curves a mod.* or shape.* record names: params.curve (a follow-curve, a cable's
## path) and params.profile_curve (a cable's custom profile, §14.2). Empty dots dropped.
static func _ix_followed_curves(rec: Dictionary) -> Array:
var out: Array = []
var k := String(rec.get("kind", ""))
if not ModFold.handles(k) and not ShapeFold.handles(k):
return out
var ps: Variant = rec.get("params", {})
if not (ps is Dictionary):
return out
for key: String in ["curve", "profile_curve"]:
var c := String((ps as Dictionary).get(key, ""))
if c != "" and not out.has(c):
out.append(c)
return out
## A curve record changed (`dot` = its own key's dot). When a modifier record names that
## curve (any, even an undone one: a spare re-fold is cheap, a missed one is a stale
## level) its copies may move, so the level re-folds IN FULL — the tail fold applies new
## brush records only. Any other curve still never re-folds it.
func _ix_curve_touched(rec: Dictionary, dot: String) -> void:
var c := dot if String(rec.get("kind", "")) == "curve.create" else String(rec.get("target", ""))
if _ix_followed.has(c):
_ix_brush_ver += 1
_ix_tail_ok = false
## Durable key + value -> fold record. The author and seq come from the KEY, which
## is where the dot lives.
func _to_record(key: String, d: Dictionary) -> Dictionary:
var author := ""
var seq := 0
var kind := String(d.get("kind", ""))
var rest: String = key.substr(PREFIX.length())
var at: int = rest.find(OP_INFIX)
if at >= 0:
author = rest.substr(0, at)
seq = int(rest.substr(at + OP_INFIX.length()))
else:
# LEGACY `:carve:<seq>` / `:face:<seq>` records, from before the journal
# landed. Kept readable so a room mid-session is not silently emptied; new
# writes are always :op:. Delete this branch once no live room predates it.
var seg: PackedStringArray = rest.split(":")
if seg.size() < 3:
return {}
author = seg[0]
seq = int(seg[2])
kind = "carve" if seg[1] == "carve" else ("face.set" if seg[1] == "face" else "")
if kind == "":
return {}
if int(d.get("v", 1)) > RECORD_V:
# A newer peer wrote something this build does not understand. Skipping is
# the only safe move: applying half a payload is worse than not applying it.
return {}
var rec: Dictionary = {
"author": author, "seq": seq, "kind": kind,
"undone": bool(d.get("undone", false)),
}
match kind:
"object.transform":
# `target` is the DOT of the create it moves — a durable name, because
# an index or a node path means something different on every peer.
rec["target"] = String(d.get("target", ""))
rec["xform"] = SceneObjects.xform_from(d.get("xform", []))
"object.remove":
rec["target"] = String(d.get("target", ""))
"object.parent":
# `parent` is a dot or "" (top level); `xform` is the child's transform in
# the NEW parent's space (SceneObjects.KIND_PARENT).
rec["target"] = String(d.get("target", ""))
rec["parent"] = String(d.get("parent", ""))
rec["xform"] = SceneObjects.xform_from(d.get("xform", []))
"object.set_prop":
# Passed through RAW. What may be set, and to what, is the catalogue's call
# (PrefabCatalogue.PROPS + sanitize) and the fold makes it, per prefab —
# this layer cannot know an object's type, so it must not guess.
rec["target"] = String(d.get("target", ""))
var p: Variant = d.get("props", {})
rec["props"] = p if p is Dictionary else {}
"asset.place":
# A LIVE INSTANCE of an asset blob (#120 L4 §15.2): the blob by its hash, the
# frame it is placed at. AssetFold expands it.
rec["asset"] = String(d.get("asset", ""))
rec["name"] = String(d.get("name", ""))
rec["frame"] = SceneObjects.xform_from(d.get("frame", []))
"asset.xform":
rec["target"] = String(d.get("target", ""))
rec["frame"] = SceneObjects.xform_from(d.get("frame", []))
"asset.set":
rec["target"] = String(d.get("target", ""))
rec["asset"] = String(d.get("asset", ""))
"asset.remove":
rec["target"] = String(d.get("target", ""))
"col.create":
rec["name"] = String(d.get("name", "Collection"))
rec["parent"] = String(d.get("parent", ""))
"col.set":
# Only the fields present: per-field last-write-wins (CollectionFold).
rec["target"] = String(d.get("target", ""))
if d.has("name"):
rec["name"] = String(d["name"])
if d.has("parent"):
rec["parent"] = String(d["parent"])
"col.remove":
rec["target"] = String(d.get("target", ""))
rec["hierarchy"] = bool(d.get("hierarchy", false))
"col.link", "col.unlink":
rec["target"] = String(d.get("target", ""))
var cm: Array = []
for m: Variant in d.get("members", []):
cm.append(String(m))
rec["members"] = cm
"shape.create":
# A parametric shape (docs/parametric-shapes-design.md). Every value here is
# a plain number or string in the record; ShapeFold reads typed ones.
rec["type"] = String(d.get("type", ""))
rec["gv"] = int(d.get("gv", 1))
var sp: Variant = d.get("params", {})
rec["params"] = sp if sp is Dictionary else {}
rec["frame"] = SceneObjects.xform_from(d.get("frame", []))
rec["size"] = _vec3(d.get("size", [1, 1, 1]))
rec["material_id"] = int(d.get("material_id", 0))
rec["level_id"] = int(d.get("level_id", 0))
# The shape's texture (§13.3), typed as DCLevel's decode reads face_tex —
# dropped here, every generated piece came out bare (gate_shape_texture).
if d.get("tex") is Dictionary and not (d["tex"] as Dictionary).is_empty():
rec["tex"] = _tex_rec(d["tex"])
# An assigned generator's INPUT (the passage), kept by Duplicate and Flatten.
if d.get("input") is Dictionary:
rec["input"] = (d["input"] as Dictionary).duplicate(true)
# A linked duplicate's root (Alt+D): its data is shared (ShapeFold._link_data).
if String(d.get("link", "")) != "":
rec["link"] = String(d["link"])
rec["params"] = _hull_tex(rec["params"])
"shape.set":
rec["target"] = String(d.get("target", ""))
var sp2: Variant = d.get("params", {})
rec["params"] = sp2 if sp2 is Dictionary else {}
if d.get("tex") is Dictionary and not (d["tex"] as Dictionary).is_empty():
rec["tex"] = _tex_rec(d["tex"])
# ASSIGN A GENERATOR (#120 F2): the new type and the generator it replaces
# (`input`, {} = cleared). Dropped here, the type never changed on any peer.
if String(d.get("type", "")) != "":
rec["type"] = String(d["type"])
rec["gv"] = int(d.get("gv", 1))
if d.get("input") is Dictionary:
rec["input"] = (d["input"] as Dictionary).duplicate(true)
rec["params"] = _hull_tex(rec["params"])
"curve.create":
# A curve (#120 F5): points stay in their wire form; CurveFold reads them.
rec["points"] = (d.get("points", []) as Array).duplicate(true) if d.get("points") is Array else []
rec["closed"] = bool(d.get("closed", false))
rec["name"] = String(d.get("name", "Curve"))
"curve.set":
rec["target"] = String(d.get("target", ""))
for f: String in ["points", "index", "fields", "closed", "name"]:
if d.has(f):
rec[f] = (d[f] as Variant) if not (d[f] is Array or d[f] is Dictionary) else d[f].duplicate(true)
"curve.remove":
rec["target"] = String(d.get("target", ""))
"mod.create":
# A modifier on a shape (#120 F2): `mod` is its kind (mirror / array / radial).
rec["target"] = String(d.get("target", ""))
rec["mod"] = String(d.get("mod", ""))
rec["params"] = (d.get("params", {}) as Dictionary).duplicate(true) if d.get("params") is Dictionary else {}
"mod.set":
rec["target"] = String(d.get("target", ""))
if d.get("params") is Dictionary:
rec["params"] = (d["params"] as Dictionary).duplicate(true)
if d.has("on"):
rec["on"] = bool(d["on"])
"mod.remove":
rec["target"] = String(d.get("target", ""))
"shape.xform":
rec["target"] = String(d.get("target", ""))
rec["frame"] = SceneObjects.xform_from(d.get("frame", []))
"shape.size":
rec["target"] = String(d.get("target", ""))
rec["size"] = _vec3(d.get("size", [1, 1, 1]))
"shape.detach", "shape.remove":
rec["target"] = String(d.get("target", ""))
"op.create":
# A LIVE CUT (docs/cutting-and-operations.md §9a). `cutter` and `targets` are
# journal names (a shape's dot, a group) — never brush ids.
rec["op"] = String(d.get("op", "subtract"))
rec["cutter"] = String(d.get("cutter", ""))
if d.has("depth"): # inset / outset (#120 L1 §12.3)
rec["depth"] = float(d["depth"])
var tg := PackedStringArray()
for t2: Variant in d.get("targets", []):
tg.append(String(t2))
rec["targets"] = tg
"op.set":
rec["target"] = String(d.get("target", ""))
rec["op"] = String(d.get("op", ""))
# The Stack panel's eye (§9b). Only when written: an absent `on` must not
# switch an op back on.
if d.has("on"):
rec["on"] = bool(d["on"])
if d.has("depth"):
rec["depth"] = float(d["depth"])
"op.remove", "op.apply":
rec["target"] = String(d.get("target", ""))
"object.create":
rec["type"] = String(d.get("type", ""))
# 12 floats: basis rows then origin. Stored flat for the same reason
# `pos` and `half` are — every value in this journal is a number, and
# an engine type in a durable record is one more thing that has to keep
# surviving var_to_str across builds.
var x: Array = d.get("xform", [])
rec["xform"] = SceneObjects.xform_from(x)
"carve":
var pos: Array = d.get("pos", [0, 0, 0])
var c: Dictionary = {
"type": String(d.get("type", "sphere")),
"pos": Vector3(float(pos[0]), float(pos[1]), float(pos[2])),
"radius": float(d.get("radius", 0.6)),
}
if c["type"] == "box":
var h: Array = d.get("half", [0.5, 0.5, 0.5])
c["half"] = Vector3(float(h[0]), float(h[1]), float(h[2]))
rec["carve"] = c
"face.set", "face.add":
var n: Array = d.get("n", [0, 1, 0])
rec["brush_key"] = _brush_key_of(d)
rec["face_key"] = String(d.get("face_key", ""))
rec["n"] = Vector3(float(n[0]), float(n[1]), float(n[2]))
rec["d"] = float(d.get("d", 0.0))
rec["face_proj"] = int(d.get("face_proj", 0))
# face.add's texture intent (#94 P1). Absent on older records: untextured.
if d.has("tex"):
rec["tex"] = _tex_rec(d.get("tex", {}))
"face.tex":
# TEXTURE INTENT on named faces (#94 P1): one record per gesture so an undo
# is atomic, and a FIELD MASK so a later "nudge offset" does not clobber a
# concurrent "change texture" (DCTexMask in libdc/include/dc_face_tex.h;
# 0 = the whole intent).
rec["brush_key"] = _brush_key_of(d)
var fk := PackedStringArray()
for k3: Variant in d.get("face_keys", []):
fk.append(String(k3))
rec["face_keys"] = fk
rec["tex"] = _tex_rec(d.get("tex", {}))
rec["mask"] = int(d.get("mask", 0))
"face.remove":
rec["brush_key"] = _brush_key_of(d)
rec["face_key"] = String(d.get("face_key", ""))
"brush.create":
var pl: Array = d.get("planes", [])
var pj: Array = d.get("proj", [])
var planes := PackedFloat32Array()
for f: float in pl:
planes.append(float(f))
var proj := PackedInt32Array()
for i: int in pj:
proj.append(int(i))
rec["planes"] = planes
rec["proj"] = proj
rec["material_id"] = int(d.get("material_id", 0))
rec["level_id"] = int(d.get("level_id", 0))
rec["sky"] = bool(d.get("sky", false))
# WRITTEN by the shape operators and READ by DCLevel's decode, but dropped
# here until 2026-10-02 -- so every refold rebuilt a clearance volume as a
# SOLID brush and forgot which operator group a brush belonged to
# (journal_texture_gate). #41 §4's trap, caught by the gate for #94 P1.
rec["clearance"] = bool(d.get("clearance", false))
rec["group"] = String(d.get("group", ""))
# Per-face texture intent (#94 P1), parallel to planes; absent = untextured.
var fts: Array = []
for t: Variant in d.get("face_tex", []):
fts.append(_tex_rec(t))
rec["face_tex"] = fts
"brush.remove":
rec["brush_key"] = _brush_key_of(d)
"brush.split":
# The target keeps the front half; the BACK half is named by this
# record's own dot, like every other create.
var sn: Array = d.get("n", [1, 0, 0])
rec["brush_key"] = _brush_key_of(d)
rec["n"] = Vector3(float(sn[0]), float(sn[1]), float(sn[2]))
rec["d"] = float(d.get("d", 0.0))
"brush.subtract", "brush.intersect":
# THE OPERAND IS A PLANE SET, never a brush id. Ids come from a local
# counter, so two peers each allocate the same id for different geometry —
# a record naming one would replay against whatever happened to land on
# that number, succeed, change real geometry, and be wrong. Carrying the
# planes also makes the record independent of a cutter brush that the
# operation itself consumes.
var cp: Array = d.get("planes", [])
var cpl := PackedFloat32Array()
for f2: float in cp:
cpl.append(float(f2))
rec["brush_key"] = _brush_key_of(d)
rec["planes"] = cpl
"brush.merge":
# KEYS, in order, and the order is load-bearing: the first brush SURVIVES,
# keeping its id and its name. That cannot be said with plane sets, which
# is why this one names brushes where the other two name geometry.
var mk: Array = d.get("brush_keys", [])
var mks := PackedStringArray()
for k2: String in mk:
mks.append(String(k2))
rec["brush_keys"] = mks
"brush.regroup":
# JOIN / SEPARATE (#120 F1): the brushes, by durable key, and the group they
# move to. DCLevel sets each live one's group; pieces made after it inherit
# it, and the rewind restores the created group (DCBrush::base_group).
var rk := PackedStringArray()
for k4: Variant in d.get("brush_keys", []):
rk.append(String(k4))
rec["brush_keys"] = rk
rec["group"] = String(d.get("group", ""))
"brush.reshape":
# TRUE VERTEX / EDGE DRAG. The corners being moved, by POSITION, plus a
# delta — positions because a vertex has no stable index: it is derived
# from the plane set, so its index changes the moment the plane set does.
# Replay is sound because the fold is ordered: every record before this
# one has already been applied, so these name the geometry that was there
# when it was authored.
var pl2: Array = d.get("points", [])
var pv := PackedVector3Array()
for t: Array in pl2:
pv.append(Vector3(float(t[0]), float(t[1]), float(t[2])))
var rd: Array = d.get("delta", [0, 0, 0])
rec["brush_key"] = _brush_key_of(d)
rec["points"] = pv
rec["delta"] = Vector3(float(rd[0]), float(rd[1]), float(rd[2]))
"brush.translate":
var dl: Array = d.get("delta", [0, 0, 0])
rec["brush_key"] = _brush_key_of(d)
rec["delta"] = Vector3(float(dl[0]), float(dl[1]), float(dl[2]))
# Texture lock (#94 P1 D5). Dropped here = the move refolds UNLOCKED and
# the texture slides on every peer. Absent = a pre-lock record.
rec["tex_lock"] = bool(d.get("tex_lock", false))
"brush.scale":
# THE CASE WHOSE ABSENCE MADE A RESIZE REVERT. Without it the match
# below falls to `_: return {}`, records() drops the record, and the
# next fold — which rewinds to the base plane sets and replays — has
# nothing to re-apply. The geometry snapped back to its pre-scale size
# and the panel's box, resynced from it, showed the old number. It
# looked like the field refusing to take a value.
#
# The pivot is CARRIED, not derived: it is the selection's centre, a
# property of what was selected when the edit was made, and a peer
# recomputing it would scale about a different point.
var sc: Array = d.get("scale", [1, 1, 1])
var pv2: Array = d.get("pivot", [0, 0, 0])
rec["brush_key"] = _brush_key_of(d)
rec["scale"] = Vector3(float(sc[0]), float(sc[1]), float(sc[2]))
rec["pivot"] = Vector3(float(pv2[0]), float(pv2[1]), float(pv2[2]))
rec["tex_lock"] = bool(d.get("tex_lock", false))
"brush.rotate":
# The rotate tool (#41 §7 item 4). Without this case the record is dropped and
# the next fold turns the brush back — the same trap brush.scale documents.
# Axis + angle in degrees + the CARRIED pivot (the group's centre at edit time).
var ax: Array = d.get("axis", [0, 1, 0])
var pv3: Array = d.get("pivot", [0, 0, 0])
rec["brush_key"] = _brush_key_of(d)
rec["axis"] = Vector3(float(ax[0]), float(ax[1]), float(ax[2]))
rec["angle"] = float(d.get("angle", 0.0))
rec["pivot"] = Vector3(float(pv3[0]), float(pv3[1]), float(pv3[2]))
rec["tex_lock"] = bool(d.get("tex_lock", false))
_:
return {}
return rec
static func _vec3(a: Variant) -> Vector3:
if a is Vector3:
return a
if a is Array and (a as Array).size() == 3:
return Vector3(float(a[0]), float(a[1]), float(a[2]))
return Vector3.ONE
## One face's texture intent as DCLevel's decode reads it (#94 P1; DCFaceTex in
## libdc/include/dc_face_tex.h). Every field typed here, because a journal value is
## whatever var_to_str round-tripped and the C++ side reads exact Variant types.
## Absent fields take DCFaceTex's defaults.
static func _tex_rec(t: Variant) -> Dictionary:
if not (t is Dictionary):
return {}
var d: Dictionary = t
var out := {
"tex": String(d.get("tex", "")), "mode": int(d.get("mode", 0)),
"off_u": float(d.get("off_u", 0.0)), "off_v": float(d.get("off_v", 0.0)),
"rot": float(d.get("rot", 0.0)),
"scale_u": float(d.get("scale_u", 1.0)), "scale_v": float(d.get("scale_v", 1.0)),
"u0": float(d.get("u0", 0.0)), "v0": float(d.get("v0", 0.0)),
# HOTSPOT (#94 P2): pinned rect (-1 = auto), tie-break seed, flags. Missing from
# this list, a pin / re-roll held locally and was lost on every refold.
"rect": int(d.get("rect", -1)), "seed": int(d.get("seed", 0)), "hflags": int(d.get("hflags", 0)),
}
for ax: String in ["U", "V"]:
var a: Variant = d.get(ax, Vector3.ZERO)
if a is Vector3:
out[ax] = a
elif a is Array and (a as Array).size() == 3:
out[ax] = Vector3(float(a[0]), float(a[1]), float(a[2]))
else:
out[ax] = Vector3.ZERO
return out
## A hull's per-face textures (params.face_tex) typed like brush.create's (_tex_rec), so
## DCLevel decodes them the same on every peer whatever the wire did to them.
static func _hull_tex(params: Dictionary) -> Dictionary:
if not (params.get("face_tex") is Array):
return params
var out := params.duplicate()
out["face_tex"] = (params["face_tex"] as Array).map(func(t: Variant) -> Dictionary: return _tex_rec(t))
return out
## The target brush's DURABLE name. Legacy records carry a local int `brush` id
## instead — which was only ever meaningful for IMPORTED brushes, whose ids are
## deterministic per .MAP, so resolving it through the level is exactly as sound as
## the record was when it was written and no sounder.
func _brush_key_of(d: Dictionary) -> String:
var k := String(d.get("brush_key", ""))
if k != "":
return k
if d.has("brush") and level != null and level.has_method("get_brush_key"):
return String(level.get_brush_key(int(d["brush"])))
return ""
## True while a fold is applying. The editor must not persist anything it sees
## during one, or an edit would multiply once per peer that folded it.
func is_applying() -> bool:
return _folding
func last_fold() -> Dictionary:
return _last_fold