feat(registry): commands, watches, samples, the timeline and conditions
The app side of Crafter.Debug, minus the session:
- Command(name, description, handler): the handler takes nothing or
one argument struct and returns void, a value, Result<T> or
Async<T>. Arguments are decoded before it runs, so a mistyped call
is answered with every mistake and never reaches it; a throwing
handler becomes an Exception error instead of unwinding into the
frame loop. This replaces 3DForts' three-file pattern per verb.
- Watch(path, variable | getter [, setter]) with bounds, help and
read-only, and Sample(path, value) from any thread with a
frame-stamped history. Paths resolve into values ("bodies[1].pos.y")
and set the same way, writing a member writes the whole value back.
Unknown paths and commands get a suggestion.
- Group: registrations under a prefix that end with their owner. Not
movable, so neither is its owner: its watches refer to the owner's
members, and a move would leave them pointing at the old object.
- Log, LogWarn, LogError, Check and Emit on one frame-stamped
timeline, with source locations captured without macros. A check
failing every frame is counted every time but stored sparsely, and
the session's listener hears every failure.
- Condition: what `wait` and `pause --when` evaluate each frame. A
condition that cannot be decided reports why instead of being false,
which would make a wait hang.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:40:59 +01:00
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Watches and samples resolve by path, including into their values
// ("bodies[1].pos.y" against the watch "bodies"), and set the same way:
// writing one member of a watched struct writes the whole struct back. Sets
// respect bounds and read-only watches, computed watches set through their
// setter (which may refuse), samples keep a frame-stamped history, unknown
// paths get a suggestion, and a Group's watches go with it.
import std ;
import Crafter . Debug ;
using namespace Crafter : : Debug ;
namespace {
std : : int32_t failures = 0 ;
void Check ( bool ok , std : : string_view what ) {
std : : println ( " {} {} " , ok ? " PASS " : " FAIL " , what ) ;
if ( ! ok ) + + failures ;
}
struct Vec3 {
float x = 0 ;
float y = 0 ;
float z = 0 ;
} ;
struct Body {
std : : int32_t id = 0 ;
Vec3 pos ;
} ;
float gravity = - 9.81F ;
const std : : int32_t build = 7 ;
std : : vector < Body > bodies = { { 1 , { 0 , 1 , 0 } } , { 2 , { 3 , 4 , 5 } } } ;
std : : int32_t lives = 3 ;
Detail : : Registry & registry ( ) {
return Detail : : Registry : : Instance ( ) ;
}
}
int main ( ) {
Watch ( " physics.gravity " , gravity , { . min = - 50 , . max = 0 , . help = " m/s^2 " } ) ;
Watch ( " build " , build ) ;
Watch ( " bodies " , bodies ) ;
Watch ( " lives " , [ ] { return lives ; } , [ ] ( const std : : int32_t & value ) - > Result < > {
if ( value < 0 ) return Error { " Negative " , " lives cannot be negative " } ;
lives = value ;
return { } ;
} ) ;
Watch ( " score " , [ ] { return 1234 ; } ) ;
2026-11-01 03:21:41 +01:00
Check ( registry ( ) . Resolve ( " physics.gravity " ) . value . AsNumber ( ) = = - 9.81 , " a variable watch reads the variable, a float as its shortest decimal " ) ;
feat(registry): commands, watches, samples, the timeline and conditions
The app side of Crafter.Debug, minus the session:
- Command(name, description, handler): the handler takes nothing or
one argument struct and returns void, a value, Result<T> or
Async<T>. Arguments are decoded before it runs, so a mistyped call
is answered with every mistake and never reaches it; a throwing
handler becomes an Exception error instead of unwinding into the
frame loop. This replaces 3DForts' three-file pattern per verb.
- Watch(path, variable | getter [, setter]) with bounds, help and
read-only, and Sample(path, value) from any thread with a
frame-stamped history. Paths resolve into values ("bodies[1].pos.y")
and set the same way, writing a member writes the whole value back.
Unknown paths and commands get a suggestion.
- Group: registrations under a prefix that end with their owner. Not
movable, so neither is its owner: its watches refer to the owner's
members, and a move would leave them pointing at the old object.
- Log, LogWarn, LogError, Check and Emit on one frame-stamped
timeline, with source locations captured without macros. A check
failing every frame is counted every time but stored sparsely, and
the session's listener hears every failure.
- Condition: what `wait` and `pause --when` evaluate each frame. A
condition that cannot be decided reports why instead of being false,
which would make a wait hang.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:40:59 +01:00
Check ( registry ( ) . Resolve ( " bodies[1].pos.y " ) . value . AsNumber ( ) = = 4 , " a path reads into a watched value " ) ;
Check ( registry ( ) . Resolve ( " bodies[1] " ) . value . Find ( " id " ) - > AsInteger ( ) = = 2 , " an index reads an item " ) ;
Check ( ! registry ( ) . Set ( " physics.gravity " , Value ( - 3.5 ) ) & & gravity = = - 3.5F , " a set writes the variable " ) ;
const auto bounded = registry ( ) . Set ( " physics.gravity " , Value ( 10 ) ) ;
feat(session): serve requests without blocking a thread, pause and step
The app's end of the connection, independent of the transport, which
reaches it through Detail::Exchange: one request's way back, safe from
any thread and never blocking.
- What needs only the session's own state (hello, status, list, log,
pause, resume) is answered on the transport thread. What touches the
app (calls, get, set, trace, screenshot) queues for the next Pump and
runs on the app's thread between frames. Waits and steps reply from
the Pump that sees them satisfied, events from the thread that emits
them. No request ever holds a thread while it waits.
- A request whose timeout passes before Pump is answered Timeout and
is guaranteed not to have run, so an agent never wonders whether a
command went through; an Async result arrives on the Pump it is
ready; requests left when the Session ends are answered ShuttingDown.
- Advance() for frame loops and Paused()/TakeSteps() for fixed-step
loops hold what the app gates on them; pausing an app that gates
nothing is refused instead of silently doing nothing. pause --when
stops the frame its condition holds; pause --on-check stops before
the next Advance in the frame a check fails, optionally raising
SIGTRAP for an attached debugger.
- A wait that times out says what its condition's paths were; a log
follower gets matching entries in order, from the backlog on, and is
dropped when it cannot keep up.
- Request parameters go through Codec, so a mistyped request gets the
same did-you-mean replies a mistyped command does. The session sets
PR_SET_PTRACER_ANY so a debugger the agent starts can attach.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:53:15 +01:00
Check ( bounded & & bounded - > code = = " InvalidParams " & & bounded - > message . find ( " outside " ) ! = std : : string : : npos & & gravity = = - 3.5F , " a set outside the bounds is refused " ) ;
Check ( registry ( ) . Set ( " build " , Value ( 8 ) ) - > code = = " ReadOnly " & & build = = 7 , " a const variable's watch is read-only " ) ;
feat(registry): commands, watches, samples, the timeline and conditions
The app side of Crafter.Debug, minus the session:
- Command(name, description, handler): the handler takes nothing or
one argument struct and returns void, a value, Result<T> or
Async<T>. Arguments are decoded before it runs, so a mistyped call
is answered with every mistake and never reaches it; a throwing
handler becomes an Exception error instead of unwinding into the
frame loop. This replaces 3DForts' three-file pattern per verb.
- Watch(path, variable | getter [, setter]) with bounds, help and
read-only, and Sample(path, value) from any thread with a
frame-stamped history. Paths resolve into values ("bodies[1].pos.y")
and set the same way, writing a member writes the whole value back.
Unknown paths and commands get a suggestion.
- Group: registrations under a prefix that end with their owner. Not
movable, so neither is its owner: its watches refer to the owner's
members, and a move would leave them pointing at the old object.
- Log, LogWarn, LogError, Check and Emit on one frame-stamped
timeline, with source locations captured without macros. A check
failing every frame is counted every time but stored sparsely, and
the session's listener hears every failure.
- Condition: what `wait` and `pause --when` evaluate each frame. A
condition that cannot be decided reports why instead of being false,
which would make a wait hang.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:40:59 +01:00
Check ( ! registry ( ) . Set ( " bodies[0].pos.x " , Value ( 9 ) ) & & bodies [ 0 ] . pos . x = = 9 & & bodies [ 0 ] . pos . y = = 1 , " a set into a member writes back the whole value " ) ;
const auto wrongType = registry ( ) . Set ( " physics.gravity " , Value ( " down " ) ) ;
feat(session): serve requests without blocking a thread, pause and step
The app's end of the connection, independent of the transport, which
reaches it through Detail::Exchange: one request's way back, safe from
any thread and never blocking.
- What needs only the session's own state (hello, status, list, log,
pause, resume) is answered on the transport thread. What touches the
app (calls, get, set, trace, screenshot) queues for the next Pump and
runs on the app's thread between frames. Waits and steps reply from
the Pump that sees them satisfied, events from the thread that emits
them. No request ever holds a thread while it waits.
- A request whose timeout passes before Pump is answered Timeout and
is guaranteed not to have run, so an agent never wonders whether a
command went through; an Async result arrives on the Pump it is
ready; requests left when the Session ends are answered ShuttingDown.
- Advance() for frame loops and Paused()/TakeSteps() for fixed-step
loops hold what the app gates on them; pausing an app that gates
nothing is refused instead of silently doing nothing. pause --when
stops the frame its condition holds; pause --on-check stops before
the next Advance in the frame a check fails, optionally raising
SIGTRAP for an attached debugger.
- A wait that times out says what its condition's paths were; a log
follower gets matching entries in order, from the backlog on, and is
dropped when it cannot keep up.
- Request parameters go through Codec, so a mistyped request gets the
same did-you-mean replies a mistyped command does. The session sets
PR_SET_PTRACER_ANY so a debugger the agent starts can attach.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:53:15 +01:00
Check ( wrongType & & wrongType - > message . find ( " expected a number " ) ! = std : : string : : npos , " a set of the wrong type is refused " ) ;
feat(registry): commands, watches, samples, the timeline and conditions
The app side of Crafter.Debug, minus the session:
- Command(name, description, handler): the handler takes nothing or
one argument struct and returns void, a value, Result<T> or
Async<T>. Arguments are decoded before it runs, so a mistyped call
is answered with every mistake and never reaches it; a throwing
handler becomes an Exception error instead of unwinding into the
frame loop. This replaces 3DForts' three-file pattern per verb.
- Watch(path, variable | getter [, setter]) with bounds, help and
read-only, and Sample(path, value) from any thread with a
frame-stamped history. Paths resolve into values ("bodies[1].pos.y")
and set the same way, writing a member writes the whole value back.
Unknown paths and commands get a suggestion.
- Group: registrations under a prefix that end with their owner. Not
movable, so neither is its owner: its watches refer to the owner's
members, and a move would leave them pointing at the old object.
- Log, LogWarn, LogError, Check and Emit on one frame-stamped
timeline, with source locations captured without macros. A check
failing every frame is counted every time but stored sparsely, and
the session's listener hears every failure.
- Condition: what `wait` and `pause --when` evaluate each frame. A
condition that cannot be decided reports why instead of being false,
which would make a wait hang.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:40:59 +01:00
Check ( ! registry ( ) . Set ( " lives " , Value ( 5 ) ) & & lives = = 5 , " a computed watch sets through its setter " ) ;
const auto negative = registry ( ) . Set ( " lives " , Value ( - 1 ) ) ;
feat(session): serve requests without blocking a thread, pause and step
The app's end of the connection, independent of the transport, which
reaches it through Detail::Exchange: one request's way back, safe from
any thread and never blocking.
- What needs only the session's own state (hello, status, list, log,
pause, resume) is answered on the transport thread. What touches the
app (calls, get, set, trace, screenshot) queues for the next Pump and
runs on the app's thread between frames. Waits and steps reply from
the Pump that sees them satisfied, events from the thread that emits
them. No request ever holds a thread while it waits.
- A request whose timeout passes before Pump is answered Timeout and
is guaranteed not to have run, so an agent never wonders whether a
command went through; an Async result arrives on the Pump it is
ready; requests left when the Session ends are answered ShuttingDown.
- Advance() for frame loops and Paused()/TakeSteps() for fixed-step
loops hold what the app gates on them; pausing an app that gates
nothing is refused instead of silently doing nothing. pause --when
stops the frame its condition holds; pause --on-check stops before
the next Advance in the frame a check fails, optionally raising
SIGTRAP for an attached debugger.
- A wait that times out says what its condition's paths were; a log
follower gets matching entries in order, from the backlog on, and is
dropped when it cannot keep up.
- Request parameters go through Codec, so a mistyped request gets the
same did-you-mean replies a mistyped command does. The session sets
PR_SET_PTRACER_ANY so a debugger the agent starts can attach.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:53:15 +01:00
Check ( negative & & negative - > message . find ( " lives cannot be negative " ) ! = std : : string : : npos & & lives = = 5 , " a setter can refuse " ) ;
Check ( registry ( ) . Set ( " score " , Value ( 1 ) ) - > code = = " ReadOnly " , " a getter-only watch is read-only " ) ;
feat(registry): commands, watches, samples, the timeline and conditions
The app side of Crafter.Debug, minus the session:
- Command(name, description, handler): the handler takes nothing or
one argument struct and returns void, a value, Result<T> or
Async<T>. Arguments are decoded before it runs, so a mistyped call
is answered with every mistake and never reaches it; a throwing
handler becomes an Exception error instead of unwinding into the
frame loop. This replaces 3DForts' three-file pattern per verb.
- Watch(path, variable | getter [, setter]) with bounds, help and
read-only, and Sample(path, value) from any thread with a
frame-stamped history. Paths resolve into values ("bodies[1].pos.y")
and set the same way, writing a member writes the whole value back.
Unknown paths and commands get a suggestion.
- Group: registrations under a prefix that end with their owner. Not
movable, so neither is its owner: its watches refer to the owner's
members, and a move would leave them pointing at the old object.
- Log, LogWarn, LogError, Check and Emit on one frame-stamped
timeline, with source locations captured without macros. A check
failing every frame is counted every time but stored sparsely, and
the session's listener hears every failure.
- Condition: what `wait` and `pause --when` evaluate each frame. A
condition that cannot be decided reports why instead of being false,
which would make a wait hang.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:40:59 +01:00
const auto missing = registry ( ) . Resolve ( " physics.gravty " ) ;
Check ( ! missing . Ok ( ) & & missing . error . find ( " did you mean 'physics.gravity' " ) ! = std : : string : : npos , " an unknown path gets a suggestion " ) ;
const auto badMember = registry ( ) . Resolve ( " bodies[0].pos.w " ) ;
Check ( ! badMember . Ok ( ) & & badMember . error . find ( " has no member 'w' " ) ! = std : : string : : npos , " an unknown member is named " ) ;
const auto outOfRange = registry ( ) . Resolve ( " bodies[5] " ) ;
Check ( ! outOfRange . Ok ( ) & & outOfRange . error . find ( " 2 items " ) ! = std : : string : : npos , " an index out of range gives the size " ) ;
for ( std : : uint64_t frame = 1 ; frame < = 3 ; + + frame ) {
Detail : : SetFrame ( frame ) ;
Sample ( " xpbd.residual " , static_cast < double > ( frame ) / 10 ) ;
}
Check ( registry ( ) . Resolve ( " xpbd.residual " ) . value . AsNumber ( ) = = 0.3 , " a sample resolves to its latest value " ) ;
const auto history = registry ( ) . SampleHistory ( " xpbd.residual " ) ;
Check ( history . size ( ) = = 3 & & history . front ( ) . frame = = 1 & & history . back ( ) . frame = = 3 , " a sample keeps a frame-stamped history " ) ;
Check ( registry ( ) . Watches ( " physics.* " ) . size ( ) = = 1 & & registry ( ) . Watches ( " * " ) . size ( ) = = 5 , " watches can be listed by glob " ) ;
Check ( registry ( ) . Watches ( " physics.gravity " ) . front ( ) - > help = = " m/s^2 " & & registry ( ) . Watches ( " physics.gravity " ) . front ( ) - > type . Find ( " maximum " ) ! = nullptr ,
" a watch carries its help and bounds " ) ;
{
struct Fort {
std : : int32_t hp = 100 ;
Group debug { " fort1 " } ;
Fort ( ) { debug . Watch ( " core.hp " , hp ) ; }
} ;
Fort fort ;
Check ( registry ( ) . Resolve ( " fort1.core.hp " ) . value . AsInteger ( ) = = 100 , " a Group prefixes its watches " ) ;
}
Check ( ! registry ( ) . Resolve ( " fort1.core.hp " ) . Ok ( ) , " a Group's watches go with it " ) ;
return failures = = 0 ? 0 : 1 ;
}