From 8dcbec072a638ab7fb3877a0c71ea9cd975429f3 Mon Sep 17 00:00:00 2001 From: R0ck Date: Wed, 14 Oct 2026 23:55:48 +0100 Subject: [PATCH] sim: a tick covers its step_ms, so faster and slower change the run The engine steps in its own 10 ms, and a tick took exactly one step whatever step_ms said, then set the clock back from the engine. The transport's slower and faster buttons and sim.speed changed the label and nothing else, and every session run went at real time with the processor mostly idle. A tick is now owed its step_ms and pays it in whole engine steps, checking due sends after each, and stops at the end of a timed run. --- docs/verb-reference.md | 12 ++--- docs/verbs.json | 12 ++--- internal/app/session/clockstep.go | 51 ++++++++++++++++++++ internal/app/session/clockstep_test.go | 56 ++++++++++++++++++++++ internal/app/session/engine.go | 3 ++ internal/app/session/simctl.verbs.json | 2 +- internal/app/session/verbs.go | 15 +----- internal/app/session/verbsclock.verbs.json | 10 ++-- 8 files changed, 130 insertions(+), 31 deletions(-) create mode 100644 internal/app/session/clockstep.go create mode 100644 internal/app/session/clockstep_test.go diff --git a/docs/verb-reference.md b/docs/verb-reference.md index 48de1e69..c820df2d 100644 --- a/docs/verb-reference.md +++ b/docs/verb-reference.md @@ -1379,13 +1379,13 @@ Not made by the test suite: this call needs more than the two-node headless sess ### `sim.faster` -Double how much simulated time one tick covers, trading detail for how long a run takes to watch. +Double how much simulated time one tick covers, which makes the run go twice as fast until the machine cannot keep up. **Takes** nothing. -**Answers** `step_ms`. `step_ms` is the new tick length. Nothing bounds it: enough calls will step past whole transmissions. +**Answers** `step_ms`. `step_ms` is the new tick length, capped at 1000. A tick is ten milliseconds of wall time and the engine still steps in its own 10 ms, so nothing is skipped: past what the machine can step in a tick the run simply goes as fast as it can, and the transport's realtime figure says how fast that is. -**Example** - run twice as coarsely +**Example** - run twice as fast ```json {"id":1,"method":"sim.faster","params":{}} @@ -1551,9 +1551,9 @@ Halve how much simulated time one tick covers. **Takes** nothing. -**Answers** `step_ms`. `step_ms` is the new tick length, which reaches zero after enough calls and stops the clock advancing at all. +**Answers** `step_ms`. `step_ms` is the new tick length, never below 1. Below the engine's own 10 ms step, a tick steps only once it has been owed a whole step, so 5 is half real time. -**Example** - run twice as finely +**Example** - run at half the speed ```json {"id":1,"method":"sim.slower","params":{}} @@ -1569,7 +1569,7 @@ Set how much simulated time one tick covers, which is the honest form of a speed | parameter | type | | what | |---|---|---|---| -| `step_ms` | number | optional | simulated milliseconds per tick, which is what the engine actually paces on | +| `step_ms` | number | optional | simulated milliseconds per tick, from 1 to 1000; a tick is ten milliseconds of wall time, so 10 is real time and 1000 is as fast as the machine can step | | `factor` | number | optional | a multiple of ten milliseconds per tick, read only when `step_ms` is absent, for scripts written against the old socket | **Answers** `step_ms`. `step_ms` is what it settled on. Neither parameter positive leaves the tick alone and reports it. diff --git a/docs/verbs.json b/docs/verbs.json index 38e15670..dc74085c 100644 --- a/docs/verbs.json +++ b/docs/verbs.json @@ -4709,14 +4709,14 @@ } }, "sim.faster": { - "what": "double how much simulated time one tick covers, trading detail for how long a run takes to watch", + "what": "double how much simulated time one tick covers, which makes the run go twice as fast until the machine cannot keep up", "returns": [ "step_ms" ], - "answers": "`step_ms` is the new tick length. Nothing bounds it: enough calls will step past whole transmissions.", + "answers": "`step_ms` is the new tick length, capped at 1000. A tick is ten milliseconds of wall time and the engine still steps in its own 10 ms, so nothing is skipped: past what the machine can step in a tick the run simply goes as fast as it can, and the transport's realtime figure says how fast that is.", "example": { "params": {}, - "what": "run twice as coarsely", + "what": "run twice as fast", "runnable": true } }, @@ -4867,10 +4867,10 @@ "returns": [ "step_ms" ], - "answers": "`step_ms` is the new tick length, which reaches zero after enough calls and stops the clock advancing at all.", + "answers": "`step_ms` is the new tick length, never below 1. Below the engine's own 10 ms step, a tick steps only once it has been owed a whole step, so 5 is half real time.", "example": { "params": {}, - "what": "run twice as finely", + "what": "run at half the speed", "runnable": true } }, @@ -4880,7 +4880,7 @@ { "name": "step_ms", "type": "number", - "what": "simulated milliseconds per tick, which is what the engine actually paces on" + "what": "simulated milliseconds per tick, from 1 to 1000; a tick is ten milliseconds of wall time, so 10 is real time and 1000 is as fast as the machine can step" }, { "name": "factor", diff --git a/internal/app/session/clockstep.go b/internal/app/session/clockstep.go new file mode 100644 index 00000000..8860361f --- /dev/null +++ b/internal/app/session/clockstep.go @@ -0,0 +1,51 @@ +package session + +import ( + "context" + + "github.com/MeshBench/meshbench/internal/app/state" +) + +// stepTick advances the engine by one tick's worth of simulated time. +// +// A tick's worth, not one engine step. The engine's step is fixed at its own +// 10 ms, and the tick used to take exactly one of them whatever the tick size +// said - then set the clock back from the engine - so sim.faster, sim.slower +// and sim.speed changed the number on the transport and nothing else: every +// run in a session went at real time, with the processor mostly idle. +// +// So the tick is owed its size and pays it in whole engine steps. A tick +// shorter than a step pays every few ticks, which is slower than real time; a +// tick longer than the machine can cover in its 10 ms of wall time simply runs +// as fast as the machine can go, which is what the lockstep clock exists to +// allow: firmware reads simulated time, so nothing in it notices. +// +// Due sends are checked after every step rather than once a tick, so a send at +// 20 s still goes at 20 s and not at the end of a 500 ms tick. The tick stops at +// the end of a timed run, so sim.run lands where it was asked to. +func (s *Sim) stepTick(w *state.World, tickMs uint32) { + // Close takes the engine away, from the workbench's own shutdown + // goroutine, while this tick stays installed - so a step arriving after a + // close finds nothing there and the panic takes the store's goroutine + // with it. + eng := s.eng + if eng == nil { + return + } + step := eng.Config.StepMs + if step == 0 { + step = 10 + } + s.tickOwed += tickMs + for s.tickOwed >= step { + if until := w.RunUntilMs; until != 0 && eng.NowMs() >= until { + s.tickOwed = 0 + return + } + _ = eng.Step(context.Background()) + s.tickOwed -= step + // After the step, so a send lands on a mesh whose clock has already + // reached its moment. + s.fireDueSends(w) + } +} diff --git a/internal/app/session/clockstep_test.go b/internal/app/session/clockstep_test.go new file mode 100644 index 00000000..920bda4a --- /dev/null +++ b/internal/app/session/clockstep_test.go @@ -0,0 +1,56 @@ +package session + +import ( + "testing" + + "github.com/MeshBench/meshbench/internal/app/state" + "github.com/MeshBench/meshbench/internal/world/scenario" +) + +// A tick covers the tick's size in simulated time, not one engine step. It +// took one step whatever the speed control said, so "faster" relabelled the +// transport and the run went at real time regardless. +func TestATickCoversItsSize(t *testing.T) { + s := &Sim{gpuAsked: true} + s.build([]scenario.Node{repeaterNode("Alpha"), repeaterNode("Bravo")}, 869.618) + w := &state.World{} + before := s.eng.NowMs() + s.stepTick(w, 250) + if got := s.eng.NowMs() - before; got != 250 { + t.Fatalf("a 250 ms tick advanced the engine %d ms", got) + } +} + +// A timed run ends where it was asked to, even when the tick is larger than +// what is left of it. +func TestATickStopsAtTheEndOfATimedRun(t *testing.T) { + s := &Sim{gpuAsked: true} + s.build([]scenario.Node{repeaterNode("Alpha"), repeaterNode("Bravo")}, 869.618) + w := &state.World{RunUntilMs: s.eng.NowMs() + 120} + s.stepTick(w, 1000) + if s.eng.NowMs() != w.RunUntilMs { + t.Fatalf("the run was to end at %d and the engine is at %d", w.RunUntilMs, s.eng.NowMs()) + } + // And the next run does not inherit what the last tick was owed. + w.RunUntilMs = 0 + before := s.eng.NowMs() + s.stepTick(w, 10) + if got := s.eng.NowMs() - before; got != 10 { + t.Fatalf("a 10 ms tick after a timed run advanced the engine %d ms", got) + } +} + +// A tick shorter than an engine step steps only once it has been owed a whole +// one: a 5 ms tick is half speed, not real time. +func TestAShortTickStepsEveryOtherTime(t *testing.T) { + s := &Sim{gpuAsked: true} + s.build([]scenario.Node{repeaterNode("Alpha"), repeaterNode("Bravo")}, 869.618) + w := &state.World{} + before := s.eng.NowMs() + for i := 0; i < 4; i++ { + s.stepTick(w, 5) + } + if got := s.eng.NowMs() - before; got != 20 { + t.Fatalf("four 5 ms ticks advanced the engine %d ms, want 20", got) + } +} diff --git a/internal/app/session/engine.go b/internal/app/session/engine.go index 8f075075..2cfcdb35 100644 --- a/internal/app/session/engine.go +++ b/internal/app/session/engine.go @@ -33,6 +33,9 @@ type Sim struct { // sendClock is when each scheduled send last fired, so a repeating one // repeats per interval rather than per tick. sendClock sendClock + // tickOwed is simulated time a tick has been given and the engine has not + // yet stepped through, because a tick can be shorter than one step. + tickOwed uint32 // statesMu guards states, which Reflash writes from its own goroutine and // the store goroutine reads to answer nodes.stats. statesMu sync.Mutex diff --git a/internal/app/session/simctl.verbs.json b/internal/app/session/simctl.verbs.json index 7a0c7012..8b54d6fd 100644 --- a/internal/app/session/simctl.verbs.json +++ b/internal/app/session/simctl.verbs.json @@ -84,7 +84,7 @@ { "name": "step_ms", "type": "number", - "what": "simulated milliseconds per tick, which is what the engine actually paces on" + "what": "simulated milliseconds per tick, from 1 to 1000; a tick is ten milliseconds of wall time, so 10 is real time and 1000 is as fast as the machine can step" }, { "name": "factor", diff --git a/internal/app/session/verbs.go b/internal/app/session/verbs.go index fb88fb86..c47ec48d 100644 --- a/internal/app/session/verbs.go +++ b/internal/app/session/verbs.go @@ -174,25 +174,14 @@ func Register(st *state.Store, s *Sim) { for i, n := range f.nodes { index[n.Name] = i } - w.Tick = func(uint32) { + w.Tick = func(tickMs uint32) { w.FirmwareRunning, w.FirmwareStarting = s.firmwareCount(), s.starting.Load() // A sweep cell paces its own engine against wall time and counts on // that pacing. Stepping it again here would not make the run // faster - it would make its airtime accounting wrong - so while a // cell owns the clock this only reads. if !s.benchOwnsTheClock() { - // Close takes the engine away, from the workbench's own - // shutdown goroutine, while this tick stays installed - so a - // step arriving after a close finds nothing there and the - // panic takes the store's goroutine with it. fireDueSends - // below has always asked; this is the call that did not. - if eng := s.eng; eng != nil { - _ = eng.Step(context.Background()) - } - // Whatever the schedule is due to say at this moment of - // simulated time. After the step, so a send lands on a mesh - // whose clock has already reached its moment. - s.fireDueSends(w) + s.stepTick(w, tickMs) } // A rebuild anywhere leaves the matrix cold; this is the one // place every run passes through, so it is where the warm is diff --git a/internal/app/session/verbsclock.verbs.json b/internal/app/session/verbsclock.verbs.json index b27dd8f5..d9ad3d3f 100644 --- a/internal/app/session/verbsclock.verbs.json +++ b/internal/app/session/verbsclock.verbs.json @@ -1,13 +1,13 @@ { "sim.faster": { - "what": "double how much simulated time one tick covers, trading detail for how long a run takes to watch", + "what": "double how much simulated time one tick covers, which makes the run go twice as fast until the machine cannot keep up", "returns": [ "step_ms" ], - "answers": "`step_ms` is the new tick length. Nothing bounds it: enough calls will step past whole transmissions.", + "answers": "`step_ms` is the new tick length, capped at 1000. A tick is ten milliseconds of wall time and the engine still steps in its own 10 ms, so nothing is skipped: past what the machine can step in a tick the run simply goes as fast as it can, and the transport's realtime figure says how fast that is.", "example": { "params": {}, - "what": "run twice as coarsely", + "what": "run twice as fast", "runnable": true } }, @@ -39,10 +39,10 @@ "returns": [ "step_ms" ], - "answers": "`step_ms` is the new tick length, which reaches zero after enough calls and stops the clock advancing at all.", + "answers": "`step_ms` is the new tick length, never below 1. Below the engine's own 10 ms step, a tick steps only once it has been owed a whole step, so 5 is half real time.", "example": { "params": {}, - "what": "run twice as finely", + "what": "run at half the speed", "runnable": true } },