OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / autoloads / content_overlay.gd

209 lines · gd ref:
extends Node
## Content-addressed asset transfer over the game's own mesh (hyper-overlay/v2).
##
## WHY THIS EXISTS. StateBase is a KV store over Autobase: every value replicates
## in full to everyone, with no chunking and no dedup. That is right for a carve
## journal and wrong for a file. `level_persistence.gd` says the quiet part out
## loud — the durable import path's second half "needs a chunked write of ~1183
## keys and there is no batch API". This is the answer to that: the BYTES move
## content-addressed over the existing UDX/Protomux connection, and StateBase
## keeps one small key naming the hash.
##
## The transfer itself is native (liboverlay, in the hyperswarm-peer GDExtension)
## and rides the mesh connection this game already has — no JS daemon in the data
## path, no second socket, no separate discovery.
##
## ATTACH ORDER DOES NOT MATTER, which is a recent property and worth knowing.
## attach() used to register for FUTURE connections only, so attaching after the
## swarm had meshed — the normal order, since you wait for peers before you want
## anything from them — produced an overlay that saw no peers at all. It now
## replays the peers that already exist (hyper-games #15), so this autoload can
## attach whenever the peer object turns up.
##
## THREADING: every method here is called from Godot's main thread and marshals
## onto the transport's loop internally. poll() drains completion callbacks; it
## must be called or signals never fire.

## Emitted when a requested hash has fully arrived and verified at `path`.
signal content_ready(content_hash: String, path: String)
## Emitted when a fetch ends without the file. `reason` is the driver's own text.
signal content_error(content_hash: String, reason: String)
## Emitted as chunks land. Bytes, not a fraction — the total is not always known
## before the holder's chunk list arrives.
signal content_progress(content_hash: String, received: int, total: int)

## Where fetched bytes land. Content-addressed, so the name IS the hash and two
## rooms asking for the same asset share one copy.
const CACHE_DIR := "user://content"

## The overlay's own index + partials. Separate from CACHE_DIR because one is a
## working store the library owns and the other is finished content the game reads.
const STORAGE_DIR := "user://overlay"

var _overlay: Object = null
var _attached_peer: Object = null
var _serving: Dictionary = {}      # hash -> local path we answer for
var _wanted: Dictionary = {}       # hash -> dest path we are fetching to


func _ready() -> void:
	if not ClassDB.class_exists(&"HyperOverlay"):
		# The extension is optional at runtime — a build without it still plays,
		# it just cannot move assets. Say so once rather than at every call site.
		push_warning("[ContentOverlay] HyperOverlay not in ClassDB — asset transfer disabled")
		set_process(false)
		return
	# BOTH directories, and neither is optional. open() opens
	# <storage>/overlay-index.log and does NOT create its own directory, so on a
	# fresh install this failed with "cannot open index under ..." and disabled
	# asset transfer entirely — silently, as far as gameplay was concerned. Found
	# on the first real run; no amount of compiling shows it.
	var storage := ProjectSettings.globalize_path(STORAGE_DIR)
	DirAccess.make_dir_recursive_absolute(storage)
	DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(CACHE_DIR))
	_overlay = ClassDB.instantiate(&"HyperOverlay")
	if not _overlay.open(storage):
		push_error("[ContentOverlay] overlay storage failed to open")
		_overlay = null
		set_process(false)
		return
	_overlay.content_fetched.connect(_on_fetched)
	_overlay.content_failed.connect(_on_failed)
	_overlay.content_progress.connect(_on_progress)


func _process(_delta: float) -> void:
	# Completion runs on the transport thread; poll() is what hands it back here.
	# Skipping it does not corrupt a transfer, it just means nothing is ever
	# reported as finished.
	if _overlay != null:
		_overlay.poll()


func is_ready() -> bool:
	return _overlay != null


## Bind to the mesh peer. Safe at any point in that peer's life — before it joins
## the DHT, or long after the swarm has meshed.
func attach_peer(peer: Object) -> bool:
	if _overlay == null or peer == null:
		return false
	if _attached_peer == peer:
		return true
	_attached_peer = peer
	var ok: bool = _overlay.attach(peer)
	print("[ContentOverlay] attached to mesh peer: %s" % ("ok" if ok else "FAILED"))
	return ok


func detach_peer() -> void:
	if _overlay == null:
		return
	_attached_peer = null
	_overlay.detach()


## How many meshed peers the overlay can actually talk to. Distinct from the
## game's peer count: a peer counts here only once its overlay channel is OPEN.
func peer_count() -> int:
	return int(_overlay.peer_count()) if _overlay != null else 0


## Hash a local file without loading it into memory, and answer for it from disk.
## Returns the content hash, or "" if the file cannot be read.
##
## Serving is a LOCATOR, not a copy: the holder reads each chunk at its offset
## when asked. A 4.7 MB WAD costs no resident memory to offer.
func publish(file_path: String) -> String:
	if _overlay == null:
		return ""
	var abs := ProjectSettings.globalize_path(file_path)
	if not FileAccess.file_exists(abs):
		return ""
	var h: String = _overlay.content_hash_of(abs)
	if h == "":
		return ""
	# serve() returns void — it registers the locator and cannot fail here; a hash
	# that does not resolve simply draws silence when a peer asks for it, which is
	# the protocol's own rule 1 (a holder serves only what it has).
	_overlay.serve(h, abs)
	_serving[h] = abs
	return h


## The content hash of a local file WITHOUT serving it — for an importer that has to
## know the name before it moves the file to where it will be served from.
func content_hash_of_file(file_path: String) -> String:
	if _overlay == null:
		return ""
	var abs := ProjectSettings.globalize_path(file_path)
	return String(_overlay.content_hash_of(abs)) if FileAccess.file_exists(abs) else ""


## Ask the mesh for a hash. Returns the path it will land at, or "" if the
## request could not be started. Already-present content returns immediately via
## content_ready on the next poll rather than going to the wire.
func request(content_hash: String, file_name: String = "") -> String:
	if _overlay == null or content_hash.length() != 64:
		return ""
	var dest := CACHE_DIR.path_join(file_name if file_name != "" else content_hash)
	var abs := ProjectSettings.globalize_path(dest)
	if FileAccess.file_exists(abs):
		# Content-addressed: a file already at this hash IS the content. Re-serve
		# it so this peer becomes a source too, and report it ready.
		_overlay.serve(content_hash, abs)
		_serving[content_hash] = abs
		call_deferred("emit_signal", "content_ready", content_hash, dest)
		return dest
	if _wanted.has(content_hash):
		return _wanted[content_hash]
	# fetch() returns void. A request with no holder attached is NOT an error and
	# must not be treated as one: it stays open and the driver re-sends it to every
	# peer that joins later, which is the normal ordering on a forming mesh. It
	# ends on the idle watchdog if nobody ever answers, via content_failed.
	_overlay.fetch(content_hash, abs)
	_wanted[content_hash] = dest
	return dest


## Stop fetching a hash. ModelCache cancels a download whose holder announces more
## than the model limit — waiting it out would spend the bytes the limit exists to save.
##
## ⚠ THE NATIVE SIDE IS A STUB TODAY: HyperOverlay::cancel (hyperswarm-peer
## src/overlay_node.cpp:293) only posts FAILED "cancel is not implemented yet" and the
## transfer runs on. The bytes still arrive; this just stops us WANTING them. swarmd
## has a real one (swarmd/src/content.h cancel) to port.
func cancel(content_hash: String) -> void:
	if _overlay == null or not _wanted.has(content_hash):
		return
	_overlay.cancel(content_hash)
	_wanted.erase(content_hash)


## True while this hash is in flight.
func is_fetching(content_hash: String) -> bool:
	return _wanted.has(content_hash)


func describe() -> String:
	return String(_overlay.describe()) if _overlay != null else "(no overlay)"


func _on_fetched(content_hash: String, path: String) -> void:
	_wanted.erase(content_hash)
	# A peer that finished a fetch is a holder. Offering it back is what makes the
	# third peer's fetch cost the first peer nothing.
	_overlay.serve(content_hash, path)
	_serving[content_hash] = path
	content_ready.emit(content_hash, ProjectSettings.localize_path(path))


func _on_failed(content_hash: String, reason: String) -> void:
	_wanted.erase(content_hash)
	content_error.emit(content_hash, reason)


func _on_progress(content_hash: String, received: int, total: int) -> void:
	content_progress.emit(content_hash, received, total)