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)