OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps commits / 17632f9

#120 L4 §15 step 2: AssetFold — placed assets as live instances in the fold
asset.place / xform / set / remove records; AssetFold expands each live placement's blob FIRST in refold into synthetic curve / shape / mod / op / brush / object records (the journal's own parser via _parse_as), named <placement author>/A<nnnn>:<seq>; M = frame x translate(-pivot); brush objects move by brush.rotate/translate with tex_lock; a move renames pieces via same-frame shape.xform; a missing blob folds to one wire box. LevelPersistence: put_asset_blob (hash-checked, decoded), missing_assets, instance_of, placements; asset records break the tail fold. gate_asset_fold 21/21 (placed at its pivot = the originals' windings bit-identical); mutants: 4 killed + the tail one after F11, 1 equivalent (Sterbenz). 10 related gates green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
17632f9953d376799af0c6ec596edef8ed9b6dcf
by plankatron <fleeky@gmail.com> · 2026-10-19T20:58:56.000Z

Changed files (5) +673 -21

docs/node-tree-and-stacks.md +33 -19
……@@ -1185,22 +1185,32 @@ placement whose blob is in the cache, it emits the blob's records as SYNTHETIC r
11851185(never journalled), so ShapeFold / ModFold / OpFold / CurveFold / the level see ordinary
11861186shapes, curves, cuts and brushes:
11871187
1188- Each local dot `L:<n>` becomes `<placement author>/A<n>:<placement seq>` — the shape
1189 pieces' naming scheme (§ shape_fold.gd header) one level up: deterministic, the same on
1190 every peer, unique per placement, sorting at the placement's create position.
1191- Frames are `placement frame × local frame`; curve points and brush planes are
1192 transformed the same way (ShapeFold.transform_planes). The whole result is RIGID
1188- Each local dot `L:<n>` (and each brush object's local GROUP name, which is not a
1189 record) becomes `<placement author>/A<nnnn>:<placement seq>`, zero-padded so the order
1190 inside a placement is the blob's order (stacks and cuts keep theirs): deterministic, the
1191 same on every peer, unique per placement, sorting at the placement's create position.
1192 Synthetic records are made by the journal's own parser (`LevelPersistence._parse_as`),
1193 so each is exactly what the same payload journalled would be.
1194- `M = frame × translate(−pivot)`: shapes' frames, curve points, a radial modifier's
1195 world pivot and top-level objects take M; brush objects are created as captured, then
1196 `brush.rotate` (about the origin) + `brush.translate` with tex_lock, so their textures
1197 follow through the store's texture lock. The whole result is RIGID
11931198 (no scale in v1: a scaled convex piece stays convex, but cables and curves would
11941199 need their radii scaled too — later).
1195- `<ver>` of each synthetic shape = max(its local ver, the placement's latest xform/set
1196 seq), so moving a placement renames its pieces (BrushCreate restores by name).
1197- Synthetic records carry `inst: <placement dot>`; group names are
1198 `<placement dot>/<local group>`. Selection maps any of them back to the placement.
1199- Nested `asset.place` inside a blob expands recursively (frames compose).
1200- **Blob not here yet** (a peer has not fetched it): the placement folds to ONE clearance
1201 -style wire box of `bounds` (no collision, csg4 ignores it), `ModelCache`-style fetch
1202 via `ContentOverlay.request(oid)`, and a refold when it arrives. Never an empty hole
1203 that looks like a delete.
1200- A moved placement renames its shapes' pieces (BrushCreate restores by name): each
1201 shape also gets a same-frame `shape.xform` at the placement's latest xform/set seq —
1202 ShapeFold's own `<ver>` rule.
1203- Synthetic records carry `inst: <placement dot>`; `LevelPersistence.instance_of(dot or
1204 group)` maps any synthetic shape, brush group or object back to its placement.
1205- `asset.*` records break the tail fold (they must be expanded, never handed to DCLevel's
1206 tail, where they would be skipped).
1207- Nested placements: deferred to step 4 (a blob holds none until placements can be
1208 collection members).
1209- **Blob not here yet** (`LevelPersistence.put_asset_blob(oid, bytes)` not yet called —
1210 it checks the hash and decodes, then re-folds): the placement folds to ONE wire box
1211 (cutter: drawn, clips nothing, not in the shell), a 1 m cube at its frame until the
1212 library knows its bounds; `missing_assets()` lists the oids to fetch. Never an empty
1213 hole that looks like a delete.
12041214- Followed curves: an instance's curves are inside it, so a curve edit there is not
12051215 possible (read-only); no new follow rules.
12061216
……@@ -1291,11 +1301,15 @@ Save as asset is menu-only for now).
12911301 cutter and its cut are left out and reported; no room dot survives, every reference
12921302 local; bounds / pivot; decode refuses Objects, other kinds, room dots, versions,
12931303 garbage. 5 mutants killed. **DONE 2026-10-19.**
12942. **AssetFold**: `asset.*` records, the expansion, nesting, the missing-blob box.
1295 `gate_asset_fold`: a collection saved and placed at its own pivot folds to the SAME
1296 geometry digest as the original (the oracle is the room's own fold — independent);
1297 two placements, one moved; a nested asset; a placement re-pointed (`asset.set`); undo
1298 of each; a missing blob → the wire box, then the real pieces when it arrives.
13042. **AssetFold**: `asset.*` records, the expansion, the missing-blob box.
1305 `gate_asset_fold` (21): placed at its own pivot = the originals' face windings
1306 bit-identical (the room's own fold is the oracle); moved / turned = the originals'
1307 boxes moved / turned; renamed on move; a second placement; undo of a turn and of a
1308 remove; missing blob → wire box → pieces; refused blobs; the instance's object and
1309 wire cutter; a placement folded with no live cut in the room (tail). 6 mutants: 4
1310 killed, 1 equivalent (two-step frame: exact by Sterbenz), the tail one killed after F11
1311 was added (a live cut's OP_SEQ_BASE hid it). `asset.set` re-pointing is exercised in
1312 step 3 (Update placements). **DONE 2026-10-19.**
129913133. **Libraries + Save as asset**: room (ContentOverlay + AssetSync kind `asset`, the
13001314 extra field) and personal (user://assets). `gate_asset_library`: save → listed in
13011315 both; re-save → new oid, YOUR placements follow (one undo restores them), OTHERS'
scenes/maps/asset_fold.gd +256 -0
……@@ -0,0 +1,256 @@
1extends RefCounted
2## PLACED ASSETS IN THE FOLD (#120 L4 §15.2): asset.* journal records -> the SYNTHETIC
3## curve / shape / mod / op / brush / object records their blobs hold, so ShapeFold,
4## ModFold, OpFold, CurveFold, DCLevel and SceneObjects see ordinary things. Pure and
5## deterministic, like the other folds; runs FIRST in LevelPersistence.refold.
6##
7## asset.place {asset: oid, name, frame} the placement's dot is its identity
8## asset.xform {target, frame} the latest wins
9## asset.set {target, asset: oid} another version of the asset (latest wins)
10## asset.remove {target} gone (undo brings it back)
11##
12## THE TRANSFORM: M = frame x translate(-pivot). A placement at its own pivot is M = identity
13## exactly, and folds to the very planes the collection had (the gate's oracle). (Applying
14## translate(-pivot) and the frame as two steps is exact there too, for any coordinates
15## within a factor of 2 of the pivot — x - p is then exact (Sterbenz) — so the gate cannot
16## and need not tell the two apart.) Shapes' frames, curve points, a radial modifier's world pivot and top-level
17## objects take M directly; brush objects are created as captured and then turned and moved
18## by brush.rotate / brush.translate records with tex_lock, so their textures follow through
19## the store's own texture lock (asset_doc.gd header). No scale (§15.2).
20##
21## THE NAMES: local dot "L:<n>" -> "<placement author>/A<nnnn>:<placement seq>", zero-padded
22## so the (seq, author) order inside a placement is the blob's order (modifier stacks and
23## cuts keep their order). A shape's <ver> must move with the placement (BrushCreate
24## restores by name): when a later asset.xform / asset.set is live, each shape also gets a
25## same-frame shape.xform at that seq, which is how ShapeFold bumps <ver> for any shape.
26##
27## A BLOB NOT HERE YET folds to ONE wire box of its bounds (cutter: drawn, clips nothing,
28## not in the shell), named "<placement author>/A0000:<seq>" — never an empty hole that
29## reads as a delete. `missing` lists the oids to fetch.
30
31const ShapeFold := preload("res://scenes/maps/shapes/param/shape_fold.gd")
32
33const KINDS := ["asset.place", "asset.xform", "asset.set", "asset.remove"]
34
35
36static func handles(kind: String) -> bool:
37 return KINDS.has(kind)
38
39
40static func _before(a: Dictionary, b: Dictionary) -> bool:
41 if int(a["seq"]) != int(b["seq"]):
42 return int(a["seq"]) < int(b["seq"])
43 return String(a["author"]) < String(b["author"])
44
45
46## Every placement's final state: {dot: {dot, author, seq, asset, name, frame, ver, removed}}.
47## `ver` = the highest seq among its live xform / set records (its create's seq without).
48static func resolve_states(records: Array) -> Dictionary:
49 var live: Array = []
50 for r: Dictionary in records:
51 if handles(String(r.get("kind", ""))) and not bool(r.get("undone", false)):
52 live.append(r)
53 live.sort_custom(_before)
54 var states := {}
55 for r: Dictionary in live:
56 var kind := String(r["kind"])
57 if kind == "asset.place":
58 var dot := "%s:%d" % [String(r["author"]), int(r["seq"])]
59 states[dot] = {"dot": dot, "author": String(r["author"]), "seq": int(r["seq"]),
60 "asset": String(r.get("asset", "")), "name": String(r.get("name", "")),
61 "frame": r.get("frame", Transform3D.IDENTITY), "ver": int(r["seq"]), "removed": false}
62 continue
63 var t := String(r.get("target", ""))
64 if not states.has(t):
65 continue
66 var s: Dictionary = states[t]
67 match kind:
68 "asset.xform":
69 s["frame"] = r.get("frame", s["frame"])
70 s["ver"] = maxi(int(s["ver"]), int(r["seq"]))
71 "asset.set":
72 if String(r.get("asset", "")) != "":
73 s["asset"] = String(r["asset"])
74 s["ver"] = maxi(int(s["ver"]), int(r["seq"]))
75 "asset.remove":
76 s["removed"] = true
77 return states
78
79
80## The synthetic records for every live placement. `docs` = oid -> decoded AssetDoc;
81## `parse` = func(author: String, seq: int, payload: Dictionary) -> Dictionary, the journal's
82## own record parser (LevelPersistence._parse_as), so a synthetic record is exactly what the
83## same payload journalled would be.
84## {records: [parsed records, each with inst = the placement dot], missing: [oid…],
85## inst: {synthetic dot / group -> placement dot}}
86static func expand(records: Array, docs: Dictionary, parse: Callable) -> Dictionary:
87 var out: Array = []
88 var missing := {}
89 var inst := {}
90 var states := resolve_states(records)
91 var order: Array = states.values()
92 order.sort_custom(_before)
93 for p: Dictionary in order:
94 if bool(p["removed"]):
95 continue
96 var pdot := String(p["dot"])
97 var doc: Dictionary = docs.get(String(p["asset"]), {})
98 if doc.is_empty():
99 missing[String(p["asset"])] = true
100 var box := _missing_box(p, parse)
101 if not box.is_empty():
102 out.append(box)
103 inst[pdot] = pdot # the box's group is the placement's dot
104 continue
105 _expand_one(p, doc, parse, out, inst)
106 var ms: Array = missing.keys()
107 ms.sort()
108 return {"records": out, "missing": ms, "inst": inst}
109
110
111## M = frame x translate(-pivot), composed so that frame = translate(pivot) gives IDENTITY.
112static func placement_xform(frame: Transform3D, pivot: Vector3) -> Transform3D:
113 return Transform3D(frame.basis, frame.origin - frame.basis * pivot)
114
115
116static func _expand_one(p: Dictionary, doc: Dictionary, parse: Callable, out: Array, inst: Dictionary) -> void:
117 var pa := String(p["author"])
118 var ps := int(p["seq"])
119 var pdot := String(p["dot"])
120 var m := placement_xform(p["frame"], doc["pivot"])
121 var moved: bool = int(p["ver"]) > ps
122 var names := {} # "L:<n>" -> synthetic author
123 for r: Dictionary in (doc["records"] as Array):
124 var n := int(String(r["dot"]).substr(2))
125 names[String(r["dot"])] = "%s/A%04d" % [pa, n]
126 # A brush object's GROUP is a local name its brush.create records share, not a
127 # record of its own: it needs a name too ("" would merge it with every ungrouped
128 # brush in the level).
129 var lg := String(r.get("group", "")) if String(r["kind"]) == "brush.create" else ""
130 if lg.begins_with("L:") and not names.has(lg):
131 names[lg] = "%s/A%04d" % [pa, int(lg.substr(2))]
132 var dot_of := func(local: String) -> String:
133 return "%s:%d" % [names[local], ps] if names.has(local) else ""
134 var emit := func(author: String, seq: int, payload: Dictionary) -> void:
135 var rec: Dictionary = parse.call(author, seq, payload)
136 if not rec.is_empty():
137 rec["inst"] = pdot
138 out.append(rec)
139 for r: Dictionary in (doc["records"] as Array):
140 var au: String = names[String(r["dot"])]
141 var kind := String(r["kind"])
142 var pl: Dictionary = r.duplicate(true)
143 pl.erase("dot")
144 match kind:
145 "curve.create":
146 var pts: Array = []
147 for q: Dictionary in (pl["points"] as Array):
148 var q2 := q.duplicate(true)
149 q2["p"] = _arr(m * _v3(q.get("p", [0, 0, 0])))
150 for hk: String in ["h_in", "h_out", "n"]:
151 if q.has(hk):
152 q2[hk] = _arr(m.basis * _v3(q[hk]))
153 pts.append(q2)
154 pl["points"] = pts
155 emit.call(au, ps, pl)
156 "shape.create":
157 var f := m * SceneObjects.xform_from(pl["frame"])
158 pl["frame"] = SceneObjects.xform_to(f)
159 var sp: Dictionary = pl["params"]
160 for ck: String in ["curve", "profile_curve"]:
161 if sp.has(ck):
162 sp[ck] = dot_of.call(String(sp[ck]))
163 if String(pl.get("link", "")) != "":
164 pl["link"] = dot_of.call(String(pl["link"]))
165 else:
166 pl.erase("link")
167 emit.call(au, ps, pl)
168 inst[dot_of.call(String(r["dot"]))] = pdot
169 if moved:
170 # Same frame, later seq: ShapeFold's <ver> rule renames the pieces.
171 emit.call(au, int(p["ver"]), {"kind": "shape.xform", "target": dot_of.call(String(r["dot"])),
172 "frame": pl["frame"]})
173 "mod.create":
174 var mp: Dictionary = pl["params"]
175 if mp.has("curve"):
176 mp["curve"] = dot_of.call(String(mp["curve"]))
177 if mp.has("pivot"):
178 mp["pivot"] = _arr(m * _v3(mp["pivot"]))
179 pl["target"] = dot_of.call(String(pl["target"]))
180 var on := bool(pl.get("on", true))
181 pl.erase("on")
182 emit.call(au, ps, pl)
183 if not on:
184 emit.call(au + "s", ps, {"kind": "mod.set", "target": dot_of.call(String(r["dot"])), "on": false})
185 "op.create":
186 pl["cutter"] = dot_of.call(String(pl["cutter"]))
187 var tg: Array = []
188 for t: Variant in (pl["targets"] as Array):
189 tg.append(dot_of.call(String(t)))
190 pl["targets"] = tg
191 var on2 := bool(pl.get("on", true))
192 pl.erase("on")
193 emit.call(au, ps, pl)
194 if not on2:
195 emit.call(au + "s", ps, {"kind": "op.set", "target": dot_of.call(String(r["dot"])), "on": false})
196 "brush.create":
197 pl["group"] = dot_of.call(String(pl["group"]))
198 inst[String(pl["group"])] = pdot
199 emit.call(au, ps, pl)
200 var key: String = dot_of.call(String(r["dot"]))
201 # Turned about the world origin by M's basis, then moved by M's origin: the
202 # same map as M, through the store's texture lock. Identity emits nothing.
203 var q3 := m.basis.get_rotation_quaternion()
204 var ang := rad_to_deg(q3.get_angle())
205 if absf(ang) > 1.0e-9:
206 emit.call(au + "r", ps, {"kind": "brush.rotate", "brush_key": key, "axis": _arr(q3.get_axis()),
207 "angle": ang, "pivot": [0, 0, 0], "tex_lock": true})
208 if m.origin != Vector3.ZERO:
209 emit.call(au + "t", ps, {"kind": "brush.translate", "brush_key": key, "delta": _arr(m.origin),
210 "tex_lock": true})
211 "object.create":
212 var par := String(pl.get("parent", ""))
213 var x := SceneObjects.xform_from(pl["xform"])
214 var props: Dictionary = pl.get("props", {})
215 emit.call(au, ps, {"kind": "object.create", "type": pl["type"],
216 "xform": SceneObjects.xform_to(x if par != "" else m * x)})
217 inst[dot_of.call(String(r["dot"]))] = pdot
218 if par != "":
219 emit.call(au + "p", ps, {"kind": "object.parent", "target": dot_of.call(String(r["dot"])),
220 "parent": dot_of.call(par), "xform": SceneObjects.xform_to(x)})
221 if not props.is_empty():
222 emit.call(au + "q", ps, {"kind": "object.set_prop", "target": dot_of.call(String(r["dot"])),
223 "props": props.duplicate(true)})
224 _:
225 pass # col.create: organisation, not geometry
226
227
228## The wire box of a placement whose blob is not here: bounds unknown too, so a 1 m cube at
229## the frame (the catalogue's bounds arrive with the blob). Cutter: drawn, clips nothing.
230static func _missing_box(p: Dictionary, parse: Callable) -> Dictionary:
231 var f: Transform3D = p["frame"]
232 var local := PackedFloat32Array([-1, 0, 0, 0.5, 1, 0, 0, 0.5, 0, -1, 0, 0.0, 0, 1, 0, 1.0,
233 0, 0, -1, 0.5, 0, 0, 1, 0.5])
234 var planes := ShapeFold.transform_planes(local, f)
235 var pa: Array = []
236 for v: float in planes:
237 pa.append(v)
238 var rec: Dictionary = parse.call("%s/A0000" % String(p["author"]), int(p["seq"]), {"kind": "brush.create",
239 "planes": pa, "proj": [], "material_id": 0, "level_id": 0, "sky": false, "clearance": false,
240 "group": String(p["dot"]), "face_tex": []})
241 if not rec.is_empty():
242 rec["cutter"] = true
243 rec["inst"] = String(p["dot"])
244 return rec
245
246
247static func _v3(a: Variant) -> Vector3:
248 if a is Vector3:
249 return a
250 if a is Array and (a as Array).size() == 3:
251 return Vector3(float(a[0]), float(a[1]), float(a[2]))
252 return Vector3.ZERO
253
254
255static func _arr(v: Vector3) -> Array:
256 return [v.x, v.y, v.z]
scenes/maps/level_persistence.gd +100 -2
……@@ -62,6 +62,10 @@ const ModFold := preload("res://scenes/maps/mod_fold.gd")
6262const CollectionFold := preload("res://scenes/maps/collection_fold.gd")
6363## Curves (#120 F5): no brushes either, so folded apart from the level too.
6464const CurveFold := preload("res://scenes/maps/curves/curve_fold.gd")
65## Placed assets (#120 L4 §15.2): asset.* records expand FIRST into synthetic records of
66## every other kind, from the blobs put_asset_blob holds.
67const AssetFold := preload("res://scenes/maps/asset_fold.gd")
68const AssetDoc := preload("res://scenes/maps/asset_doc.gd")
6569
6670const PREFIX := "world:level:"
6771const OP_INFIX := ":op:"
……@@ -296,6 +300,9 @@ const KIND_LABELS := {
296300 "curve.create": "Add curve", "curve.set": "Edit curve", "curve.remove": "Delete curve",
297301 # Modifiers (#120 F2).
298302 "mod.create": "Add modifier", "mod.set": "Edit modifier", "mod.remove": "Remove modifier",
303 # Placed assets (#120 L4).
304 "asset.place": "Place asset", "asset.xform": "Move asset", "asset.set": "Update asset",
305 "asset.remove": "Delete asset",
299306}
300307
301308
……@@ -758,8 +765,12 @@ func refold() -> Dictionary:
758765 var op_recs: Array = []
759766 var mod_recs: Array = []
760767 var curve_recs: Array = []
768 var asset_recs: Array = []
761769 for r: Dictionary in records(false):
762770 var rk := String(r.get("kind", ""))
771 if AssetFold.handles(rk):
772 asset_recs.append(r)
773 continue
763774 if CurveFold.handles(rk):
764775 curve_recs.append(r) # no brushes, but a follow-curve reads them
765776 continue
……@@ -773,6 +784,28 @@ func refold() -> Dictionary:
773784 mod_recs.append(r)
774785 else:
775786 brush_recs.append(r)
787 # PLACED ASSETS (#120 L4 §15.2) first: each live placement's blob becomes
788 # synthetic records of every kind, sorted into the same lists as journalled
789 # ones — so everything below treats an instance as ordinary shapes, curves,
790 # cuts and brushes. Its objects go to the objects half (_asset_objects).
791 var ex: Dictionary = AssetFold.expand(asset_recs, _asset_docs, _parse_as)
792 _asset_missing = ex["missing"]
793 _asset_inst = ex["inst"]
794 _asset_objects = []
795 for sr: Dictionary in (ex["records"] as Array):
796 var sk := String(sr["kind"])
797 if CurveFold.handles(sk):
798 curve_recs.append(sr)
799 elif ShapeFold.handles(sk):
800 shape_recs.append(sr)
801 elif OpFold.handles(sk):
802 op_recs.append(sr)
803 elif ModFold.handles(sk):
804 mod_recs.append(sr)
805 elif SceneObjects.handles(sk):
806 _asset_objects.append(sr)
807 else:
808 brush_recs.append(sr)
776809 # PARAMETRIC SHAPES become ordinary brush.create records here, named
777810 # "<author>/<i>:<seq>" at the shape's own seq, so DCLevel folds them in order with
778811 # everything else. They never reach DCLevel as shape.* (it would count them skipped).
……@@ -820,7 +853,7 @@ func refold() -> Dictionary:
820853 else:
821854 _last_fold = {"applied": 0, "skipped": 0, "undone": 0, "records": 0}
822855 if has_objects:
823 var os: Dictionary = objects.apply_journal(_object_records())
856 var os: Dictionary = objects.apply_journal(_object_records() + _asset_objects)
824857 # Merged so one `folded` payload still describes the whole journal, and
825858 # reported separately so a skipped OBJECT (an unknown prefab id — the
826859 # version-skew case) is not read as geometry disagreeing.
……@@ -867,6 +900,57 @@ var _shape_states: Dictionary = {}
867900var _live_shapes: Dictionary = {}
868901
869902
903## ---- PLACED ASSETS (#120 L4 §15.2) ------------------------------------------------
904
905var _asset_docs: Dictionary = {} # oid -> decoded AssetDoc (put_asset_blob)
906var _asset_missing: Array = [] # oids the last brush fold wanted and did not have
907var _asset_inst: Dictionary = {} # synthetic dot / group -> placement dot
908var _asset_objects: Array = [] # the last brush fold's synthetic object records
909
910
911## Hold an asset blob for the fold. Refused unless its bytes hash to `oid` and decode
912## (AssetDoc.decode trusts nothing). A new one re-folds the level: a placement waiting on
913## it turns from its wire box into its pieces.
914func put_asset_blob(oid: String, bytes: PackedByteArray) -> bool:
915 if AssetDoc.hash_of(bytes) != oid:
916 return false
917 var doc := AssetDoc.decode(bytes)
918 if doc.is_empty():
919 return false
920 if _asset_docs.has(oid):
921 return true
922 _asset_docs[oid] = doc
923 _ix_brush_ver += 1
924 _ix_tail_ok = false
925 _dirty = true
926 return true
927
928
929func has_asset_blob(oid: String) -> bool:
930 return _asset_docs.has(oid)
931
932
933## The oids live placements need and this peer has not got (the library fetches them).
934func missing_assets() -> Array:
935 return _asset_missing.duplicate()
936
937
938## The placement a synthetic shape / group / object belongs to, or "".
939func instance_of(dot_or_group: String) -> String:
940 return String(_asset_inst.get(dot_or_group, ""))
941
942
943## Every placement's state (AssetFold.resolve_states over the journal).
944func placements() -> Dictionary:
945 return AssetFold.resolve_states(records(false))
946
947
948## A payload parsed exactly as the journal parses one written by `author` at `seq` — how
949## AssetFold makes its synthetic records.
950func _parse_as(author: String, seq: int, payload: Dictionary) -> Dictionary:
951 return _to_record("%s%s%s%d" % [PREFIX, author, OP_INFIX, seq], payload)
952
953
870954## ---- FLATTEN HISTORY (2026-10-05) --------------------------------------------
871955##
872956## Replace this MAP's journal with the minimum records that rebuild what is there NOW,
……@@ -1387,7 +1471,7 @@ func _ix_put(key: String, value: Variant) -> bool:
13871471 elif _ix_meta.has(key) and not _ix_obj_keys.has(key) and not _ix_col_keys.has(key) \
13881472 and not _ix_curve_keys.has(key):
13891473 var tk := String(_ix_meta[key]["kind"])
1390 if ShapeFold.handles(tk) or OpFold.handles(tk) or ModFold.handles(tk):
1474 if ShapeFold.handles(tk) or OpFold.handles(tk) or ModFold.handles(tk) or AssetFold.handles(tk):
13911475 _ix_tail_ok = false
13921476 else:
13931477 _ix_tail_keys.append(key)
……@@ -1577,6 +1661,20 @@ func _to_record(key: String, d: Dictionary) -> Dictionary:
15771661 rec["target"] = String(d.get("target", ""))
15781662 var p: Variant = d.get("props", {})
15791663 rec["props"] = p if p is Dictionary else {}
1664 "asset.place":
1665 # A LIVE INSTANCE of an asset blob (#120 L4 §15.2): the blob by its hash, the
1666 # frame it is placed at. AssetFold expands it.
1667 rec["asset"] = String(d.get("asset", ""))
1668 rec["name"] = String(d.get("name", ""))
1669 rec["frame"] = SceneObjects.xform_from(d.get("frame", []))
1670 "asset.xform":
1671 rec["target"] = String(d.get("target", ""))
1672 rec["frame"] = SceneObjects.xform_from(d.get("frame", []))
1673 "asset.set":
1674 rec["target"] = String(d.get("target", ""))
1675 rec["asset"] = String(d.get("asset", ""))
1676 "asset.remove":
1677 rec["target"] = String(d.get("target", ""))
15801678 "col.create":
15811679 rec["name"] = String(d.get("name", "Collection"))
15821680 rec["parent"] = String(d.get("parent", ""))
scenes/test/gate_asset_fold.gd +278 -0
……@@ -0,0 +1,278 @@
1extends Node
2## GATE — PLACED ASSETS IN THE FOLD (#120 L4 step 2, docs/node-tree-and-stacks.md §15.2,
3## asset_fold.gd). gate_asset_doc's room ("Kit": a box A with a mirror cut by K from
4## "Cutters", a cable on its curve, a plain brush object, a scene object, "Sub" with B),
5## saved as a blob, then PLACED. The oracle is the room's OWN fold of the originals — an
6## independent path (shape / mod / op / curve / brush records the user wrote) against the
7## synthetic one:
8## F1 placed at its own pivot: the SAME brushes, face windings bit-identical (digest)
9## F2 moved +20 m x: every instance brush = an original's box + 20 m (1 mm)
10## F3 turned 90° about y: every instance brush = an original's box turned (1 mm)
11## F4 the instance's pieces are RENAMED by the move (BrushCreate restores by name)
12## F5 a second placement: twice the brushes; both instance groups map to their placement
13## F6 undo the turn -> F2's brushes again; asset.remove -> none; undo -> back
14## F7 a blob not here: ONE wire (cutter) box, the oid listed missing; the blob arrives ->
15## the real pieces
16## F8 put_asset_blob refuses bytes that do not hash to the oid, and garbage
17## F9 the instance's object exists, moved with it
18## F10 the instance's cut: its cutter is wire (not solid), the target is cut (A's pieces)
19## F11 a placement right after a fold with NO live cut (so the tail fold is tried): it is
20## expanded, not handed to DCLevel's tail and skipped
21##
22## RUN AS A SCENE: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . res://scenes/test/gate_asset_fold.tscn
23
24const AssetDoc := preload("res://scenes/maps/asset_doc.gd")
25
26var fails := 0
27var checks := 0
28var map: Node3D
29var persist: Node
30var level: Node
31
32
33func _ok(what: String, cond: bool, note: Variant = "") -> void:
34 checks += 1
35 if not cond:
36 fails += 1
37 print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
38
39
40func _ready() -> void:
41 var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
42 gs.route("world:level:*", 1, "peer")
43 var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
44 if keet != null:
45 keet.current_member_id = "probe"
46 get_tree().root.get_node("/root/GameMode").set_mode("build")
47 map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
48 add_child(map)
49 for i in 6:
50 await get_tree().process_frame
51 persist = map.get("_persist")
52 level = map.get("level")
53 _run()
54 if checks < 18:
55 print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
56 fails += 1
57 print("\nGATE asset_fold: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
58 get_tree().quit(0 if fails == 0 else 1)
59
60
61func _box(at: Vector3, size: Vector3) -> String:
62 return persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
63 "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, at)), "size": [size.x, size.y, size.z],
64 "material_id": 2, "level_id": 0})
65
66
67static func _box_planes(lo: Vector3, hi: Vector3) -> Array:
68 return [-1, 0, 0, -lo.x, 1, 0, 0, hi.x, 0, -1, 0, -lo.y, 0, 1, 0, hi.y, 0, 0, -1, -lo.z, 0, 0, 1, hi.z]
69
70
71## Live brushes whose group is in `groups`.
72func _brushes_in(groups: Dictionary) -> Array:
73 var out: Array = []
74 for b: int in (level.get_live_brush_ids() as PackedInt32Array):
75 if groups.has(String(level.get_brush_group(b))):
76 out.append(b)
77 return out
78
79
80## Live brushes of placement `pdot`.
81func _brushes_of(pdot: String) -> Array:
82 var out: Array = []
83 for b: int in (level.get_live_brush_ids() as PackedInt32Array):
84 if persist.instance_of(String(level.get_brush_group(b))) == pdot:
85 out.append(b)
86 return out
87
88
89## Every brush's face windings, as bytes, sorted; hashed.
90func _digest(ids: Array) -> String:
91 var blobs: Array = []
92 for b: int in ids:
93 var bb := PackedByteArray()
94 for f in (level.get_brush(b)["planes"] as PackedFloat32Array).size() / 4:
95 bb.append_array((level.get_face_winding(b, f) as PackedVector3Array).to_byte_array())
96 bb.append(255)
97 blobs.append(bb)
98 blobs.sort_custom(func(x: PackedByteArray, y: PackedByteArray) -> bool: return x.hex_encode() < y.hex_encode())
99 var h := HashingContext.new()
100 h.start(HashingContext.HASH_SHA256)
101 for bb2: PackedByteArray in blobs:
102 h.update(bb2)
103 return h.finish().hex_encode().substr(0, 16)
104
105
106## Each brush's box (corners mapped by `xf`), matched one to one against `want` (1 mm).
107func _boxes_match(got: Array, want: Array, xf: Transform3D) -> bool:
108 if got.size() != want.size():
109 return false
110 var pool: Array = []
111 for b: int in want:
112 var d: Dictionary = level.get_brush(b)
113 pool.append(xf * AABB(d["aabb_min"], (d["aabb_max"] as Vector3) - (d["aabb_min"] as Vector3)))
114 for b2: int in got:
115 var d2: Dictionary = level.get_brush(b2)
116 var g := AABB(d2["aabb_min"], (d2["aabb_max"] as Vector3) - (d2["aabb_min"] as Vector3))
117 var hit := -1
118 for i in pool.size():
119 var w: AABB = pool[i]
120 if w.position.distance_to(g.position) < 1.0e-3 and w.end.distance_to(g.end) < 1.0e-3:
121 hit = i
122 break
123 if hit < 0:
124 return false
125 pool.remove_at(hit)
126 return true
127
128
129func _keys(ids: Array) -> Array:
130 var out: Array = []
131 for b: int in ids:
132 out.append(String(level.get_brush_key(b)))
133 out.sort()
134 return out
135
136
137func _place(oid: String, frame: Transform3D) -> String:
138 persist.begin_gesture("Place asset")
139 var d: String = persist.record("asset.place", {"asset": oid, "name": "Kit", "frame": SceneObjects.xform_to(frame)})
140 persist.end_gesture()
141 persist.refold()
142 return d
143
144
145func _xform(pdot: String, frame: Transform3D) -> void:
146 persist.begin_gesture("Move asset")
147 persist.record("asset.xform", {"target": pdot, "frame": SceneObjects.xform_to(frame)})
148 persist.end_gesture()
149 persist.refold()
150
151
152func _run() -> void:
153 # NON-ROUND on purpose: at exact binary values (500, 502.5) (x - p) + p == x anyway, and
154 # F1 could not tell a transform composed first from one applied in two steps.
155 var O0 := Vector3(500.137, 0.31, 499.873)
156 persist.begin_gesture("asset room")
157 var a := _box(O0, Vector3(2, 2, 2))
158 var b := _box(O0 + Vector3(6, 0, 0), Vector3(1, 3, 1))
159 var k := _box(O0 + Vector3(0.5, 0.5, 0), Vector3(1, 1, 3))
160 persist.record("mod.create", {"target": a, "mod": "mirror", "params": {"axis": "x", "offset": 0.0}})
161 var op1: String = persist.record("op.create", {"op": "subtract", "cutter": k, "targets": [a]})
162 var grp := "probe:grp1"
163 for i in 2:
164 var lo := O0 + Vector3(0, 0, 6 + i * 2)
165 persist.record("brush.create", {"planes": _box_planes(lo, lo + Vector3(1, 1, 1)), "proj": [],
166 "material_id": 1, "level_id": 0, "sky": false, "clearance": false, "group": grp, "face_tex": []})
167 persist.end_gesture()
168 persist.refold()
169 var cable: String = map.add_cable([CurveMath.point(O0 + Vector3(0, 1, -4)), CurveMath.point(O0 + Vector3(6, 1, -4))],
170 false, {"radius": 0.05})
171 var obj: String = map.create_object(PrefabCatalogue.ids()[0], Transform3D(Basis.IDENTITY, O0 + Vector3(3, 0, 3)))
172 persist.begin_gesture("asset collections")
173 var kit: String = persist.record("col.create", {"name": "Kit", "parent": ""})
174 var sub: String = persist.record("col.create", {"name": "Sub", "parent": kit})
175 var cuts: String = persist.record("col.create", {"name": "Cutters", "parent": ""})
176 persist.record("col.link", {"target": kit, "members": ["g:" + a, "g:" + cable, "g:" + grp, "obj:" + obj]})
177 persist.record("col.link", {"target": sub, "members": ["g:" + b]})
178 persist.record("col.link", {"target": cuts, "members": ["g:" + k]})
179 persist.end_gesture()
180 persist.refold()
181
182 var w: Dictionary = AssetDoc.write(persist, kit)
183 var bytes := AssetDoc.encode(w["doc"])
184 var oid := AssetDoc.hash_of(bytes)
185 var doc: Dictionary = w["doc"]
186 var pivot: Vector3 = doc["pivot"]
187 var originals := _brushes_in({a: true, b: true, k: true, cable: true, grp: true})
188 _ok("setup: the Kit's brushes (A + mirror + cut pieces, B, K, the cable, 2 brushes)", originals.size() >= 8, originals.size())
189
190 # ---- F8 first: refusals ----
191 var tampered := bytes.duplicate()
192 tampered[tampered.size() - 1] ^= 1
193 _ok("F8 put_asset_blob refuses bytes that do not hash to the oid", not persist.put_asset_blob(oid, tampered))
194 var junk := PackedByteArray([9, 9, 9, 9])
195 _ok("F8 ... and garbage under its own hash", not persist.put_asset_blob(AssetDoc.hash_of(junk), junk))
196
197 # ---- F7: placed before the blob is here ----
198 var at_pivot := Transform3D(Basis.IDENTITY, pivot)
199 var p1 := _place(oid, at_pivot)
200 var waiting := _brushes_of(p1)
201 _ok("F7 no blob yet: ONE wire box", waiting.size() == 1 and bool(level.get_brush(waiting[0]).get("cutter", false)) if waiting.size() == 1 else false, waiting.size())
202 _ok("F7 ... and the oid is listed missing", (persist.missing_assets() as Array).has(oid))
203 _ok("F7 the blob arrives (put_asset_blob)", persist.put_asset_blob(oid, bytes))
204 persist.refold()
205
206 # ---- F1 ----
207 var inst := _brushes_of(p1)
208 _ok("F1 at its own pivot: as many brushes as the originals", inst.size() == originals.size(), [inst.size(), originals.size()])
209 _ok("F1 ... with bit-identical face windings (digest)", _digest(inst) == _digest(originals), [_digest(inst), _digest(originals)])
210 _ok("F7 ... and nothing is missing any more", (persist.missing_assets() as Array).is_empty())
211
212 # ---- F10 ----
213 var cutter_n := 0
214 for b3: int in inst:
215 if bool(level.get_brush(b3).get("cutter", false)):
216 cutter_n += 1
217 var k_n := (level.get_group_brush_ids(k) as PackedInt32Array).size()
218 _ok("F10 the instance's cutter is wire, as the original's", cutter_n == k_n and k_n > 0, [cutter_n, k_n])
219
220 # ---- F2 / F4 ----
221 var keys0 := _keys(inst)
222 _xform(p1, Transform3D(Basis.IDENTITY, pivot + Vector3(20, 0, 0)))
223 var moved := _brushes_of(p1)
224 _ok("F2 moved +20 m x: every brush = an original's box + 20 m", _boxes_match(moved, originals, Transform3D(Basis.IDENTITY, Vector3(20, 0, 0))))
225 var keys1 := _keys(moved)
226 var shared := keys1.filter(func(x: String) -> bool: return keys0.has(x) and not x.contains("/A0007") and not x.contains("/A0008"))
227 _ok("F4 the move renamed the shapes' pieces", keys1.size() == keys0.size() and shared.size() < keys0.size() / 2, [shared.size(), keys0.size()])
228
229 # ---- F3 ----
230 var turn := Basis(Vector3.UP, PI / 2)
231 _xform(p1, Transform3D(turn, pivot + Vector3(20, 0, 0)))
232 var turned := _brushes_of(p1)
233 var m := Transform3D(turn, pivot + Vector3(20, 0, 0)) * Transform3D(Basis.IDENTITY, -pivot)
234 _ok("F3 turned 90° about y: every brush = an original's box turned", _boxes_match(turned, originals, m))
235
236 # ---- F9 ----
237 var objn := 0
238 var obj_ok := false
239 for e: Dictionary in (map.get_node("SceneObjects").entries() as Array):
240 if persist.instance_of(String(e["dot"])) == p1:
241 objn += 1
242 var want: Vector3 = m * (map.get_node("SceneObjects").node_for(obj) as Node3D).position
243 obj_ok = (e["node"] as Node3D).position.distance_to(want) < 1.0e-3
244 _ok("F9 the instance's object exists, turned and moved with it", objn == 1 and obj_ok, objn)
245
246 # ---- F6 ----
247 persist.undo_last()
248 persist.refold()
249 _ok("F6 undo the turn -> F2's brushes again", _boxes_match(_brushes_of(p1), originals, Transform3D(Basis.IDENTITY, Vector3(20, 0, 0))))
250 persist.begin_gesture("Delete asset")
251 persist.record("asset.remove", {"target": p1})
252 persist.end_gesture()
253 persist.refold()
254 _ok("F6 asset.remove -> no instance brushes", _brushes_of(p1).is_empty())
255 persist.undo_last()
256 persist.refold()
257 _ok("F6 undo the remove -> back", _brushes_of(p1).size() == originals.size())
258
259 # ---- F5 ----
260 var p2 := _place(oid, Transform3D(Basis.IDENTITY, pivot + Vector3(0, 0, 40)))
261 _ok("F5 a second placement: its own brushes, as many", _brushes_of(p2).size() == originals.size()
262 and _boxes_match(_brushes_of(p2), originals, Transform3D(Basis.IDENTITY, Vector3(0, 0, 40))))
263 _ok("F5 ... and the first is untouched", _boxes_match(_brushes_of(p1), originals, Transform3D(Basis.IDENTITY, Vector3(20, 0, 0))))
264 _ok("F5 the fold skipped nothing", int(persist.get("_last_fold").get("skipped", 0)) == 0, persist.get("_last_fold").get("skipped", 0))
265
266 # ---- F11: a placement as the ONLY new record after a fold with no live cut ----
267 # A live cut folds at OP_SEQ_BASE + seq, so while one exists every new record sorts
268 # before it and the tail fold is refused anyway. With none, an asset.place must still
269 # not be handed to DCLevel's tail (it would be skipped there, never expanded).
270 persist.begin_gesture("clear")
271 persist.record("asset.remove", {"target": p1})
272 persist.record("asset.remove", {"target": p2})
273 persist.record("op.remove", {"target": op1})
274 persist.end_gesture()
275 persist.refold()
276 var p3 := _place(oid, Transform3D(Basis.IDENTITY, pivot + Vector3(0, 0, 80)))
277 _ok("F11 placed with no live cut in the room: all its brushes", _brushes_of(p3).size() == inst.size(),
278 [_brushes_of(p3).size(), inst.size(), persist.get("_last_fold").get("brush_fold", "")])
scenes/test/gate_asset_fold.tscn +6 -0
……@@ -0,0 +1,6 @@
1[gd_scene load_steps=2 format=3]
2
3[ext_resource type="Script" path="res://scenes/test/gate_asset_fold.gd" id="1"]
4
5[node name="GateAssetFold" type="Node"]
6script = ExtResource("1")