OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / autoloads / asset_sync.gd

289 lines · gd ref:
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 {}