diff --git a/PPO_Bot/PPO_Bot b/PPO_Bot/PPO_Bot
index 996e4b9..2dab3c3 100755
Binary files a/PPO_Bot/PPO_Bot and b/PPO_Bot/PPO_Bot differ
diff --git a/PPO_Bot/tests/test_actions b/PPO_Bot/tests/test_actions
index 27e5f1a..f53939f 100755
Binary files a/PPO_Bot/tests/test_actions and b/PPO_Bot/tests/test_actions differ
diff --git a/PPO_Bot/tests/test_actions.nim b/PPO_Bot/tests/test_actions.nim
index 5d5c3bc..f7695d0 100644
--- a/PPO_Bot/tests/test_actions.nim
+++ b/PPO_Bot/tests/test_actions.nim
@@ -20,7 +20,7 @@ proc makeRaw(vals: array[6, float32]): Tensor[float32] =
# --- 6-dim input produces a valid BotActions ---
block basicDecode:
let raw = makeRaw([0.0'f32, 0.0, 0.0, 0.0, 0.0, 0.0])
- let acts = mapActions(raw, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0)
+ let acts = mapActions(raw, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0, 600.0, 400.0)
# sigmoid(0)*arenaW = 0.5*1200 = 600, sigmoid(0)*arenaH = 0.5*800 = 400
check abs(acts.gotoX - 600.0) < 1e-6, "gotoX = sigmoid(0)*arenaW"
check abs(acts.gotoY - 400.0) < 1e-6, "gotoY = sigmoid(0)*arenaH"
@@ -31,13 +31,13 @@ block basicDecode:
block coordBounds:
# Large positive raw → sigmoid ≈ 1 → close to arenaW/arenaH
let rawHigh = makeRaw([100.0'f32, 100.0, 100.0, 100.0, 0.0, 0.0])
- let actsHigh = mapActions(rawHigh, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0)
+ let actsHigh = mapActions(rawHigh, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0, 600.0, 400.0)
check actsHigh.gotoX <= arenaW + 1e-9, "gotoX <= arenaWidth"
check actsHigh.gotoY <= arenaH + 1e-9, "gotoY <= arenaHeight"
check actsHigh.gotoX >= 0.0, "gotoX >= 0"
# Large negative raw → sigmoid ≈ 0 → close to 0
let rawLow = makeRaw([-100.0'f32, -100.0, -100.0, -100.0, 0.0, 0.0])
- let actsLow = mapActions(rawLow, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0)
+ let actsLow = mapActions(rawLow, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0, 600.0, 400.0)
check actsLow.gotoX >= -1e-9, "gotoX >= 0 (low raw)"
check actsLow.gotoY >= -1e-9, "gotoY >= 0 (low raw)"
@@ -45,24 +45,24 @@ block coordBounds:
block fireTrigger:
# tanh(positive) >= 0 → fire when gunHeat = 0
let rawFire = makeRaw([0.0'f32, 0.0, 0.0, 0.0, 1.0, 0.0])
- let actsFire = mapActions(rawFire, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0)
+ let actsFire = mapActions(rawFire, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0, 600.0, 400.0)
check actsFire.shouldFire, "positive tanh → should fire when gun cool"
# tanh(negative) < 0 → no fire
let rawNoFire = makeRaw([0.0'f32, 0.0, 0.0, 0.0, -1.0, 0.0])
- let actsNoFire = mapActions(rawNoFire, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0)
+ let actsNoFire = mapActions(rawNoFire, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0, 600.0, 400.0)
check not actsNoFire.shouldFire, "negative tanh → no fire"
# gunHeat > 0 → no fire even with positive decision
- let actsHot = mapActions(rawFire, 0.5, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0)
+ let actsHot = mapActions(rawFire, 0.5, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0, 600.0, 400.0)
check not actsHot.shouldFire, "positive tanh but gun hot → no fire"
# --- Fire power in [0.1, 3.0] ---
block firePowerRange:
let rawMin = makeRaw([0.0'f32, 0.0, 0.0, 0.0, 0.0, -100.0])
let rawMax = makeRaw([0.0'f32, 0.0, 0.0, 0.0, 0.0, 100.0])
- let actsMin = mapActions(rawMin, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0)
- let actsMax = mapActions(rawMax, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0)
+ let actsMin = mapActions(rawMin, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0, 600.0, 400.0)
+ let actsMax = mapActions(rawMax, 0.0, arenaW, arenaH, 600.0, 400.0, 0.0, 0.0, 0.0, 600.0, 400.0)
check actsMin.firePower >= 0.1 - 1e-6, "firePower >= 0.1"
check actsMax.firePower <= 3.0 + 1e-6, "firePower <= 3.0"
diff --git a/PPO_Bot/tests/test_controllers b/PPO_Bot/tests/test_controllers
index 9d93e25..d05b120 100755
Binary files a/PPO_Bot/tests/test_controllers and b/PPO_Bot/tests/test_controllers differ
diff --git a/PPO_Bot/tests/test_network b/PPO_Bot/tests/test_network
index 93b4b89..a8e5d0d 100755
Binary files a/PPO_Bot/tests/test_network and b/PPO_Bot/tests/test_network differ
diff --git a/PPO_Bot/tests/test_network.nim b/PPO_Bot/tests/test_network.nim
index d0299ea..5aaed99 100644
--- a/PPO_Bot/tests/test_network.nim
+++ b/PPO_Bot/tests/test_network.nim
@@ -40,7 +40,7 @@ when isMainModule:
let raw = randomNormalTensor[float32](ACTION_DIM)
let speed = 4.0'f32
# arena 800×600, bot at centre, heading north, gun north
- let botActs = mapActions(raw, 0.0'f32, 800.0, 600.0, 400.0, 300.0, 0.0, speed.float, 0.0) # gunHeat=0 → fire allowed
+ let botActs = mapActions(raw, 0.0'f32, 800.0, 600.0, 400.0, 300.0, 0.0, speed.float, 0.0, 400.0, 300.0) # gunHeat=0 → fire allowed
assert botActs.targetSpeed >= -8.0'f32 and botActs.targetSpeed <= 8.0'f32,
&"targetSpeed out of range: {botActs.targetSpeed}"
@@ -52,7 +52,7 @@ when isMainModule:
&"firePower out of range: {botActs.firePower}"
# shouldFire=false when gunHeat > 0
- let noFire = mapActions(raw, 1.0'f32, 800.0, 600.0, 400.0, 300.0, 0.0, speed.float, 0.0)
+ let noFire = mapActions(raw, 1.0'f32, 800.0, 600.0, 400.0, 300.0, 0.0, speed.float, 0.0, 400.0, 300.0)
assert not noFire.shouldFire, "shouldFire should be false when gunHeat > 0"
echo "All tests passed"
diff --git a/PPO_Bot/tests/test_state b/PPO_Bot/tests/test_state
index 3171ca6..bd92d05 100755
Binary files a/PPO_Bot/tests/test_state and b/PPO_Bot/tests/test_state differ
diff --git a/PPO_Bot/tests/test_training b/PPO_Bot/tests/test_training
index 964f826..49f8bba 100755
Binary files a/PPO_Bot/tests/test_training and b/PPO_Bot/tests/test_training differ
diff --git a/PPO_Bot/tests/test_weights b/PPO_Bot/tests/test_weights
index ef5feb5..0f16f0a 100755
Binary files a/PPO_Bot/tests/test_weights and b/PPO_Bot/tests/test_weights differ
diff --git a/libs/tankroyale_botapi/tankroyale_botapi/bot.nim b/libs/tankroyale_botapi/tankroyale_botapi/bot.nim
index 6bcbc81..d9147c5 100644
--- a/libs/tankroyale_botapi/tankroyale_botapi/bot.nim
+++ b/libs/tankroyale_botapi/tankroyale_botapi/bot.nim
@@ -198,9 +198,21 @@ var gIntentGunColor: Color = Color(0)
var gIntentAdjGunBody: bool = false
var gIntentAdjRadarBody: bool = false
var gIntentAdjRadarGun: bool = false
-var gIntentTeamMessages: seq[TeamMessage] = @[]
-var gIntentStdOut: string = ""
-var gIntentStdErr: string = ""
+# ponytail: static buffers instead of strings/seq. go()'s stop path returns
+# before buildIntentJson clears these, so a round's last tick can leave heap
+# blocks owned by the exiting bot thread -> the fresh next-round thread
+# reallocs a dead allocator block (rawDealloc SIGSEGV). Static storage:
+# no heap block crosses threads.
+const INTENT_STDOUT_CAP = 4096
+const INTENT_STDERR_CAP = 4096
+const INTENT_MSG_CAP = 16
+
+var gIntentTeamMessages: array[INTENT_MSG_CAP, TeamMessage]
+var gIntentTeamMsgsLen: int
+var gIntentStdOut: array[INTENT_STDOUT_CAP, char]
+var gIntentStdOutLen: int
+var gIntentStdErr: array[INTENT_STDERR_CAP, char]
+var gIntentStdErrLen: int
proc buildIntentJson*(): string =
## Serialise current intent to JSON for sending to server.
@@ -237,9 +249,10 @@ proc buildIntentJson*(): string =
obj["tracksColor"] = %gIntentTracksColor.toHex
if gIntentGunColor != Color(0):
obj["gunColor"] = %gIntentGunColor.toHex
- if gIntentTeamMessages.len > 0:
+ if gIntentTeamMsgsLen > 0:
var msgs = newJArray()
- for m in gIntentTeamMessages:
+ for i in 0 ..< gIntentTeamMsgsLen:
+ let m = gIntentTeamMessages[i]
var mo = newJObject()
mo["message"] = %m.message
mo["messageType"] = %m.messageType
@@ -247,13 +260,14 @@ proc buildIntentJson*(): string =
mo["receiverId"] = %m.receiverId
msgs.add mo
obj["teamMessages"] = msgs
- gIntentTeamMessages.setLen 0
- if gIntentStdOut.len > 0:
- obj["stdOut"] = %gIntentStdOut
- gIntentStdOut = ""
- if gIntentStdErr.len > 0:
- obj["stdErr"] = %gIntentStdErr
- gIntentStdErr = ""
+ for i in 0 ..< gIntentTeamMsgsLen: gIntentTeamMessages[i].reset
+ gIntentTeamMsgsLen = 0
+ if gIntentStdOutLen > 0:
+ obj["stdOut"] = %($gIntentStdOut[0 ..< gIntentStdOutLen])
+ gIntentStdOutLen = 0
+ if gIntentStdErrLen > 0:
+ obj["stdErr"] = %($gIntentStdErr[0 ..< gIntentStdErrLen])
+ gIntentStdErrLen = 0
let svg = svgOutput()
if svg.len > 0:
obj["debugGraphics"] = %svg
@@ -311,19 +325,27 @@ proc setGunColor*(color: Color) = gIntentGunColor = color
proc printToStdOut*(s: string) =
## Append s to this tick's stdOut payload (sent to server in BotIntent).
- gIntentStdOut.add s
+ let n = min(s.len, INTENT_STDOUT_CAP - gIntentStdOutLen)
+ for i in 0 ..< n: gIntentStdOut[gIntentStdOutLen + i] = s[i]
+ inc gIntentStdOutLen, n
proc printToStdErr*(s: string) =
## Append s to this tick's stdErr payload (sent to server in BotIntent).
- gIntentStdErr.add s
+ let n = min(s.len, INTENT_STDERR_CAP - gIntentStdErrLen)
+ for i in 0 ..< n: gIntentStdErr[gIntentStdErrLen + i] = s[i]
+ inc gIntentStdErrLen, n
proc broadcastTeamMessage*(message: string) =
## Send a message to all teammates this tick.
- gIntentTeamMessages.add TeamMessage(message: message, messageType: "String")
+ if gIntentTeamMsgsLen < INTENT_MSG_CAP:
+ gIntentTeamMessages[gIntentTeamMsgsLen] = TeamMessage(message: message, messageType: "String")
+ inc gIntentTeamMsgsLen
proc sendTeamMessage*(botId: int; message: string) =
## Send a message to a specific teammate this tick.
- gIntentTeamMessages.add TeamMessage(message: message, messageType: "String", receiverId: botId)
+ if gIntentTeamMsgsLen < INTENT_MSG_CAP:
+ gIntentTeamMessages[gIntentTeamMsgsLen] = TeamMessage(message: message, messageType: "String", receiverId: botId)
+ inc gIntentTeamMsgsLen
proc setAdjustGunForBodyTurn*(v: bool) = gIntentAdjGunBody = v
proc setAdjustRadarForBodyTurn*(v: bool) = gIntentAdjRadarBody = v
@@ -808,6 +830,16 @@ proc botThreadEntry() {.thread.} =
" prevDir=" & $gPreviousDirection &
" prevGunDir=" & $gPreviousGunDirection)
+ # Reset graphics + intent buffers on the thread that owns them. go()'s
+ # stop path (round end) returns before buildIntentJson/clearGraphics, so
+ # the previous round's thread can leave content behind; resetting here
+ # keeps it from leaking into this round's first intent.
+ clearGraphics()
+ gIntentStdOutLen = 0
+ gIntentStdErrLen = 0
+ for i in 0 ..< gIntentTeamMsgsLen: gIntentTeamMessages[i].reset
+ gIntentTeamMsgsLen = 0
+
dispatchPendingEvents(gBot) # dispatch events embedded in the first tick
try:
diff --git a/libs/tankroyale_botapi/tankroyale_botapi/graphics.nim b/libs/tankroyale_botapi/tankroyale_botapi/graphics.nim
index 503c68b..1483e41 100644
--- a/libs/tankroyale_botapi/tankroyale_botapi/graphics.nim
+++ b/libs/tankroyale_botapi/tankroyale_botapi/graphics.nim
@@ -9,13 +9,29 @@ import ./color
# Module-level state (single bot per process)
# ---------------------------------------------------------------------------
-var gSvgBuffer: string
+# ponytail: static char array + length instead of a heap string. A fresh bot
+# thread runs each round; a module-level string grown by thread N and cleared
+# ("") by thread N+1 free/reallocs a dead thread's allocator block ->
+# rawDealloc SIGSEGV (same crash class as the event queue seq; gdb-confirmed
+# in drawText mid-campaign). Static storage: no heap block crosses threads.
+const SVG_BUFFER_CAP = 16384
+
+var gSvgLen: int
+var gSvgBuffer: array[SVG_BUFFER_CAP, char]
var gStrokeColor: Color = WHITE
var gFillColor: Color = WHITE
var gStrokeWidth: float = 1.0
-var gFontFamily: string = "Arial"
+var gFontFamily: string = "Arial" # never rebound at runtime (setFont unused)
var gFontSize: float = 12.0
+proc appendSvg(s: string) =
+ ## Append an SVG fragment, dropping anything past the static cap.
+ let room = SVG_BUFFER_CAP - gSvgLen
+ if room > 0:
+ let n = min(room, s.len)
+ for i in 0 ..< n: gSvgBuffer[gSvgLen + i] = s[i]
+ inc gSvgLen, n
+
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
@@ -26,12 +42,12 @@ proc svgAttrs(): string =
proc svgOutput*(): string =
## Returns the SVG fragment for this tick, or "" if nothing was drawn.
- if gSvgBuffer.len == 0: return ""
- "" & gSvgBuffer & ""
+ if gSvgLen == 0: return ""
+ "" & $gSvgBuffer[0 ..< gSvgLen] & ""
proc clearGraphics*() =
## Reset buffer and all style globals to defaults. Called after each tick.
- gSvgBuffer = ""
+ gSvgLen = 0
gStrokeColor = WHITE
gFillColor = WHITE
gStrokeWidth = 1.0
@@ -54,26 +70,26 @@ proc setFont*(family: string; size: float) =
# ---------------------------------------------------------------------------
proc drawLine*(x1, y1, x2, y2: float) =
- gSvgBuffer.add &""
+ appendSvg(&"")
proc drawRectangle*(x, y, w, h: float) =
let attrs = &"stroke=\"{gStrokeColor.toHex}\" fill=\"none\" stroke-width=\"{gStrokeWidth}\""
- gSvgBuffer.add &""
+ appendSvg(&"")
proc fillRectangle*(x, y, w, h: float) =
let attrs = &"stroke=\"none\" fill=\"{gFillColor.toHex}\""
- gSvgBuffer.add &""
+ appendSvg(&"")
proc drawCircle*(x, y, r: float) =
let attrs = &"stroke=\"{gStrokeColor.toHex}\" fill=\"none\" stroke-width=\"{gStrokeWidth}\""
- gSvgBuffer.add &""
+ appendSvg(&"")
proc fillCircle*(x, y, r: float) =
let attrs = &"stroke=\"none\" fill=\"{gFillColor.toHex}\""
- gSvgBuffer.add &""
+ appendSvg(&"")
proc drawText*(text: string; x, y: float) =
- gSvgBuffer.add &"{text}"
+ appendSvg(&"{text}")
proc drawPolygon*(points: seq[(float, float)]) =
var pts = ""
@@ -81,7 +97,7 @@ proc drawPolygon*(points: seq[(float, float)]) =
if pts.len > 0: pts.add ' '
pts.add &"{px},{py}"
let attrs = &"stroke=\"{gStrokeColor.toHex}\" fill=\"none\" stroke-width=\"{gStrokeWidth}\""
- gSvgBuffer.add &""
+ appendSvg(&"")
proc fillPolygon*(points: seq[(float, float)]) =
var pts = ""
@@ -89,4 +105,4 @@ proc fillPolygon*(points: seq[(float, float)]) =
if pts.len > 0: pts.add ' '
pts.add &"{px},{py}"
let attrs = &"stroke=\"none\" fill=\"{gFillColor.toHex}\""
- gSvgBuffer.add &""
+ appendSvg(&"")