vfs
Virtual filesystem — read, write, list, watch.
A write to authored /source WHILE PLAY IS RUNNING lands on the play
shadow: the bytes are live in the session immediately, and a guarded
play-exit puts the pre-play copy back unless they were kept. Where the
bound world syncs the path, the write is carried to it as it lands, so a
restart or crash before that exit keeps it; where nothing syncs the path,
a restart ends it.
vfs.durability(path) answers where any write's bytes went: durable,
whether a restart keeps them in survivesRestart, the state in warning, the routes out in playShadow, and which of the
paths you asked about the shadow holds in shadowed.
vfs.playShadowPaths() lists everything the session is holding, and
vfs.playShadowAuthors() says whose each one is.
Three routes keep a write, differing in what they cost the session:
vfs.promotePlayShadow(path) -- or a list of paths: THOSE
-- paths onto canonical source,
-- play keeps running, the rest
-- of the set untouched
vfs.write(path, bytes, { durable = true }) -- the write skips the
-- shadow, nothing to promote
tools.use("sceneAuthoring", "changes") -- then acceptChanges: reaches
-- ENTITY changes too, the
-- session keeps running
vfs.revertPlayShadow takes the same path or list and does the opposite.
A folder asset settles whole: a path carrying an asset-type suffix names
the asset, so one .component write shadows its entry file, its README
and its metadata together, and promoting or reverting any one of them
settles all three, naming each in its second return value. A play-exit
refuses over whatever is still shadowed; vfs.playShadowPaths() lists
it.
The core/vfs guide has the model and what each route costs.
require("@builtin::modules.api.engine.vfs") -- vfs (also available as global 'vfs')
The vfs namespace: 132 functions.
globals/vfs/claimWalks
vfs.claimWalks() -> number
Lifetime count of walks over every asset the world offers: the part of
an enumeration's cost that grows with how many assets exist. asset.list
reads a snapshot of every registered claim, built once for each change to
what the world offers and shared by every list after it;
asset.categories reads the category registry and walks nothing. Read it
around a burst to learn what that burst cost in walks, a number that reads
the same on a loaded machine and a quiet one.
Returns number — Count of claim walks this session. Monotonic.
local at = vfs.claimWalks()
vfs.write("/zero/source/probe.module/init.luau", "return {}")
asset.list("module")
print(vfs.claimWalks() - at)
globals/vfs/clearPlayShadow
vfs.clearPlayShadow() -> boolean
Forget the entire play-shadow set after a bulk promote or discard. Tracking only — never touches the bytes.
Returns boolean — Always true.
vfs.clearPlayShadow()
globals/vfs/contentAddress
vfs.contentAddress(path: string, opts: VfsOpts?) -> string?
The content address (sha-256 hex) of the bytes vfs.read(path)
returns, for a path whose bytes the blob store holds, answered
synchronously and without the bytes. Two answers that agree name the
same bytes, and a write that changes them changes it. Nil for a path
whose bytes are held in memory (text under the inline size threshold,
a play shadow, a kept read), which a synchronous vfs.read already
returns, and for a path nothing is stored at. Keep it beside a copy of
a file you read to tell, from a caller that cannot yield, whether the
copy is still what the path holds.
Parameters
pathstring— VFS path.optsVfsOpts(optional) —{ root = "/source/" }.
Returns string? — The sha-256 hex of the blob-held bytes at path, or nil.
local address = vfs.contentAddress("/zero/source/scenes/main.scene/scene.json")
globals/vfs/contentVersion
vfs.contentVersion(path: string) -> number
A number that moves every time the file at path changes this
session: each write, and each move onto it, stamps it with a version no
file has held this session, and while nothing is stored at path (before
it is written, after it is removed or moved away) it reads 0. It moves
the moment any change lands, from every origin: a script's write, a
peer's, a dev-mode disk pickup, a world-load or branch-swap write made
before the world's content is ready, a world's unmount, both ends of a
move, and a directory removal. Keep the value you saw when you last read
the file, and read it again only when the number moved.
Parameters
pathstring— The file's path, in any formvfs.readtakes.
Returns number — The path's content version.
local seen = vfs.contentVersion("/zero/source/.world_settings")
globals/vfs/copy
vfs.copy(src: string, dst: string) -> (boolean, (WriteReport | string)?)
Copy a file OR directory from src to dst, cp -r style. A
directory recurses — every descendant is replicated at the same
relative path under dst, .refs sidecars included. .meta
sidecars are minted fresh, so a copy is a distinct asset with its
own identity. Both paths are absolute. The source may live in any
layer (writable, library mount, builtin, runtime-generated); the
destination must be a writable route.
Parameters
srcstring— Source absolute VFS path (file or directory).dststring— Destination absolute VFS path.
Returns (boolean, (WriteReport | string)?) — True and a WriteReport on success. A copy is a write at its destination, so it reports what vfs.write reports for the same bytes at that path: where they went, and what the static passes read in the Luau among them, per landed leaf when the copy took a folder. A module that is advisory where it sat reads as a script error once it sits on a component path, and the copy is what put it there. (false, errmsg) on failure.
vfs.copy("/zero/runtime/recordings/take1.mp4", "/zero/source/clips/take1.mp4")
local ok, report = vfs.copy(mod, "/zero/source/game/Vent.component")
globals/vfs/currentAuthor
vfs.currentAuthor() -> { id: string, stable: string, connection: string, agent: string?, name: string?, account: string? }?
The caller this call is attributed to: the author a /source write
made right now would be recorded under in the play shadow. id names the
calling session where the transport attested one, so two agent sessions
driving one engine read as two callers even when one linked ZeroMind
account minted both their tokens. stable is the key the caller had
before the world's roster named it, which a rename leaves where it was;
one of several agents sharing a connection, which names itself with the
as argument every tool takes, has a stable of its own. connection
is the key of the connection the caller called over, the one the
caller's staging area and the writer its dirty rows record are kept
under, so the agents of one connection stage and commit one another's
work. agent is the name the world's roster holds for the caller now;
name is its account's username, which every agent of the account
shares; account is that shared account. Nil
when the engine can name no caller at all, which is the case for
engine-authored work and for a caller that presented neither a session
nor a token. Compare its id against vfs.playShadowAuthors() to
separate your own pending edits from a co-author's.
Returns { id: string, stable: string, connection: string, agent: string?, name: string?, account: string? }? — { id, stable, connection, agent, name, account } for the acting caller, or nil when unattributed.
local me = vfs.currentAuthor()
print(if me ~= nil then me.id else "unattributed")
globals/vfs/dirSink
vfs.dirSink(path: string, opts: DirSinkOpts) -> PluginHandle
A sink for new files under the folder at path, subfolders
included, to pass a plugin in a call. The plugin makes whatever files it
needs within opts.maxFiles files and opts.maxBytes bytes in all.
Only the world's own files take a sink.
Parameters
pathstring— The folder to write under.optsDirSinkOpts—{ maxFiles = <n>, maxBytes = <bytes>, extensions = { "png" }? }.
Returns PluginHandle — The folder sink.
local frames = vfs.dirSink("/source/frames", { maxFiles = 240, maxBytes = 200 * 1024 * 1024, extensions = { "png" } })
globals/vfs/discardPlayShadow
vfs.discardPlayShadow() -> { discarded: { string }, owed: { { path: string, reason: string } } }
Throw away every play-shadow edit: the discard a play-exit takes when
the play session's work is not kept. Each path reverts to its pre-play
copy, as vfs.revertPlayShadow does. Every path it throws away is
returned in discarded, posted as a notice and logged by name. A path
whose pre-play copy cannot be put back yet (the world's blob, still
being pulled; the world's manifest row, read once the manifest has taken
the update it is applying; or a copy whose write back is refused) leaves
the play shadow all the same and is OWED: it holds the play bytes until
the copy lands and its write is taken, and the copy is put back then,
unless the path has changed in the meantime. Every owed path is returned
in owed, posted as a notice and logged by name. Calling it again asks
for every owed restore again, pulling a copy whose pull failed.
Returns { discarded: { string }, owed: { { path: string, reason: string } } } — discarded: every path the discard took off the play shadow, sorted, owed ones included. owed: every path it left owed, sorted by path, with why its restore has not landed; empty when every path reverted.
local result = vfs.discardPlayShadow()
for _, path in ipairs(result.discarded) do
print("discarded", path)
end
for _, owed in ipairs(result.owed) do
print(owed.path, owed.reason)
end
globals/vfs/durability
vfs.durability(paths: string | { string }, opts: VfsOpts?) -> Durability
Did that write save to disk, and if not how is it kept? What became of
the bytes just written at paths — one path, or an array of them
answered as ONE write. durable is true when they are where
the call filed them and the next session reads them back, and false when
the play shadow took any of them (live in this session and undone by a
guarded play-exit unless kept) or any of them is in /source/tmp/ scratch
(this session's working space: it reads back while the engine runs and is
gone when it stops or restarts, in edit mode and in play alike).
/source/local/ is durable: the machine keeps it. durable is
present whatever the answer is, so its absence is never a reading. A
non-durable answer carries survivesRestart (true when a restart of this
engine keeps the bytes, because the bound world syncs them as they land;
false when a restart ends them, which it always does for scratch) and
warning (the state and what a restart does with it, for a reader
scanning values rather than checking a field; it opens with PLAY SHADOW
or SCRATCH). When the shadow holds a path the answer also carries
playShadow (the routes to disk and what each costs a session other
people are running in) and shadowed (which of the given paths the shadow
has, or under pending would take); when a path is in scratch it carries
scratch (which of them are in scratch).
This is the answer, off the same shadow set and in the same words, that the
write_file / edit_file / capture tools attach to their own results and
that asset.create reports as its second return value. Ask it here at any
other site that lands files, so every write surface states where the bytes
went in one set of terms.
Parameters
pathsstring | { string }— One VFS path, or an array of paths answered together as one write. A path resolves the wayvfs.writeresolves its own — absolute or@-rooted as it stands, a bare one under/source/— so the answer is about the file that write landed. A value that is not a path — an AssetRef, a record, a number, a string with nothing in it — raises rather than being answered off the empty set it reads as; a list with no entries names nothing and answers durable.optsVfsOpts(optional) —{ root = "/source/", pending = false }.pending = trueasks the same question of a write that has NOT landed yet: where bytes written at these paths would go, read off the same play-lock verdict that will decide it. That is what a surface arranging a write it files later reports from —av.recordopens a take here and the film is written when the take ends — because until those bytes land the shadow is holding nothing under the path and the answer would be durable at the one moment the caller can still act on it. The fields and the words are the same either way; the state names the tense it is true in.
Returns Durability — { durable, survivesRestart?, warning?, playShadow?, shadowed?, scratch? }: survivesRestart and warning present exactly when durable is false, playShadow and shadowed when the play shadow holds a path, scratch when a path is in /source/tmp/, which reads back while the engine runs and is gone when it stops or restarts.
local ok = vfs.write(path, body)
local d = vfs.durability(path)
if not d.durable then log.warn(d.warning) end
local ahead = vfs.durability("/zero/source/clips/take.mp4", { pending = true })
globals/vfs/evict
vfs.evict(path: string, opts: VfsOpts?) -> boolean
Drop the in-memory bytes for path from the writable
MemFs layer without removing the asset. Use after processing
large binaries to reclaim RAM.
Parameters
pathstring— VFS path whose bytes should be evicted.optsVfsOpts(optional) —{ root = "/source/" }.
Returns boolean — True if MemFs bytes were dropped; false otherwise.
vfs.evict("/zero/source/textures/imported_big.png")
globals/vfs/exists
vfs.exists(path: string, opts: VfsOpts?) -> boolean
Is the path known to the VFS? Checks the Stage-1 metadata
(.meta sidecar / ManifestView) — NOT "are the bytes locally
cached?". Use vfs.read(path) ~= nil to confirm bytes are
reachable.
Parameters
pathstring— VFS path to check.optsVfsOpts(optional) —{ root = "/source/" }.
Returns boolean — True if the path is known regardless of byte cache state.
assert(vfs.exists("@builtin/models/Cube"))
globals/vfs/folderHashWalks
vfs.folderHashWalks() -> number
Lifetime count of category-folder checksum walks — the part of a write's cost that grows with how much content already exists. A folder's checksum is the tree hash over the descendants that describe the asset, so deriving one reads every key under it, and a write re-derives it for each enclosing category folder whose bytes that write reaches. Content the checksum is made of therefore moves this once per enclosing folder per write; bytes that reach no checksum — a managed sidecar, a scene's autosave overlay — move it by nothing. Read it around a burst to learn what that burst cost in walks, a number that reads the same on a loaded machine and a quiet one.
Returns number — Count of folder checksum walks this session. Monotonic.
local at = vfs.folderHashWalks()
vfs.write("/zero/source/scenes/probe.scene/scene_dirty/entities/e1.json", "{}")
print(vfs.folderHashWalks() - at)
globals/vfs/holdContentSync
vfs.holdContentSync(held: boolean)
Hold (true) or release (false) the content sync of the world this
engine binds. While it is held, a take of the world's content (the
initial sync a binding runs, or the one a session that lost its
connection runs when it subscribes again) applies no row: the rows the
backend sends wait unapplied, in order, and are applied the moment the
hold is released. The binding stands and writes to authored source are
taken and uploaded meanwhile, so the engine sits between binding a world
and being shown its content for as long as a test needs it there:
world.syncStatus() reads content_synced = false and
content_sync_held = true, and the world's load waits at its content
step. A take that already finished carries on applying live changes; the
hold stops the next take at its first row. A boot started with
--hold-content-sync (holdContentSync=1 in a browser launch) raises the
hold before the world binds, which is how the initial sync of a fresh
engine is held.
Parameters
heldboolean— True to hold the content sync, false to release it.
vfs.holdContentSync(true)
vfs.holdContentSync(false)
globals/vfs/installedFrom
vfs.installedFrom(path: string, opts: VfsOpts?) -> InstalledFrom?
Where the asset holding path comes from, when the library
carries it by reference. The library's source then holds an install
record naming the asset, the world that published it and the commit
its bytes are, and the engine serves the asset's files from those.
path is the asset's own folder or anything inside it. Nil for a
path whose bytes the library or the world holds itself.
Parameters
pathstring— VFS path of the asset or of anything inside it.optsVfsOpts(optional) —{ root = "/source/" }.
Returns InstalledFrom? — The origin, or nil when the path is not inside an asset carried by reference.
local from = vfs.installedFrom(ref.path) if from then print(from.world, from.commit) end
globals/vfs/isDirectory
vfs.isDirectory(path: string, opts: VfsOpts?) -> boolean
Is ONE path a directory? Answers from reality — the writable
layer's children, a resolver-served folder listing, an explicit
empty-directory marker — so a loose file whose extension collides
with an assetType name (notes.json) reads as the file it is while
a real <name>.<type>/ folder reads as a folder. Costs the same
whatever the containing folder holds; use vfs.list when you want
every entry's kind, this when you hold one path.
Parameters
pathstring— VFS path to classify.optsVfsOpts(optional) —{ root = "/source/" }.
Returns boolean — True if the path is a directory.
if vfs.isDirectory("/zero/source/Goblin.dynamicAsset") then print("folder asset") end
globals/vfs/isSaveExcluded
vfs.isSaveExcluded(path: string, opts: VfsOpts?) -> boolean
Does this path hold content the machine keeps to itself?
/source/tmp/ is session scratch and /source/local/ is this
machine's own durable content — each directory itself included,
and everything under it. Both are writable, hot-reloadable and
enumerable like the rest of /source/; what separates them is
where they stop. The engine filters them out of every world save
and every peer broadcast, so they reach no world, carry no
manifest row there, and a staging verb handed one refuses it by
name. The match reads a whole path segment, so
/source/tmpfoo/ is ordinary content. Ask here whenever your
code has to agree with what a world can hold.
Parameters
pathstring— VFS path to classify.optsVfsOpts(optional) —{ root = "/source/" }.
Returns boolean — True when the path is held back from world saves and sync.
if not vfs.isSaveExcluded(p) then table.insert(publishable, p) end
globals/vfs/list
vfs.list(path: string?) -> { VfsListEntry }
List entries in a VFS directory. A directory whose current
listing the next frame assembles (/zero/runtime/sync/entities after a
stretch nothing read the replication surface) is waited for where the
calling code can yield (coroutine.isyieldable()), as vfs.read waits;
where it cannot, the listing raises VFS_READ_CANNOT_WAIT, naming the
path.
Parameters
pathstring(optional) — Directory path (defaults to/zero).
Returns { VfsListEntry } — Array of { name, isDirectory, path } tables; vfs.stat(entry.path) gives an entry's size and modification time.
for _, e in ipairs(vfs.list("/zero/source")) do print(e.name) end
globals/vfs/memResident
vfs.memResident() -> { { path: string, bytes: number, kind: string } }
List the MemFs entries that are NOT resident-by-default — the writable
in-memory layer's binary blobs and its large text files (text at or above
the inline-text size threshold). These are the bytes vfs.evict can
reclaim: the ones kept in RAM rather than left to fall through to the
on-disk BlobStore cache. Small text (resident by default) is omitted. The
audit counterpart to vfs.evict and to reading with { keep = true } —
use it to see what encoded bytes are held in RAM, and why.
Returns { { path: string, bytes: number, kind: string } } — Array of { path, bytes, kind }; kind is "binary" for non-text content and "large-text" for oversized text. Empty when the VFS isn't up.
for _, e in ipairs(vfs.memResident()) do print(e.path, e.bytes, e.kind) end
globals/vfs/mkdir
vfs.mkdir(path: string, opts: VfsOpts?) -> boolean
Create a directory. mkdir -p semantics — idempotent.
Errors if a file already exists at the same path. While play is
running an authored /source directory waits for the lock to
lift and the refusal RAISES with the reason; writing a file under
the path creates it as part of that write, and scratch under
/source/tmp/ creates as in edit mode.
Parameters
pathstring— Directory path.optsVfsOpts(optional) —{ root = "/source/" }.
Returns boolean — True if the directory exists after the call. A creation the running play session refuses raises with the whole reason.
vfs.mkdir("/zero/source/scenes/")
globals/vfs/move
vfs.move(src: string, dst: string, opts: { quiet: boolean? }?) -> (boolean, (WriteReport | string)?)
Move a file from src to dst. By default fires the destination's
write side effects; pass opts.quiet = true to suppress them. A quiet
move still returns the destination's report and still posts the resolve
rule the publish gate reads, which is what vfs.write does with the same
option: a publish takes no override, so the finding is stated at the line
that carries it.
While play is running a move takes the source away, so authored
/source content that predates play is refused and the refusal
RAISES with the reason; content this play session created moves and
stays tracked on the play shadow.
Parameters
srcstring— Source absolute VFS path.dststring— Destination absolute VFS path.opts{ quiet: boolean? }(optional) — Optional{ quiet: boolean? }.
Returns (boolean, (WriteReport | string)?) — True and a WriteReport on success. A move IS a write at its destination, so it reports what vfs.write reports for the same bytes at that path: where they went, and what the static passes read in the Luau among them, per landed leaf when the move took a folder. (false, errmsg) when the paths themselves refuse it. A move the running play session refuses raises with the whole reason instead.
vfs.move("/zero/source/a.luau", "/zero/source/b.luau")
local ok, report = vfs.move(mod .. "/init.luau", comp .. "/init.luau")
globals/vfs/movedReferenceNames
vfs.movedReferenceNames(path: string?, opts: VfsOpts?) -> { MovedReferenceSource }
The world sources that spell a reference by a name its target no
longer has. A source records, beside each name it uses, the guid of the
asset the name reached; when that asset later sits at another path (it
was moved, or the engine's library files it elsewhere), the reference
keeps resolving through the guid while the text keeps the old name. This
lists those sources and changes nothing. Writing an entry's currentText
back with vfs.write(source, currentText) brings the text up to date
without changing what any reference resolves to.
Only sources the world authors are considered: the engine library and
/source/deps/ keep the text they were published with.
Parameters
pathstring(optional) — One source to ask about; every source the world authors when absent.optsVfsOpts(optional) —{ root = "/source/" }.
Returns { MovedReferenceSource } — One entry per source holding moved names, in path order; empty when none does.
for _, s in ipairs(vfs.movedReferenceNames()) do print(s.source, #s.names) end
globals/vfs/mutationSeq
vfs.mutationSeq() -> number
Lifetime count of VFS mutations the engine has APPLIED — the drain's
clock. A write queues its side effects (an asset's content reload, the
assetType's onChange, a component or scene registration) and a later
frame runs them; this number advances as each one completes. Read it,
write, then poll for a larger value to learn the queue has moved past the
point you wrote at — instead of waiting a guessed number of frames. It
counts every mutation kind, so it answers about the pipeline rather than
about one file; asset.reloadSeq(ref) is the per-asset reading.
Returns number — Count of applied VFS mutations this session. Monotonic.
local at = vfs.mutationSeq()
vfs.write("/zero/source/tmp/note.txt", "hi")
repeat task.wait() until vfs.mutationSeq() > at
globals/vfs/open
vfs.open(path: string, opts: VfsOpts?) -> PluginHandle
A reader of the file at path, to pass a plugin in a call. The
plugin reads it by range and reaches nothing else through it; it is
refused, raising the reason, where this script could not read. A
reader whose file leaves its path answers the plugin that the asset
is gone.
Parameters
pathstring— The file to read.optsVfsOpts(optional) —{ root = "/source/" }.
Returns PluginHandle — The reader.
local weights = vfs.open(asset.containing(__FILE__).path .. "/weights.gguf")
globals/vfs/openDir
vfs.openDir(path: string, opts: VfsOpts?) -> PluginHandle
A reader of the folder at path and everything under it, to pass
a plugin in a call. The plugin lists it and opens readers of its files,
by paths relative to the folder that cannot leave it.
Parameters
pathstring— The folder to read.optsVfsOpts(optional) —{ root = "/source/" }.
Returns PluginHandle — The folder reader.
local model = vfs.openDir(asset.containing(__FILE__).path .. "/model")
globals/vfs/pendingWrites
vfs.pendingWrites() -> { string }
List the /source paths with a local write or removal the synced
manifest has not reflected yet: everything this client still owes the
server. A just-written or just-removed file appears here until its
upload round-trips and the synced dirty state catches up;
world.vcsStatus unions these so a fresh edit reads back as dirty
immediately, and world.syncStatus().owed_paths reports the same list.
Empty when fully synced.
Returns { string } — Array of VFS paths with a pending (unconfirmed) local write or removal.
for _, p in ipairs(vfs.pendingWrites()) do print(p) end
globals/vfs/playDiscardsOwed
vfs.playDiscardsOwed() -> { { path: string, reason: string } }
The play edits a discard threw away whose pre-play copy has not been put back yet. Each of these paths holds the play session's bytes until its restore lands, which happens on its own once the copy arrives and its write is taken. A path that changes first keeps its new content and leaves this list.
Returns { { path: string, reason: string } } — Every owed path, sorted by path, with why its restore has not landed.
for _, owed in ipairs(vfs.playDiscardsOwed()) do
print(owed.path, owed.reason)
end
globals/vfs/playHoldsInPlace
vfs.playHoldsInPlace(path: string, opts: VfsOpts?) -> boolean
Whether the running play session holds path where it stands:
vfs.move of it, and vfs.remove of it without durable = true, are
refused while play runs, because what they would take away is content
that predates play (or that a durable write made canonical). Content
only the play shadow holds (this play session wrote it) answers false and
moves freely, and so does every path once play ends or pauses, a path in
/source/tmp/ or /source/local/, and a path outside authored source.
Ask it before the first step of a change that ends by taking path
away, so a change the lock would refuse at its last step is not started:
the importers ask it before relocating a source into the asset they
produce, and owe the import until play ends.
Parameters
pathstring— The VFS path: absolute,@-rooted, or bare under/source/.optsVfsOpts(optional) —{ root = "/source/" }.
Returns boolean — True while a move or ambient removal of path would be refused.
if vfs.playHoldsInPlace(src) then log.warn(src .. " moves once play ends") end
globals/vfs/playShadowAuthors
vfs.playShadowAuthors() -> { [string]: { id: string, stable: string, connection: string, agent: string?, name: string?, account: string? } }
The caller behind each currently-shadowed /source path, in the
shape vfs.currentAuthor() answers in: the id the write was attributed
to, the name the world's roster holds for that caller now (agent, the
one to show), its account's username, and the ZeroMind account it
authenticated as. Several agents drive one engine at once and
every one of their in-play source edits sits in the same shadow set, so
this is how a review, a refusal or a verdict tells one agent's pending
work from another's. id names the calling session where the call
carried one, so two agent sessions on a machine linked to one ZeroMind
account are two authors. A path written by a caller the engine could not
name carries no entry, because it belongs to nobody in particular, and
stays settleable by anyone.
Returns { [string]: { id: string, stable: string, connection: string, agent: string?, name: string?, account: string? } } — Map of shadowed path to { id, stable, connection, agent, name, account }.
local mine = vfs.currentAuthor()
for path, who in pairs(vfs.playShadowAuthors()) do
if mine == nil or who.stable ~= mine.stable then print(path, "belongs to", who.agent or who.name) end
end
globals/vfs/playShadowPaths
vfs.playShadowPaths() -> { string }
Every source write made during play that is not durable yet — the set
to keep or discard before leaving play. Lists the /source paths edited
during running play that the play shadow holds, each beside the
pre-play copy a guarded discard puts back: the universal play
shadow-copy set. These are the in-play edits persist
promotes over the originals on confirm, or drops on a guarded discard.
Empty outside play or when nothing was edited.
Returns { string } — Array of normalized VFS paths currently shadowed.
for _, p in ipairs(vfs.playShadowPaths()) do print(p) end
globals/vfs/promotePlayShadow
vfs.promotePlayShadow(path: string | { string }) -> ((string | { string })?, { string }, { string })
KEEP a source write made while play was running — the call that saves a play-mode edit onto disk and makes it durable. Promotes play-shadow edits into canonical writes: re-asserts the live overlay bytes through the full write pipeline, then unmarks each path. The bytes stay in the engine end to end, so binary content promotes exactly. It answers while play is RUNNING and leaves the mode, the clock and every shadow entry it did not name exactly where they stood. Takes ONE path, or an ARRAY of them — a single folder-asset write shadows the entry file, the README and the metadata together, so a slice is tens of paths, and naming them keeps the call to the caller's own work on an engine other sessions are running in. Every named path is attempted; one that refuses does not stop the ones after it. A path with nothing on the play shadow at it or beneath it settles nothing and raises nothing, since no play edit stands there: the third return value names it. A promotion that cannot happen raises with the reason: the path is a plain folder covering shadowed edits (the refusal names the first five assets and loose files under it by the paths that settle them, and counts the rest), the workspace is read-only, or the write-through failed. A shadow entry whose bytes are gone is dropped as promoted, so the path comes back with nothing written for it.
A folder asset is kept whole: a file of one promotes with every other
shadowed file of the same asset, so the files its type requires reach
source with whichever of them was named, and naming the folder asset's
own path (Gold.material, controls.inputMap) promotes every shadowed
file of it, the assets nested in it included. An asset nested inside another
settles with its own files, and takes the own files of every enclosing
asset the play session made with it, since it stands on source only
inside them. A path an earlier call settled, by name or carried this
way, answers as settled when it is named again, so a walk over a
vfs.playShadowPaths() snapshot one path at a time settles every path
in it.
Parameters
pathstring | { string }— A shadowed VFS path to promote, or an array of them (from vfs.playShadowPaths()).
Returns ((string | { string })?, { string }, { string }) — The path the call settled (nil when nothing was on the play shadow there), or the array of settled paths when an array went in; then the array of every path the call settled, a folder asset's carried files included; then the array of named paths that held nothing on the play shadow. None of the named paths are shadowed any more.
local promoted = vfs.promotePlayShadow("/zero/source/cover.jpg")
local _, settled = vfs.promotePlayShadow("/zero/source/Hero.module/init.luau")
local _, kept = vfs.promotePlayShadow("/zero/source/game/Gold.material")
local named, _, unshadowed = vfs.promotePlayShadow("/zero/source/maybe_written.md")
local mine = {}
for _, p in ipairs(vfs.playShadowPaths()) do
if string.find(p, "/zero/source/mine/", 1, true) == 1 then table.insert(mine, p) end
end
local settled = vfs.promotePlayShadow(mine)
globals/vfs/read
vfs.read(path: string, opts: VfsOpts?) -> (string?, string?)
Read a file from the virtual filesystem. Binary-safe.
Returns file contents as a string. A read that finds no bytes
returns nil and the reason: one sentence naming the path and why,
the text the read_file tool and the VFS HTTP route give for the
same path (/zero/source/a.txt: not found, a directory's refusal,
a /runtime node's account of what it holds). Handed straight to
another call, the read passes that reason on as the next argument
too; wrap it in parentheses, Json.decode((vfs.read(path))), to
pass the bytes alone. Relative paths resolve under opts.root
(default /source/). When the bytes aren't locally cached and the
calling code can yield (coroutine.isyieldable(): a task,
awake, start, update, onEnable, onOwnerChanged,
onSyncReceived, a replicated event's
handler), the read waits while the lazy fetch runs and answers
the bytes. Code that cannot yield (onDisable, onDestroy,
onPropertyChanged, a received synced-function call, a vfs.watch callback, a
module's top level while require runs it, a metamethod, a
table.sort comparator) cannot wait: a read there of bytes still on their
way (a file vfs.exists knows, a render surface being captured, a
/runtime/sync node whose current reading the next frame assembles)
raises VFS_READ_CANNOT_WAIT, naming the path and where to read it.
Parameters
pathstring— VFS path.optsVfsOpts(optional) —{ root = "/source/" }.
Returns (string?, string?) — File contents (binary-safe); nil and the reason (the path and why it holds no bytes to read) for a read that finds none.
local src, why = vfs.read("/zero/source/notes.txt"); if src == nil then print(why) end
globals/vfs/readAsync
vfs.readAsync(path: string, opts: VfsOpts?) -> string
Asynchronous binary-safe read. Returns a promise ID that resolves to the file contents. Useful for reading render textures from the main thread without blocking.
Parameters
pathstring— VFS path.optsVfsOpts(optional) —{ root = "/source/" }.
Returns string — Promise ID — pass to task.await().
local data = task.await(vfs.readAsync("/zero/runtime/screenshots/last.png"))
globals/vfs/readBounded
vfs.readBounded(path: string, maxBytes: number) -> (string?, string?)
Read a file from the virtual filesystem, at most maxBytes
long. Binary-safe, and read the way vfs.read reads it. A file
longer than maxBytes answers nil, "too_large"; where the layer
holding it states its length (a file held in memory, a synced or
staged file) its bytes are never read. Relative paths resolve under
/source/. Where the calling code can yield, a file whose bytes
are not cached is fetched and held to the same bound; where it
cannot, a known file whose bytes have not arrived raises
VFS_READ_CANNOT_WAIT, as vfs.read does. A read that finds no
bytes answers nil and the reason vfs.read gives.
Parameters
pathstring— VFS path.maxBytesnumber— The most bytes the read answers, a whole number 0 or more.
Returns (string?, string?) — File contents (binary-safe); nil and "too_large" for a file longer than maxBytes; nil and the reason vfs.read gives for a read that finds no bytes.
local raw, why = vfs.readBounded("/zero/source/notes.json", 65536)
globals/vfs/reload
vfs.reload(modulePath: string?) -> boolean
Clear entries from the require() cache so the next
require(name) re-runs the module's source. Pass a single
module identity to drop only that entry; call with no
arguments to drop every cached module of the world's own. A
module of the built-in library runs once for the process — its
body registers into registries that outlive it — and stands
through a reload with no argument.
The next require runs the source the module's file holds
when the reload is called, whichever write brought those bytes
there, a quiet one included.
Parameters
modulePathstring(optional) — Module identity to reload (omit to reload every module of the world's own).
Returns boolean — For a single identity, whether a module was cached under that name and has now been dropped — false says the name matched nothing. The no-arg form returns true.
vfs.reload("@mylib/utils.helpers")
globals/vfs/remove
vfs.remove(path: string, opts: VfsOpts?) -> (boolean, string?)
Remove a file. Refuses to remove directories unless
opts.recursive = true. Refuses protected system roots. While
play is running, authored /source content that predates play is
refused and the refusal RAISES with the reason; content this play
session created is removable, a folder included. Pass
opts.durable = true to say the removal IS a change to the source:
it takes the route edit mode takes with play still running and the
session still in play, the counterpart of
vfs.write(path, bytes, { durable = true }) for taking a path away
rather than replacing its bytes. Every other refusal stands in
either terms — a protected root, a read-only route, a plain
directory without recursive.
Parameters
pathstring— VFS path to remove.optsVfsOpts(optional) —{ root = "/source/", recursive = false, durable = false }.
Returns (boolean, string?) — True on success; (false, errmsg) when the path itself refuses the removal. A removal the running play session refuses raises with the whole reason instead, so a pcall around the call reads it — and the lock answers ahead of whether the path is there, so a locked /source path raises whether or not it holds anything.
vfs.remove("/zero/source/scratch.luau")
globals/vfs/revertPlayShadow
vfs.revertPlayShadow(path: string | { string }) -> ((string | { string })?, { string }, { string })
DISCARD a source write made while play was running, keeping nothing — the opposite of promoting it. Reverts play-shadow edits: restores the pre-play copy captured at the first play-mode write (the last edit-mode state, unstaged edits included) into the live slot — or remove the file when it did not exist at that moment — then unmark the path. Hot-reload picks the original back up, so the running session actually reverts. It answers while play is RUNNING and takes ONE path or an ARRAY of them, on the same terms as vfs.promotePlayShadow. A revert that cannot happen raises with the reason. A file of a folder asset reverts with every other shadowed file of the same asset, as a promotion carries them, and a file of an asset the play session made reverts with every shadowed file beneath it, the assets nested in it included. Naming the folder asset's own path reverts every shadowed file of it. A path with nothing on the play shadow settles nothing and raises nothing, as for vfs.promotePlayShadow. A path an earlier revert settled answers as settled when it is named again; one an earlier promotion settled raises, naming that promotion.
Parameters
pathstring | { string }— A shadowed VFS path to revert, or an array of them (from vfs.playShadowPaths()).
Returns ((string | { string })?, { string }, { string }) — The path that is now reverted (nil when nothing was on the play shadow there), or the array of reverted paths when an array went in; then the array of every path the call reverted, a folder asset's carried files included; then the array of named paths that held nothing on the play shadow. None of the named paths are shadowed any more.
local reverted = vfs.revertPlayShadow("/zero/source/Foo.component/init.luau")
local dropped = vfs.revertPlayShadow({ "/zero/source/a.md", "/zero/source/b.md" })
globals/vfs/sessionLost
vfs.sessionLost() -> boolean
Whether the live world session this engine established is lost and
reconnecting. While it is, every write this engine makes to authored
source (/source outside /source/tmp/) is refused, because the backend
of a dropped session can never receive it; the refusal names the
reconnect. It answers false again from the frame the session
re-establishes, and is false on an engine whose session never established
(an offline boot, or a world still binding) unless vfs.simulateSessionLoss
put it in that state. Ask it before, or after a
failure of, work that writes source in steps: the importers do, and owe
such an import until the session is back.
Returns boolean — True while writes to authored source are refused because the live session is lost.
if vfs.sessionLost() then log.warn("writes wait for the live session") end
globals/vfs/simulateSessionLoss
vfs.simulateSessionLoss(lost: boolean)
Put the VFS in the state a lost live session puts it in (true), or
take it out of it (false), without touching the connection:
vfs.sessionLost() answers it, and every write to authored source is
refused exactly as it is while a real session is reconnecting. Once a
live session has established, the engine's connection observer states
the real session every frame, so the simulation holds on an engine whose
live session never established (an offline boot), which is where a test
drives the paths that answer a lost session.
Parameters
lostboolean— True to refuse writes to authored source as a lost session does, false to accept them again.
vfs.simulateSessionLoss(true)
vfs.simulateSessionLoss(false)
globals/vfs/sink
vfs.sink(path: string, opts: SinkOpts) -> PluginHandle
A sink for the file at path, to pass a plugin in a call. The
plugin writes it from empty, up to opts.maxBytes, and the file lands
when the plugin closes the sink or drops it. Only the world's own files
take a sink.
Parameters
pathstring— The file to write.optsSinkOpts—{ maxBytes = <bytes> }, required.
Returns PluginHandle — The sink.
local out = vfs.sink("/source/imported/sphere.zvol", { maxBytes = 64 * 1024 * 1024 })
globals/vfs/stat
vfs.stat(path: string, opts: VfsOpts?) -> (VfsStat?, string?)
What is at path, how big it is and when it last changed, without
reading it. size is how many bytes vfs.read(path) returns: the
length the layer holding the file states (a file held in memory, a
synced or staged file), so a large file costs nothing to measure, and
where no layer states one (a runtime node, a built-in library file)
the length of the bytes this engine holds. It is absent for a directory
and for a file whose length nothing states and whose bytes are not on
this engine. modified is when this engine's copy last changed, in
seconds since the Unix epoch (the scale os.time() reads): the write
or arrival that put the current content there, which a move carries
along with the content. The memory layer
records it for every file whose bytes it holds: text up to the inline
size threshold, and everything on an engine with no world bound. It is
absent where no time is recorded: a directory nothing was written into
as such, a runtime node, and a file whose bytes the blob store holds
(binary content, and text past the inline size threshold, on a
world-bound engine). For a file, the shell's stat and ls -l print
the same size and time. A path nothing is at answers nil and the reason, the
sentence a vfs.read of it gives.
To tell whether a file changed without reading it: vfs.contentAddress(path)
names the bytes the blob store holds, and vfs.contentVersion(path)
moves on every change this session to a file the memory layer holds.
Parameters
pathstring— VFS path.optsVfsOpts(optional) —{ root = "/source/" }.
Returns (VfsStat?, string?) — The entry's description, or nil and the reason nothing is at the path.
local s = vfs.stat("/zero/source/scenes/main.scene/scene.json"); if s then print(s.size, s.modified) end
globals/vfs/unmarkPlayShadow
vfs.unmarkPlayShadow(path: string) -> boolean
Forget a single play-shadow path after it has been promoted (saved over source) or discarded. Tracking only — never touches the bytes.
Parameters
pathstring— VFS path to unmark.
Returns boolean — Always true.
vfs.unmarkPlayShadow("/zero/source/Foo.component/init.luau")
globals/vfs/unwatch
vfs.unwatch(watcherId: number) -> boolean
Remove a previously registered VFS watcher.
Parameters
watcherIdnumber— Watcher id returned byvfs.watch.
Returns boolean — True if the watcher was found and removed.
vfs.unwatch(id)
globals/vfs/watch
vfs.watch(path: string, callback: (string, string, string) -> ()) -> number
Register a callback that fires when a VFS path is written or
removed. Two match modes: exact, or folder/prefix (key ends with
/, and fires for any descendant). The callback runs in the VM
that registered it. Returns a watcher id for vfs.unwatch.
Parameters
pathstring— Exact path, or folder path ending in/.callback(string, string, string) -> ()—(mutated_path, kind, origin) -> (), kind"write"or"remove"; origin"local"for a change this engine made (throughvfs.*, the shell or any other surface) and"remote"for one that arrived from another participant in the world.
Returns number — Watcher id.
local id = vfs.watch("/zero/source/", function(path, kind, origin) print(kind, origin, path) end)
globals/vfs/write
vfs.write(path: string, content: string, opts: VfsOpts?) -> (boolean, (WriteReport | string)?)
Write content to a file. Binary-safe. Overwrites existing
files by default — pass opts.overwrite = false to refuse to
clobber. While play is running a /source write lands on the play
shadow: it succeeds and reads back, live in the session, and is
undone by a guarded play-exit unless accepted; the report's
survivesRestart says whether a restart keeps it. Scratch under /source/tmp/ writes through untouched,
in edit mode and in play, and is gone when the engine stops or restarts, so
its report says durable = false, survivesRestart = false and a warning
opening SCRATCH.
Pass opts.durable = true to say these bytes ARE the source: the
write reaches canonical /source with play still running and the
session still in play, hot-reloading the modules and components that
read it, so the edit is observed running in the same play session
with nothing left to promote. A durable write RAISES with the reason
when the bytes cannot become canonical source. One write carries at
most 256 MiB: longer content RAISES, naming its size and that limit,
and the path keeps what it held.
Parameters
pathstring— VFS path to write to.contentstring— File content (binary-safe).optsVfsOpts(optional) —{ root = "/source/", overwrite = true, quiet = false, durable = false }.
Returns (boolean, (WriteReport | string)?) — True and a WriteReport on success: durable says where the bytes went, with survivesRestart / warning / playShadow / shadowed beside it when the play shadow took them and survivesRestart / warning / scratch when they landed in /source/tmp/ scratch, diagnostics carries what the static passes read in the Luau it landed, and contentRequirements names what the asset it landed in must still say before the world publishes. It is the answer the MCP write_file tool gives for the same bytes at the same path. On failure returns false + error message; a durable write, and content past 256 MiB, raise instead of returning false.
vfs.write("/zero/source/notes.md", body)
local ok, report = vfs.write("/zero/source/game/Vent.component/init.luau", src)
if report.diagnostics then for _, d in report.diagnostics do print(d.severity, d.path, d.line, d.message) end end
if report.contentRequirements then for _, r in report.contentRequirements do print(r.path, r.detail) end end
if not report.durable then log.warn(report.warning) end
vfs.write("/zero/source/game/Vent.component/init.luau", src, { durable = true })
modules/vfs/claimWalks
claimWalks(): number
Lifetime count of walks over every asset the world offers: the part of
an enumeration's cost that grows with how many assets exist. asset.list
reads a snapshot of every registered claim, built once for each change to
what the world offers and shared by every list after it;
asset.categories reads the category registry and walks nothing. Read it
around a burst to learn what that burst cost in walks, a number that reads
the same on a loaded machine and a quiet one.
local at = vfs.claimWalks()
vfs.write("/zero/source/probe.module/init.luau", "return {}")
asset.list("module")
print(vfs.claimWalks() - at)
modules/vfs/clearPlayShadow
clearPlayShadow(): boolean
Forget the entire play-shadow set after a bulk promote or discard. Tracking only — never touches the bytes.
vfs.clearPlayShadow()
modules/vfs/contentAddress
contentAddress(path: string, opts: VfsOpts?): string?
The content address (sha-256 hex) of the bytes vfs.read(path)
returns, for a path whose bytes the blob store holds, answered
synchronously and without the bytes. Two answers that agree name the
same bytes, and a write that changes them changes it. Nil for a path
whose bytes are held in memory (text under the inline size threshold,
a play shadow, a kept read), which a synchronous vfs.read already
returns, and for a path nothing is stored at. Keep it beside a copy of
a file you read to tell, from a caller that cannot yield, whether the
copy is still what the path holds.
Parameters
pathstring— VFS path.optsVfsOpts?(optional) —{ root = "/source/" }.
local address = vfs.contentAddress("/zero/source/scenes/main.scene/scene.json")
modules/vfs/contentVersion
contentVersion(path: string): number
A number that moves every time the file at path changes this
session: each write, and each move onto it, stamps it with a version no
file has held this session, and while nothing is stored at path (before
it is written, after it is removed or moved away) it reads 0. It moves
the moment any change lands, from every origin: a script's write, a
peer's, a dev-mode disk pickup, a world-load or branch-swap write made
before the world's content is ready, a world's unmount, both ends of a
move, and a directory removal. Keep the value you saw when you last read
the file, and read it again only when the number moved.
Parameters
pathstring— The file's path, in any formvfs.readtakes.
local seen = vfs.contentVersion("/zero/source/.world_settings")
modules/vfs/copy
copy(src: string, dst: string): (boolean, (WriteReport | string)?)
Copy a file OR directory from src to dst, cp -r style. A
directory recurses — every descendant is replicated at the same
relative path under dst, .refs sidecars included. .meta
sidecars are minted fresh, so a copy is a distinct asset with its
own identity. Both paths are absolute. The source may live in any
layer (writable, library mount, builtin, runtime-generated); the
destination must be a writable route.
Parameters
srcstring— Source absolute VFS path (file or directory).dststring— Destination absolute VFS path.
vfs.copy("/zero/runtime/recordings/take1.mp4", "/zero/source/clips/take1.mp4")
local ok, report = vfs.copy(mod, "/zero/source/game/Vent.component")
modules/vfs/currentAuthor
currentAuthor(): { id: string, stable: string, connection: string, agent: string?, name: string?, account: string? }?
The caller this call is attributed to: the author a /source write
made right now would be recorded under in the play shadow. id names the
calling session where the transport attested one, so two agent sessions
driving one engine read as two callers even when one linked ZeroMind
account minted both their tokens. stable is the key the caller had
before the world's roster named it, which a rename leaves where it was;
one of several agents sharing a connection, which names itself with the
as argument every tool takes, has a stable of its own. connection
is the key of the connection the caller called over, the one the
caller's staging area and the writer its dirty rows record are kept
under, so the agents of one connection stage and commit one another's
work. agent is the name the world's roster holds for the caller now;
name is its account's username, which every agent of the account
shares; account is that shared account. Nil
when the engine can name no caller at all, which is the case for
engine-authored work and for a caller that presented neither a session
nor a token. Compare its id against vfs.playShadowAuthors() to
separate your own pending edits from a co-author's.
local me = vfs.currentAuthor()
print(if me ~= nil then me.id else "unattributed")
modules/vfs/dirSink
dirSink(path: string, opts: DirSinkOpts): PluginHandle
A sink for new files under the folder at path, subfolders
included, to pass a plugin in a call. The plugin makes whatever files it
needs within opts.maxFiles files and opts.maxBytes bytes in all.
Only the world's own files take a sink.
Parameters
pathstring— The folder to write under.optsDirSinkOpts—{ maxFiles = <n>, maxBytes = <bytes>, extensions = { "png" }? }.
local frames = vfs.dirSink("/source/frames", { maxFiles = 240, maxBytes = 200 * 1024 * 1024, extensions = { "png" } })
modules/vfs/discardPlayShadow
discardPlayShadow(): { discarded: { string }, owed: { { path: string, reason: string } } }
Throw away every play-shadow edit: the discard a play-exit takes when
the play session's work is not kept. Each path reverts to its pre-play
copy, as vfs.revertPlayShadow does. Every path it throws away is
returned in discarded, posted as a notice and logged by name. A path
whose pre-play copy cannot be put back yet (the world's blob, still
being pulled; the world's manifest row, read once the manifest has taken
the update it is applying; or a copy whose write back is refused) leaves
the play shadow all the same and is OWED: it holds the play bytes until
the copy lands and its write is taken, and the copy is put back then,
unless the path has changed in the meantime. Every owed path is returned
in owed, posted as a notice and logged by name. Calling it again asks
for every owed restore again, pulling a copy whose pull failed.
local result = vfs.discardPlayShadow()
for _, path in ipairs(result.discarded) do
print("discarded", path)
end
for _, owed in ipairs(result.owed) do
print(owed.path, owed.reason)
end
modules/vfs/durability
durability(paths: string | { string }, opts: VfsOpts?): Durability
Did that write save to disk, and if not how is it kept? What became of
the bytes just written at paths — one path, or an array of them
answered as ONE write. durable is true when they are where
the call filed them and the next session reads them back, and false when
the play shadow took any of them (live in this session and undone by a
guarded play-exit unless kept) or any of them is in /source/tmp/ scratch
(this session's working space: it reads back while the engine runs and is
gone when it stops or restarts, in edit mode and in play alike).
/source/local/ is durable: the machine keeps it. durable is
present whatever the answer is, so its absence is never a reading. A
non-durable answer carries survivesRestart (true when a restart of this
engine keeps the bytes, because the bound world syncs them as they land;
false when a restart ends them, which it always does for scratch) and
warning (the state and what a restart does with it, for a reader
scanning values rather than checking a field; it opens with PLAY SHADOW
or SCRATCH). When the shadow holds a path the answer also carries
playShadow (the routes to disk and what each costs a session other
people are running in) and shadowed (which of the given paths the shadow
has, or under pending would take); when a path is in scratch it carries
scratch (which of them are in scratch).
This is the answer, off the same shadow set and in the same words, that the
write_file / edit_file / capture tools attach to their own results and
that asset.create reports as its second return value. Ask it here at any
other site that lands files, so every write surface states where the bytes
went in one set of terms.
Parameters
pathsstring | { string }— One VFS path, or an array of paths answered together as one write. A path resolves the wayvfs.writeresolves its own — absolute or@-rooted as it stands, a bare one under/source/— so the answer is about the file that write landed. A value that is not a path — an AssetRef, a record, a number, a string with nothing in it — raises rather than being answered off the empty set it reads as; a list with no entries names nothing and answers durable.optsVfsOpts?(optional) —{ root = "/source/", pending = false }.pending = trueasks the same question of a write that has NOT landed yet: where bytes written at these paths would go, read off the same play-lock verdict that will decide it. That is what a surface arranging a write it files later reports from —av.recordopens a take here and the film is written when the take ends — because until those bytes land the shadow is holding nothing under the path and the answer would be durable at the one moment the caller can still act on it. The fields and the words are the same either way; the state names the tense it is true in.
local ok = vfs.write(path, body)
local d = vfs.durability(path)
if not d.durable then log.warn(d.warning) end
local ahead = vfs.durability("/zero/source/clips/take.mp4", { pending = true })
modules/vfs/evict
evict(path: string, opts: VfsOpts?): boolean
Drop the in-memory bytes for path from the writable
MemFs layer without removing the asset. Use after processing
large binaries to reclaim RAM.
Parameters
pathstring— VFS path whose bytes should be evicted.optsVfsOpts?(optional) —{ root = "/source/" }.
vfs.evict("/zero/source/textures/imported_big.png")
modules/vfs/exists
exists(path: string, opts: VfsOpts?): boolean
Is the path known to the VFS? Checks the Stage-1 metadata
(.meta sidecar / ManifestView) — NOT "are the bytes locally
cached?". Use vfs.read(path) ~= nil to confirm bytes are
reachable.
Parameters
pathstring— VFS path to check.optsVfsOpts?(optional) —{ root = "/source/" }.
assert(vfs.exists("@builtin/models/Cube"))
modules/vfs/folderHashWalks
folderHashWalks(): number
Lifetime count of category-folder checksum walks — the part of a write's cost that grows with how much content already exists. A folder's checksum is the tree hash over the descendants that describe the asset, so deriving one reads every key under it, and a write re-derives it for each enclosing category folder whose bytes that write reaches. Content the checksum is made of therefore moves this once per enclosing folder per write; bytes that reach no checksum — a managed sidecar, a scene's autosave overlay — move it by nothing. Read it around a burst to learn what that burst cost in walks, a number that reads the same on a loaded machine and a quiet one.
local at = vfs.folderHashWalks()
vfs.write("/zero/source/scenes/probe.scene/scene_dirty/entities/e1.json", "{}")
print(vfs.folderHashWalks() - at)
modules/vfs/holdContentSync
holdContentSync(held: boolean)
Hold (true) or release (false) the content sync of the world this
engine binds. While it is held, a take of the world's content (the
initial sync a binding runs, or the one a session that lost its
connection runs when it subscribes again) applies no row: the rows the
backend sends wait unapplied, in order, and are applied the moment the
hold is released. The binding stands and writes to authored source are
taken and uploaded meanwhile, so the engine sits between binding a world
and being shown its content for as long as a test needs it there:
world.syncStatus() reads content_synced = false and
content_sync_held = true, and the world's load waits at its content
step. A take that already finished carries on applying live changes; the
hold stops the next take at its first row. A boot started with
--hold-content-sync (holdContentSync=1 in a browser launch) raises the
hold before the world binds, which is how the initial sync of a fresh
engine is held.
Parameters
heldboolean— True to hold the content sync, false to release it.
vfs.holdContentSync(true)
vfs.holdContentSync(false)
modules/vfs/installedFrom
installedFrom(path: string, opts: VfsOpts?): InstalledFrom?
Where the asset holding path comes from, when the library
carries it by reference. The library's source then holds an install
record naming the asset, the world that published it and the commit
its bytes are, and the engine serves the asset's files from those.
path is the asset's own folder or anything inside it. Nil for a
path whose bytes the library or the world holds itself.
Parameters
pathstring— VFS path of the asset or of anything inside it.optsVfsOpts?(optional) —{ root = "/source/" }.
local from = vfs.installedFrom(ref.path) if from then print(from.world, from.commit) end
modules/vfs/isDirectory
isDirectory(path: string, opts: VfsOpts?): boolean
Is ONE path a directory? Answers from reality — the writable
layer's children, a resolver-served folder listing, an explicit
empty-directory marker — so a loose file whose extension collides
with an assetType name (notes.json) reads as the file it is while
a real <name>.<type>/ folder reads as a folder. Costs the same
whatever the containing folder holds; use vfs.list when you want
every entry's kind, this when you hold one path.
Parameters
pathstring— VFS path to classify.optsVfsOpts?(optional) —{ root = "/source/" }.
if vfs.isDirectory("/zero/source/Goblin.dynamicAsset") then print("folder asset") end
modules/vfs/isSaveExcluded
isSaveExcluded(path: string, opts: VfsOpts?): boolean
Does this path hold content the machine keeps to itself?
/source/tmp/ is session scratch and /source/local/ is this
machine's own durable content — each directory itself included,
and everything under it. Both are writable, hot-reloadable and
enumerable like the rest of /source/; what separates them is
where they stop. The engine filters them out of every world save
and every peer broadcast, so they reach no world, carry no
manifest row there, and a staging verb handed one refuses it by
name. The match reads a whole path segment, so
/source/tmpfoo/ is ordinary content. Ask here whenever your
code has to agree with what a world can hold.
Parameters
pathstring— VFS path to classify.optsVfsOpts?(optional) —{ root = "/source/" }.
if not vfs.isSaveExcluded(p) then table.insert(publishable, p) end
modules/vfs/list
list(path: string?): { VfsListEntry }
List entries in a VFS directory. A directory whose current
listing the next frame assembles (/zero/runtime/sync/entities after a
stretch nothing read the replication surface) is waited for where the
calling code can yield (coroutine.isyieldable()), as vfs.read waits;
where it cannot, the listing raises VFS_READ_CANNOT_WAIT, naming the
path.
Parameters
pathstring?(optional) — Directory path (defaults to/zero).
for _, e in ipairs(vfs.list("/zero/source")) do print(e.name) end
modules/vfs/memResident
memResident(): { { path: string, bytes: number, kind: string } }
List the MemFs entries that are NOT resident-by-default — the writable
in-memory layer's binary blobs and its large text files (text at or above
the inline-text size threshold). These are the bytes vfs.evict can
reclaim: the ones kept in RAM rather than left to fall through to the
on-disk BlobStore cache. Small text (resident by default) is omitted. The
audit counterpart to vfs.evict and to reading with { keep = true } —
use it to see what encoded bytes are held in RAM, and why.
for _, e in ipairs(vfs.memResident()) do print(e.path, e.bytes, e.kind) end
modules/vfs/mkdir
mkdir(path: string, opts: VfsOpts?): boolean
Create a directory. mkdir -p semantics — idempotent.
Errors if a file already exists at the same path. While play is
running an authored /source directory waits for the lock to
lift and the refusal RAISES with the reason; writing a file under
the path creates it as part of that write, and scratch under
/source/tmp/ creates as in edit mode.
Parameters
pathstring— Directory path.optsVfsOpts?(optional) —{ root = "/source/" }.
vfs.mkdir("/zero/source/scenes/")
modules/vfs/move
move(src: string, dst: string, opts: { quiet: boolean? }?): (boolean, (WriteReport | string)?)
Move a file from src to dst. By default fires the destination's
write side effects; pass opts.quiet = true to suppress them. A quiet
move still returns the destination's report and still posts the resolve
rule the publish gate reads, which is what vfs.write does with the same
option: a publish takes no override, so the finding is stated at the line
that carries it.
While play is running a move takes the source away, so authored
/source content that predates play is refused and the refusal
RAISES with the reason; content this play session created moves and
stays tracked on the play shadow.
Parameters
srcstring— Source absolute VFS path.dststring— Destination absolute VFS path.opts{ quiet: boolean? }?(optional) — Optional{ quiet: boolean? }.
vfs.move("/zero/source/a.luau", "/zero/source/b.luau")
local ok, report = vfs.move(mod .. "/init.luau", comp .. "/init.luau")
modules/vfs/movedReferenceNames
movedReferenceNames(path: string?, opts: VfsOpts?): { MovedReferenceSource }
The world sources that spell a reference by a name its target no
longer has. A source records, beside each name it uses, the guid of the
asset the name reached; when that asset later sits at another path (it
was moved, or the engine's library files it elsewhere), the reference
keeps resolving through the guid while the text keeps the old name. This
lists those sources and changes nothing. Writing an entry's currentText
back with vfs.write(source, currentText) brings the text up to date
without changing what any reference resolves to.
Only sources the world authors are considered: the engine library and
/source/deps/ keep the text they were published with.
Parameters
pathstring?(optional) — One source to ask about; every source the world authors when absent.optsVfsOpts?(optional) —{ root = "/source/" }.
for _, s in ipairs(vfs.movedReferenceNames()) do print(s.source, #s.names) end
modules/vfs/mutationSeq
mutationSeq(): number
Lifetime count of VFS mutations the engine has APPLIED — the drain's
clock. A write queues its side effects (an asset's content reload, the
assetType's onChange, a component or scene registration) and a later
frame runs them; this number advances as each one completes. Read it,
write, then poll for a larger value to learn the queue has moved past the
point you wrote at — instead of waiting a guessed number of frames. It
counts every mutation kind, so it answers about the pipeline rather than
about one file; asset.reloadSeq(ref) is the per-asset reading.
local at = vfs.mutationSeq()
vfs.write("/zero/source/tmp/note.txt", "hi")
repeat task.wait() until vfs.mutationSeq() > at
modules/vfs/open
open(path: string, opts: VfsOpts?): PluginHandle
A reader of the file at path, to pass a plugin in a call. The
plugin reads it by range and reaches nothing else through it; it is
refused, raising the reason, where this script could not read. A
reader whose file leaves its path answers the plugin that the asset
is gone.
Parameters
pathstring— The file to read.optsVfsOpts?(optional) —{ root = "/source/" }.
local weights = vfs.open(asset.containing(__FILE__).path .. "/weights.gguf")
modules/vfs/openDir
openDir(path: string, opts: VfsOpts?): PluginHandle
A reader of the folder at path and everything under it, to pass
a plugin in a call. The plugin lists it and opens readers of its files,
by paths relative to the folder that cannot leave it.
Parameters
pathstring— The folder to read.optsVfsOpts?(optional) —{ root = "/source/" }.
local model = vfs.openDir(asset.containing(__FILE__).path .. "/model")
modules/vfs/pendingWrites
pendingWrites(): { string }
List the /source paths with a local write or removal the synced
manifest has not reflected yet: everything this client still owes the
server. A just-written or just-removed file appears here until its
upload round-trips and the synced dirty state catches up;
world.vcsStatus unions these so a fresh edit reads back as dirty
immediately, and world.syncStatus().owed_paths reports the same list.
Empty when fully synced.
for _, p in ipairs(vfs.pendingWrites()) do print(p) end
modules/vfs/playDiscardsOwed
playDiscardsOwed(): { { path: string, reason: string } }
The play edits a discard threw away whose pre-play copy has not been put back yet. Each of these paths holds the play session's bytes until its restore lands, which happens on its own once the copy arrives and its write is taken. A path that changes first keeps its new content and leaves this list.
for _, owed in ipairs(vfs.playDiscardsOwed()) do
print(owed.path, owed.reason)
end
modules/vfs/playHoldsInPlace
playHoldsInPlace(path: string, opts: VfsOpts?): boolean
Whether the running play session holds path where it stands:
vfs.move of it, and vfs.remove of it without durable = true, are
refused while play runs, because what they would take away is content
that predates play (or that a durable write made canonical). Content
only the play shadow holds (this play session wrote it) answers false and
moves freely, and so does every path once play ends or pauses, a path in
/source/tmp/ or /source/local/, and a path outside authored source.
Ask it before the first step of a change that ends by taking path
away, so a change the lock would refuse at its last step is not started:
the importers ask it before relocating a source into the asset they
produce, and owe the import until play ends.
Parameters
pathstring— The VFS path: absolute,@-rooted, or bare under/source/.optsVfsOpts?(optional) —{ root = "/source/" }.
if vfs.playHoldsInPlace(src) then log.warn(src .. " moves once play ends") end
modules/vfs/playShadowAuthors
playShadowAuthors(): { [string]: { id: string, stable: string, connection: string, agent: string?, name: string?, account: string? } }
The caller behind each currently-shadowed /source path, in the
shape vfs.currentAuthor() answers in: the id the write was attributed
to, the name the world's roster holds for that caller now (agent, the
one to show), its account's username, and the ZeroMind account it
authenticated as. Several agents drive one engine at once and
every one of their in-play source edits sits in the same shadow set, so
this is how a review, a refusal or a verdict tells one agent's pending
work from another's. id names the calling session where the call
carried one, so two agent sessions on a machine linked to one ZeroMind
account are two authors. A path written by a caller the engine could not
name carries no entry, because it belongs to nobody in particular, and
stays settleable by anyone.
local mine = vfs.currentAuthor()
for path, who in pairs(vfs.playShadowAuthors()) do
if mine == nil or who.stable ~= mine.stable then print(path, "belongs to", who.agent or who.name) end
end
modules/vfs/playShadowPaths
playShadowPaths(): { string }
Every source write made during play that is not durable yet — the set
to keep or discard before leaving play. Lists the /source paths edited
during running play that the play shadow holds, each beside the
pre-play copy a guarded discard puts back: the universal play
shadow-copy set. These are the in-play edits persist
promotes over the originals on confirm, or drops on a guarded discard.
Empty outside play or when nothing was edited.
for _, p in ipairs(vfs.playShadowPaths()) do print(p) end
modules/vfs/promotePlayShadow
promotePlayShadow(path: string | { string }): ((string | { string })?, { string }, { string })
KEEP a source write made while play was running — the call that saves a play-mode edit onto disk and makes it durable. Promotes play-shadow edits into canonical writes: re-asserts the live overlay bytes through the full write pipeline, then unmarks each path. The bytes stay in the engine end to end, so binary content promotes exactly. It answers while play is RUNNING and leaves the mode, the clock and every shadow entry it did not name exactly where they stood. Takes ONE path, or an ARRAY of them — a single folder-asset write shadows the entry file, the README and the metadata together, so a slice is tens of paths, and naming them keeps the call to the caller's own work on an engine other sessions are running in. Every named path is attempted; one that refuses does not stop the ones after it. A path with nothing on the play shadow at it or beneath it settles nothing and raises nothing, since no play edit stands there: the third return value names it. A promotion that cannot happen raises with the reason: the path is a plain folder covering shadowed edits (the refusal names the first five assets and loose files under it by the paths that settle them, and counts the rest), the workspace is read-only, or the write-through failed. A shadow entry whose bytes are gone is dropped as promoted, so the path comes back with nothing written for it.
A folder asset is kept whole: a file of one promotes with every other
shadowed file of the same asset, so the files its type requires reach
source with whichever of them was named, and naming the folder asset's
own path (Gold.material, controls.inputMap) promotes every shadowed
file of it, the assets nested in it included. An asset nested inside another
settles with its own files, and takes the own files of every enclosing
asset the play session made with it, since it stands on source only
inside them. A path an earlier call settled, by name or carried this
way, answers as settled when it is named again, so a walk over a
vfs.playShadowPaths() snapshot one path at a time settles every path
in it.
Parameters
pathstring | { string }— A shadowed VFS path to promote, or an array of them (from vfs.playShadowPaths()).
local promoted = vfs.promotePlayShadow("/zero/source/cover.jpg")
local _, settled = vfs.promotePlayShadow("/zero/source/Hero.module/init.luau")
local _, kept = vfs.promotePlayShadow("/zero/source/game/Gold.material")
local named, _, unshadowed = vfs.promotePlayShadow("/zero/source/maybe_written.md")
local mine = {}
for _, p in ipairs(vfs.playShadowPaths()) do
if string.find(p, "/zero/source/mine/", 1, true) == 1 then table.insert(mine, p) end
end
local settled = vfs.promotePlayShadow(mine)
modules/vfs/read
read(path: string, opts: VfsOpts?): (string?, string?)
Read a file from the virtual filesystem. Binary-safe.
Returns file contents as a string. A read that finds no bytes
returns nil and the reason: one sentence naming the path and why,
the text the read_file tool and the VFS HTTP route give for the
same path (/zero/source/a.txt: not found, a directory's refusal,
a /runtime node's account of what it holds). Handed straight to
another call, the read passes that reason on as the next argument
too; wrap it in parentheses, Json.decode((vfs.read(path))), to
pass the bytes alone. Relative paths resolve under opts.root
(default /source/). When the bytes aren't locally cached and the
calling code can yield (coroutine.isyieldable(): a task,
awake, start, update, onEnable, onOwnerChanged,
onSyncReceived, a replicated event's
handler), the read waits while the lazy fetch runs and answers
the bytes. Code that cannot yield (onDisable, onDestroy,
onPropertyChanged, a received synced-function call, a vfs.watch callback, a
module's top level while require runs it, a metamethod, a
table.sort comparator) cannot wait: a read there of bytes still on their
way (a file vfs.exists knows, a render surface being captured, a
/runtime/sync node whose current reading the next frame assembles)
raises VFS_READ_CANNOT_WAIT, naming the path and where to read it.
Parameters
pathstring— VFS path.optsVfsOpts?(optional) —{ root = "/source/" }.
local src, why = vfs.read("/zero/source/notes.txt"); if src == nil then print(why) end
modules/vfs/readAsync
readAsync(path: string, opts: VfsOpts?): string
Asynchronous binary-safe read. Returns a promise ID that resolves to the file contents. Useful for reading render textures from the main thread without blocking.
Parameters
pathstring— VFS path.optsVfsOpts?(optional) —{ root = "/source/" }.
local data = task.await(vfs.readAsync("/zero/runtime/screenshots/last.png"))
modules/vfs/readBounded
readBounded(path: string, maxBytes: number): (string?, string?)
Read a file from the virtual filesystem, at most maxBytes
long. Binary-safe, and read the way vfs.read reads it. A file
longer than maxBytes answers nil, "too_large"; where the layer
holding it states its length (a file held in memory, a synced or
staged file) its bytes are never read. Relative paths resolve under
/source/. Where the calling code can yield, a file whose bytes
are not cached is fetched and held to the same bound; where it
cannot, a known file whose bytes have not arrived raises
VFS_READ_CANNOT_WAIT, as vfs.read does. A read that finds no
bytes answers nil and the reason vfs.read gives.
Parameters
pathstring— VFS path.maxBytesnumber— The most bytes the read answers, a whole number 0 or more.
local raw, why = vfs.readBounded("/zero/source/notes.json", 65536)
modules/vfs/reload
reload(modulePath: string?): boolean
Clear entries from the require() cache so the next
require(name) re-runs the module's source. Pass a single
module identity to drop only that entry; call with no
arguments to drop every cached module of the world's own. A
module of the built-in library runs once for the process — its
body registers into registries that outlive it — and stands
through a reload with no argument.
The next require runs the source the module's file holds
when the reload is called, whichever write brought those bytes
there, a quiet one included.
Parameters
modulePathstring?(optional) — Module identity to reload (omit to reload every module of the world's own).
vfs.reload("@mylib/utils.helpers")
modules/vfs/remove
remove(path: string, opts: VfsOpts?): (boolean, string?)
Remove a file. Refuses to remove directories unless
opts.recursive = true. Refuses protected system roots. While
play is running, authored /source content that predates play is
refused and the refusal RAISES with the reason; content this play
session created is removable, a folder included. Pass
opts.durable = true to say the removal IS a change to the source:
it takes the route edit mode takes with play still running and the
session still in play, the counterpart of
vfs.write(path, bytes, { durable = true }) for taking a path away
rather than replacing its bytes. Every other refusal stands in
either terms — a protected root, a read-only route, a plain
directory without recursive.
Parameters
pathstring— VFS path to remove.optsVfsOpts?(optional) —{ root = "/source/", recursive = false, durable = false }.
vfs.remove("/zero/source/scratch.luau")
modules/vfs/revertPlayShadow
revertPlayShadow(path: string | { string }): ((string | { string })?, { string }, { string })
DISCARD a source write made while play was running, keeping nothing — the opposite of promoting it. Reverts play-shadow edits: restores the pre-play copy captured at the first play-mode write (the last edit-mode state, unstaged edits included) into the live slot — or remove the file when it did not exist at that moment — then unmark the path. Hot-reload picks the original back up, so the running session actually reverts. It answers while play is RUNNING and takes ONE path or an ARRAY of them, on the same terms as vfs.promotePlayShadow. A revert that cannot happen raises with the reason. A file of a folder asset reverts with every other shadowed file of the same asset, as a promotion carries them, and a file of an asset the play session made reverts with every shadowed file beneath it, the assets nested in it included. Naming the folder asset's own path reverts every shadowed file of it. A path with nothing on the play shadow settles nothing and raises nothing, as for vfs.promotePlayShadow. A path an earlier revert settled answers as settled when it is named again; one an earlier promotion settled raises, naming that promotion.
Parameters
pathstring | { string }— A shadowed VFS path to revert, or an array of them (from vfs.playShadowPaths()).
local reverted = vfs.revertPlayShadow("/zero/source/Foo.component/init.luau")
local dropped = vfs.revertPlayShadow({ "/zero/source/a.md", "/zero/source/b.md" })
modules/vfs/sessionLost
sessionLost(): boolean
Whether the live world session this engine established is lost and
reconnecting. While it is, every write this engine makes to authored
source (/source outside /source/tmp/) is refused, because the backend
of a dropped session can never receive it; the refusal names the
reconnect. It answers false again from the frame the session
re-establishes, and is false on an engine whose session never established
(an offline boot, or a world still binding) unless vfs.simulateSessionLoss
put it in that state. Ask it before, or after a
failure of, work that writes source in steps: the importers do, and owe
such an import until the session is back.
if vfs.sessionLost() then log.warn("writes wait for the live session") end
modules/vfs/simulateSessionLoss
simulateSessionLoss(lost: boolean)
Put the VFS in the state a lost live session puts it in (true), or
take it out of it (false), without touching the connection:
vfs.sessionLost() answers it, and every write to authored source is
refused exactly as it is while a real session is reconnecting. Once a
live session has established, the engine's connection observer states
the real session every frame, so the simulation holds on an engine whose
live session never established (an offline boot), which is where a test
drives the paths that answer a lost session.
Parameters
lostboolean— True to refuse writes to authored source as a lost session does, false to accept them again.
vfs.simulateSessionLoss(true)
vfs.simulateSessionLoss(false)
modules/vfs/sink
sink(path: string, opts: SinkOpts): PluginHandle
A sink for the file at path, to pass a plugin in a call. The
plugin writes it from empty, up to opts.maxBytes, and the file lands
when the plugin closes the sink or drops it. Only the world's own files
take a sink.
Parameters
pathstring— The file to write.optsSinkOpts—{ maxBytes = <bytes> }, required.
local out = vfs.sink("/source/imported/sphere.zvol", { maxBytes = 64 * 1024 * 1024 })
modules/vfs/stat
stat(path: string, opts: VfsOpts?): (VfsStat?, string?)
What is at path, how big it is and when it last changed, without
reading it. size is how many bytes vfs.read(path) returns: the
length the layer holding the file states (a file held in memory, a
synced or staged file), so a large file costs nothing to measure, and
where no layer states one (a runtime node, a built-in library file)
the length of the bytes this engine holds. It is absent for a directory
and for a file whose length nothing states and whose bytes are not on
this engine. modified is when this engine's copy last changed, in
seconds since the Unix epoch (the scale os.time() reads): the write
or arrival that put the current content there, which a move carries
along with the content. The memory layer
records it for every file whose bytes it holds: text up to the inline
size threshold, and everything on an engine with no world bound. It is
absent where no time is recorded: a directory nothing was written into
as such, a runtime node, and a file whose bytes the blob store holds
(binary content, and text past the inline size threshold, on a
world-bound engine). For a file, the shell's stat and ls -l print
the same size and time. A path nothing is at answers nil and the reason, the
sentence a vfs.read of it gives.
To tell whether a file changed without reading it: vfs.contentAddress(path)
names the bytes the blob store holds, and vfs.contentVersion(path)
moves on every change this session to a file the memory layer holds.
Parameters
pathstring— VFS path.optsVfsOpts?(optional) —{ root = "/source/" }.
local s = vfs.stat("/zero/source/scenes/main.scene/scene.json"); if s then print(s.size, s.modified) end
modules/vfs/unmarkPlayShadow
unmarkPlayShadow(path: string): boolean
Forget a single play-shadow path after it has been promoted (saved over source) or discarded. Tracking only — never touches the bytes.
Parameters
pathstring— VFS path to unmark.
vfs.unmarkPlayShadow("/zero/source/Foo.component/init.luau")
modules/vfs/unwatch
unwatch(watcherId: number): boolean
Remove a previously registered VFS watcher.
Parameters
watcherIdnumber— Watcher id returned byvfs.watch.
vfs.unwatch(id)
modules/vfs/watch
watch(path: string, callback: (string, string, string) -> ()): number
Register a callback that fires when a VFS path is written or
removed. Two match modes: exact, or folder/prefix (key ends with
/, and fires for any descendant). The callback runs in the VM
that registered it. Returns a watcher id for vfs.unwatch.
Parameters
pathstring— Exact path, or folder path ending in/.callback(string, string, string) -> ()—(mutated_path, kind, origin) -> (), kind"write"or"remove"; origin"local"for a change this engine made (throughvfs.*, the shell or any other surface) and"remote"for one that arrived from another participant in the world.
local id = vfs.watch("/zero/source/", function(path, kind, origin) print(kind, origin, path) end)
modules/vfs/write
write(path: string, content: string, opts: VfsOpts?): (boolean, (WriteReport | string)?)
Write content to a file. Binary-safe. Overwrites existing
files by default — pass opts.overwrite = false to refuse to
clobber. While play is running a /source write lands on the play
shadow: it succeeds and reads back, live in the session, and is
undone by a guarded play-exit unless accepted; the report's
survivesRestart says whether a restart keeps it. Scratch under /source/tmp/ writes through untouched,
in edit mode and in play, and is gone when the engine stops or restarts, so
its report says durable = false, survivesRestart = false and a warning
opening SCRATCH.
Pass opts.durable = true to say these bytes ARE the source: the
write reaches canonical /source with play still running and the
session still in play, hot-reloading the modules and components that
read it, so the edit is observed running in the same play session
with nothing left to promote. A durable write RAISES with the reason
when the bytes cannot become canonical source. One write carries at
most 256 MiB: longer content RAISES, naming its size and that limit,
and the path keeps what it held.
Parameters
pathstring— VFS path to write to.contentstring— File content (binary-safe).optsVfsOpts?(optional) —{ root = "/source/", overwrite = true, quiet = false, durable = false }.
vfs.write("/zero/source/notes.md", body)
local ok, report = vfs.write("/zero/source/game/Vent.component/init.luau", src)
if report.diagnostics then for _, d in report.diagnostics do print(d.severity, d.path, d.line, d.message) end end
if report.contentRequirements then for _, r in report.contentRequirements do print(r.path, r.detail) end end
if not report.durable then log.warn(report.warning) end
vfs.write("/zero/source/game/Vent.component/init.luau", src, { durable = true })
typed/builtin//modules/api/engine/vfs/vfs/claimWalks
vfs.claimWalks() -> number
Lifetime count of walks over every asset the world offers: the part of
an enumeration's cost that grows with how many assets exist. asset.list
reads a snapshot of every registered claim, built once for each change to
what the world offers and shared by every list after it;
asset.categories reads the category registry and walks nothing. Read it
around a burst to learn what that burst cost in walks, a number that reads
the same on a loaded machine and a quiet one.
Returns number — Count of claim walks this session. Monotonic.
local at = vfs.claimWalks()
vfs.write("/zero/source/probe.module/init.luau", "return {}")
asset.list("module")
print(vfs.claimWalks() - at)
typed/builtin//modules/api/engine/vfs/vfs/clearPlayShadow
vfs.clearPlayShadow() -> boolean
Forget the entire play-shadow set after a bulk promote or discard. Tracking only — never touches the bytes.
Returns boolean — Always true.
vfs.clearPlayShadow()
typed/builtin//modules/api/engine/vfs/vfs/contentAddress
vfs.contentAddress(path: string, opts: VfsOpts?) -> string?
The content address (sha-256 hex) of the bytes vfs.read(path)
returns, for a path whose bytes the blob store holds, answered
synchronously and without the bytes. Two answers that agree name the
same bytes, and a write that changes them changes it. Nil for a path
whose bytes are held in memory (text under the inline size threshold,
a play shadow, a kept read), which a synchronous vfs.read already
returns, and for a path nothing is stored at. Keep it beside a copy of
a file you read to tell, from a caller that cannot yield, whether the
copy is still what the path holds.
Parameters
pathstring— VFS path.optsVfsOpts(optional) —{ root = "/source/" }.
Returns string? — The sha-256 hex of the blob-held bytes at path, or nil.
local address = vfs.contentAddress("/zero/source/scenes/main.scene/scene.json")
typed/builtin//modules/api/engine/vfs/vfs/contentVersion
vfs.contentVersion(path: string) -> number
A number that moves every time the file at path changes this
session: each write, and each move onto it, stamps it with a version no
file has held this session, and while nothing is stored at path (before
it is written, after it is removed or moved away) it reads 0. It moves
the moment any change lands, from every origin: a script's write, a
peer's, a dev-mode disk pickup, a world-load or branch-swap write made
before the world's content is ready, a world's unmount, both ends of a
move, and a directory removal. Keep the value you saw when you last read
the file, and read it again only when the number moved.
Parameters
pathstring— The file's path, in any formvfs.readtakes.
Returns number — The path's content version.
local seen = vfs.contentVersion("/zero/source/.world_settings")
typed/builtin//modules/api/engine/vfs/vfs/copy
vfs.copy(src: string, dst: string) -> (boolean, (WriteReport | string)?)
Copy a file OR directory from src to dst, cp -r style. A
directory recurses — every descendant is replicated at the same
relative path under dst, .refs sidecars included. .meta
sidecars are minted fresh, so a copy is a distinct asset with its
own identity. Both paths are absolute. The source may live in any
layer (writable, library mount, builtin, runtime-generated); the
destination must be a writable route.
Parameters
srcstring— Source absolute VFS path (file or directory).dststring— Destination absolute VFS path.
Returns (boolean, (WriteReport | string)?) — True and a WriteReport on success. A copy is a write at its destination, so it reports what vfs.write reports for the same bytes at that path: where they went, and what the static passes read in the Luau among them, per landed leaf when the copy took a folder. A module that is advisory where it sat reads as a script error once it sits on a component path, and the copy is what put it there. (false, errmsg) on failure.
vfs.copy("/zero/runtime/recordings/take1.mp4", "/zero/source/clips/take1.mp4")
local ok, report = vfs.copy(mod, "/zero/source/game/Vent.component")
typed/builtin//modules/api/engine/vfs/vfs/currentAuthor
vfs.currentAuthor() -> { id: string, stable: string, connection: string, agent: string?, name: string?, account: string? }?
The caller this call is attributed to: the author a /source write
made right now would be recorded under in the play shadow. id names the
calling session where the transport attested one, so two agent sessions
driving one engine read as two callers even when one linked ZeroMind
account minted both their tokens. stable is the key the caller had
before the world's roster named it, which a rename leaves where it was;
one of several agents sharing a connection, which names itself with the
as argument every tool takes, has a stable of its own. connection
is the key of the connection the caller called over, the one the
caller's staging area and the writer its dirty rows record are kept
under, so the agents of one connection stage and commit one another's
work. agent is the name the world's roster holds for the caller now;
name is its account's username, which every agent of the account
shares; account is that shared account. Nil
when the engine can name no caller at all, which is the case for
engine-authored work and for a caller that presented neither a session
nor a token. Compare its id against vfs.playShadowAuthors() to
separate your own pending edits from a co-author's.
Returns { id: string, stable: string, connection: string, agent: string?, name: string?, account: string? }? — { id, stable, connection, agent, name, account } for the acting caller, or nil when unattributed.
local me = vfs.currentAuthor()
print(if me ~= nil then me.id else "unattributed")
typed/builtin//modules/api/engine/vfs/vfs/dirSink
vfs.dirSink(path: string, opts: DirSinkOpts) -> PluginHandle
A sink for new files under the folder at path, subfolders
included, to pass a plugin in a call. The plugin makes whatever files it
needs within opts.maxFiles files and opts.maxBytes bytes in all.
Only the world's own files take a sink.
Parameters
pathstring— The folder to write under.optsDirSinkOpts—{ maxFiles = <n>, maxBytes = <bytes>, extensions = { "png" }? }.
Returns PluginHandle — The folder sink.
local frames = vfs.dirSink("/source/frames", { maxFiles = 240, maxBytes = 200 * 1024 * 1024, extensions = { "png" } })
typed/builtin//modules/api/engine/vfs/vfs/discardPlayShadow
vfs.discardPlayShadow() -> { discarded: { string }, owed: { { path: string, reason: string } } }
Throw away every play-shadow edit: the discard a play-exit takes when
the play session's work is not kept. Each path reverts to its pre-play
copy, as vfs.revertPlayShadow does. Every path it throws away is
returned in discarded, posted as a notice and logged by name. A path
whose pre-play copy cannot be put back yet (the world's blob, still
being pulled; the world's manifest row, read once the manifest has taken
the update it is applying; or a copy whose write back is refused) leaves
the play shadow all the same and is OWED: it holds the play bytes until
the copy lands and its write is taken, and the copy is put back then,
unless the path has changed in the meantime. Every owed path is returned
in owed, posted as a notice and logged by name. Calling it again asks
for every owed restore again, pulling a copy whose pull failed.
Returns { discarded: { string }, owed: { { path: string, reason: string } } } — discarded: every path the discard took off the play shadow, sorted, owed ones included. owed: every path it left owed, sorted by path, with why its restore has not landed; empty when every path reverted.
local result = vfs.discardPlayShadow()
for _, path in ipairs(result.discarded) do
print("discarded", path)
end
for _, owed in ipairs(result.owed) do
print(owed.path, owed.reason)
end
typed/builtin//modules/api/engine/vfs/vfs/durability
vfs.durability(paths: string | { string }, opts: VfsOpts?) -> Durability
Did that write save to disk, and if not how is it kept? What became of
the bytes just written at paths — one path, or an array of them
answered as ONE write. durable is true when they are where
the call filed them and the next session reads them back, and false when
the play shadow took any of them (live in this session and undone by a
guarded play-exit unless kept) or any of them is in /source/tmp/ scratch
(this session's working space: it reads back while the engine runs and is
gone when it stops or restarts, in edit mode and in play alike).
/source/local/ is durable: the machine keeps it. durable is
present whatever the answer is, so its absence is never a reading. A
non-durable answer carries survivesRestart (true when a restart of this
engine keeps the bytes, because the bound world syncs them as they land;
false when a restart ends them, which it always does for scratch) and
warning (the state and what a restart does with it, for a reader
scanning values rather than checking a field; it opens with PLAY SHADOW
or SCRATCH). When the shadow holds a path the answer also carries
playShadow (the routes to disk and what each costs a session other
people are running in) and shadowed (which of the given paths the shadow
has, or under pending would take); when a path is in scratch it carries
scratch (which of them are in scratch).
This is the answer, off the same shadow set and in the same words, that the
write_file / edit_file / capture tools attach to their own results and
that asset.create reports as its second return value. Ask it here at any
other site that lands files, so every write surface states where the bytes
went in one set of terms.
Parameters
pathsstring | { string }— One VFS path, or an array of paths answered together as one write. A path resolves the wayvfs.writeresolves its own — absolute or@-rooted as it stands, a bare one under/source/— so the answer is about the file that write landed. A value that is not a path — an AssetRef, a record, a number, a string with nothing in it — raises rather than being answered off the empty set it reads as; a list with no entries names nothing and answers durable.optsVfsOpts(optional) —{ root = "/source/", pending = false }.pending = trueasks the same question of a write that has NOT landed yet: where bytes written at these paths would go, read off the same play-lock verdict that will decide it. That is what a surface arranging a write it files later reports from —av.recordopens a take here and the film is written when the take ends — because until those bytes land the shadow is holding nothing under the path and the answer would be durable at the one moment the caller can still act on it. The fields and the words are the same either way; the state names the tense it is true in.
Returns Durability — { durable, survivesRestart?, warning?, playShadow?, shadowed?, scratch? }: survivesRestart and warning present exactly when durable is false, playShadow and shadowed when the play shadow holds a path, scratch when a path is in /source/tmp/, which reads back while the engine runs and is gone when it stops or restarts.
local ok = vfs.write(path, body)
local d = vfs.durability(path)
if not d.durable then log.warn(d.warning) end
local ahead = vfs.durability("/zero/source/clips/take.mp4", { pending = true })
typed/builtin//modules/api/engine/vfs/vfs/evict
vfs.evict(path: string, opts: VfsOpts?) -> boolean
Drop the in-memory bytes for path from the writable
MemFs layer without removing the asset. Use after processing
large binaries to reclaim RAM.
Parameters
pathstring— VFS path whose bytes should be evicted.optsVfsOpts(optional) —{ root = "/source/" }.
Returns boolean — True if MemFs bytes were dropped; false otherwise.
vfs.evict("/zero/source/textures/imported_big.png")
typed/builtin//modules/api/engine/vfs/vfs/exists
vfs.exists(path: string, opts: VfsOpts?) -> boolean
Is the path known to the VFS? Checks the Stage-1 metadata
(.meta sidecar / ManifestView) — NOT "are the bytes locally
cached?". Use vfs.read(path) ~= nil to confirm bytes are
reachable.
Parameters
pathstring— VFS path to check.optsVfsOpts(optional) —{ root = "/source/" }.
Returns boolean — True if the path is known regardless of byte cache state.
assert(vfs.exists("@builtin/models/Cube"))
typed/builtin//modules/api/engine/vfs/vfs/folderHashWalks
vfs.folderHashWalks() -> number
Lifetime count of category-folder checksum walks — the part of a write's cost that grows with how much content already exists. A folder's checksum is the tree hash over the descendants that describe the asset, so deriving one reads every key under it, and a write re-derives it for each enclosing category folder whose bytes that write reaches. Content the checksum is made of therefore moves this once per enclosing folder per write; bytes that reach no checksum — a managed sidecar, a scene's autosave overlay — move it by nothing. Read it around a burst to learn what that burst cost in walks, a number that reads the same on a loaded machine and a quiet one.
Returns number — Count of folder checksum walks this session. Monotonic.
local at = vfs.folderHashWalks()
vfs.write("/zero/source/scenes/probe.scene/scene_dirty/entities/e1.json", "{}")
print(vfs.folderHashWalks() - at)
typed/builtin//modules/api/engine/vfs/vfs/holdContentSync
vfs.holdContentSync(held: boolean)
Hold (true) or release (false) the content sync of the world this
engine binds. While it is held, a take of the world's content (the
initial sync a binding runs, or the one a session that lost its
connection runs when it subscribes again) applies no row: the rows the
backend sends wait unapplied, in order, and are applied the moment the
hold is released. The binding stands and writes to authored source are
taken and uploaded meanwhile, so the engine sits between binding a world
and being shown its content for as long as a test needs it there:
world.syncStatus() reads content_synced = false and
content_sync_held = true, and the world's load waits at its content
step. A take that already finished carries on applying live changes; the
hold stops the next take at its first row. A boot started with
--hold-content-sync (holdContentSync=1 in a browser launch) raises the
hold before the world binds, which is how the initial sync of a fresh
engine is held.
Parameters
heldboolean— True to hold the content sync, false to release it.
vfs.holdContentSync(true)
vfs.holdContentSync(false)
typed/builtin//modules/api/engine/vfs/vfs/installedFrom
vfs.installedFrom(path: string, opts: VfsOpts?) -> InstalledFrom?
Where the asset holding path comes from, when the library
carries it by reference. The library's source then holds an install
record naming the asset, the world that published it and the commit
its bytes are, and the engine serves the asset's files from those.
path is the asset's own folder or anything inside it. Nil for a
path whose bytes the library or the world holds itself.
Parameters
pathstring— VFS path of the asset or of anything inside it.optsVfsOpts(optional) —{ root = "/source/" }.
Returns InstalledFrom? — The origin, or nil when the path is not inside an asset carried by reference.
local from = vfs.installedFrom(ref.path) if from then print(from.world, from.commit) end
typed/builtin//modules/api/engine/vfs/vfs/isDirectory
vfs.isDirectory(path: string, opts: VfsOpts?) -> boolean
Is ONE path a directory? Answers from reality — the writable
layer's children, a resolver-served folder listing, an explicit
empty-directory marker — so a loose file whose extension collides
with an assetType name (notes.json) reads as the file it is while
a real <name>.<type>/ folder reads as a folder. Costs the same
whatever the containing folder holds; use vfs.list when you want
every entry's kind, this when you hold one path.
Parameters
pathstring— VFS path to classify.optsVfsOpts(optional) —{ root = "/source/" }.
Returns boolean — True if the path is a directory.
if vfs.isDirectory("/zero/source/Goblin.dynamicAsset") then print("folder asset") end
typed/builtin//modules/api/engine/vfs/vfs/isSaveExcluded
vfs.isSaveExcluded(path: string, opts: VfsOpts?) -> boolean
Does this path hold content the machine keeps to itself?
/source/tmp/ is session scratch and /source/local/ is this
machine's own durable content — each directory itself included,
and everything under it. Both are writable, hot-reloadable and
enumerable like the rest of /source/; what separates them is
where they stop. The engine filters them out of every world save
and every peer broadcast, so they reach no world, carry no
manifest row there, and a staging verb handed one refuses it by
name. The match reads a whole path segment, so
/source/tmpfoo/ is ordinary content. Ask here whenever your
code has to agree with what a world can hold.
Parameters
pathstring— VFS path to classify.optsVfsOpts(optional) —{ root = "/source/" }.
Returns boolean — True when the path is held back from world saves and sync.
if not vfs.isSaveExcluded(p) then table.insert(publishable, p) end
typed/builtin//modules/api/engine/vfs/vfs/list
vfs.list(path: string?) -> { VfsListEntry }
List entries in a VFS directory. A directory whose current
listing the next frame assembles (/zero/runtime/sync/entities after a
stretch nothing read the replication surface) is waited for where the
calling code can yield (coroutine.isyieldable()), as vfs.read waits;
where it cannot, the listing raises VFS_READ_CANNOT_WAIT, naming the
path.
Parameters
pathstring(optional) — Directory path (defaults to/zero).
Returns { VfsListEntry } — Array of { name, isDirectory, path } tables; vfs.stat(entry.path) gives an entry's size and modification time.
for _, e in ipairs(vfs.list("/zero/source")) do print(e.name) end
typed/builtin//modules/api/engine/vfs/vfs/memResident
vfs.memResident() -> { { path: string, bytes: number, kind: string } }
List the MemFs entries that are NOT resident-by-default — the writable
in-memory layer's binary blobs and its large text files (text at or above
the inline-text size threshold). These are the bytes vfs.evict can
reclaim: the ones kept in RAM rather than left to fall through to the
on-disk BlobStore cache. Small text (resident by default) is omitted. The
audit counterpart to vfs.evict and to reading with { keep = true } —
use it to see what encoded bytes are held in RAM, and why.
Returns { { path: string, bytes: number, kind: string } } — Array of { path, bytes, kind }; kind is "binary" for non-text content and "large-text" for oversized text. Empty when the VFS isn't up.
for _, e in ipairs(vfs.memResident()) do print(e.path, e.bytes, e.kind) end
typed/builtin//modules/api/engine/vfs/vfs/mkdir
vfs.mkdir(path: string, opts: VfsOpts?) -> boolean
Create a directory. mkdir -p semantics — idempotent.
Errors if a file already exists at the same path. While play is
running an authored /source directory waits for the lock to
lift and the refusal RAISES with the reason; writing a file under
the path creates it as part of that write, and scratch under
/source/tmp/ creates as in edit mode.
Parameters
pathstring— Directory path.optsVfsOpts(optional) —{ root = "/source/" }.
Returns boolean — True if the directory exists after the call. A creation the running play session refuses raises with the whole reason.
vfs.mkdir("/zero/source/scenes/")
typed/builtin//modules/api/engine/vfs/vfs/move
vfs.move(src: string, dst: string, opts: { quiet: boolean? }?) -> (boolean, (WriteReport | string)?)
Move a file from src to dst. By default fires the destination's
write side effects; pass opts.quiet = true to suppress them. A quiet
move still returns the destination's report and still posts the resolve
rule the publish gate reads, which is what vfs.write does with the same
option: a publish takes no override, so the finding is stated at the line
that carries it.
While play is running a move takes the source away, so authored
/source content that predates play is refused and the refusal
RAISES with the reason; content this play session created moves and
stays tracked on the play shadow.
Parameters
srcstring— Source absolute VFS path.dststring— Destination absolute VFS path.opts{ quiet: boolean? }(optional) — Optional{ quiet: boolean? }.
Returns (boolean, (WriteReport | string)?) — True and a WriteReport on success. A move IS a write at its destination, so it reports what vfs.write reports for the same bytes at that path: where they went, and what the static passes read in the Luau among them, per landed leaf when the move took a folder. (false, errmsg) when the paths themselves refuse it. A move the running play session refuses raises with the whole reason instead.
vfs.move("/zero/source/a.luau", "/zero/source/b.luau")
local ok, report = vfs.move(mod .. "/init.luau", comp .. "/init.luau")
typed/builtin//modules/api/engine/vfs/vfs/movedReferenceNames
vfs.movedReferenceNames(path: string?, opts: VfsOpts?) -> { MovedReferenceSource }
The world sources that spell a reference by a name its target no
longer has. A source records, beside each name it uses, the guid of the
asset the name reached; when that asset later sits at another path (it
was moved, or the engine's library files it elsewhere), the reference
keeps resolving through the guid while the text keeps the old name. This
lists those sources and changes nothing. Writing an entry's currentText
back with vfs.write(source, currentText) brings the text up to date
without changing what any reference resolves to.
Only sources the world authors are considered: the engine library and
/source/deps/ keep the text they were published with.
Parameters
pathstring(optional) — One source to ask about; every source the world authors when absent.optsVfsOpts(optional) —{ root = "/source/" }.
Returns { MovedReferenceSource } — One entry per source holding moved names, in path order; empty when none does.
for _, s in ipairs(vfs.movedReferenceNames()) do print(s.source, #s.names) end
typed/builtin//modules/api/engine/vfs/vfs/mutationSeq
vfs.mutationSeq() -> number
Lifetime count of VFS mutations the engine has APPLIED — the drain's
clock. A write queues its side effects (an asset's content reload, the
assetType's onChange, a component or scene registration) and a later
frame runs them; this number advances as each one completes. Read it,
write, then poll for a larger value to learn the queue has moved past the
point you wrote at — instead of waiting a guessed number of frames. It
counts every mutation kind, so it answers about the pipeline rather than
about one file; asset.reloadSeq(ref) is the per-asset reading.
Returns number — Count of applied VFS mutations this session. Monotonic.
local at = vfs.mutationSeq()
vfs.write("/zero/source/tmp/note.txt", "hi")
repeat task.wait() until vfs.mutationSeq() > at
typed/builtin//modules/api/engine/vfs/vfs/open
vfs.open(path: string, opts: VfsOpts?) -> PluginHandle
A reader of the file at path, to pass a plugin in a call. The
plugin reads it by range and reaches nothing else through it; it is
refused, raising the reason, where this script could not read. A
reader whose file leaves its path answers the plugin that the asset
is gone.
Parameters
pathstring— The file to read.optsVfsOpts(optional) —{ root = "/source/" }.
Returns PluginHandle — The reader.
local weights = vfs.open(asset.containing(__FILE__).path .. "/weights.gguf")
typed/builtin//modules/api/engine/vfs/vfs/openDir
vfs.openDir(path: string, opts: VfsOpts?) -> PluginHandle
A reader of the folder at path and everything under it, to pass
a plugin in a call. The plugin lists it and opens readers of its files,
by paths relative to the folder that cannot leave it.
Parameters
pathstring— The folder to read.optsVfsOpts(optional) —{ root = "/source/" }.
Returns PluginHandle — The folder reader.
local model = vfs.openDir(asset.containing(__FILE__).path .. "/model")
typed/builtin//modules/api/engine/vfs/vfs/pendingWrites
vfs.pendingWrites() -> { string }
List the /source paths with a local write or removal the synced
manifest has not reflected yet: everything this client still owes the
server. A just-written or just-removed file appears here until its
upload round-trips and the synced dirty state catches up;
world.vcsStatus unions these so a fresh edit reads back as dirty
immediately, and world.syncStatus().owed_paths reports the same list.
Empty when fully synced.
Returns { string } — Array of VFS paths with a pending (unconfirmed) local write or removal.
for _, p in ipairs(vfs.pendingWrites()) do print(p) end
typed/builtin//modules/api/engine/vfs/vfs/playDiscardsOwed
vfs.playDiscardsOwed() -> { { path: string, reason: string } }
The play edits a discard threw away whose pre-play copy has not been put back yet. Each of these paths holds the play session's bytes until its restore lands, which happens on its own once the copy arrives and its write is taken. A path that changes first keeps its new content and leaves this list.
Returns { { path: string, reason: string } } — Every owed path, sorted by path, with why its restore has not landed.
for _, owed in ipairs(vfs.playDiscardsOwed()) do
print(owed.path, owed.reason)
end
typed/builtin//modules/api/engine/vfs/vfs/playHoldsInPlace
vfs.playHoldsInPlace(path: string, opts: VfsOpts?) -> boolean
Whether the running play session holds path where it stands:
vfs.move of it, and vfs.remove of it without durable = true, are
refused while play runs, because what they would take away is content
that predates play (or that a durable write made canonical). Content
only the play shadow holds (this play session wrote it) answers false and
moves freely, and so does every path once play ends or pauses, a path in
/source/tmp/ or /source/local/, and a path outside authored source.
Ask it before the first step of a change that ends by taking path
away, so a change the lock would refuse at its last step is not started:
the importers ask it before relocating a source into the asset they
produce, and owe the import until play ends.
Parameters
pathstring— The VFS path: absolute,@-rooted, or bare under/source/.optsVfsOpts(optional) —{ root = "/source/" }.
Returns boolean — True while a move or ambient removal of path would be refused.
if vfs.playHoldsInPlace(src) then log.warn(src .. " moves once play ends") end
typed/builtin//modules/api/engine/vfs/vfs/playShadowAuthors
vfs.playShadowAuthors() -> { [string]: { id: string, stable: string, connection: string, agent: string?, name: string?, account: string? } }
The caller behind each currently-shadowed /source path, in the
shape vfs.currentAuthor() answers in: the id the write was attributed
to, the name the world's roster holds for that caller now (agent, the
one to show), its account's username, and the ZeroMind account it
authenticated as. Several agents drive one engine at once and
every one of their in-play source edits sits in the same shadow set, so
this is how a review, a refusal or a verdict tells one agent's pending
work from another's. id names the calling session where the call
carried one, so two agent sessions on a machine linked to one ZeroMind
account are two authors. A path written by a caller the engine could not
name carries no entry, because it belongs to nobody in particular, and
stays settleable by anyone.
Returns { [string]: { id: string, stable: string, connection: string, agent: string?, name: string?, account: string? } } — Map of shadowed path to { id, stable, connection, agent, name, account }.
local mine = vfs.currentAuthor()
for path, who in pairs(vfs.playShadowAuthors()) do
if mine == nil or who.stable ~= mine.stable then print(path, "belongs to", who.agent or who.name) end
end
typed/builtin//modules/api/engine/vfs/vfs/playShadowPaths
vfs.playShadowPaths() -> { string }
Every source write made during play that is not durable yet — the set
to keep or discard before leaving play. Lists the /source paths edited
during running play that the play shadow holds, each beside the
pre-play copy a guarded discard puts back: the universal play
shadow-copy set. These are the in-play edits persist
promotes over the originals on confirm, or drops on a guarded discard.
Empty outside play or when nothing was edited.
Returns { string } — Array of normalized VFS paths currently shadowed.
for _, p in ipairs(vfs.playShadowPaths()) do print(p) end
typed/builtin//modules/api/engine/vfs/vfs/promotePlayShadow
vfs.promotePlayShadow(path: string | { string }) -> ((string | { string })?, { string }, { string })
KEEP a source write made while play was running — the call that saves a play-mode edit onto disk and makes it durable. Promotes play-shadow edits into canonical writes: re-asserts the live overlay bytes through the full write pipeline, then unmarks each path. The bytes stay in the engine end to end, so binary content promotes exactly. It answers while play is RUNNING and leaves the mode, the clock and every shadow entry it did not name exactly where they stood. Takes ONE path, or an ARRAY of them — a single folder-asset write shadows the entry file, the README and the metadata together, so a slice is tens of paths, and naming them keeps the call to the caller's own work on an engine other sessions are running in. Every named path is attempted; one that refuses does not stop the ones after it. A path with nothing on the play shadow at it or beneath it settles nothing and raises nothing, since no play edit stands there: the third return value names it. A promotion that cannot happen raises with the reason: the path is a plain folder covering shadowed edits (the refusal names the first five assets and loose files under it by the paths that settle them, and counts the rest), the workspace is read-only, or the write-through failed. A shadow entry whose bytes are gone is dropped as promoted, so the path comes back with nothing written for it.
A folder asset is kept whole: a file of one promotes with every other
shadowed file of the same asset, so the files its type requires reach
source with whichever of them was named, and naming the folder asset's
own path (Gold.material, controls.inputMap) promotes every shadowed
file of it, the assets nested in it included. An asset nested inside another
settles with its own files, and takes the own files of every enclosing
asset the play session made with it, since it stands on source only
inside them. A path an earlier call settled, by name or carried this
way, answers as settled when it is named again, so a walk over a
vfs.playShadowPaths() snapshot one path at a time settles every path
in it.
Parameters
pathstring | { string }— A shadowed VFS path to promote, or an array of them (from vfs.playShadowPaths()).
Returns ((string | { string })?, { string }, { string }) — The path the call settled (nil when nothing was on the play shadow there), or the array of settled paths when an array went in; then the array of every path the call settled, a folder asset's carried files included; then the array of named paths that held nothing on the play shadow. None of the named paths are shadowed any more.
local promoted = vfs.promotePlayShadow("/zero/source/cover.jpg")
local _, settled = vfs.promotePlayShadow("/zero/source/Hero.module/init.luau")
local _, kept = vfs.promotePlayShadow("/zero/source/game/Gold.material")
local named, _, unshadowed = vfs.promotePlayShadow("/zero/source/maybe_written.md")
local mine = {}
for _, p in ipairs(vfs.playShadowPaths()) do
if string.find(p, "/zero/source/mine/", 1, true) == 1 then table.insert(mine, p) end
end
local settled = vfs.promotePlayShadow(mine)
typed/builtin//modules/api/engine/vfs/vfs/read
vfs.read(path: string, opts: VfsOpts?) -> (string?, string?)
Read a file from the virtual filesystem. Binary-safe.
Returns file contents as a string. A read that finds no bytes
returns nil and the reason: one sentence naming the path and why,
the text the read_file tool and the VFS HTTP route give for the
same path (/zero/source/a.txt: not found, a directory's refusal,
a /runtime node's account of what it holds). Handed straight to
another call, the read passes that reason on as the next argument
too; wrap it in parentheses, Json.decode((vfs.read(path))), to
pass the bytes alone. Relative paths resolve under opts.root
(default /source/). When the bytes aren't locally cached and the
calling code can yield (coroutine.isyieldable(): a task,
awake, start, update, onEnable, onOwnerChanged,
onSyncReceived, a replicated event's
handler), the read waits while the lazy fetch runs and answers
the bytes. Code that cannot yield (onDisable, onDestroy,
onPropertyChanged, a received synced-function call, a vfs.watch callback, a
module's top level while require runs it, a metamethod, a
table.sort comparator) cannot wait: a read there of bytes still on their
way (a file vfs.exists knows, a render surface being captured, a
/runtime/sync node whose current reading the next frame assembles)
raises VFS_READ_CANNOT_WAIT, naming the path and where to read it.
Parameters
pathstring— VFS path.optsVfsOpts(optional) —{ root = "/source/" }.
Returns (string?, string?) — File contents (binary-safe); nil and the reason (the path and why it holds no bytes to read) for a read that finds none.
local src, why = vfs.read("/zero/source/notes.txt"); if src == nil then print(why) end
typed/builtin//modules/api/engine/vfs/vfs/readAsync
vfs.readAsync(path: string, opts: VfsOpts?) -> string
Asynchronous binary-safe read. Returns a promise ID that resolves to the file contents. Useful for reading render textures from the main thread without blocking.
Parameters
pathstring— VFS path.optsVfsOpts(optional) —{ root = "/source/" }.
Returns string — Promise ID — pass to task.await().
local data = task.await(vfs.readAsync("/zero/runtime/screenshots/last.png"))
typed/builtin//modules/api/engine/vfs/vfs/readBounded
vfs.readBounded(path: string, maxBytes: number) -> (string?, string?)
Read a file from the virtual filesystem, at most maxBytes
long. Binary-safe, and read the way vfs.read reads it. A file
longer than maxBytes answers nil, "too_large"; where the layer
holding it states its length (a file held in memory, a synced or
staged file) its bytes are never read. Relative paths resolve under
/source/. Where the calling code can yield, a file whose bytes
are not cached is fetched and held to the same bound; where it
cannot, a known file whose bytes have not arrived raises
VFS_READ_CANNOT_WAIT, as vfs.read does. A read that finds no
bytes answers nil and the reason vfs.read gives.
Parameters
pathstring— VFS path.maxBytesnumber— The most bytes the read answers, a whole number 0 or more.
Returns (string?, string?) — File contents (binary-safe); nil and "too_large" for a file longer than maxBytes; nil and the reason vfs.read gives for a read that finds no bytes.
local raw, why = vfs.readBounded("/zero/source/notes.json", 65536)
typed/builtin//modules/api/engine/vfs/vfs/reload
vfs.reload(modulePath: string?) -> boolean
Clear entries from the require() cache so the next
require(name) re-runs the module's source. Pass a single
module identity to drop only that entry; call with no
arguments to drop every cached module of the world's own. A
module of the built-in library runs once for the process — its
body registers into registries that outlive it — and stands
through a reload with no argument.
The next require runs the source the module's file holds
when the reload is called, whichever write brought those bytes
there, a quiet one included.
Parameters
modulePathstring(optional) — Module identity to reload (omit to reload every module of the world's own).
Returns boolean — For a single identity, whether a module was cached under that name and has now been dropped — false says the name matched nothing. The no-arg form returns true.
vfs.reload("@mylib/utils.helpers")
typed/builtin//modules/api/engine/vfs/vfs/remove
vfs.remove(path: string, opts: VfsOpts?) -> (boolean, string?)
Remove a file. Refuses to remove directories unless
opts.recursive = true. Refuses protected system roots. While
play is running, authored /source content that predates play is
refused and the refusal RAISES with the reason; content this play
session created is removable, a folder included. Pass
opts.durable = true to say the removal IS a change to the source:
it takes the route edit mode takes with play still running and the
session still in play, the counterpart of
vfs.write(path, bytes, { durable = true }) for taking a path away
rather than replacing its bytes. Every other refusal stands in
either terms — a protected root, a read-only route, a plain
directory without recursive.
Parameters
pathstring— VFS path to remove.optsVfsOpts(optional) —{ root = "/source/", recursive = false, durable = false }.
Returns (boolean, string?) — True on success; (false, errmsg) when the path itself refuses the removal. A removal the running play session refuses raises with the whole reason instead, so a pcall around the call reads it — and the lock answers ahead of whether the path is there, so a locked /source path raises whether or not it holds anything.
vfs.remove("/zero/source/scratch.luau")
typed/builtin//modules/api/engine/vfs/vfs/revertPlayShadow
vfs.revertPlayShadow(path: string | { string }) -> ((string | { string })?, { string }, { string })
DISCARD a source write made while play was running, keeping nothing — the opposite of promoting it. Reverts play-shadow edits: restores the pre-play copy captured at the first play-mode write (the last edit-mode state, unstaged edits included) into the live slot — or remove the file when it did not exist at that moment — then unmark the path. Hot-reload picks the original back up, so the running session actually reverts. It answers while play is RUNNING and takes ONE path or an ARRAY of them, on the same terms as vfs.promotePlayShadow. A revert that cannot happen raises with the reason. A file of a folder asset reverts with every other shadowed file of the same asset, as a promotion carries them, and a file of an asset the play session made reverts with every shadowed file beneath it, the assets nested in it included. Naming the folder asset's own path reverts every shadowed file of it. A path with nothing on the play shadow settles nothing and raises nothing, as for vfs.promotePlayShadow. A path an earlier revert settled answers as settled when it is named again; one an earlier promotion settled raises, naming that promotion.
Parameters
pathstring | { string }— A shadowed VFS path to revert, or an array of them (from vfs.playShadowPaths()).
Returns ((string | { string })?, { string }, { string }) — The path that is now reverted (nil when nothing was on the play shadow there), or the array of reverted paths when an array went in; then the array of every path the call reverted, a folder asset's carried files included; then the array of named paths that held nothing on the play shadow. None of the named paths are shadowed any more.
local reverted = vfs.revertPlayShadow("/zero/source/Foo.component/init.luau")
local dropped = vfs.revertPlayShadow({ "/zero/source/a.md", "/zero/source/b.md" })
typed/builtin//modules/api/engine/vfs/vfs/sessionLost
vfs.sessionLost() -> boolean
Whether the live world session this engine established is lost and
reconnecting. While it is, every write this engine makes to authored
source (/source outside /source/tmp/) is refused, because the backend
of a dropped session can never receive it; the refusal names the
reconnect. It answers false again from the frame the session
re-establishes, and is false on an engine whose session never established
(an offline boot, or a world still binding) unless vfs.simulateSessionLoss
put it in that state. Ask it before, or after a
failure of, work that writes source in steps: the importers do, and owe
such an import until the session is back.
Returns boolean — True while writes to authored source are refused because the live session is lost.
if vfs.sessionLost() then log.warn("writes wait for the live session") end
typed/builtin//modules/api/engine/vfs/vfs/simulateSessionLoss
vfs.simulateSessionLoss(lost: boolean)
Put the VFS in the state a lost live session puts it in (true), or
take it out of it (false), without touching the connection:
vfs.sessionLost() answers it, and every write to authored source is
refused exactly as it is while a real session is reconnecting. Once a
live session has established, the engine's connection observer states
the real session every frame, so the simulation holds on an engine whose
live session never established (an offline boot), which is where a test
drives the paths that answer a lost session.
Parameters
lostboolean— True to refuse writes to authored source as a lost session does, false to accept them again.
vfs.simulateSessionLoss(true)
vfs.simulateSessionLoss(false)
typed/builtin//modules/api/engine/vfs/vfs/sink
vfs.sink(path: string, opts: SinkOpts) -> PluginHandle
A sink for the file at path, to pass a plugin in a call. The
plugin writes it from empty, up to opts.maxBytes, and the file lands
when the plugin closes the sink or drops it. Only the world's own files
take a sink.
Parameters
pathstring— The file to write.optsSinkOpts—{ maxBytes = <bytes> }, required.
Returns PluginHandle — The sink.
local out = vfs.sink("/source/imported/sphere.zvol", { maxBytes = 64 * 1024 * 1024 })
typed/builtin//modules/api/engine/vfs/vfs/stat
vfs.stat(path: string, opts: VfsOpts?) -> (VfsStat?, string?)
What is at path, how big it is and when it last changed, without
reading it. size is how many bytes vfs.read(path) returns: the
length the layer holding the file states (a file held in memory, a
synced or staged file), so a large file costs nothing to measure, and
where no layer states one (a runtime node, a built-in library file)
the length of the bytes this engine holds. It is absent for a directory
and for a file whose length nothing states and whose bytes are not on
this engine. modified is when this engine's copy last changed, in
seconds since the Unix epoch (the scale os.time() reads): the write
or arrival that put the current content there, which a move carries
along with the content. The memory layer
records it for every file whose bytes it holds: text up to the inline
size threshold, and everything on an engine with no world bound. It is
absent where no time is recorded: a directory nothing was written into
as such, a runtime node, and a file whose bytes the blob store holds
(binary content, and text past the inline size threshold, on a
world-bound engine). For a file, the shell's stat and ls -l print
the same size and time. A path nothing is at answers nil and the reason, the
sentence a vfs.read of it gives.
To tell whether a file changed without reading it: vfs.contentAddress(path)
names the bytes the blob store holds, and vfs.contentVersion(path)
moves on every change this session to a file the memory layer holds.
Parameters
pathstring— VFS path.optsVfsOpts(optional) —{ root = "/source/" }.
Returns (VfsStat?, string?) — The entry's description, or nil and the reason nothing is at the path.
local s = vfs.stat("/zero/source/scenes/main.scene/scene.json"); if s then print(s.size, s.modified) end
typed/builtin//modules/api/engine/vfs/vfs/unmarkPlayShadow
vfs.unmarkPlayShadow(path: string) -> boolean
Forget a single play-shadow path after it has been promoted (saved over source) or discarded. Tracking only — never touches the bytes.
Parameters
pathstring— VFS path to unmark.
Returns boolean — Always true.
vfs.unmarkPlayShadow("/zero/source/Foo.component/init.luau")
typed/builtin//modules/api/engine/vfs/vfs/unwatch
vfs.unwatch(watcherId: number) -> boolean
Remove a previously registered VFS watcher.
Parameters
watcherIdnumber— Watcher id returned byvfs.watch.
Returns boolean — True if the watcher was found and removed.
vfs.unwatch(id)
typed/builtin//modules/api/engine/vfs/vfs/watch
vfs.watch(path: string, callback: (string, string, string) -> ()) -> number
Register a callback that fires when a VFS path is written or
removed. Two match modes: exact, or folder/prefix (key ends with
/, and fires for any descendant). The callback runs in the VM
that registered it. Returns a watcher id for vfs.unwatch.
Parameters
pathstring— Exact path, or folder path ending in/.callback(string, string, string) -> ()—(mutated_path, kind, origin) -> (), kind"write"or"remove"; origin"local"for a change this engine made (throughvfs.*, the shell or any other surface) and"remote"for one that arrived from another participant in the world.
Returns number — Watcher id.
local id = vfs.watch("/zero/source/", function(path, kind, origin) print(kind, origin, path) end)
typed/builtin//modules/api/engine/vfs/vfs/write
vfs.write(path: string, content: string, opts: VfsOpts?) -> (boolean, (WriteReport | string)?)
Write content to a file. Binary-safe. Overwrites existing
files by default — pass opts.overwrite = false to refuse to
clobber. While play is running a /source write lands on the play
shadow: it succeeds and reads back, live in the session, and is
undone by a guarded play-exit unless accepted; the report's
survivesRestart says whether a restart keeps it. Scratch under /source/tmp/ writes through untouched,
in edit mode and in play, and is gone when the engine stops or restarts, so
its report says durable = false, survivesRestart = false and a warning
opening SCRATCH.
Pass opts.durable = true to say these bytes ARE the source: the
write reaches canonical /source with play still running and the
session still in play, hot-reloading the modules and components that
read it, so the edit is observed running in the same play session
with nothing left to promote. A durable write RAISES with the reason
when the bytes cannot become canonical source. One write carries at
most 256 MiB: longer content RAISES, naming its size and that limit,
and the path keeps what it held.
Parameters
pathstring— VFS path to write to.contentstring— File content (binary-safe).optsVfsOpts(optional) —{ root = "/source/", overwrite = true, quiet = false, durable = false }.
Returns (boolean, (WriteReport | string)?) — True and a WriteReport on success: durable says where the bytes went, with survivesRestart / warning / playShadow / shadowed beside it when the play shadow took them and survivesRestart / warning / scratch when they landed in /source/tmp/ scratch, diagnostics carries what the static passes read in the Luau it landed, and contentRequirements names what the asset it landed in must still say before the world publishes. It is the answer the MCP write_file tool gives for the same bytes at the same path. On failure returns false + error message; a durable write, and content past 256 MiB, raise instead of returning false.
vfs.write("/zero/source/notes.md", body)
local ok, report = vfs.write("/zero/source/game/Vent.component/init.luau", src)
if report.diagnostics then for _, d in report.diagnostics do print(d.severity, d.path, d.line, d.message) end end
if report.contentRequirements then for _, r in report.contentRequirements do print(r.path, r.detail) end end
if not report.durable then log.warn(report.warning) end
vfs.write("/zero/source/game/Vent.component/init.luau", src, { durable = true })