Log inGet started

vfs

Updated October 20, 2026

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

  • path string — VFS path.
  • opts VfsOpts (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

  • path string — The file's path, in any form vfs.read takes.

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

  • src string — Source absolute VFS path (file or directory).
  • dst string — 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

  • path string — The folder to write under.
  • opts DirSinkOpts — { 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

  • paths string | { string } — One VFS path, or an array of paths answered together as one write. A path resolves the way vfs.write resolves 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.
  • opts VfsOpts (optional) — { root = "/source/", pending = false }. pending = true asks 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.record opens 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

  • path string — VFS path whose bytes should be evicted.
  • opts VfsOpts (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

  • path string — VFS path to check.
  • opts VfsOpts (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

  • held boolean — 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

  • path string — VFS path of the asset or of anything inside it.
  • opts VfsOpts (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

  • path string — VFS path to classify.
  • opts VfsOpts (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

  • path string — VFS path to classify.
  • opts VfsOpts (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

  • path string (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

  • path string — Directory path.
  • opts VfsOpts (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

  • src string — Source absolute VFS path.
  • dst string — 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

  • path string (optional) — One source to ask about; every source the world authors when absent.
  • opts VfsOpts (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

  • path string — The file to read.
  • opts VfsOpts (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

  • path string — The folder to read.
  • opts VfsOpts (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

  • path string — The VFS path: absolute, @-rooted, or bare under /source/.
  • opts VfsOpts (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

  • path string | { 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

  • path string — VFS path.
  • opts VfsOpts (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

  • path string — VFS path.
  • opts VfsOpts (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

  • path string — VFS path.
  • maxBytes number — 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

  • modulePath string (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

  • path string — VFS path to remove.
  • opts VfsOpts (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

  • path string | { 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

  • lost boolean — 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

  • path string — The file to write.
  • opts SinkOpts — { 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

  • path string — VFS path.
  • opts VfsOpts (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

  • path string — 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

  • watcherId number — Watcher id returned by vfs.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

  • path string — 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 (through vfs.*, 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

  • path string — VFS path to write to.
  • content string — File content (binary-safe).
  • opts VfsOpts (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

  • path string — VFS path.
  • opts VfsOpts? (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

  • path string — The file's path, in any form vfs.read takes.
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

  • src string — Source absolute VFS path (file or directory).
  • dst string — 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

  • path string — The folder to write under.
  • opts DirSinkOpts — { 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

  • paths string | { string } — One VFS path, or an array of paths answered together as one write. A path resolves the way vfs.write resolves 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.
  • opts VfsOpts? (optional) — { root = "/source/", pending = false }. pending = true asks 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.record opens 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

  • path string — VFS path whose bytes should be evicted.
  • opts VfsOpts? (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

  • path string — VFS path to check.
  • opts VfsOpts? (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

  • held boolean — 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

  • path string — VFS path of the asset or of anything inside it.
  • opts VfsOpts? (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

  • path string — VFS path to classify.
  • opts VfsOpts? (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

  • path string — VFS path to classify.
  • opts VfsOpts? (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

  • path string? (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

  • path string — Directory path.
  • opts VfsOpts? (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

  • src string — Source absolute VFS path.
  • dst string — 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

  • path string? (optional) — One source to ask about; every source the world authors when absent.
  • opts VfsOpts? (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

  • path string — The file to read.
  • opts VfsOpts? (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

  • path string — The folder to read.
  • opts VfsOpts? (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

  • path string — The VFS path: absolute, @-rooted, or bare under /source/.
  • opts VfsOpts? (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

  • path string | { 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

  • path string — VFS path.
  • opts VfsOpts? (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

  • path string — VFS path.
  • opts VfsOpts? (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

  • path string — VFS path.
  • maxBytes number — 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

  • modulePath string? (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

  • path string — VFS path to remove.
  • opts VfsOpts? (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

  • path string | { 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

  • lost boolean — 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

  • path string — The file to write.
  • opts SinkOpts — { 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

  • path string — VFS path.
  • opts VfsOpts? (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

  • path string — 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

  • watcherId number — Watcher id returned by vfs.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

  • path string — 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 (through vfs.*, 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

  • path string — VFS path to write to.
  • content string — File content (binary-safe).
  • opts VfsOpts? (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

  • path string — VFS path.
  • opts VfsOpts (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

  • path string — The file's path, in any form vfs.read takes.

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

  • src string — Source absolute VFS path (file or directory).
  • dst string — 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

  • path string — The folder to write under.
  • opts DirSinkOpts — { 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

  • paths string | { string } — One VFS path, or an array of paths answered together as one write. A path resolves the way vfs.write resolves 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.
  • opts VfsOpts (optional) — { root = "/source/", pending = false }. pending = true asks 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.record opens 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

  • path string — VFS path whose bytes should be evicted.
  • opts VfsOpts (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

  • path string — VFS path to check.
  • opts VfsOpts (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

  • held boolean — 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

  • path string — VFS path of the asset or of anything inside it.
  • opts VfsOpts (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

  • path string — VFS path to classify.
  • opts VfsOpts (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

  • path string — VFS path to classify.
  • opts VfsOpts (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

  • path string (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

  • path string — Directory path.
  • opts VfsOpts (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

  • src string — Source absolute VFS path.
  • dst string — 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

  • path string (optional) — One source to ask about; every source the world authors when absent.
  • opts VfsOpts (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

  • path string — The file to read.
  • opts VfsOpts (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

  • path string — The folder to read.
  • opts VfsOpts (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

  • path string — The VFS path: absolute, @-rooted, or bare under /source/.
  • opts VfsOpts (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

  • path string | { 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

  • path string — VFS path.
  • opts VfsOpts (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

  • path string — VFS path.
  • opts VfsOpts (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

  • path string — VFS path.
  • maxBytes number — 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

  • modulePath string (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

  • path string — VFS path to remove.
  • opts VfsOpts (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

  • path string | { 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

  • lost boolean — 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

  • path string — The file to write.
  • opts SinkOpts — { 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

  • path string — VFS path.
  • opts VfsOpts (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

  • path string — 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

  • watcherId number — Watcher id returned by vfs.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

  • path string — 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 (through vfs.*, 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

  • path string — VFS path to write to.
  • content string — File content (binary-safe).
  • opts VfsOpts (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 })
  • api
  • reference