| … | … | @@ -1143,18 +1143,31 @@ An asset is ONE immutable blob, `.hasset`, named by its content hash (the oid): |
| 1143 | 1143 | ``` |
| 1144 | 1144 | |
| 1145 | 1145 | - `records` are the payloads `flatten_map` would write for the collection's subtree: |
| 1146 | `shape.create` (merged params, frame, size, tex, input, `link` re-rooted inside the | |
| 1147 | asset), `mod.create`, live `op.create` (cutter and targets both inside — an op whose | |
| 1148 | cutter or target is OUTSIDE the collection is dropped and reported), `curve.create`, | |
| 1146 | `shape.create` (merged params, frame, size, tex, input, `link` kept when its root is | |
| 1147 | inside, else dropped — the resolved data is written whole), `mod.create` (+ `on`), live | |
| 1148 | `op.create` (+ `on`), `curve.create`, | |
| 1149 | 1149 | `brush.create` for brush objects (current planes, group kept), `object.create` + |
| 1150 | 1150 | props + parent (parents outside → none), `col.create` + `col.link` for |
| 1151 | 1151 | sub-collections, and `asset.place` for placements inside it (nesting, §15.2). |
| 1152 | 1152 | - LOCAL dots: `L:<n>`, numbered in fold order. Every reference (link, target, cutter, |
| 1153 | 1153 | curve, parent, member) is remapped to them. Nothing in the blob names the room. |
| 1154 | - Positions are relative to the PIVOT, the collection's bounds' base centre (so a pillar | |
| 1155 | sits ON the surface it is dropped on). Every frame / point / plane is moved by −pivot. | |
| 1156 | - CANONICAL bytes: keys sorted, floats written exactly (var_to_str of each value), so the | |
| 1157 | same contents give the same oid on every peer and on every save. | |
| 1154 | - **Cuts (built 2026-10-19):** an op comes along when one of its TARGETS is a shape in | |
| 1155 | the asset, narrowed to those targets, and its CUTTER comes too even from outside (Box | |
| 1156 | Cutter files cutters in their own "Cutters" collection — "both inside" would have | |
| 1157 | dropped most cuts). Targets that are brush objects are dropped from it: a brush | |
| 1158 | object's planes are its CURRENT ones, already cut. A shape inside that only cuts | |
| 1159 | outside is left out (it would fold as a solid). Every one of these is REPORTED. | |
| 1160 | - **Positions as captured, in world space, plus the PIVOT** (the contents' bounds' base | |
| 1161 | centre, so a pillar sits ON the surface it is dropped on). A placement folds to | |
| 1162 | `frame × translate(−pivot) × captured` (§15.2). Not shifted at save: a brush's texture | |
| 1163 | follows it only through the store's texture lock (`brush.translate / rotate` with | |
| 1164 | tex_lock, `dc_face_tex_lock_*` in C++), so the expansion lets the store do that rather | |
| 1165 | than a second copy of the math in GDScript. Cost: the same contents saved elsewhere | |
| 1166 | hash differently (a new version, harmless). | |
| 1167 | - **Bytes:** `var_to_bytes` with every Dictionary's keys sorted (Binary Variant keeps ints | |
| 1168 | ints and floats exact; JSON does neither). `bytes_to_var` refuses Objects by default, | |
| 1169 | and decode checks the shape, the kinds and that every dot is local: a blob comes from | |
| 1170 | another peer (`str_to_var` would construct Objects — never used for blobs). | |
| 1158 | 1171 | - A nested asset is named by oid; a blob cannot contain its own oid (the hash depends |
| 1159 | 1172 | on the contents), so cycles are impossible by construction. Depth is still capped (8). |
| 1160 | 1173 | |
| … | … | @@ -1272,9 +1285,12 @@ Save as asset is menu-only for now). |
| 1272 | 1285 | |
| 1273 | 1286 | ### 15.8 Steps (each gated, one commit each) |
| 1274 | 1287 | |
| 1275 | 1. **AssetDoc** (pure): write a collection subtree to a blob and read it back. | |
| 1276 | `gate_asset_doc`: round trip byte-identical; the same contents → the same oid on two | |
| 1277 | saves; an outside op is dropped and reported; local dots remapped everywhere. | |
| 1288 | 1. **AssetDoc**: write a collection subtree to a blob and read it back. | |
| 1289 | `gate_asset_doc` (24): round trip byte-identical, key order irrelevant; the same room → | |
| 1290 | the same oid, a moved shape → another; the cutter from "Cutters" comes, the outside-only | |
| 1291 | cutter and its cut are left out and reported; no room dot survives, every reference | |
| 1292 | local; bounds / pivot; decode refuses Objects, other kinds, room dots, versions, | |
| 1293 | garbage. 5 mutants killed. **DONE 2026-10-19.** | |
| 1278 | 1294 | 2. **AssetFold**: `asset.*` records, the expansion, nesting, the missing-blob box. |
| 1279 | 1295 | `gate_asset_fold`: a collection saved and placed at its own pivot folds to the SAME |
| 1280 | 1296 | geometry digest as the original (the oracle is the room's own fold — independent); |
| … | … | @@ -0,0 +1,342 @@ |
| 1 | extends RefCounted | |
| 2 | ## THE ASSET DOCUMENT (#120 L4, docs/node-tree-and-stacks.md §15.1): a collection's | |
| 3 | ## contents as ONE immutable blob, named by its hash. Written from the room's folded state, | |
| 4 | ## read back without trusting it. | |
| 5 | ## | |
| 6 | ## {v, name, pivot: Vector3, bounds: [min, max], (world, as captured) | |
| 7 | ## records: [{kind, dot: "L:<n>", …payload}], ordinary payloads, LOCAL dots | |
| 8 | ## assets: [oid…]} blobs it needs: models (nested later) | |
| 9 | ## | |
| 10 | ## AS CAPTURED, IN WORLD SPACE, plus the PIVOT (the contents' base centre). A placement | |
| 11 | ## folds to frame x translate(-pivot) x captured (§15.2). Not shifted here: a brush's | |
| 12 | ## texture moves with it only through the store's texture lock (brush.translate / rotate | |
| 13 | ## with tex_lock — dc_face_tex_lock_*), so the expansion lets the store do it rather than | |
| 14 | ## a second copy of that math in GDScript. Cost: the same contents saved somewhere else | |
| 15 | ## hash differently. | |
| 16 | ## | |
| 17 | ## WHAT IS IN IT — the collection's subtree (members_of, recursive): | |
| 18 | ## "g:<shape dot>" the shape's RESOLVED state (shape.create), its modifiers | |
| 19 | ## (mod.create + on), and every curve it or a modifier names | |
| 20 | ## "g:<group>" a brush object: each live brush as brush.create (its CURRENT planes, | |
| 21 | ## so cuts already made on it are baked in) | |
| 22 | ## "obj:<dot>" object.create {type, xform (scene space), props, parent}; a parent | |
| 23 | ## outside the asset -> top level, keeping its scene-space xform | |
| 24 | ## sub-collections col.create {name, parent, members} | |
| 25 | ## live cuts an op whose TARGET is a shape in the asset comes along, narrowed to | |
| 26 | ## its targets inside, and its CUTTER comes too even from outside (Box | |
| 27 | ## Cutter keeps cutters in their own "Cutters" collection). Targets that | |
| 28 | ## are brush objects are dropped from it: their planes are already cut. | |
| 29 | ## Left out, and REPORTED: an op with no shape target inside; a shape inside that only cuts | |
| 30 | ## outside (it would fold as a solid); a linked duplicate's root outside (the link is | |
| 31 | ## dropped — its resolved data is written whole). | |
| 32 | ## | |
| 33 | ## CANONICAL BYTES: var_to_bytes of the doc with every Dictionary's keys sorted. Binary | |
| 34 | ## Variant keeps ints as ints and floats exact (JSON does neither), and bytes_to_var | |
| 35 | ## refuses Objects by default — a blob comes from another peer. | |
| 36 | ||
| 37 | const ShapeFold := preload("res://scenes/maps/shapes/param/shape_fold.gd") | |
| 38 | const ModFold := preload("res://scenes/maps/mod_fold.gd") | |
| 39 | const OpFold := preload("res://scenes/maps/op_fold.gd") | |
| 40 | const CollectionFold := preload("res://scenes/maps/collection_fold.gd") | |
| 41 | ||
| 42 | const V := 1 | |
| 43 | const EXT := "hasset" | |
| 44 | ## Record kinds a blob may hold (decode refuses anything else). | |
| 45 | const KINDS := ["curve.create", "shape.create", "mod.create", "op.create", "brush.create", | |
| 46 | "object.create", "col.create"] | |
| 47 | ||
| 48 | ||
| 49 | ## Write the subtree of collection `col` from the room `persist` folds (LevelPersistence, | |
| 50 | ## with its level and objects). {doc, report: [String]} — doc {} when there is nothing. | |
| 51 | static func write(persist: Node, col: String, name: String = "") -> Dictionary: | |
| 52 | var report: Array = [] | |
| 53 | var t: Dictionary = persist.collection_tree() | |
| 54 | if not (t["cols"] as Dictionary).has(col): | |
| 55 | return {"doc": {}, "report": ["no such collection"]} | |
| 56 | var level: Node = persist.get("level") | |
| 57 | var objects: Node = persist.get("objects") | |
| 58 | var recs: Array = persist.records(false) | |
| 59 | var curves: Dictionary = persist.curve_states() | |
| 60 | var shape_recs: Array = recs.filter(func(r: Dictionary) -> bool: return ShapeFold.handles(String(r.get("kind", "")))) | |
| 61 | var states: Dictionary = ShapeFold.resolve_states(shape_recs, curves) | |
| 62 | var live_shape := func(d: String) -> bool: | |
| 63 | return states.has(d) and not bool(states[d]["detached"]) and not bool(states[d].get("removed", false)) | |
| 64 | ||
| 65 | # ---- what the collection holds ---- | |
| 66 | var shapes := {} # shape dot -> true | |
| 67 | var groups := {} # brush-object group -> true | |
| 68 | var objs := {} # object dot -> true | |
| 69 | for m: String in CollectionFold.members_of(t, col, true): | |
| 70 | if m.begins_with("g:"): | |
| 71 | var g := m.substr(2) | |
| 72 | if live_shape.call(g): | |
| 73 | shapes[g] = true | |
| 74 | elif level != null and not (level.get_group_brush_ids(g) as PackedInt32Array).is_empty(): | |
| 75 | groups[g] = true | |
| 76 | elif m.begins_with("obj:") and objects != null and objects.node_for(m.substr(4)) != null: | |
| 77 | objs[m.substr(4)] = true | |
| 78 | ||
| 79 | # ---- live cuts: an op comes with a shape target inside, and brings its cutter ---- | |
| 80 | var ops: Array = [] | |
| 81 | var cutter_of_kept := {} | |
| 82 | var cutter_any := {} | |
| 83 | for s: Dictionary in OpFold.existing_ops(recs): | |
| 84 | cutter_any[String(s["cutter"])] = true | |
| 85 | var inside: Array = [] | |
| 86 | var baked := 0 | |
| 87 | for g2: String in (s["targets"] as PackedStringArray): | |
| 88 | if shapes.has(g2): | |
| 89 | inside.append(g2) | |
| 90 | elif groups.has(g2): | |
| 91 | baked += 1 | |
| 92 | if baked > 0: | |
| 93 | report.append("cut %s: its brush-object targets are baked (their planes are already cut)" % s["dot"]) | |
| 94 | if inside.is_empty(): | |
| 95 | if baked == 0 and shapes.has(String(s["cutter"])): | |
| 96 | report.append("cut %s: it cuts only outside the collection — left out" % s["dot"]) | |
| 97 | continue | |
| 98 | if not live_shape.call(String(s["cutter"])): | |
| 99 | report.append("cut %s: its cutter is gone — left out" % s["dot"]) | |
| 100 | continue | |
| 101 | var s2 := s.duplicate() | |
| 102 | s2["targets"] = inside | |
| 103 | ops.append(s2) | |
| 104 | cutter_of_kept[String(s["cutter"])] = true | |
| 105 | if not shapes.has(String(s["cutter"])): | |
| 106 | shapes[String(s["cutter"])] = true | |
| 107 | report.append("cutter %s: brought in from outside the collection" % s["cutter"]) | |
| 108 | for d: String in shapes.keys(): | |
| 109 | if cutter_any.has(d) and not cutter_of_kept.has(d): | |
| 110 | shapes.erase(d) | |
| 111 | report.append("shape %s: it only cuts outside the collection — left out" % d) | |
| 112 | ||
| 113 | # ---- curves the shapes and their modifiers name ---- | |
| 114 | var mods: Array = [] | |
| 115 | for m2: Dictionary in ModFold.resolve_states(recs).values(): | |
| 116 | if not bool(m2["removed"]) and shapes.has(String(m2["target"])): | |
| 117 | mods.append(m2) | |
| 118 | mods.sort_custom(_before) | |
| 119 | var used_curves := {} | |
| 120 | for d2: String in shapes: | |
| 121 | var sp: Dictionary = states[d2]["params"] | |
| 122 | for ck: String in ["curve", "profile_curve"]: | |
| 123 | if curves.has(String(sp.get(ck, ""))): | |
| 124 | used_curves[String(sp[ck])] = true | |
| 125 | for m3: Dictionary in mods: | |
| 126 | if curves.has(String((m3["params"] as Dictionary).get("curve", ""))): | |
| 127 | used_curves[String(m3["params"]["curve"])] = true | |
| 128 | ||
| 129 | # ---- LOCAL DOTS, in a fixed order: curves, shapes, modifiers, cuts, brush objects | |
| 130 | # (their brushes), objects, collections — each by its room order ---- | |
| 131 | var local := {} # room dot / group -> "L:<n>" | |
| 132 | var n := [0] | |
| 133 | var mint := func(k: String) -> String: | |
| 134 | n[0] += 1 | |
| 135 | local[k] = "L:%d" % n[0] | |
| 136 | return local[k] | |
| 137 | var out: Array = [] | |
| 138 | var cdots: Array = used_curves.keys() | |
| 139 | cdots.sort_custom(func(a: String, b: String) -> bool: return _dot_before(curves[a], curves[b])) | |
| 140 | for cd: String in cdots: | |
| 141 | var cs: Dictionary = curves[cd] | |
| 142 | out.append({"kind": "curve.create", "dot": mint.call(cd), | |
| 143 | "points": LevelPersistence.CurveFold.to_wire(cs["points"]), "closed": bool(cs["closed"]), | |
| 144 | "name": String(cs.get("name", ""))}) | |
| 145 | var sdots: Array = shapes.keys() | |
| 146 | sdots.sort_custom(func(a: String, b: String) -> bool: return _dot_before(states[a], states[b])) | |
| 147 | for sd: String in sdots: | |
| 148 | mint.call(sd) | |
| 149 | for sd2: String in sdots: | |
| 150 | var s3: Dictionary = states[sd2] | |
| 151 | var ps: Dictionary = (s3["params"] as Dictionary).duplicate(true) | |
| 152 | for ck2: String in ["curve", "profile_curve"]: | |
| 153 | if ps.has(ck2): | |
| 154 | ps[ck2] = String(local.get(String(ps[ck2]), "")) | |
| 155 | var sz: Vector3 = s3["size"] | |
| 156 | var rec := {"kind": "shape.create", "dot": local[sd2], "type": String(s3["type"]), | |
| 157 | "gv": int(s3["gv"]), "params": ps, "frame": SceneObjects.xform_to(s3["frame"]), | |
| 158 | "size": [sz.x, sz.y, sz.z], "material_id": int(s3["material_id"]), | |
| 159 | "level_id": int(s3["level_id"]), "tex": (s3.get("tex", {}) as Dictionary).duplicate(true), | |
| 160 | "input": (s3.get("input", {}) as Dictionary).duplicate(true)} | |
| 161 | var root := String(s3.get("link", "")) | |
| 162 | if root != "": | |
| 163 | if local.has(root): | |
| 164 | rec["link"] = local[root] | |
| 165 | else: | |
| 166 | report.append("shape %s: its linked-duplicate root is outside — written unlinked" % sd2) | |
| 167 | out.append(rec) | |
| 168 | for m4: Dictionary in mods: | |
| 169 | var mp: Dictionary = (m4["params"] as Dictionary).duplicate(true) | |
| 170 | if mp.has("curve"): | |
| 171 | mp["curve"] = String(local.get(String(mp["curve"]), "")) | |
| 172 | out.append({"kind": "mod.create", "dot": mint.call(String(m4["dot"])), | |
| 173 | "target": local[String(m4["target"])], "mod": String(m4["kind"]), "params": mp, | |
| 174 | "on": bool(m4["on"])}) | |
| 175 | for s4: Dictionary in ops: | |
| 176 | var tg: Array = [] | |
| 177 | for g3: String in s4["targets"]: | |
| 178 | tg.append(local[g3]) | |
| 179 | out.append({"kind": "op.create", "dot": mint.call(String(s4["dot"])), "op": String(s4["kind"]), | |
| 180 | "cutter": local[String(s4["cutter"])], "targets": tg, "depth": float(s4["depth"]), | |
| 181 | "on": bool(s4["on"])}) | |
| 182 | var gk: Array = groups.keys() | |
| 183 | gk.sort() | |
| 184 | var brush_ids := PackedInt32Array() | |
| 185 | for g4: String in gk: | |
| 186 | var lg: String = mint.call("g:" + g4) | |
| 187 | var ids: Array = Array(level.get_group_brush_ids(g4) as PackedInt32Array) | |
| 188 | ids.sort_custom(func(a: int, b: int) -> bool: return String(level.get_brush_key(a)) < String(level.get_brush_key(b))) | |
| 189 | for b: int in ids: | |
| 190 | brush_ids.append(b) | |
| 191 | var p: Dictionary = persist.call("_brush_payload", level.get_brush(b)) | |
| 192 | p["group"] = lg | |
| 193 | p["kind"] = "brush.create" | |
| 194 | p["dot"] = mint.call("b:" + String(level.get_brush_key(b))) | |
| 195 | out.append(p) | |
| 196 | var odots: Array = [] | |
| 197 | if objects != null: | |
| 198 | for e: Dictionary in (objects.entries() as Array): # fold order | |
| 199 | if objs.has(String(e["dot"])): | |
| 200 | odots.append(String(e["dot"])) | |
| 201 | for od: String in odots: | |
| 202 | mint.call("obj:" + od) | |
| 203 | var model_oids := {} | |
| 204 | for od2: String in odots: | |
| 205 | var node := objects.node_for(od2) as Node3D | |
| 206 | var pd := String(objects.parent_of(od2)) | |
| 207 | var props := {} | |
| 208 | var base: Dictionary = (objects.get("_base") as Dictionary).get(od2, {}) | |
| 209 | for k: String in (base.get("props", {}) as Dictionary): | |
| 210 | props[k] = base["props"][k] | |
| 211 | var set_here: Dictionary = (objects.get("_props") as Dictionary).get(od2, {}) | |
| 212 | for k2: String in set_here: | |
| 213 | props[k2] = set_here[k2] | |
| 214 | if String(props.get("asset", "")) != "": | |
| 215 | model_oids[String(props["asset"])] = true | |
| 216 | var rec2 := {"kind": "object.create", "dot": local["obj:" + od2], | |
| 217 | "type": String(node.get_meta("object_type", "")), "props": props.duplicate(true)} | |
| 218 | if pd != "" and local.has("obj:" + pd): | |
| 219 | rec2["parent"] = local["obj:" + pd] | |
| 220 | rec2["xform"] = SceneObjects.xform_to(node.transform) | |
| 221 | else: | |
| 222 | if pd != "": | |
| 223 | report.append("object %s: its parent is outside — placed at the top level" % od2) | |
| 224 | rec2["xform"] = SceneObjects.xform_to(objects.scene_xform(node)) | |
| 225 | out.append(rec2) | |
| 226 | # Sub-collections (the root itself is the asset): parents first, members remapped. | |
| 227 | var cols: Dictionary = t["cols"] | |
| 228 | var corder: Array = [] | |
| 229 | var stack: Array = [col] | |
| 230 | while not stack.is_empty(): | |
| 231 | var c: String = stack.pop_front() | |
| 232 | if c != col: | |
| 233 | corder.append(c) | |
| 234 | var kids: Array = (cols[c]["children"] as Array).duplicate() | |
| 235 | kids.sort() | |
| 236 | stack.append_array(kids) | |
| 237 | for c2: String in corder: | |
| 238 | mint.call("col:" + c2) | |
| 239 | for c3: String in corder: | |
| 240 | var ms: Array = [] | |
| 241 | for m5: String in cols[c3]["members"]: | |
| 242 | var key := m5.substr(2) if m5.begins_with("g:") else m5 | |
| 243 | if m5.begins_with("g:") and groups.has(key): | |
| 244 | key = "g:" + key | |
| 245 | if local.has(key): | |
| 246 | ms.append(("g:" if m5.begins_with("g:") else "obj:") + String(local[key])) | |
| 247 | var par := String(cols[c3]["parent"]) | |
| 248 | out.append({"kind": "col.create", "dot": local["col:" + c3], "name": String(cols[c3]["name"]), | |
| 249 | "parent": String(local.get("col:" + par, "")), "members": ms}) | |
| 250 | # Members of the ROOT collection are the asset's top level: no record needed. | |
| 251 | ||
| 252 | if out.is_empty(): | |
| 253 | return {"doc": {}, "report": report + ["the collection holds nothing an asset can carry"]} | |
| 254 | ||
| 255 | # ---- bounds and pivot: every brush the contents fold to, every object's origin ---- | |
| 256 | var lo := Vector3.INF | |
| 257 | var hi := -Vector3.INF | |
| 258 | var any := false | |
| 259 | for sd3: String in sdots: | |
| 260 | brush_ids.append_array(level.get_group_brush_ids(sd3) as PackedInt32Array) | |
| 261 | var bb: Dictionary = level.get_brushes_bounds(brush_ids) | |
| 262 | if int(bb["count"]) > 0: | |
| 263 | lo = bb["aabb_min"] | |
| 264 | hi = bb["aabb_max"] | |
| 265 | any = true | |
| 266 | for od3: String in odots: | |
| 267 | var o: Vector3 = objects.scene_xform(objects.node_for(od3) as Node3D).origin | |
| 268 | lo = lo.min(o) | |
| 269 | hi = hi.max(o) | |
| 270 | any = true | |
| 271 | if not any: | |
| 272 | lo = Vector3.ZERO | |
| 273 | hi = Vector3.ZERO | |
| 274 | var deps: Array = model_oids.keys() | |
| 275 | deps.sort() | |
| 276 | var doc := {"v": V, "name": name if name != "" else String(cols[col]["name"]), | |
| 277 | "pivot": Vector3((lo.x + hi.x) * 0.5, lo.y, (lo.z + hi.z) * 0.5), | |
| 278 | "bounds": [lo, hi], "records": out, "assets": deps} | |
| 279 | return {"doc": doc, "report": report} | |
| 280 | ||
| 281 | ||
| 282 | static func _before(a: Dictionary, b: Dictionary) -> bool: | |
| 283 | return _dot_before(a, b) | |
| 284 | ||
| 285 | ||
| 286 | ## Room order: (seq, author) — the fold's order. | |
| 287 | static func _dot_before(a: Dictionary, b: Dictionary) -> bool: | |
| 288 | if int(a.get("seq", 0)) != int(b.get("seq", 0)): | |
| 289 | return int(a.get("seq", 0)) < int(b.get("seq", 0)) | |
| 290 | return String(a.get("author", a.get("dot", ""))) < String(b.get("author", b.get("dot", ""))) | |
| 291 | ||
| 292 | ||
| 293 | # ---- bytes ------------------------------------------------------------------- | |
| 294 | ||
| 295 | ## The blob: var_to_bytes of `doc` with every Dictionary's keys sorted. | |
| 296 | static func encode(doc: Dictionary) -> PackedByteArray: | |
| 297 | return var_to_bytes(_canon(doc)) | |
| 298 | ||
| 299 | ||
| 300 | static func _canon(v: Variant) -> Variant: | |
| 301 | if v is Dictionary: | |
| 302 | var keys: Array = (v as Dictionary).keys() | |
| 303 | keys.sort_custom(func(a: Variant, b: Variant) -> bool: return str(a) < str(b)) | |
| 304 | var out := {} | |
| 305 | for k: Variant in keys: | |
| 306 | out[k] = _canon((v as Dictionary)[k]) | |
| 307 | return out | |
| 308 | if v is Array: | |
| 309 | var out2: Array = [] | |
| 310 | for x: Variant in (v as Array): | |
| 311 | out2.append(_canon(x)) | |
| 312 | return out2 | |
| 313 | return v | |
| 314 | ||
| 315 | ||
| 316 | ## The blob's name: SHA-256, hex. | |
| 317 | static func hash_of(bytes: PackedByteArray) -> String: | |
| 318 | var h := HashingContext.new() | |
| 319 | h.start(HashingContext.HASH_SHA256) | |
| 320 | h.update(bytes) | |
| 321 | return h.finish().hex_encode() | |
| 322 | ||
| 323 | ||
| 324 | ## Read a blob — FROM ANOTHER PEER, so nothing is trusted: no Objects (bytes_to_var's | |
| 325 | ## default), the shape checked, every record's kind one of KINDS and every dot local. | |
| 326 | ## {} when any of it fails. | |
| 327 | static func decode(bytes: PackedByteArray) -> Dictionary: | |
| 328 | if bytes.is_empty(): | |
| 329 | return {} | |
| 330 | var v: Variant = bytes_to_var(bytes) | |
| 331 | if not (v is Dictionary): | |
| 332 | return {} | |
| 333 | var d: Dictionary = v | |
| 334 | if int(d.get("v", 0)) != V or not (d.get("records") is Array) or not (d.get("pivot") is Vector3) \ | |
| 335 | or not (d.get("bounds") is Array) or (d["bounds"] as Array).size() != 2 \ | |
| 336 | or not (d.get("assets", []) is Array) or not (d.get("name", "") is String): | |
| 337 | return {} | |
| 338 | for r: Variant in (d["records"] as Array): | |
| 339 | if not (r is Dictionary) or not KINDS.has(String((r as Dictionary).get("kind", ""))) \ | |
| 340 | or not String((r as Dictionary).get("dot", "")).begins_with("L:"): | |
| 341 | return {} | |
| 342 | return d |
| … | … | @@ -0,0 +1,240 @@ |
| 1 | extends Node | |
| 2 | ## GATE — THE ASSET DOCUMENT (#120 L4 step 1, docs/node-tree-and-stacks.md §15.1, asset_doc.gd). | |
| 3 | ## A room built through the map's own paths, one collection "Kit" holding: a box A with a | |
| 4 | ## mirror, cut by K (a cutter in its own "Cutters" collection, as Box Cutter files it); a | |
| 5 | ## cutter K2 that only cuts a box O OUTSIDE Kit; a cable on its curve; a plain brush object; | |
| 6 | ## a scene object; a sub-collection "Sub" with a box B. | |
| 7 | ## D1 encode -> decode -> encode: byte-identical; D1b key order does not matter | |
| 8 | ## D2 two writes of the same room: the same oid | |
| 9 | ## D3 nothing in the records names the room (no "probe" dot or group survives) | |
| 10 | ## D4 every reference (target, cutter, targets, link, curve, profile, parent, members) | |
| 11 | ## resolves to a local dot the doc defines (a record, or a brush object's group) | |
| 12 | ## D5 contents: A, B, K (brought in), the cable shape; its curve; the mirror; ONE cut | |
| 13 | ## (A by K) with only A as target; the plain brushes under one local group; the | |
| 14 | ## object; the sub-collection with B | |
| 15 | ## D6 K2 and its cut are left out and REPORTED; K's arrival is reported | |
| 16 | ## D7 pivot = the bounds' base centre; the bounds hold every brush the contents fold to | |
| 17 | ## D8 decode refuses: a blob with an Object, a record of another kind, a non-local dot, | |
| 18 | ## a wrong version, garbage | |
| 19 | ## D9 the room changes (A moved) -> a different oid (D2 is not vacuous) | |
| 20 | ## | |
| 21 | ## RUN AS A SCENE: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . res://scenes/test/gate_asset_doc.tscn | |
| 22 | ||
| 23 | const AssetDoc := preload("res://scenes/maps/asset_doc.gd") | |
| 24 | ||
| 25 | var fails := 0 | |
| 26 | var checks := 0 | |
| 27 | var map: Node3D | |
| 28 | var persist: Node | |
| 29 | ||
| 30 | ||
| 31 | func _ok(what: String, cond: bool, note: Variant = "") -> void: | |
| 32 | checks += 1 | |
| 33 | if not cond: | |
| 34 | fails += 1 | |
| 35 | print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)]) | |
| 36 | ||
| 37 | ||
| 38 | func _ready() -> void: | |
| 39 | var gs: Node = get_tree().root.get_node_or_null("/root/GameState") | |
| 40 | gs.route("world:level:*", 1, "peer") | |
| 41 | var keet: Node = get_tree().root.get_node_or_null("/root/KeetService") | |
| 42 | if keet != null: | |
| 43 | keet.current_member_id = "probe" | |
| 44 | get_tree().root.get_node("/root/GameMode").set_mode("build") | |
| 45 | map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate() | |
| 46 | add_child(map) | |
| 47 | for i in 6: | |
| 48 | await get_tree().process_frame | |
| 49 | persist = map.get("_persist") | |
| 50 | _run() | |
| 51 | if checks < 20: | |
| 52 | print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks) | |
| 53 | fails += 1 | |
| 54 | print("\nGATE asset_doc: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails]) | |
| 55 | get_tree().quit(0 if fails == 0 else 1) | |
| 56 | ||
| 57 | ||
| 58 | func _box(at: Vector3, size: Vector3) -> String: | |
| 59 | return persist.record("shape.create", {"type": "box", "gv": 1, "params": {}, | |
| 60 | "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, at)), "size": [size.x, size.y, size.z], | |
| 61 | "material_id": 2, "level_id": 0}) | |
| 62 | ||
| 63 | ||
| 64 | static func _box_planes(lo: Vector3, hi: Vector3) -> Array: | |
| 65 | 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] | |
| 66 | ||
| 67 | ||
| 68 | ## Every String anywhere in `v`. | |
| 69 | func _strings(v: Variant, out: Array) -> void: | |
| 70 | if v is String or v is StringName: | |
| 71 | out.append(String(v)) | |
| 72 | elif v is Dictionary: | |
| 73 | for k: Variant in (v as Dictionary): | |
| 74 | _strings(k, out) | |
| 75 | _strings((v as Dictionary)[k], out) | |
| 76 | elif v is Array: | |
| 77 | for x: Variant in (v as Array): | |
| 78 | _strings(x, out) | |
| 79 | ||
| 80 | ||
| 81 | func _run() -> void: | |
| 82 | var O0 := Vector3(500, 0, 500) | |
| 83 | persist.begin_gesture("asset room") | |
| 84 | var a := _box(O0, Vector3(2, 2, 2)) | |
| 85 | var b := _box(O0 + Vector3(6, 0, 0), Vector3(1, 3, 1)) | |
| 86 | var k := _box(O0 + Vector3(0.5, 0.5, 0), Vector3(1, 1, 3)) | |
| 87 | var o := _box(O0 + Vector3(-20, 0, 0), Vector3(2, 2, 2)) | |
| 88 | var k2 := _box(O0 + Vector3(-20, 0.5, 0), Vector3(1, 1, 3)) | |
| 89 | persist.record("mod.create", {"target": a, "mod": "mirror", "params": {"axis": "x", "offset": 0.0}}) | |
| 90 | persist.record("op.create", {"op": "subtract", "cutter": k, "targets": [a]}) | |
| 91 | persist.record("op.create", {"op": "subtract", "cutter": k2, "targets": [o]}) | |
| 92 | var grp := "probe:grp1" | |
| 93 | for i in 2: | |
| 94 | var lo := O0 + Vector3(0, 0, 6 + i * 2) | |
| 95 | persist.record("brush.create", {"planes": _box_planes(lo, lo + Vector3(1, 1, 1)), "proj": [], | |
| 96 | "material_id": 1, "level_id": 0, "sky": false, "clearance": false, "group": grp, "face_tex": []}) | |
| 97 | persist.end_gesture() | |
| 98 | persist.refold() | |
| 99 | var cable: String = map.add_cable([CurveMath.point(O0 + Vector3(0, 1, -4)), CurveMath.point(O0 + Vector3(6, 1, -4))], | |
| 100 | false, {"radius": 0.05}) | |
| 101 | var obj: String = map.create_object(PrefabCatalogue.ids()[0], Transform3D(Basis.IDENTITY, O0 + Vector3(3, 0, 3))) | |
| 102 | var cable_curve := String((persist.shape_state(cable)["params"] as Dictionary).get("curve", "")) | |
| 103 | persist.begin_gesture("asset collections") | |
| 104 | var kit: String = persist.record("col.create", {"name": "Kit", "parent": ""}) | |
| 105 | var sub: String = persist.record("col.create", {"name": "Sub", "parent": kit}) | |
| 106 | var cuts: String = persist.record("col.create", {"name": "Cutters", "parent": ""}) | |
| 107 | persist.record("col.link", {"target": kit, "members": ["g:" + a, "g:" + k2, "g:" + cable, "g:" + grp, "obj:" + obj]}) | |
| 108 | persist.record("col.link", {"target": sub, "members": ["g:" + b]}) | |
| 109 | persist.record("col.link", {"target": cuts, "members": ["g:" + k]}) | |
| 110 | persist.end_gesture() | |
| 111 | persist.refold() | |
| 112 | _ok("setup: the room has A, B, K, the cable, the brush object, the object", | |
| 113 | (persist.live_shapes() as Dictionary).has(a) and (persist.live_shapes() as Dictionary).has(cable) | |
| 114 | and cable_curve != "" and obj != "" and not (map.level.get_group_brush_ids(grp) as PackedInt32Array).is_empty(), | |
| 115 | [a, cable, cable_curve, obj]) | |
| 116 | ||
| 117 | var w1: Dictionary = AssetDoc.write(persist, kit) | |
| 118 | var doc: Dictionary = w1["doc"] | |
| 119 | var bytes := AssetDoc.encode(doc) | |
| 120 | _ok("setup: a doc was written", not doc.is_empty() and bytes.size() > 0, bytes.size()) | |
| 121 | if doc.is_empty(): | |
| 122 | return | |
| 123 | var recs: Array = doc["records"] | |
| 124 | ||
| 125 | # ---- D1 / D2 ---- | |
| 126 | var back := AssetDoc.decode(bytes) | |
| 127 | _ok("D1 encode -> decode -> encode is byte-identical", not back.is_empty() and AssetDoc.encode(back) == bytes) | |
| 128 | # D1b: another peer may build the same Dictionaries in another key order. | |
| 129 | _ok("D1b the same doc with every key inserted in reverse order: the same bytes", | |
| 130 | AssetDoc.encode(_reversed(doc)) == bytes) | |
| 131 | var oid := AssetDoc.hash_of(bytes) | |
| 132 | _ok("D2 a second write of the same room: the same oid", | |
| 133 | AssetDoc.hash_of(AssetDoc.encode(AssetDoc.write(persist, kit)["doc"])) == oid, oid.substr(0, 12)) | |
| 134 | ||
| 135 | # ---- D3 / D4 ---- | |
| 136 | var strs: Array = [] | |
| 137 | _strings(recs, strs) | |
| 138 | var leaks := strs.filter(func(s: String) -> bool: return s.contains("probe")) | |
| 139 | _ok("D3 no room dot or group survives in the records", leaks.is_empty(), leaks) | |
| 140 | # Defined: every record's dot, and every brush object's group (a name its brush.create | |
| 141 | # records share — not a record of its own). | |
| 142 | var defined := {} | |
| 143 | for r: Dictionary in recs: | |
| 144 | defined[String(r["dot"])] = true | |
| 145 | if String(r["kind"]) == "brush.create": | |
| 146 | defined[String(r["group"])] = true | |
| 147 | var refs: Array = [] | |
| 148 | for r2: Dictionary in recs: | |
| 149 | for f: String in ["target", "cutter", "link"]: | |
| 150 | if r2.has(f) and String(r2[f]) != "": | |
| 151 | refs.append(String(r2[f])) | |
| 152 | if r2.has("parent") and String(r2["parent"]) != "": | |
| 153 | refs.append(String(r2["parent"])) | |
| 154 | for t: Variant in (r2.get("targets", []) as Array): | |
| 155 | refs.append(String(t)) | |
| 156 | for m: Variant in (r2.get("members", []) as Array): | |
| 157 | refs.append(String(m).substr(String(m).find(":") + 1)) | |
| 158 | var p: Dictionary = r2.get("params", {}) | |
| 159 | for pk: String in ["curve", "profile_curve"]: | |
| 160 | if String(p.get(pk, "")) != "": | |
| 161 | refs.append(String(p[pk])) | |
| 162 | var dangling := refs.filter(func(x: String) -> bool: return not defined.has(x)) | |
| 163 | _ok("D4 every reference names a local dot the doc defines (%d refs)" % refs.size(), | |
| 164 | refs.size() >= 5 and dangling.is_empty(), [refs, dangling]) | |
| 165 | ||
| 166 | # ---- D5 ---- | |
| 167 | var by_kind := {} | |
| 168 | for r3: Dictionary in recs: | |
| 169 | (by_kind.get_or_add(String(r3["kind"]), []) as Array).append(r3) | |
| 170 | var shapes: Array = by_kind.get("shape.create", []) | |
| 171 | _ok("D5 shapes: A, B, K and the cable (4); K2 left out", shapes.size() == 4, | |
| 172 | shapes.map(func(r4: Dictionary) -> String: return String(r4["type"]))) | |
| 173 | _ok("D5 the cable's curve is in it", (by_kind.get("curve.create", []) as Array).size() == 1) | |
| 174 | var mods: Array = by_kind.get("mod.create", []) | |
| 175 | _ok("D5 the mirror is in it", mods.size() == 1 and String(mods[0]["mod"]) == "mirror") | |
| 176 | var ops: Array = by_kind.get("op.create", []) | |
| 177 | _ok("D5 ONE cut, A by K, with only A as its target", ops.size() == 1 and (ops[0]["targets"] as Array).size() == 1 | |
| 178 | and String(ops[0]["targets"][0]) == String(mods[0]["target"]) if not mods.is_empty() and ops.size() == 1 else false, ops) | |
| 179 | var bc: Array = by_kind.get("brush.create", []) | |
| 180 | var bgroups := {} | |
| 181 | for r5: Dictionary in bc: | |
| 182 | bgroups[String(r5["group"])] = true | |
| 183 | _ok("D5 the brush object: 2 brushes, one local group", bc.size() == 2 and bgroups.size() == 1, bgroups.keys()) | |
| 184 | _ok("D5 the object is in it", (by_kind.get("object.create", []) as Array).size() == 1) | |
| 185 | var cols: Array = by_kind.get("col.create", []) | |
| 186 | _ok("D5 the sub-collection with B (its one member)", cols.size() == 1 and String(cols[0]["name"]) == "Sub" | |
| 187 | and (cols[0]["members"] as Array).size() == 1 if cols.size() == 1 else false, cols) | |
| 188 | ||
| 189 | # ---- D6 ---- | |
| 190 | var rep: Array = w1["report"] | |
| 191 | _ok("D6 K2 (cuts only outside) is reported left out", rep.any(func(s: String) -> bool: return s.contains(k2) and s.contains("left out")), rep) | |
| 192 | _ok("D6 K (from Cutters) is reported brought in", rep.any(func(s: String) -> bool: return s.contains(k) and s.contains("brought in")), rep) | |
| 193 | ||
| 194 | # ---- D7 ---- | |
| 195 | var ids := PackedInt32Array() | |
| 196 | for g: String in [a, b, k, cable, grp]: | |
| 197 | ids.append_array(map.level.get_group_brush_ids(g) as PackedInt32Array) | |
| 198 | var bb: Dictionary = map.level.get_brushes_bounds(ids) | |
| 199 | var lo2: Vector3 = (bb["aabb_min"] as Vector3).min(SceneObjects.xform_from(by_kind["object.create"][0]["xform"]).origin) | |
| 200 | var hi2: Vector3 = (bb["aabb_max"] as Vector3).max(SceneObjects.xform_from(by_kind["object.create"][0]["xform"]).origin) | |
| 201 | var pv: Vector3 = doc["pivot"] | |
| 202 | _ok("D7 the bounds = every brush the contents fold to + the object", (doc["bounds"][0] as Vector3).is_equal_approx(lo2) | |
| 203 | and (doc["bounds"][1] as Vector3).is_equal_approx(hi2), [doc["bounds"], lo2, hi2]) | |
| 204 | _ok("D7 the pivot = the bounds' base centre", pv.is_equal_approx(Vector3((lo2.x + hi2.x) * 0.5, lo2.y, (lo2.z + hi2.z) * 0.5)), pv) | |
| 205 | ||
| 206 | # ---- D8 ---- | |
| 207 | var with_obj := doc.duplicate(true) | |
| 208 | with_obj["evil"] = Resource.new() | |
| 209 | _ok("D8 refuses a blob carrying an Object", AssetDoc.decode(var_to_bytes_with_objects(with_obj)).is_empty()) | |
| 210 | var bad_kind := doc.duplicate(true) | |
| 211 | (bad_kind["records"] as Array).append({"kind": "brush.remove", "dot": "L:999", "brush_key": "x"}) | |
| 212 | _ok("D8 refuses a record of another kind", AssetDoc.decode(AssetDoc.encode(bad_kind)).is_empty()) | |
| 213 | var bad_dot := doc.duplicate(true) | |
| 214 | (bad_dot["records"][0] as Dictionary)["dot"] = "probe:1" | |
| 215 | _ok("D8 refuses a non-local dot", AssetDoc.decode(AssetDoc.encode(bad_dot)).is_empty()) | |
| 216 | var bad_v := doc.duplicate(true) | |
| 217 | bad_v["v"] = 99 | |
| 218 | _ok("D8 refuses another version", AssetDoc.decode(AssetDoc.encode(bad_v)).is_empty()) | |
| 219 | _ok("D8 refuses garbage", AssetDoc.decode(PackedByteArray([1, 2, 3, 4, 5, 6, 7, 8])).is_empty()) | |
| 220 | ||
| 221 | # ---- D9 ---- | |
| 222 | persist.begin_gesture("move A") | |
| 223 | persist.record("shape.xform", {"target": a, "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, O0 + Vector3(0, 0, 1)))}) | |
| 224 | persist.end_gesture() | |
| 225 | persist.refold() | |
| 226 | _ok("D9 A moved -> a different oid", AssetDoc.hash_of(AssetDoc.encode(AssetDoc.write(persist, kit)["doc"])) != oid) | |
| 227 | ||
| 228 | ||
| 229 | ## `v` with every Dictionary's keys inserted in reverse order. | |
| 230 | func _reversed(v: Variant) -> Variant: | |
| 231 | if v is Dictionary: | |
| 232 | var keys: Array = (v as Dictionary).keys() | |
| 233 | keys.reverse() | |
| 234 | var out := {} | |
| 235 | for k: Variant in keys: | |
| 236 | out[k] = _reversed((v as Dictionary)[k]) | |
| 237 | return out | |
| 238 | if v is Array: | |
| 239 | return (v as Array).map(func(x: Variant) -> Variant: return _reversed(x)) | |
| 240 | return v |
| … | … | @@ -0,0 +1,6 @@ |
| 1 | [gd_scene load_steps=2 format=3] | |
| 2 | ||
| 3 | [ext_resource type="Script" path="res://scenes/test/gate_asset_doc.gd" id="1"] | |
| 4 | ||
| 5 | [node name="GateAssetDoc" type="Node"] | |
| 6 | script = ExtResource("1") |