Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,17 @@ suspends, later events wait for the next update. Watcher changes use the same
boundary. The scheduler therefore commits a phase once and extraction never
observes half of an external batch.

Synthetic input takes the same boundary. The debug server's `send_event` used
to fold its event into input state the moment the tool ran, which is between
frames: the next frame's `beginFrame` then cleared the edges, so an injected
key was down but never pressed, and a test harness had to queue events inside
the game instead. The application now queues them and delivers them after the
host's batch on the next frame that runs. Pausing is the application's rather
than the host's for the same reason: a held iteration keeps its events for the
frame that runs and a stepped frame takes a fixed delta, so a paused game
advances by exactly the frames granted, on any host, while the host keeps
rendering and serving screenshots of the held world.

### Application lifecycle and presentation timing

The application owns asset orchestration and optional debugging because both
Expand Down
5 changes: 5 additions & 0 deletions docs/gfx/screenshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,8 @@ Only requested frames allocate readback storage and wait for GPU completion.
Keep captures for as long as needed; releasing your references allows their
pixel and PNG data to be collected. Several requests for one frame share its
GPU readback.

The debug server's `screenshot` tool requests the same capture from outside
the game and writes the PNG once the host has rendered the frame;
`screenshot_info` reports when it is written. See [](tecs.mcp) for the tool
and for pausing and stepping the simulation around it.
178 changes: 176 additions & 2 deletions src/tecs/application.nupp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,29 @@ parks asynchronous startup as well as updates. A custom host must follow the
same ordering. Diagnostics remain available during a parked operation; tools
that could touch its in-progress world report a retryable refusal.

## Driving frames from outside

A test harness or debugger drives an application deterministically with three
calls, which the MCP `send_event` and `clock_*` tools reach. `queueEvent`
queues a platform event for the next frame that runs, delivered after the
host's batch for that turn exactly as a platform event is, so per-frame edges
such as `keyPressed` survive the frame's `beginFrame`. `pause` holds the
simulation: an iteration that has no granted frame runs no system, folds no
input, leaves `frame` and `elapsed` unchanged, and keeps the host's events for
the frame that eventually runs, honoring only a quit. The host keeps rendering
the held world, so a screenshot requested while paused shows it. `step` grants
frames that run while paused, each with the fixed delta it names rather than
the host's measured one, so a paused application advances by exactly the
granted frames and simulated time.

```nupp
app:pause()
app:queueEvent(tecs.platform.events.key(true, "Space", " ", " ", 0, false, 0))
app:step(1, 1 / 60)
app:iterate(0.5, nil) -- runs one frame with dt 1/60 and sees Space pressed
app:iterate(0.5, nil) -- holds: no frame, no time
```

Shutdown first unwinds a parked host operation, then attempts world shutdown,
stops the loader, and closes the debug listener. Later application cleanup
still runs if world shutdown raises. Resident asset handles retain their
Expand Down Expand Up @@ -72,6 +95,10 @@ export record Application is mcpbindings.Application
elapsed: number
frame: integer

--- Read-only. Reports whether `pause` holds the simulation. Change it through
--- `pause` and `resume`.
paused: boolean

@private state: State
@private plugin: Plugin?
@private onEvent: (function(event: platformevents.Event, exclusive app: Application): nil)?
Expand All @@ -81,6 +108,10 @@ export record Application is mcpbindings.Application
@private debugMaxFrames: integer?
@private failure: string?
@private shutdownDone: boolean
@private steps: integer
@private stepDt: number
@private held: {platformevents.Event}
@private injected: {platformevents.Event}

--- Polls optional debugging once per host turn, outside the world update scope.
--- @param self the application to serve.
Expand All @@ -96,6 +127,64 @@ export record Application is mcpbindings.Application
clearCrash: function(exclusive self: Application): (boolean, string?)
shutdown: function(exclusive self: Application): boolean
getState: function(borrows self: Application): State

--- Queues a platform event for the next frame that runs.
---
--- The event reaches window state, input state, the world's observers and
--- `Config.onEvent` after that frame's `beginFrame`, behind the host's own
--- batch for the same turn, so per-frame edges such as `keyPressed` read
--- true for exactly that frame. While paused the event waits for the next
--- frame `step` or `resume` lets run.
--- @param self the application to deliver to
--- @param event the event, delivered as if the platform had sent it
queueEvent: function(exclusive self: Application, event: platformevents.Event): nil

--- Holds the simulation from the next iteration on.
---
--- A held iteration runs no system and folds no input, and leaves `frame`
--- and `elapsed` unchanged. Host events wait for the next frame that runs,
--- up to the latest 1024, except that a quit or close request still stops
--- the application. The
--- host keeps rendering the held world. Pausing again does nothing, and
--- frames already granted by `step` are kept.
--- @param self the application to hold
pause: function(exclusive self: Application): nil

--- Lets the simulation run on the host's measured delta again.
---
--- Discards any frames `step` granted and has not run yet. Resuming a
--- running application does nothing.
--- @param self the application to release
resume: function(exclusive self: Application): nil

--- Grants frames that run while paused, one per host iteration.
---
--- Each granted frame passes `dt` to the world in place of the host's
--- delta, so a script of steps reproduces the same simulated time on every
--- run and every host. The delta applies to every granted frame not yet
--- run, including ones an earlier call granted.
--- @param self the paused application
--- @param frames how many more frames to run, at least one
--- @param dt the fixed delta of each granted frame, in seconds
--- @raises string when the application is not paused
--- @raises string when `frames` is not a positive integer or `dt` is not a
--- positive finite number
step: function(exclusive self: Application, frames: integer, dt: number): nil

--- Returns the frames `step` granted that have not started.
--- @param self the application to inspect
--- @return the count, zero while running or once every granted frame ran
pendingSteps: function(borrows self: Application): integer

--- Returns the fixed delta granted frames run with.
--- @param self the application to inspect
--- @return the delta in seconds the last `step` named, or 1/60 before one
stepDelta: function(borrows self: Application): number

--- Returns how many events wait for the next frame that runs.
--- @param self the application to inspect
--- @return the count of queued and held events
pendingEvents: function(borrows self: Application): integer
end

--- Settings copied by `newApplication`.
Expand All @@ -112,6 +201,10 @@ export type Config = {
debugMaxFrames: integer?
}

-- The most events a paused application holds for its next frame. Past it the
-- oldest are dropped, as the host's own input routes drop theirs.
local HELD_LIMIT: integer = 1024

-- The tool registry names one application per runtime. A second listener must
-- not silently redirect the first application's tools into another world.
local debugOwner: Application? = nil
Expand Down Expand Up @@ -179,7 +272,12 @@ export function newApplication(config: Config?): Application
debug = config ~= nil and config.debug == true,
debugMaxFrames = maxFrames,
failure = nil,
shutdownDone = false
shutdownDone = false,
paused = false,
steps = 0,
stepDt = 1 / 60,
held = {},
injected = {}
)
app.bindDebug = function(): nil
debugOwner = app
Expand Down Expand Up @@ -263,8 +361,45 @@ function Application.iterate(exclusive self: Application, dt: number, batch: {pl
return false
end

-- Arrival order: events held while paused, then this turn's host batch,
-- then what `queueEvent` added since the last iteration.
local incoming = self.held
for _, event in ipairs(batch or {}) do
incoming[#incoming + 1] = event
end
for _, event in ipairs(self.injected) do
incoming[#incoming + 1] = event
end
self.held = {}
self.injected = {}
if self.paused then
if self.steps <= 0 then
-- A held turn: nothing runs and the events wait for a frame that
-- does, but a quit is a request to the process rather than to a
-- frame, so it is still honored.
for index = 1, #incoming do
if incoming[index] is platformevents.QuitEvent then
self.quitRequested = true
return false
end
end
if #incoming > HELD_LIMIT then
-- A long pause under a live window must not grow without bound.
local kept: {platformevents.Event} = {}
for index = #incoming - HELD_LIMIT + 1, #incoming do
kept[#kept + 1] = incoming[index]
end
incoming = kept
end
self.held = incoming

return true
end
self.steps = self.steps - 1
dt = self.stepDt
end

self.input:beginFrame()
local incoming = batch or {}
local handler = self.onEvent
for index = 1, #incoming do
local event = incoming[index]
Expand Down Expand Up @@ -307,6 +442,45 @@ function Application.iterate(exclusive self: Application, dt: number, batch: {pl
return not self.quitRequested
end

function Application.queueEvent(exclusive self: Application, event: platformevents.Event): nil
self.injected[#self.injected + 1] = event
end

function Application.pause(exclusive self: Application): nil
self.paused = true
end

function Application.resume(exclusive self: Application): nil
self.paused = false
self.steps = 0
end

function Application.step(exclusive self: Application, frames: integer, dt: number): nil
if not self.paused then
error("application.step: the application is not paused", 2)
end
if frames < 1 or frames % 1 ~= 0 then
error("application.step: frames must be a positive integer", 2)
end
if not (dt > 0) or dt == math.huge then
error("application.step: dt must be a positive finite number", 2)
end
self.steps = self.steps + frames
self.stepDt = dt
end

function Application.pendingSteps(borrows self: Application): integer
return self.steps
end

function Application.stepDelta(borrows self: Application): number
return self.stepDt
end

function Application.pendingEvents(borrows self: Application): integer
return #self.held + #self.injected
end

--- Requests that the host stop after the current callback returns.
--- @param self the application to stop
function Application.requestQuit(exclusive self: Application): nil
Expand Down
9 changes: 9 additions & 0 deletions src/tecs/internal/mcpbindings.nupp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ share one binding without either requiring the other.
local ecs = require("tecs.ecs")

local input = require("tecs.input")
local platformevents = require("tecs.platform.events")
local window = require("tecs.platform.window")

--- The application surface debug tools use, independent of lifecycle ownership.
Expand All @@ -29,6 +30,14 @@ export interface Application
getState: function(borrows self: Application): string
requestQuit: function(exclusive self: Application): nil
clearCrash: function(exclusive self: Application): (boolean, string?)
paused: boolean
queueEvent: function(exclusive self: Application, event: platformevents.Event): nil
pause: function(exclusive self: Application): nil
resume: function(exclusive self: Application): nil
step: function(exclusive self: Application, frames: integer, dt: number): nil
pendingSteps: function(borrows self: Application): integer
stepDelta: function(borrows self: Application): number
pendingEvents: function(borrows self: Application): integer
end

local boundWorld: ecs.World? = nil
Expand Down
14 changes: 14 additions & 0 deletions src/tecs/internal/mcpregistry.nupp
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ local revision: integer = 0
--- The traceback of a guarded game failure, once one has been recorded.
local crashedText: string? = nil

--- Whether the tool call in progress runs while a lifecycle operation is parked.
local callingParked: boolean = false

--- Reports whether the tool call in progress runs while a lifecycle
--- operation is parked, which a `whenParked` handler reads to tell an agent
--- that a frame is still mid-flight.
--- @return true inside a handler called during a parked operation, and false
--- otherwise, including outside any handler
export function parkedCall(): boolean
return callingParked
end

--- Records a crash so every world-touching tool reports it instead of running.
--- @param traceback the failure text, or nil to clear the crashed state
export function setCrashed(traceback: string?): nil
Expand Down Expand Up @@ -202,7 +214,9 @@ export function callTool(tool: Tool, arguments: {[string]: any}, parked: boolean
end
-- A tool that raises must not take the server with it: the agent is told,
-- and the frame carries on.
callingParked = parked == true
local called, payload = pcall(tool.handler, arguments)
callingParked = false
if not called then
logger:warn("tool %s failed: %s", tool.name, tostring(payload))

Expand Down
72 changes: 72 additions & 0 deletions src/tecs/internal/mcpschemas.nupp
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,75 @@ export const inputs: {
["additionalProperties"] = false
}
}

-- Schemas for tools added after that listing, so not checked against it.
-- `screenshot` keeps the historical tool's `name` key and adds `path`. Names
-- and argument keys are external compatibility contracts all the same.
export const added: {
[string]: any
} = {
["clock_info"] = {["properties"] = json.EMPTY_OBJECT, ["type"] = "object", ["additionalProperties"] = false},
["clock_pause"] = {["properties"] = json.EMPTY_OBJECT, ["type"] = "object", ["additionalProperties"] = false},
["clock_resume"] = {["properties"] = json.EMPTY_OBJECT, ["type"] = "object", ["additionalProperties"] = false},
[
"clock_step"
] = {
[
"properties"
] = {
[
"frames"
] = {
["type"] = "number",
["description"] = "Sets how many more frames run, as a whole number. (default: 1)",
["minimum"] = 1
},
[
"dt"
] = {
["type"] = "number",
["description"] = "Sets the fixed delta of each granted frame in seconds. (default: 1/60)",
["minimum"] = 1e-06,
["maximum"] = 1
}
},
["type"] = "object",
["additionalProperties"] = false
},
[
"screenshot"
] = {
[
"properties"
] = {
[
"name"
] = {
["type"] = "string",
["description"] = "File name under the writable root. Defaults to screenshot.png."
},
[
"path"
] = {
["type"] = "string",
["description"] = "Writes the PNG at this absolute path instead of under the writable root."
}
},
["type"] = "object",
["additionalProperties"] = false
},
[
"screenshot_info"
] = {
[
"properties"
] = {
[
"id"
] = {["type"] = "number", ["description"] = "Selects the capture `screenshot` reported.", ["minimum"] = 1}
},
["required"] = {"id"},
["type"] = "object",
["additionalProperties"] = false
}
}
Loading