From 548fcc39f4c853dc33760f8f029f4c1e4807f995 Mon Sep 17 00:00:00 2001 From: VirxEC Date: Tue, 2 Jun 2026 00:57:46 -0500 Subject: [PATCH 01/12] Port state setting sandbox Add new debug rendering controls --- app.go | 9 +- frontend/src/App.svelte | 14 +- frontend/src/pages/StateSettingSandbox.svelte | 1043 +++++++++++++++++ sandbox.go | 499 ++++++++ 4 files changed, 1561 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/StateSettingSandbox.svelte create mode 100644 sandbox.go diff --git a/app.go b/app.go index 954d389..1087768 100644 --- a/app.go +++ b/app.go @@ -9,6 +9,7 @@ import ( "runtime" "slices" "strings" + "sync" "time" rlbot "github.com/RLBot/go-interface" @@ -27,6 +28,8 @@ type App struct { app *application.App latestReleaseJson []RawReleaseInfo rlbotAddress string + sandbox *SandboxState + sandboxMu sync.Mutex } func (a *App) IgnoreMe( @@ -113,9 +116,9 @@ func NewApp() *App { var latest_release_json []RawReleaseInfo return &App{ - nil, - latest_release_json, - rlbot_address, + app: nil, + latestReleaseJson: latest_release_json, + rlbotAddress: rlbot_address, } } diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 2bd3fe1..93ed248 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -7,6 +7,7 @@ import Events from "./components/Events.svelte"; import GuiSettings from "./components/GuiSettings.svelte"; import Home from "./pages/Home.svelte"; import RocketHost from "./pages/RocketHost.svelte"; +import StateSettingSandbox from "./pages/StateSettingSandbox.svelte"; import StoryMode from "./pages/StoryMode.svelte"; import Welcome from "./pages/Welcome.svelte"; import { parseJSON } from "./index"; @@ -75,6 +76,9 @@ let paths: { {#if activePage == "rhost"}

  / Rocket Host

{/if} + {#if activePage == "statesettingsandbox"} +

  / State Setting Sandbox

+ {/if} {#if activePage == "storymode"}

  / Story Mode

{/if} @@ -110,7 +114,9 @@ let paths: {
+
+ +
+
diff --git a/frontend/src/pages/StateSettingSandbox.svelte b/frontend/src/pages/StateSettingSandbox.svelte new file mode 100644 index 0000000..8ed1202 --- /dev/null +++ b/frontend/src/pages/StateSettingSandbox.svelte @@ -0,0 +1,1043 @@ + + +
+ {#if connectionState === "connecting"} +
+
+

Connecting to RLBotServer...

+ {#if errorMessage} +

{errorMessage}

+ {/if} +
+ {:else if connectionState === "disconnected"} +
+
!
+

Disconnected

+

+ {errorMessage || "Not connected to RLBotServer. Make sure a match is running."} +

+ +
+ {:else} +
+
+ +
+ +
+
+

Commands

+ +
+ + +
+ +
+ + +
+ + +
+ +
+
+ + + +
+ +
+ + { + if (e.key === "Enter") executeCommand(); + }} + placeholder="e.g. QueSaveReplay" + /> + +
+

See { Browser.OpenURL('https://wiki.rlbot.org/v5/framework/console-commands/'); }}>available console commands

+
+ +
+

Debug Rendering

+ {#if matchConfig} + {#if renderingDisabled} +

Rendering is globally disabled

+ {/if} + {#each matchConfig.agents as agent} + {@const key = `${agent.is_bot ? "bot" : "script"}-${agent.index}`} +
+ +
+ {/each} + {:else} +

Waiting for match config...

+ {/if} +
+ +

+ You can drag and drop to move objects around in the game! +

+
+
+ {/if} +
+ + diff --git a/sandbox.go b/sandbox.go new file mode 100644 index 0000000..9d58dc6 --- /dev/null +++ b/sandbox.go @@ -0,0 +1,499 @@ +package main + +import ( + "sync" + "time" + + rlbot "github.com/RLBot/go-interface" + "github.com/RLBot/go-interface/flat" +) + +// SandboxState holds the current sandbox connection and manages concurrent access. +type SandboxState struct { + conn *rlbot.RLBotConnection + mu sync.Mutex + active bool +} + +// SandboxBallPacket is sent to the frontend for each game tick. +type SandboxGamePacket struct { + Ball SandboxBall `json:"ball"` + Cars []SandboxCar `json:"cars"` + SecondsElapsed float32 `json:"seconds_elapsed"` +} + +type SandboxBall struct { + Physics SandboxPhysics `json:"physics"` +} + +type SandboxCar struct { + Index int32 `json:"index"` + Physics SandboxPhysics `json:"physics"` + Team uint32 `json:"team"` + Boost float32 `json:"boost"` + IsBot bool `json:"is_bot"` + Name string `json:"name"` +} + +type SandboxPhysics struct { + Location SandboxVec3 `json:"location"` + Rotation SandboxRot3 `json:"rotation"` + Velocity SandboxVec3 `json:"velocity"` + AngularVelocity SandboxVec3 `json:"angular_velocity"` +} + +type SandboxVec3 struct { + X float32 `json:"x"` + Y float32 `json:"y"` + Z float32 `json:"z"` +} + +type SandboxRot3 struct { + Pitch float32 `json:"pitch"` + Yaw float32 `json:"yaw"` + Roll float32 `json:"roll"` +} + +// SandboxStateSetting is sent from the frontend to set game state. +type SandboxStateSetting struct { + Ball *SandboxBallSetting `json:"ball,omitempty"` + Cars []SandboxCarSetting `json:"cars,omitempty"` + Game *SandboxGameSetting `json:"game_info,omitempty"` + Cmds []string `json:"console_commands,omitempty"` +} + +type SandboxBallSetting struct { + Location *SandboxVec3 `json:"location,omitempty"` + Velocity *SandboxVec3 `json:"velocity,omitempty"` +} + +type SandboxCarSetting struct { + Index int32 `json:"index"` + Location *SandboxVec3 `json:"location,omitempty"` + Velocity *SandboxVec3 `json:"velocity,omitempty"` + Rotation *SandboxRot3 `json:"rotation,omitempty"` + Boost *float32 `json:"boost,omitempty"` +} + +type SandboxGameSetting struct { + GravityZ *float32 `json:"world_gravity_z,omitempty"` + GameSpeed *float32 `json:"game_speed,omitempty"` +} + +// SandboxMatchConfig is sent to the frontend with match configuration info for debug rendering. +type SandboxMatchConfig struct { + EnableRendering int `json:"enable_rendering"` + Agents []SandboxRenderAgent `json:"agents"` +} + +type SandboxRenderAgent struct { + Index int `json:"index"` + Name string `json:"name"` + IsBot bool `json:"is_bot"` +} + +type SandboxRenderingStatus struct { + Index uint32 `json:"index"` + IsBot bool `json:"is_bot"` + Status bool `json:"status"` +} + +// OpenSandbox connects to RLBotServer and starts reading game packets. +// The frontend should call this when entering the sandbox page. +func (a *App) OpenSandbox() error { + a.sandboxMu.Lock() + defer a.sandboxMu.Unlock() + + if a.sandbox != nil && a.sandbox.active { + return nil // already connected + } + + a.app.Event.Emit("sandbox:connecting", nil) + + conn, err := rlbot.Connect(a.rlbotAddress) + if err != nil { + a.app.Event.Emit("sandbox:error", map[string]any{ + "message": "Failed to connect to RLBotServer: " + err.Error(), + }) + return err + } + + // Send initial connection settings + err = conn.SendPacket(&flat.ConnectionSettingsT{ + AgentId: "", + WantsBallPredictions: false, + WantsComms: false, + CloseBetweenMatches: false, + }) + if err != nil { + conn.SendPacket(&flat.DisconnectSignalT{}) + a.app.Event.Emit("sandbox:error", map[string]any{ + "message": "Failed to send connection settings: " + err.Error(), + }) + return err + } + + state := &SandboxState{ + conn: &conn, + active: true, + } + a.sandbox = state + + // Start the reader goroutine + go a.sandboxReader(state, conn) + + return nil +} + +// CloseSandbox disconnects from RLBotServer. +// The frontend should call this when leaving the sandbox page. +func (a *App) CloseSandbox() { + a.sandboxMu.Lock() + state := a.sandbox + a.sandbox = nil + a.sandboxMu.Unlock() + + if state == nil || !state.active { + return + } + + state.mu.Lock() + state.active = false + if state.conn != nil { + state.conn.SendPacket(&flat.DisconnectSignalT{}) + } + state.mu.Unlock() +} + +// SandboxSetState sets the game state (ball, cars, game info, console commands). +func (a *App) SandboxSetState(setting SandboxStateSetting) error { + a.sandboxMu.Lock() + state := a.sandbox + a.sandboxMu.Unlock() + + if state == nil || !state.active { + return nil // silently ignore if not connected + } + + desired := &flat.DesiredGameStateT{} + + if setting.Ball != nil { + ballState := &flat.DesiredBallStateT{} + if setting.Ball.Location != nil { + ballState.Physics = &flat.DesiredPhysicsT{ + Location: &flat.Vector3PartialT{ + X: &flat.FloatT{Val: setting.Ball.Location.X}, + Y: &flat.FloatT{Val: setting.Ball.Location.Y}, + Z: &flat.FloatT{Val: setting.Ball.Location.Z}, + }, + } + } + if setting.Ball.Velocity != nil { + if ballState.Physics == nil { + ballState.Physics = &flat.DesiredPhysicsT{} + } + ballState.Physics.Velocity = &flat.Vector3PartialT{ + X: &flat.FloatT{Val: setting.Ball.Velocity.X}, + Y: &flat.FloatT{Val: setting.Ball.Velocity.Y}, + Z: &flat.FloatT{Val: setting.Ball.Velocity.Z}, + } + } + desired.BallStates = []*flat.DesiredBallStateT{ballState} + } + + if len(setting.Cars) > 0 { + carStates := make([]*flat.DesiredCarStateT, len(setting.Cars)) + for i, cs := range setting.Cars { + carState := &flat.DesiredCarStateT{} + if cs.Location != nil { + carState.Physics = &flat.DesiredPhysicsT{ + Location: &flat.Vector3PartialT{ + X: &flat.FloatT{Val: cs.Location.X}, + Y: &flat.FloatT{Val: cs.Location.Y}, + Z: &flat.FloatT{Val: cs.Location.Z}, + }, + } + } + if cs.Velocity != nil { + if carState.Physics == nil { + carState.Physics = &flat.DesiredPhysicsT{} + } + carState.Physics.Velocity = &flat.Vector3PartialT{ + X: &flat.FloatT{Val: cs.Velocity.X}, + Y: &flat.FloatT{Val: cs.Velocity.Y}, + Z: &flat.FloatT{Val: cs.Velocity.Z}, + } + } + if cs.Rotation != nil { + if carState.Physics == nil { + carState.Physics = &flat.DesiredPhysicsT{} + } + carState.Physics.Rotation = &flat.RotatorPartialT{ + Pitch: &flat.FloatT{Val: cs.Rotation.Pitch}, + Yaw: &flat.FloatT{Val: cs.Rotation.Yaw}, + Roll: &flat.FloatT{Val: cs.Rotation.Roll}, + } + } + if cs.Boost != nil { + carState.BoostAmount = &flat.FloatT{Val: *cs.Boost} + } + // Pad with empty states up to the car index so RLBotServer applies to the right car + if cs.Index > 0 { + padded := make([]*flat.DesiredCarStateT, cs.Index+1) + padded[cs.Index] = carState + carStates = padded + } else { + carStates[i] = carState + } + } + desired.CarStates = carStates + } + + if setting.Game != nil { + desired.MatchInfo = &flat.DesiredMatchInfoT{} + if setting.Game.GravityZ != nil { + desired.MatchInfo.WorldGravityZ = &flat.FloatT{Val: *setting.Game.GravityZ} + } + if setting.Game.GameSpeed != nil { + desired.MatchInfo.GameSpeed = &flat.FloatT{Val: *setting.Game.GameSpeed} + } + } + + if len(setting.Cmds) > 0 { + cmds := make([]*flat.ConsoleCommandT, len(setting.Cmds)) + for i, cmd := range setting.Cmds { + cmds[i] = &flat.ConsoleCommandT{Command: cmd} + } + desired.ConsoleCommands = cmds + } + + state.mu.Lock() + defer state.mu.Unlock() + + if state.conn != nil { + return state.conn.SendPacket(desired) + } + return nil +} + +// sandboxReader runs in a goroutine and reads packets from the RLBot connection. +func (a *App) sandboxReader(state *SandboxState, conn rlbot.RLBotConnection) { + defer func() { + a.sandboxMu.Lock() + if a.sandbox == state { + a.sandbox = nil + } + a.sandboxMu.Unlock() + + a.app.Event.Emit("sandbox:disconnected", nil) + }() + + // Wait for MatchConfigurationT and FieldInfoT + hasFieldInfo := false + waitStart := time.Now() + timeout := 30 * time.Second + + for !hasFieldInfo { + if time.Since(waitStart) > timeout { + a.app.Event.Emit("sandbox:error", map[string]any{ + "message": "Timed out waiting for FieldInfo", + }) + return + } + + packet, err := conn.RecvPacket() + if err != nil { + a.app.Event.Emit("sandbox:error", map[string]any{ + "message": "Connection error: " + err.Error(), + }) + return + } + + switch p := packet.Value.(type) { + case *flat.FieldInfoT: + // Send InitCompleteT to signal we're ready for GamePackets + conn.SendPacket(&flat.InitCompleteT{}) + hasFieldInfo = true + case *flat.MatchConfigurationT: + // Extract match config info for debug rendering UI + matchConfig := simplifyMatchConfig(p) + a.app.Event.Emit("sandbox:match-config", matchConfig) + case *flat.DisconnectSignalT: + a.app.Event.Emit("sandbox:error", map[string]any{ + "message": "Received disconnect signal while connecting", + }) + return + } + } + + // Signal that we're connected and ready + a.app.Event.Emit("sandbox:connected", nil) + + // Now read GamePackets in a loop + for { + state.mu.Lock() + isActive := state.active + state.mu.Unlock() + + if !isActive { + return + } + + packet, err := conn.RecvPacket() + if err != nil { + // Connection closed + return + } + + switch p := packet.Value.(type) { + case *flat.GamePacketT: + sandboxPacket := simplifyGamePacket(p) + a.app.Event.Emit("sandbox:game-packet", sandboxPacket) + case *flat.DisconnectSignalT: + return + } + } +} + +// SandboxSetRendering sends per-agent rendering status updates to RLBot. +func (a *App) SandboxSetRendering(settings []SandboxRenderingStatus) error { + a.sandboxMu.Lock() + state := a.sandbox + a.sandboxMu.Unlock() + + if state == nil || !state.active { + return nil + } + + state.mu.Lock() + defer state.mu.Unlock() + + if state.conn == nil { + return nil + } + + for _, s := range settings { + err := state.conn.SendPacket(&flat.RenderingStatusT{ + Index: s.Index, + IsBot: s.IsBot, + Status: s.Status, + }) + if err != nil { + return err + } + } + return nil +} + +// simplifyMatchConfig extracts rendering-relevant info from MatchConfigurationT. +func simplifyMatchConfig(mc *flat.MatchConfigurationT) SandboxMatchConfig { + cfg := SandboxMatchConfig{ + EnableRendering: int(mc.EnableRendering), + } + + // Collect bots (players that are not humans) + // The loop index corresponds to the player's position in the GamePacket's Players array. + for i, player := range mc.PlayerConfigurations { + if player.Variety != nil && player.Variety.Type != flat.PlayerClassHuman { + name := "" + switch v := player.Variety.Value.(type) { + case *flat.CustomBotT: + name = v.Name + case *flat.PsyonixBotT: + name = v.Name + } + cfg.Agents = append(cfg.Agents, SandboxRenderAgent{ + Index: i, + Name: name, + IsBot: true, + }) + } + } + + // Collect scripts + for i, script := range mc.ScriptConfigurations { + cfg.Agents = append(cfg.Agents, SandboxRenderAgent{ + Index: i, + Name: script.Name, + IsBot: false, + }) + } + + return cfg +} + +// simplifyGamePacket converts a flat.GamePacketT to a lightweight SandboxGamePacket for the frontend. +func simplifyGamePacket(gp *flat.GamePacketT) SandboxGamePacket { + pkt := SandboxGamePacket{} + + if gp.MatchInfo != nil { + pkt.SecondsElapsed = gp.MatchInfo.SecondsElapsed + } + + if len(gp.Balls) > 0 && gp.Balls[0] != nil && gp.Balls[0].Physics != nil { + phys := gp.Balls[0].Physics + pkt.Ball.Physics = SandboxPhysics{ + Location: SandboxVec3{ + X: phys.Location.X, + Y: phys.Location.Y, + Z: phys.Location.Z, + }, + Rotation: SandboxRot3{ + Pitch: phys.Rotation.Pitch, + Yaw: phys.Rotation.Yaw, + Roll: phys.Rotation.Roll, + }, + Velocity: SandboxVec3{ + X: phys.Velocity.X, + Y: phys.Velocity.Y, + Z: phys.Velocity.Z, + }, + AngularVelocity: SandboxVec3{ + X: phys.AngularVelocity.X, + Y: phys.AngularVelocity.Y, + Z: phys.AngularVelocity.Z, + }, + } + } + + pkt.Cars = make([]SandboxCar, len(gp.Players)) + for i, player := range gp.Players { + if player == nil { + continue + } + car := SandboxCar{ + Index: int32(i), + Team: player.Team, + Boost: player.Boost, + IsBot: player.IsBot, + Name: player.Name, + } + if player.Physics != nil { + car.Physics = SandboxPhysics{ + Location: SandboxVec3{ + X: player.Physics.Location.X, + Y: player.Physics.Location.Y, + Z: player.Physics.Location.Z, + }, + Rotation: SandboxRot3{ + Pitch: player.Physics.Rotation.Pitch, + Yaw: player.Physics.Rotation.Yaw, + Roll: player.Physics.Rotation.Roll, + }, + Velocity: SandboxVec3{ + X: player.Physics.Velocity.X, + Y: player.Physics.Velocity.Y, + Z: player.Physics.Velocity.Z, + }, + AngularVelocity: SandboxVec3{ + X: player.Physics.AngularVelocity.X, + Y: player.Physics.AngularVelocity.Y, + Z: player.Physics.AngularVelocity.Z, + }, + } + } + pkt.Cars[i] = car + } + + return pkt +} From 0edb2ea66925edc202b759b2d20a7dc4efc01a5a Mon Sep 17 00:00:00 2001 From: VirxEC Date: Tue, 2 Jun 2026 11:44:58 -0500 Subject: [PATCH 02/12] Render field with `arena_diagram.png` Remove (BOT) & (SCRIPT) suffixes --- frontend/src/assets/arena_diagram.png | Bin 0 -> 9095 bytes frontend/src/pages/StateSettingSandbox.svelte | 55 +++++++----------- 2 files changed, 22 insertions(+), 33 deletions(-) create mode 100644 frontend/src/assets/arena_diagram.png diff --git a/frontend/src/assets/arena_diagram.png b/frontend/src/assets/arena_diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..6478433a6b209b3b78e83e3a6d01154f5d95f6ab GIT binary patch literal 9095 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 Gh5rO{gv_=8 literal 0 HcmV?d00001 diff --git a/frontend/src/pages/StateSettingSandbox.svelte b/frontend/src/pages/StateSettingSandbox.svelte index 8ed1202..f20bd33 100644 --- a/frontend/src/pages/StateSettingSandbox.svelte +++ b/frontend/src/pages/StateSettingSandbox.svelte @@ -1,9 +1,21 @@
+
+

Performance monitor display mode

+ { + App.SandboxSetPerfMonDisplayMode(perfMonDisplayMode); + }} + /> +
+

You can drag and drop to move objects around in the game!

diff --git a/sandbox.go b/sandbox.go index 8bcc990..c09d396 100644 --- a/sandbox.go +++ b/sandbox.go @@ -83,8 +83,9 @@ type SandboxGameSetting struct { // SandboxMatchConfig is sent to the frontend with match configuration info for debug rendering. type SandboxMatchConfig struct { - EnableRendering int `json:"enable_rendering"` - Agents []SandboxRenderAgent `json:"agents"` + EnableRendering int `json:"enable_rendering"` + PerformanceMonitor int `json:"performance_monitor"` + Agents []SandboxRenderAgent `json:"agents"` } type SandboxRenderAgent struct { @@ -386,10 +387,44 @@ func (a *App) SandboxSetRendering(settings []SandboxRenderingStatus) error { return nil } +func (a *App) SandboxSetPerfMonDisplayMode(mode int) error { + a.sandboxMu.Lock() + state := a.sandbox + a.sandboxMu.Unlock() + + if state == nil || !state.active { + return nil + } + + state.mu.Lock() + defer state.mu.Unlock() + + if state.conn == nil { + return nil + } + + var displayMode flat.PerformanceMonitor + switch mode { + case 0: + displayMode = flat.PerformanceMonitorShowWhenSuboptimal + case 1: + displayMode = flat.PerformanceMonitorAlwaysShow + case 2: + displayMode = flat.PerformanceMonitorNeverShow + default: + return nil // invalid mode, ignore + } + + return state.conn.SendPacket(&flat.UpdatePerformanceMonitorT{ + Show: displayMode, + }) +} + // simplifyMatchConfig extracts rendering-relevant info from MatchConfigurationT. func simplifyMatchConfig(mc *flat.MatchConfigurationT) SandboxMatchConfig { cfg := SandboxMatchConfig{ - EnableRendering: int(mc.EnableRendering), + EnableRendering: int(mc.EnableRendering), + PerformanceMonitor: int(mc.PerformanceMonitor), } // Collect bots (players that are not humans) From c722454f1a4391223b530263c31e5f9a3e5bbb00 Mon Sep 17 00:00:00 2001 From: VirxEC Date: Fri, 5 Jun 2026 11:33:02 -0500 Subject: [PATCH 09/12] Prevent drag and show overlay when not watching --- frontend/src/pages/StateSettingSandbox.svelte | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/frontend/src/pages/StateSettingSandbox.svelte b/frontend/src/pages/StateSettingSandbox.svelte index 2297eeb..3aa076c 100644 --- a/frontend/src/pages/StateSettingSandbox.svelte +++ b/frontend/src/pages/StateSettingSandbox.svelte @@ -300,6 +300,7 @@ function hitTestCar(pos: { x: number; y: number }): number { } function handleMouseDown(e: MouseEvent) { + if (!watching) return; const pos = getCanvasPos(e); if (!pos) return; @@ -776,6 +777,11 @@ function executeCommand() { onmouseup={handleMouseUp} onmouseleave={handleMouseUp} > + {#if !watching} +
+ Enable 'Watch Game' to drag and drop objects around +
+ {/if}
@@ -1001,6 +1007,22 @@ function executeCommand() { .canvas-container { flex-shrink: 0; + position: relative; + } + + .canvas-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.55); + color: #fff; + font-size: 24px; + border-radius: 4px; + text-align: center; + padding: 20px; + pointer-events: none; } .arena-canvas { From 5be5c208d7d39b1484eb4e39c4dbbc4c6096cf65 Mon Sep 17 00:00:00 2001 From: VirxEC Date: Fri, 5 Jun 2026 22:24:42 -0500 Subject: [PATCH 10/12] Refactor sandbox rendering and error handling - Change SandboxSetRendering from slice to single status - Emit sandbox:error when connection is lost unexpectedly - Refactor rendering toggle into separate function - Update go-interface dependency --- frontend/src/pages/StateSettingSandbox.svelte | 28 +++++++++++-------- sandbox.go | 24 +++++++--------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/frontend/src/pages/StateSettingSandbox.svelte b/frontend/src/pages/StateSettingSandbox.svelte index 3aa076c..208ddb1 100644 --- a/frontend/src/pages/StateSettingSandbox.svelte +++ b/frontend/src/pages/StateSettingSandbox.svelte @@ -1,8 +1,8 @@
@@ -875,17 +889,7 @@ function executeCommand() { type="checkbox" checked={renderStatuses.get(key) ?? false} disabled={renderingDisabled} - onchange={(e) => { - if (renderingDisabled) return; - const newStatuses = new Map(renderStatuses); - newStatuses.set(key, e.currentTarget.checked); - renderStatuses = newStatuses; - App.SandboxSetRendering([{ - index: agent.index, - is_bot: agent.is_bot, - status: e.currentTarget.checked, - }]); - }} + onchange={(e) => handleRenderToggle(agent, e)} /> {agent.name} diff --git a/sandbox.go b/sandbox.go index c09d396..15b151d 100644 --- a/sandbox.go +++ b/sandbox.go @@ -343,7 +343,9 @@ func (a *App) sandboxReader(state *SandboxState, conn rlbot.RLBotConnection) { packet, err := conn.RecvPacket() if err != nil { - // Connection closed + a.app.Event.Emit("sandbox:error", map[string]any{ + "message": "Connection unexpectedly closed: " + err.Error(), + }) return } @@ -357,8 +359,8 @@ func (a *App) sandboxReader(state *SandboxState, conn rlbot.RLBotConnection) { } } -// SandboxSetRendering sends per-agent rendering status updates to RLBot. -func (a *App) SandboxSetRendering(settings []SandboxRenderingStatus) error { +// SandboxSetRendering sends a per-agent rendering status update to RLBot. +func (a *App) SandboxSetRendering(settings SandboxRenderingStatus) error { a.sandboxMu.Lock() state := a.sandbox a.sandboxMu.Unlock() @@ -374,17 +376,11 @@ func (a *App) SandboxSetRendering(settings []SandboxRenderingStatus) error { return nil } - for _, s := range settings { - err := state.conn.SendPacket(&flat.RenderingStatusT{ - Index: s.Index, - IsBot: s.IsBot, - Status: s.Status, - }) - if err != nil { - return err - } - } - return nil + return state.conn.SendPacket(&flat.RenderingStatusT{ + Index: settings.Index, + IsBot: settings.IsBot, + Status: settings.Status, + }) } func (a *App) SandboxSetPerfMonDisplayMode(mode int) error { From 391bb41accaa6e417058d3fabf898d0bcb6d6606 Mon Sep 17 00:00:00 2001 From: VirxEC Date: Sat, 6 Jun 2026 09:58:07 -0500 Subject: [PATCH 11/12] Simplify and optimize packet history and bot config --- frontend/src/pages/StateSettingSandbox.svelte | 2 +- sandbox.go | 16 +++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/StateSettingSandbox.svelte b/frontend/src/pages/StateSettingSandbox.svelte index 208ddb1..443222a 100644 --- a/frontend/src/pages/StateSettingSandbox.svelte +++ b/frontend/src/pages/StateSettingSandbox.svelte @@ -523,7 +523,7 @@ $effect(() => { result.seconds_elapsed - tail.seconds_elapsed > HISTORY_INCREMENT_SECONDS ) { - packetHistory = [...packetHistory, result]; + packetHistory.push(result); if ( packetHistory.length > HISTORY_SECONDS / HISTORY_INCREMENT_SECONDS diff --git a/sandbox.go b/sandbox.go index 15b151d..b51c514 100644 --- a/sandbox.go +++ b/sandbox.go @@ -426,17 +426,15 @@ func simplifyMatchConfig(mc *flat.MatchConfigurationT) SandboxMatchConfig { // Collect bots (players that are not humans) // The loop index corresponds to the player's position in the GamePacket's Players array. for i, player := range mc.PlayerConfigurations { - if player.Variety != nil && player.Variety.Type != flat.PlayerClassHuman { - name := "" - switch v := player.Variety.Value.(type) { - case *flat.CustomBotT: - name = v.Name - case *flat.PsyonixBotT: - name = v.Name - } + if player.Variety == nil { + continue + } + + switch v := player.Variety.Value.(type) { + case *flat.CustomBotT: cfg.Agents = append(cfg.Agents, SandboxRenderAgent{ Index: i, - Name: name, + Name: v.Name, IsBot: true, }) } From c94f096fad19e8518e909e3638d4ae33d5cf9072 Mon Sep 17 00:00:00 2001 From: VirxEC Date: Sat, 6 Jun 2026 16:25:47 -0500 Subject: [PATCH 12/12] Remove unneeded snippet --- frontend/src/pages/StateSettingSandbox.svelte | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/frontend/src/pages/StateSettingSandbox.svelte b/frontend/src/pages/StateSettingSandbox.svelte index 443222a..2bc6899 100644 --- a/frontend/src/pages/StateSettingSandbox.svelte +++ b/frontend/src/pages/StateSettingSandbox.svelte @@ -447,30 +447,6 @@ $effect(() => { const result = event.data; latestPacket = result; - // Detect match reset: seconds_elapsed dropped significantly (new match) - if (result.seconds_elapsed < secondsElapsed - 1) { - previousSecondsElapsed = 0; - packetHistory = []; - hasPacketHistory = false; - savedState = null; - hasSavedState = false; - lastUpdateTime = 0; - - // Reset debug rendering to defaults for the current match config - if (matchConfig) { - perfMonDisplayMode = matchConfig.performance_monitor; - - const defaultChecked = matchConfig.enable_rendering === 1; - renderingDisabled = matchConfig.enable_rendering === 2; - const statuses = new Map(); - for (const agent of matchConfig.agents) { - const key = `${agent.is_bot ? "bot" : "script"}-${agent.index}`; - statuses.set(key, defaultChecked); - } - renderStatuses = statuses; - } - } - secondsElapsed = result.seconds_elapsed; // Throttle visual updates to ~20 fps to prevent lag