OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / level_persistence.gd

2050 lines · gd ref:
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