fix(botapi): static SVG + intent buffers to kill cross-thread heap realloc

Round N+1's fresh bot thread realloc'd module-level strings/seqs (SVG buffer,
intent stdout/stderr, team messages) left behind by dead round N's thread —
same rawDealloc SIGSEGV class as the event queue, seen at graphics.nim:274
(drawText->prepareAdd, core 2490478 @ 03:13:40, battle round 257).

- graphics.nim: gSvgBuffer -> array[16384, char] + gSvgLen, appendSvg
- bot.nim: intent stdout/stderr -> static char arrays; team messages ->
  array[16, TeamMessage] + len; buildIntentJson/printToStdOut/Err/
  broadcastTeamMessage bounded appends
- botThreadEntry: reset graphics+intent buffers on the owning thread

Also fixes stale mapActions call sites in tests/ (missing enemyX/enemyY).
This commit is contained in:
2026-08-19 03:32:28 +02:00
parent 64697f917e
commit a4e830531b
11 changed files with 87 additions and 39 deletions
BIN
View File
Binary file not shown.
Binary file not shown.
+8 -8
View File
@@ -20,7 +20,7 @@ proc makeRaw(vals: array[6, float32]): Tensor[float32] =
# --- 6-dim input produces a valid BotActions --- # --- 6-dim input produces a valid BotActions ---
block basicDecode: block basicDecode:
let raw = makeRaw([0.0'f32, 0.0, 0.0, 0.0, 0.0, 0.0]) 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 # 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.gotoX - 600.0) < 1e-6, "gotoX = sigmoid(0)*arenaW"
check abs(acts.gotoY - 400.0) < 1e-6, "gotoY = sigmoid(0)*arenaH" check abs(acts.gotoY - 400.0) < 1e-6, "gotoY = sigmoid(0)*arenaH"
@@ -31,13 +31,13 @@ block basicDecode:
block coordBounds: block coordBounds:
# Large positive raw → sigmoid ≈ 1 → close to arenaW/arenaH # 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 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.gotoX <= arenaW + 1e-9, "gotoX <= arenaWidth"
check actsHigh.gotoY <= arenaH + 1e-9, "gotoY <= arenaHeight" check actsHigh.gotoY <= arenaH + 1e-9, "gotoY <= arenaHeight"
check actsHigh.gotoX >= 0.0, "gotoX >= 0" check actsHigh.gotoX >= 0.0, "gotoX >= 0"
# Large negative raw → sigmoid ≈ 0 → close to 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 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.gotoX >= -1e-9, "gotoX >= 0 (low raw)"
check actsLow.gotoY >= -1e-9, "gotoY >= 0 (low raw)" check actsLow.gotoY >= -1e-9, "gotoY >= 0 (low raw)"
@@ -45,24 +45,24 @@ block coordBounds:
block fireTrigger: block fireTrigger:
# tanh(positive) >= 0 → fire when gunHeat = 0 # tanh(positive) >= 0 → fire when gunHeat = 0
let rawFire = makeRaw([0.0'f32, 0.0, 0.0, 0.0, 1.0, 0.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" check actsFire.shouldFire, "positive tanh → should fire when gun cool"
# tanh(negative) < 0 → no fire # tanh(negative) < 0 → no fire
let rawNoFire = makeRaw([0.0'f32, 0.0, 0.0, 0.0, -1.0, 0.0]) 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" check not actsNoFire.shouldFire, "negative tanh → no fire"
# gunHeat > 0 → no fire even with positive decision # 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" check not actsHot.shouldFire, "positive tanh but gun hot → no fire"
# --- Fire power in [0.1, 3.0] --- # --- Fire power in [0.1, 3.0] ---
block firePowerRange: block firePowerRange:
let rawMin = makeRaw([0.0'f32, 0.0, 0.0, 0.0, 0.0, -100.0]) 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 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 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) 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 actsMin.firePower >= 0.1 - 1e-6, "firePower >= 0.1"
check actsMax.firePower <= 3.0 + 1e-6, "firePower <= 3.0" check actsMax.firePower <= 3.0 + 1e-6, "firePower <= 3.0"
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -40,7 +40,7 @@ when isMainModule:
let raw = randomNormalTensor[float32](ACTION_DIM) let raw = randomNormalTensor[float32](ACTION_DIM)
let speed = 4.0'f32 let speed = 4.0'f32
# arena 800×600, bot at centre, heading north, gun north # 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, assert botActs.targetSpeed >= -8.0'f32 and botActs.targetSpeed <= 8.0'f32,
&"targetSpeed out of range: {botActs.targetSpeed}" &"targetSpeed out of range: {botActs.targetSpeed}"
@@ -52,7 +52,7 @@ when isMainModule:
&"firePower out of range: {botActs.firePower}" &"firePower out of range: {botActs.firePower}"
# shouldFire=false when gunHeat > 0 # 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" assert not noFire.shouldFire, "shouldFire should be false when gunHeat > 0"
echo "All tests passed" echo "All tests passed"
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -198,9 +198,21 @@ var gIntentGunColor: Color = Color(0)
var gIntentAdjGunBody: bool = false var gIntentAdjGunBody: bool = false
var gIntentAdjRadarBody: bool = false var gIntentAdjRadarBody: bool = false
var gIntentAdjRadarGun: bool = false var gIntentAdjRadarGun: bool = false
var gIntentTeamMessages: seq[TeamMessage] = @[] # ponytail: static buffers instead of strings/seq. go()'s stop path returns
var gIntentStdOut: string = "" # before buildIntentJson clears these, so a round's last tick can leave heap
var gIntentStdErr: string = "" # 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 = proc buildIntentJson*(): string =
## Serialise current intent to JSON for sending to server. ## Serialise current intent to JSON for sending to server.
@@ -237,9 +249,10 @@ proc buildIntentJson*(): string =
obj["tracksColor"] = %gIntentTracksColor.toHex obj["tracksColor"] = %gIntentTracksColor.toHex
if gIntentGunColor != Color(0): if gIntentGunColor != Color(0):
obj["gunColor"] = %gIntentGunColor.toHex obj["gunColor"] = %gIntentGunColor.toHex
if gIntentTeamMessages.len > 0: if gIntentTeamMsgsLen > 0:
var msgs = newJArray() var msgs = newJArray()
for m in gIntentTeamMessages: for i in 0 ..< gIntentTeamMsgsLen:
let m = gIntentTeamMessages[i]
var mo = newJObject() var mo = newJObject()
mo["message"] = %m.message mo["message"] = %m.message
mo["messageType"] = %m.messageType mo["messageType"] = %m.messageType
@@ -247,13 +260,14 @@ proc buildIntentJson*(): string =
mo["receiverId"] = %m.receiverId mo["receiverId"] = %m.receiverId
msgs.add mo msgs.add mo
obj["teamMessages"] = msgs obj["teamMessages"] = msgs
gIntentTeamMessages.setLen 0 for i in 0 ..< gIntentTeamMsgsLen: gIntentTeamMessages[i].reset
if gIntentStdOut.len > 0: gIntentTeamMsgsLen = 0
obj["stdOut"] = %gIntentStdOut if gIntentStdOutLen > 0:
gIntentStdOut = "" obj["stdOut"] = %($gIntentStdOut[0 ..< gIntentStdOutLen])
if gIntentStdErr.len > 0: gIntentStdOutLen = 0
obj["stdErr"] = %gIntentStdErr if gIntentStdErrLen > 0:
gIntentStdErr = "" obj["stdErr"] = %($gIntentStdErr[0 ..< gIntentStdErrLen])
gIntentStdErrLen = 0
let svg = svgOutput() let svg = svgOutput()
if svg.len > 0: if svg.len > 0:
obj["debugGraphics"] = %svg obj["debugGraphics"] = %svg
@@ -311,19 +325,27 @@ proc setGunColor*(color: Color) = gIntentGunColor = color
proc printToStdOut*(s: string) = proc printToStdOut*(s: string) =
## Append s to this tick's stdOut payload (sent to server in BotIntent). ## 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) = proc printToStdErr*(s: string) =
## Append s to this tick's stdErr payload (sent to server in BotIntent). ## 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) = proc broadcastTeamMessage*(message: string) =
## Send a message to all teammates this tick. ## 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) = proc sendTeamMessage*(botId: int; message: string) =
## Send a message to a specific teammate this tick. ## 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 setAdjustGunForBodyTurn*(v: bool) = gIntentAdjGunBody = v
proc setAdjustRadarForBodyTurn*(v: bool) = gIntentAdjRadarBody = v proc setAdjustRadarForBodyTurn*(v: bool) = gIntentAdjRadarBody = v
@@ -808,6 +830,16 @@ proc botThreadEntry() {.thread.} =
" prevDir=" & $gPreviousDirection & " prevDir=" & $gPreviousDirection &
" prevGunDir=" & $gPreviousGunDirection) " 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 dispatchPendingEvents(gBot) # dispatch events embedded in the first tick
try: try:
@@ -9,13 +9,29 @@ import ./color
# Module-level state (single bot per process) # 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 gStrokeColor: Color = WHITE
var gFillColor: Color = WHITE var gFillColor: Color = WHITE
var gStrokeWidth: float = 1.0 var gStrokeWidth: float = 1.0
var gFontFamily: string = "Arial" var gFontFamily: string = "Arial" # never rebound at runtime (setFont unused)
var gFontSize: float = 12.0 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 # Internal helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -26,12 +42,12 @@ proc svgAttrs(): string =
proc svgOutput*(): string = proc svgOutput*(): string =
## Returns the SVG fragment for this tick, or "" if nothing was drawn. ## Returns the SVG fragment for this tick, or "" if nothing was drawn.
if gSvgBuffer.len == 0: return "" if gSvgLen == 0: return ""
"<g>" & gSvgBuffer & "</g>" "<g>" & $gSvgBuffer[0 ..< gSvgLen] & "</g>"
proc clearGraphics*() = proc clearGraphics*() =
## Reset buffer and all style globals to defaults. Called after each tick. ## Reset buffer and all style globals to defaults. Called after each tick.
gSvgBuffer = "" gSvgLen = 0
gStrokeColor = WHITE gStrokeColor = WHITE
gFillColor = WHITE gFillColor = WHITE
gStrokeWidth = 1.0 gStrokeWidth = 1.0
@@ -54,26 +70,26 @@ proc setFont*(family: string; size: float) =
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
proc drawLine*(x1, y1, x2, y2: float) = proc drawLine*(x1, y1, x2, y2: float) =
gSvgBuffer.add &"<line x1=\"{x1}\" y1=\"{y1}\" x2=\"{x2}\" y2=\"{y2}\" {svgAttrs()}/>" appendSvg(&"<line x1=\"{x1}\" y1=\"{y1}\" x2=\"{x2}\" y2=\"{y2}\" {svgAttrs()}/>")
proc drawRectangle*(x, y, w, h: float) = proc drawRectangle*(x, y, w, h: float) =
let attrs = &"stroke=\"{gStrokeColor.toHex}\" fill=\"none\" stroke-width=\"{gStrokeWidth}\"" let attrs = &"stroke=\"{gStrokeColor.toHex}\" fill=\"none\" stroke-width=\"{gStrokeWidth}\""
gSvgBuffer.add &"<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" {attrs}/>" appendSvg(&"<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" {attrs}/>")
proc fillRectangle*(x, y, w, h: float) = proc fillRectangle*(x, y, w, h: float) =
let attrs = &"stroke=\"none\" fill=\"{gFillColor.toHex}\"" let attrs = &"stroke=\"none\" fill=\"{gFillColor.toHex}\""
gSvgBuffer.add &"<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" {attrs}/>" appendSvg(&"<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" {attrs}/>")
proc drawCircle*(x, y, r: float) = proc drawCircle*(x, y, r: float) =
let attrs = &"stroke=\"{gStrokeColor.toHex}\" fill=\"none\" stroke-width=\"{gStrokeWidth}\"" let attrs = &"stroke=\"{gStrokeColor.toHex}\" fill=\"none\" stroke-width=\"{gStrokeWidth}\""
gSvgBuffer.add &"<circle cx=\"{x}\" cy=\"{y}\" r=\"{r}\" {attrs}/>" appendSvg(&"<circle cx=\"{x}\" cy=\"{y}\" r=\"{r}\" {attrs}/>")
proc fillCircle*(x, y, r: float) = proc fillCircle*(x, y, r: float) =
let attrs = &"stroke=\"none\" fill=\"{gFillColor.toHex}\"" let attrs = &"stroke=\"none\" fill=\"{gFillColor.toHex}\""
gSvgBuffer.add &"<circle cx=\"{x}\" cy=\"{y}\" r=\"{r}\" {attrs}/>" appendSvg(&"<circle cx=\"{x}\" cy=\"{y}\" r=\"{r}\" {attrs}/>")
proc drawText*(text: string; x, y: float) = proc drawText*(text: string; x, y: float) =
gSvgBuffer.add &"<text x=\"{x}\" y=\"{y}\" font-family=\"{gFontFamily}\" font-size=\"{gFontSize}\">{text}</text>" appendSvg(&"<text x=\"{x}\" y=\"{y}\" font-family=\"{gFontFamily}\" font-size=\"{gFontSize}\">{text}</text>")
proc drawPolygon*(points: seq[(float, float)]) = proc drawPolygon*(points: seq[(float, float)]) =
var pts = "" var pts = ""
@@ -81,7 +97,7 @@ proc drawPolygon*(points: seq[(float, float)]) =
if pts.len > 0: pts.add ' ' if pts.len > 0: pts.add ' '
pts.add &"{px},{py}" pts.add &"{px},{py}"
let attrs = &"stroke=\"{gStrokeColor.toHex}\" fill=\"none\" stroke-width=\"{gStrokeWidth}\"" let attrs = &"stroke=\"{gStrokeColor.toHex}\" fill=\"none\" stroke-width=\"{gStrokeWidth}\""
gSvgBuffer.add &"<polygon points=\"{pts}\" {attrs}/>" appendSvg(&"<polygon points=\"{pts}\" {attrs}/>")
proc fillPolygon*(points: seq[(float, float)]) = proc fillPolygon*(points: seq[(float, float)]) =
var pts = "" var pts = ""
@@ -89,4 +105,4 @@ proc fillPolygon*(points: seq[(float, float)]) =
if pts.len > 0: pts.add ' ' if pts.len > 0: pts.add ' '
pts.add &"{px},{py}" pts.add &"{px},{py}"
let attrs = &"stroke=\"none\" fill=\"{gFillColor.toHex}\"" let attrs = &"stroke=\"none\" fill=\"{gFillColor.toHex}\""
gSvgBuffer.add &"<polygon points=\"{pts}\" {attrs}/>" appendSvg(&"<polygon points=\"{pts}\" {attrs}/>")