tecs.assets

Acquires assets without blocking the host, and owns what a caller holds.

A load returns the asset. There is no future, no promise and no callback in the game-facing signature: a request that cannot answer at once parks its coroutine through nupp.suspension and resumes with the answer, so the same call reads the same way inside a frame system and inside a startup script.

local loader = app.assets
local handle, reason = loader:request(tecs.assets.bytes, "levels/one.json")
if handle == nil then
    return nil, reason
end
local text <const> = handle:value() as string
handle:release()

Application creates app.assets and shuts it down after world shutdown. Standalone tools may use newAssets and must call shutdown themselves. Settled handles remain valid until their owners release them; stopping the loader cancels acquisitions, not those existing holds.

Inside a system the same two lines suspend the logical update rather than the thread, and the host renders the frames that pass while the file is read. Where no suspension handler is installed, the built-in driver runs the readiness pumps on this thread and the call still returns the asset.

What this module owns, and what it does not#

This is orchestration: requesting, sharing an acquisition between callers that want the same thing, counting holds, replacing a payload in place and reporting a failure to the call that asked for it. Reading bytes, decoding pixels, uploading a texture and freeing a native allocation belong to a Kind, which supplies a Job per acquisition and a release for the payload it produced. A GPU service registers a kind whose job issues its own upload command; nothing about textures is written here.

Failure#

An invalid argument raises at the call: an empty path, a released handle, a loader that has shut down. A load that could not produce its asset answers nil, reason from request, whether it failed before parking or hours later. The readiness pump never raises a load failure of its own, because the call that asked for the asset is the only place the reason means anything; the pump resumes that call and lets it return the pair its signature promises.

Holds and residency#

A Handle is a hold, not a cache entry. Two requests for one path that overlap share one acquisition and receive holds on one handle, because decoding the same file twice at once duplicates work without producing another result. A request made after the first settles acquires again: nothing here retains a settled asset behind the caller's back, and the payload lives exactly as long as the holds on it.

release gives up one hold, and the last one frees the payload through the kind. Releasing a handle already down to nothing does nothing, so a shutdown path need not know whether something else got there first.

Replacement#

reload acquires the same path again and swaps the result into a handle every holder already has, then frees what it displaced. The handle's identity does not change and its revision advances, which is what a consumer holding device state keyed on the handle watches to know its copy is stale. A failed reload leaves the resident payload exactly as it was and answers false, reason.

Module contents

Types

TypeKindDescription
AssetsrecordA loader: the acquisitions in flight, the holds they produced, and the readiness pump that advances them.
HandlerecordA caller's hold on one acquired asset.
JobrecordOne acquisition in progress, owned by the kind that opened it.
KindrecordHow one class of asset is acquired and freed.
StatustypeWhere one acquisition stands.

Functions

FunctionKindDescription
newAssetsfunctionCreates a loader with nothing in flight and its readiness pump registered.
readJobfunctionBegins a complete file read that settles on the readiness pump.

Values

ValueKindDescription
bytesvariableReads a complete file into a binary-safe string without interpreting it.
DOCUMENTvariableRead-only.
FONTvariableRead-only.
IMAGEvariableRead-only.
MODELvariableRead-only.
SHADERvariableRead-only.
SOUNDvariableRead-only.

Types#

Assetsrecord#

record Assets ...

A loader: the acquisitions in flight, the holds they produced, and the readiness pump that advances them.

One loader per application. Two loaders share nothing, which is what makes a test able to shut one down without disturbing anything else in the process.

Members

NameKindDescription
entriesfieldEngine-owned.
orderfieldEngine-owned.
inFlightfieldEngine-owned.
residentCountfieldEngine-owned.
serialfieldEngine-owned.
drainersfieldEngine-owned.
pumpfieldEngine-owned.
openfieldEngine-owned.
requestmethod
reloadmethod
drainmethod
pendingmethod
residentmethod
runningmethod
shutdownmethod

entriesfield#

entries: {[string]: Entry}
@private

Engine-owned. Indexes acquisitions in flight by kind and path so two overlapping requests share one. Ordinary game code reads pending.

orderfield#

order: {Entry}
@private

Engine-owned. Holds the same acquisitions in a stable order the readiness pump walks without allocating.

inFlightfield#

inFlight: integer
@private

Engine-owned. Counts acquisitions in flight.

residentCountfield#

residentCount: integer
@private

Engine-owned. Counts handles still holding a payload.

serialfield#

serial: integer
@private

Engine-owned. Numbers private replacement acquisitions so two reloads never share an entry.

drainersfield#

drainers: {Waiter}
@private

Engine-owned. Holds the callers parked in drain.

pumpfield#

pump: suspension.Source?
@private

Engine-owned. Holds the registered readiness pump until shutdown.

openfield#

open: boolean
@private

Engine-owned. Reports whether this loader still accepts requests.

requestmethod#

request: function(self: Assets, kind: Kind, path: string): (Handle?, string?)
Arguments
NameTypeDescription
selfAssets
kindKind
pathstring
Returns
TypeDescription
Handle?
string?

reloadmethod#

reload: function(self: Assets, kind: Kind, handle: Handle): (boolean, string?)
Arguments
NameTypeDescription
selfAssets
kindKind
handleHandle
Returns
TypeDescription
boolean
string?

drainmethod#

drain: function(exclusive self: Assets): (boolean, string?)
Arguments
NameTypeDescription
exclusive selfAssets
Returns
TypeDescription
boolean
string?

pendingmethod#

pending: function(borrows self: Assets): integer
Arguments
NameTypeDescription
borrows selfAssets
Returns
TypeDescription
integer

residentmethod#

resident: function(borrows self: Assets): integer
Arguments
NameTypeDescription
borrows selfAssets
Returns
TypeDescription
integer

runningmethod#

running: function(borrows self: Assets): boolean
Arguments
NameTypeDescription
borrows selfAssets
Returns
TypeDescription
boolean

shutdownmethod#

shutdown: function(exclusive self: Assets): nil
Arguments
NameTypeDescription
exclusive selfAssets
Returns
TypeDescription
nil

Handlerecord#

record Handle ...

A caller's hold on one acquired asset.

Handles are shared: every caller whose request joined one acquisition gets this same object, and the payload lives until the last of them releases it.

Members

NameKindDescription
pathfieldRead-only.
kindfieldRead-only.
refsfieldEngine-owned.
payloadfieldEngine-owned.
replacementsfieldEngine-owned.
freefieldEngine-owned.
ownerfieldEngine-owned.
valuemethod
residentmethod
revisionmethod
holdsmethod
retainmethod
releasemethod

pathfield#

path: string

Read-only. Contains the requested path unchanged. The loader does not resolve it, so it is whatever the caller passed.

kindfield#

kind: string

Read-only. Contains the content kind name this asset was acquired under.

refsfield#

refs: integer
@private

Engine-owned. Counts the callers holding this payload. Ordinary game code reads holds instead.

payloadfield#

payload: any?
@private

Engine-owned. Holds the acquired payload until the last release, and nil after it. Ordinary game code reads value instead.

replacementsfield#

replacements: integer
@private

Engine-owned. Counts the replacements this handle has taken. Ordinary game code reads revision instead.

freefield#

free: (function(value: any): nil)?
@private

Engine-owned. Frees the payload at the last release, or nil where the collector owns it.

ownerfield#

owner: Assets
@private

Engine-owned. Names the loader that counts this handle as resident.

valuemethod#

value: function(borrows self: Handle): any?
Arguments
NameTypeDescription
borrows selfHandle
Returns
TypeDescription
any?

residentmethod#

resident: function(borrows self: Handle): boolean
Arguments
NameTypeDescription
borrows selfHandle
Returns
TypeDescription
boolean

revisionmethod#

revision: function(borrows self: Handle): integer
Arguments
NameTypeDescription
borrows selfHandle
Returns
TypeDescription
integer

holdsmethod#

holds: function(borrows self: Handle): integer
Arguments
NameTypeDescription
borrows selfHandle
Returns
TypeDescription
integer

retainmethod#

retain: function(exclusive self: Handle): nil
Arguments
NameTypeDescription
exclusive selfHandle
Returns
TypeDescription
nil

releasemethod#

release: function(exclusive self: Handle): nil
Arguments
NameTypeDescription
exclusive selfHandle
Returns
TypeDescription
nil

Jobrecord#

record Job ...

One acquisition in progress, owned by the kind that opened it.

A job is driven by the loader's readiness pump, never by the caller. It runs on whatever the kind uses underneath: a file read, a worker channel, a native service reply. Nothing here assumes which.

Members

NameKindDescription
pollmethodAdvances the acquisition and reports where it stands.
cancelmethodAbandons the acquisition and frees anything it holds.

pollmethod#

poll: function(): (Status, any?, string?)

Advances the acquisition and reports where it stands.

Answers "pending" alone while the work continues, "ready" and the payload once it is done, or "failed" and a non-empty reason. The loader stops calling it after the first answer that is not "pending", and the payload it hands back becomes the loader's to free through the kind.

Returns
TypeDescription
Status
any?
string?

cancelmethod#

cancel: function(): nil

Abandons the acquisition and frees anything it holds.

The loader calls it at most once, and only for a job that has not answered "ready" or "failed". It must not raise.

Returns
TypeDescription
nil

Kindrecord#

record Kind ...

How one class of asset is acquired and freed.

A payload is whatever the kind's job produced, so this layer types it as any and the caller casts once at value. A phantom type parameter would move that cast rather than check it, and the loader would still be handing back what the job supplied.

Members

NameKindDescription
namefieldRead-only.
openmethodRead-only.
releasefieldRead-only.

namefield#

name: string

Read-only. Contains the content kind name this asset is recorded under, which is the string file watching and tooling filter on.

openmethod#

open: function(path: string): (Job?, string?)

Read-only. Begins one acquisition of path.

Answers the job that will produce the payload, or nil, reason where the acquisition cannot start at all. A refusal here reaches the requesting call without parking it.

Arguments
NameTypeDescription
pathstring
Returns
TypeDescription
Job?
string?

releasefield#

release: (function(value: any): nil)?

Read-only. Frees one payload after its last hold goes. Nil where the collector owns the payload outright, which is true of a plain Lua string.

Statustype#

type Status = "pending" | "ready" | "failed"

Where one acquisition stands.

Functions#

newAssetsfunction#

function newAssets(): Assets

Creates a loader with nothing in flight and its readiness pump registered.

The pump reports itself inactive while nothing is in flight, so a program with no suspension handler never sleeps on a loader that has no work.

Returns

TypeDescription
Assets

the running loader

readJobfunction#

function readJob(path: string): Job?, string?

Begins a complete file read that settles on the readiness pump.

The read itself runs in the pump's pass rather than in the requesting call, so a request for a file parks and resumes exactly as one for a decoded asset does. A kind that needs the bytes before it can do its own work composes this job rather than reading the file inline.

Arguments

NameTypeDescription
pathstring

the file to read

Returns

TypeDescription
Job?

the job that answers the complete bytes

string?

a failure reason, when the read cannot be started

Values#

bytesvariable#

const bytes: Kind

Reads a complete file into a binary-safe string without interpreting it.

A handle of this kind carries a string, so handle:value() as string is the cast its caller writes. The collector owns the string and the kind frees nothing. Releasing the handle still matters: it is what tells the loader the asset is no longer resident.

DOCUMENTvariable#

const DOCUMENT

Read-only. Names the content kind of a file loaded without interpretation.

FONTvariable#

const FONT

Read-only. Names the content kind of a loaded font.

IMAGEvariable#

const IMAGE

Read-only. Names the content kind of a decoded image.

MODELvariable#

const MODEL

Read-only. Names the content kind of a decoded model scene.

SHADERvariable#

const SHADER

Read-only. Names the content kind of a loaded shader.

SOUNDvariable#

const SOUND

Read-only. Names the content kind of a loaded sound.