extends Node
## ⚠ NO `class_name`: this is registered as the autoload singleton `AssetSync`
## (project.godot), and a global class of the same name makes Godot refuse to
## compile it — "Class \"AssetSync\" hides an autoload singleton". The autoload
## name is what call sites use, so it wins. Tests instantiate the script
## directly with load("res://autoloads/asset_sync.gd").new().
## One way to sync any runtime file over the mesh. See spec/asset-sync.md.
##
## This is a CATALOGUE, not a transport. ContentOverlay already moves bytes
## content-addressed over the game's own mesh connection and is verified between
## real peers (#14, #19, #22); what was missing is a shared registry so a peer
## can resolve "the base.wad for this room" without already knowing its hash.
##
## ⛔ THE ONE THING TO GET RIGHT: assets have TWO lifecycles and they need
## OPPOSITE conflict rules. Keying an immutable asset the mutable way is a
## convergence bug, not a style choice.
##
## MUTABLE world:assets:<kind>:<name> -> newest wins
## StateBase resolves same-key conflicts by LAST-WRITE-WINS on ts
## (state-base.js:578-582), which is EXACTLY right here: re-exporting
## base.wad should supersede the old entry everywhere.
##
## IMMUTABLE <prefix><id>:<oid> -> lowest oid wins
## The oid goes in the KEY so LWW never fires, and the reader picks
## the lowest oid — a deterministic read-side tiebreak. REQUIRED when
## two peers can produce different bytes for the same identity: CPU
## and CUDA diverge up to 5.00 m for the same (seed,tx,ty), so under
## the mutable rule a peer with a skewed clock would flip which
## terrain everyone sees.
##
## The shape is precedented both ways: world:level:<...>:source is a mutable
## pointer, and terrain_edit_order.gd exists to order
## world:terrain:edit:<author>:<seq> deterministically on read.
## Emitted when a named asset's bytes have arrived and are usable at `path`.
signal asset_ready(kind: String, name: String, path: String)
const ASSET_PREFIX := "world:assets:"
## Kinds needing the IMMUTABLE rule, and their own route prefix.
##
## A separate prefix is not tidiness: STATE_ROUTES scoping is per-prefix, and
## tiles are map-scoped (a map is a contained world) while assets are
## room-global (a WAD is not part of one map). One prefix cannot be both.
## Anything not listed here defaults to MUTABLE under ASSET_PREFIX, which is the
## safe default — a wrong-but-mutable asset is superseded by the next publish,
## where a wrong-but-immutable one is frozen by the tiebreak.
const IMMUTABLE_KINDS := {
"tile": "world:tiles:",
}
## Injected so the rules are testable with no daemon, room or mesh
## (scenes/test/gate_asset_sync.gd). Default to the autoloads at runtime.
var state: Object = null
var overlay: Object = null
func _ready() -> void:
if state == null and Engine.has_singleton("GameState"):
state = Engine.get_singleton("GameState")
elif state == null:
state = get_node_or_null(^"/root/GameState")
if overlay == null:
overlay = get_node_or_null(^"/root/ContentOverlay")
if state != null and state.has_signal("state_changed"):
if not state.state_changed.is_connected(_on_state_changed):
state.state_changed.connect(_on_state_changed)
if overlay != null and overlay.has_signal("content_ready"):
if not overlay.content_ready.is_connected(_on_content_ready):
overlay.content_ready.connect(_on_content_ready)
func is_ready() -> bool:
return state != null and overlay != null and overlay.is_ready()
# ---- keys -------------------------------------------------------------------
func _is_immutable(kind: String) -> bool:
return IMMUTABLE_KINDS.has(kind)
func _prefix(kind: String) -> String:
if _is_immutable(kind):
return String(IMMUTABLE_KINDS[kind])
return ASSET_PREFIX + kind + ":"
func _mutable_key(kind: String, name: String) -> String:
return _prefix(kind) + name
func _immutable_key(kind: String, id: String, oid: String) -> String:
return _prefix(kind) + id + ":" + oid
# ---- publish ----------------------------------------------------------------
## Hash a file, serve it from disk, and name it in the registry. Returns the oid,
## or "" if the overlay cannot take it.
##
## Two steps on purpose: publishing BYTES and publishing a POINTER are separable,
## which is what lets the rules be gated without touching a filesystem.
func publish_file(kind: String, name: String, abs_path: String, ext: String = "") -> String:
if not is_ready():
return ""
var oid: String = String(overlay.publish(abs_path))
if oid == "":
return ""
var sz: int = 0
var f := FileAccess.open(abs_path, FileAccess.READ)
if f != null:
sz = f.get_length()
var e := ext if ext != "" else abs_path.get_extension()
if _is_immutable(kind):
return oid if publish_immutable(kind, name, oid, sz) else ""
return publish_asset(kind, name, oid, sz, e)
## MUTABLE: name the newest bytes for this kind+name. Newest publish wins.
## `extra`: more fields for the entry (an asset's bounds, #120 L4) — never oid/size/ext.
func publish_asset(kind: String, name: String, oid: String, size: int, ext: String = "",
extra: Dictionary = {}) -> String:
if not is_ready() or oid == "":
return ""
var entry := extra.duplicate(true)
entry["oid"] = oid
entry["size"] = size
entry["ext"] = ext
state.set_state(_mutable_key(kind, name), entry)
return oid
## IMMUTABLE: record THIS oid for this identity. Never supersedes a sibling —
## the read side picks the lowest oid across all of them.
func publish_immutable(kind: String, id: String, oid: String, size: int) -> bool:
if not is_ready() or oid == "":
return false
state.set_state(_immutable_key(kind, id, oid), {"size": size})
return true
# ---- resolve ----------------------------------------------------------------
## MUTABLE: the oid currently named for kind+name, or "".
func resolve(kind: String, name: String) -> String:
if state == null:
return ""
var v: Variant = state.get_state(_mutable_key(kind, name), null)
if typeof(v) != TYPE_DICTIONARY:
return ""
return String(v.get("oid", ""))
## IMMUTABLE: the LOWEST oid recorded for this identity, or "".
##
## Lowest and not newest, and not arrival order: every peer must independently
## reach the same answer with no messages, and a timestamp rule does not converge
## when clocks are not trustworthy.
func resolve_immutable(kind: String, id: String) -> String:
var oids := list_immutable(kind, id)
if oids.is_empty():
return ""
oids.sort()
return String(oids[0])
## Every oid recorded for this identity.
func list_immutable(kind: String, id: String) -> Array:
if state == null or not state.has_method("keys_with_prefix"):
return []
var pre := _prefix(kind) + id + ":"
var out: Array = []
for k in state.keys_with_prefix(pre):
out.append(String(k).substr(pre.length()))
return out
## Everything the room has of this kind, as [{name, oid, size, ext}].
func list(kind: String) -> Array:
if state == null or not state.has_method("keys_with_prefix"):
return []
var pre := _prefix(kind)
var out: Array = []
for k in state.keys_with_prefix(pre):
var v: Variant = state.get_state(k, null)
if typeof(v) != TYPE_DICTIONARY:
continue
var row: Dictionary = (v as Dictionary).duplicate()
row["name"] = String(k).substr(pre.length())
out.append(row)
return out
# ---- ensure ----------------------------------------------------------------
## Resolve kind+name to a LOCAL path, starting a fetch if we do not hold it.
##
## Returns "" when the bytes are not here yet — NOT an error. A fetch with no
## holder stays open and is re-sent to every peer that joins later, and
## asset_ready fires whenever it lands.
func ensure(kind: String, name: String) -> String:
if not is_ready():
return ""
var oid := resolve_immutable(kind, name) if _is_immutable(kind) else resolve(kind, name)
if oid == "":
return ""
# Ask by OID with an oid-derived filename. request(oid, name) would land
# every version of base.wad on the same path and they would overwrite each
# other; the extension is kept so Godot's loaders can still sniff the format.
var ext := ""
if not _is_immutable(kind):
var v: Variant = state.get_state(_mutable_key(kind, name), null)
if typeof(v) == TYPE_DICTIONARY:
ext = String((v as Dictionary).get("ext", ""))
var fname := oid + ("." + ext if ext != "" else ".bin")
return String(overlay.request(oid, fname))
## Resolve an asset to a usable local path: the AUTHORING machine's own copy if
## it has one, otherwise the room's copy over the mesh.
##
## This is PURE RESOLUTION and deliberately does not publish. Publishing is a
## once-per-load act by whoever holds the file; resolving happens per use, and
## fusing them would let a consumer re-point the registry at whatever it happened
## to have locally.
##
## Returns "" when the bytes are not here yet, which is NOT an error — the fetch
## stays open and asset_ready fires when it lands.
##
## ⛔ A local_fallback that does not exist is never passed through. That is the
## whole bug this exists to kill: map_dc_level.gd and map_dc_terrain.gd both hold
## absolute paths into /storage/projects/gamedev/quake/... guarded by
## file_exists() checks that degrade SILENTLY to untextured / albedo-only, so a
## level renders bare on every machine that is not the authoring one.
func resolve_path(kind: String, name: String, local_fallback: String = "") -> String:
if local_fallback != "" and FileAccess.file_exists(local_fallback):
return local_fallback
return ensure(kind, name)
# ---- events -----------------------------------------------------------------
## Registry appends are the ONLY trigger for a retry. No polling timer: the
## append IS the event, and a tile or texture we could not resolve a moment ago
## becomes resolvable the instant its pointer replicates.
func _on_state_changed(key: String, _value: Variant, _channel: int) -> void:
var k := String(key)
if not (k.begins_with(ASSET_PREFIX) or _is_tile_key(k)):
return
var parsed := _parse(k)
if parsed.is_empty():
return
var path := ensure(String(parsed["kind"]), String(parsed["name"]))
if path != "":
asset_ready.emit(String(parsed["kind"]), String(parsed["name"]), path)
func _on_content_ready(_content_hash: String, _path: String) -> void:
# Which asset arrived is a registry question, not an overlay one — re-resolve
# rather than reverse-mapping oid -> name, so a name that was superseded
# mid-flight reports the entry that is CURRENT.
pass
func _is_tile_key(k: String) -> bool:
for kind in IMMUTABLE_KINDS:
if k.begins_with(String(IMMUTABLE_KINDS[kind])):
return true
return false
func _parse(key: String) -> Dictionary:
for kind in IMMUTABLE_KINDS:
var pre := String(IMMUTABLE_KINDS[kind])
if key.begins_with(pre):
var rest := key.substr(pre.length())
var cut := rest.rfind(":")
if cut <= 0:
return {}
return {"kind": kind, "name": rest.substr(0, cut)}
if key.begins_with(ASSET_PREFIX):
var rest2 := key.substr(ASSET_PREFIX.length())
var cut2 := rest2.find(":")
if cut2 <= 0:
return {}
return {"kind": rest2.substr(0, cut2), "name": rest2.substr(cut2 + 1)}
return {}