Linked repository source
examples/slime_colony/subsystem/slime.odin
This read-only view comes from the file in the Gododin repository. It retains its design, example or evidence context.
Download the original file · Return to the guide
// Package slime is the slime_colony example's pure Odin kernel: a Physarum
// colony simulation in the style of Sebastian Lague's episode. It imports
// neither Godot, Gododin, nor Raylib — only Odin's core math and core memory
// packages — and it owns every byte it needs in one arena allocated when the
// colony is created, so a warm colony_step or colony_render performs no
// allocation.
//
// The simulation is deterministic. Every agent's identity derives from the
// population seed and its index through integer mixing with rational f32
// constants (the multimesh stream fixture's proven pattern), so the same
// configuration replays exactly on this host. Time is never read: one frame is
// one colony_step call, and delta time is implicit in Params (fixed-step
// design).
//
// Verification scope. Movement reads the previous field, then all agents
// deposit into the shared field before diffusion. Whole-frame replay checks
// preserve those phases; they are not an independent algorithm oracle.
// This package provides:
//
// - pure helpers (`sense_at`, `diffuse_pass`, `apply_decay`) whose math is
// unit-tested directly against hand-computed values, which is where the
// kernel's correctness is actually pinned;
// - `state_digest`, a whole-buffer fingerprint that carries determinism
// claims across runs, colonies, and transports without holding bytes;
// - `colony_clone`, a test-only deep copy; and
// - `oracle_frame`, which recomputes ONE full step from a cloned pre-step
// state into explicit outputs and compares them elementwise against the
// caller's observed post-step buffers. This detects changed outputs from
// the same implementation; independent helper/steering tests establish
// arithmetic expectations. It allocates a full replica and runs a step.
package slime
import "core:math"
import "core:mem"
MIN_DIMENSION :: 32
MAX_DIMENSION :: 2048
MAX_CELLS :: MAX_DIMENSION * MAX_DIMENSION
MIN_AGENTS :: 1
MAX_AGENTS :: 1_000_000
// Params is one fixed-step configuration. dt is implicit: every quantity is
// per step, not per second. `gain` is display-only — colony_render scales the
// trail field by it before saturating to u8; the shader downstream owns all
// color.
Params :: struct {
move_speed: f32,
turn_speed: f32,
sensor_angle: f32,
sensor_distance: f32,
sensor_size: i32,
deposit: f32,
decay: f32,
jitter: f32,
gain: f32,
// attraction weights the nutrient mask's contribution to each sensor
// sample (EX-SLIME). Zero — the default — keeps the unmasked canonical
// workload byte-for-byte unchanged.
attraction: f32,
}
// DEFAULT_PARAMS is a sane starting configuration; the Godot project drives
// every field explicitly through its own UI.
DEFAULT_PARAMS :: Params{
move_speed = 1.0,
turn_speed = 0.3,
sensor_angle = 0.39269908169872414, // 22.5 degrees
sensor_distance = 9.0,
sensor_size = 1,
deposit = 1.0,
decay = 0.03,
jitter = 0.2,
gain = 1.0,
attraction = 0.0,
}
// params_valid defines supported finite per-step inputs. Bounded coordinates
// keep float-to-index conversion safe; deposit/gain bounds keep field and
// display arithmetic finite over the u32 step-counter domain.
params_valid :: proc(params: Params) -> bool {
return finite_range(params.move_speed, 0, MAX_DIMENSION) &&
finite_range(params.turn_speed, 0, TAU) &&
finite_range(params.sensor_angle, 0, TAU / 2) &&
finite_range(params.sensor_distance, 0, MAX_DIMENSION) &&
(params.sensor_size == 1 || params.sensor_size == 3) &&
finite_range(params.deposit, 0, 1_000_000) &&
finite_range(params.decay, 0, 1) &&
finite_range(params.jitter, 0, TAU) &&
finite_range(params.gain, 0, 1_000_000) &&
finite_range(params.attraction, 0, 1_000_000)
}
@(private)
finite_range :: proc(value, lower, upper: f32) -> bool {
// The conjunction rejects NaN as well as either infinity.
return value >= lower && value <= upper
}
// Colony is the sole allocation owner for the simulation. One allocation holds
// the struct and the arena backing buffer; the arena backs the trail grid, the
// diffusion scratch, and the agent structure-of-arrays. All of it is freed by
// one colony_destroy.
Colony :: struct {
live: bool,
allocator: mem.Allocator,
arena: mem.Arena,
storage_bytes: i64,
width: i32,
height: i32,
agent_count: i32,
seed: u32,
steps_done: u32,
params: Params,
grid: []f32,
scratch: []f32,
x: []f32,
y: []f32,
heading: []f32,
display: []u8,
// The nutrient mask (EX-SLIME): one Odin-owned copy of the authored
// spatial input, covering the same toroidal world proportionally. nil
// means no mask; its allocation is separate from the arena so replacing,
// resizing, and clearing all have one real cleanup path.
nutrient: []u8,
nutrient_width: i32,
nutrient_height: i32,
}
// stream_mix folds the population seed, an agent index, and a salt into one
// well-distributed u32. Two multiply-xor rounds let every seed, index, or salt
// bit reach every result bit, so a one-bit seed change moves every agent; the
// multipliers are fixed odd integers, so f32 values derived from the result
// stay exactly reproducible.
stream_mix :: proc(seed: u32, index: i32, salt: u32) -> u32 {
mixed := seed ~ (u32(index) * 2654435761) ~ (salt * 0x9E3779B1)
mixed = mixed * 2654435761 ~ (mixed >> 16)
mixed = mixed * 2654435761 ~ (mixed >> 16)
return mixed
}
// stream_unit projects one mixed word into [0, 1) with a rational scale.
stream_unit :: proc(word: u32) -> f32 {
return f32(word & 0xFFFF) * (1.0 / 65536.0)
}
TAU :: 6.283185307179586
// slime_create validates the configuration and allocates the colony: one
// block carrying the struct and the arena backing buffer. It returns
// (nil, false) on any rejected bound or allocation failure and never
// half-owns memory on failure.
slime_create :: proc(
width, height: i32,
agent_count: i32,
seed: u32,
allocator: mem.Allocator,
) -> (^Colony, bool) {
if width < MIN_DIMENSION || width > MAX_DIMENSION do return nil, false
if height < MIN_DIMENSION || height > MAX_DIMENSION do return nil, false
if int(width) * int(height) > MAX_CELLS do return nil, false
if agent_count < MIN_AGENTS || agent_count > MAX_AGENTS do return nil, false
cells := int(width) * int(height)
// grid + scratch + three agent arrays, all f32, plus one display byte per
// cell for colony_render's output buffer.
storage := (cells*2 + int(agent_count)*3) * size_of(f32) + cells
block, block_error := mem.alloc(size_of(Colony) + storage, mem.DEFAULT_ALIGNMENT, allocator)
if block_error != nil || block == nil do return nil, false
// mem.alloc provides zeroed storage; assign the owner fields directly so
// no whole-struct temporary is materialized.
colony := cast(^Colony)block
colony.live = true
colony.allocator = allocator
colony.storage_bytes = i64(storage)
colony.width = width
colony.height = height
colony.agent_count = agent_count
colony.seed = seed
colony.steps_done = 0
colony.params = DEFAULT_PARAMS
backing := (cast([^]byte)block)[size_of(Colony):size_of(Colony)+storage]
mem.arena_init(&colony.arena, backing)
if !colony_layout(colony) {
_ = mem.free(block, allocator)
return nil, false
}
spawn_agents(colony)
return colony, true
}
// colony_layout slices the arena backing into the grid, scratch, and agent
// arrays. It fails only if the arena was never initialized.
colony_layout :: proc(colony: ^Colony) -> bool {
cells := int(colony.width) * int(colony.height)
agents := int(colony.agent_count)
arena := mem.arena_allocator(&colony.arena)
grid, grid_error := mem.make_slice([]f32, cells, arena)
if grid_error != nil do return false
scratch, scratch_error := mem.make_slice([]f32, cells, arena)
if scratch_error != nil do return false
x, x_error := mem.make_slice([]f32, agents, arena)
if x_error != nil do return false
y, y_error := mem.make_slice([]f32, agents, arena)
if y_error != nil do return false
heading, heading_error := mem.make_slice([]f32, agents, arena)
if heading_error != nil do return false
display, display_error := mem.make_slice([]u8, cells, arena)
if display_error != nil do return false
colony.grid = grid
colony.scratch = scratch
colony.x = x
colony.y = y
colony.heading = heading
colony.display = display
return true
}
// spawn_agents places every agent deterministically: an even random spread
// across the whole grid, each with a random heading. A full-grid start is
// what lets the trail field settle into the recognizable physarum network —
// trails form wherever agents roam and coarsen into highways everywhere —
// where a tight center cluster only ever paints one frontier ring around its
// own spawn disc.
spawn_agents :: proc(colony: ^Colony) {
for index in 0..<colony.agent_count {
x_unit := stream_unit(stream_mix(colony.seed, index, 0x51ED270B))
y_unit := stream_unit(stream_mix(colony.seed, index, 0x2545F491))
heading_unit := stream_unit(stream_mix(colony.seed, index, 0x9E3779B1))
colony.x[index] = wrap_float(x_unit * f32(colony.width), f32(colony.width))
colony.y[index] = wrap_float(y_unit * f32(colony.height), f32(colony.height))
colony.heading[index] = TAU * heading_unit
}
}
// slime_destroy frees the whole colony: one block, one free. It takes a
// double pointer so the caller's owner is provably cleared, and refuses an
// already-dead owner. A refused allocator free preserves the live pointer
// so the same owner can retry cleanup without reading freed memory.
slime_destroy :: proc(colony: ^^Colony) -> bool {
if colony != nil && colony^ != nil {
colony_clear_nutrient(colony^)
}
if colony == nil || colony^ == nil do return false
owner := colony^
if !owner.live do return false
allocator := owner.allocator
if mem.free(rawptr(owner), allocator) != nil do return false
colony^ = nil
return true
}
// wrap_index maps an integer coordinate onto [0, extent) toroidally.
wrap_index :: proc(value, extent: int) -> int {
wrapped := value % extent
if wrapped < 0 do wrapped += extent
return wrapped
}
// wrap_float maps a position onto [0, extent) toroidally in f32.
wrap_float :: proc(value, extent: f32) -> f32 {
wrapped := math.mod(value, extent)
if wrapped < 0 do wrapped += extent
if wrapped >= extent do wrapped -= extent
return wrapped
}
// sense_at samples the trail field at one world-space point: the average of a
// sensor_size x sensor_size square of cells centered on (x, y), toroidally
// wrapped. sensor_size 1 samples exactly one cell; 3 averages the 3x3 block.
// This is a pure helper, unit-tested directly.
sense_at :: proc(grid: []f32, width, height: i32, x, y: f32, sensor_size: i32) -> f32 {
if sensor_size < 1 || sensor_size % 2 == 0 do return 0
if len(grid) < int(width) * int(height) do return 0
base_x := int(math.floor(x))
base_y := int(math.floor(y))
half := int(sensor_size) / 2
reciprocal := 1.0 / f32(sensor_size * sensor_size)
// Wrap the neighbourhood's origin once. Advancing one cell needs only
// the edge check, even when the sensor spans the toroidal extent more
// than once. Keep the original row-major accumulation order.
first_x := wrap_index(base_x - half, int(width))
iy := wrap_index(base_y - half, int(height))
total: f32
for _ in -half..=half {
row := iy * int(width)
ix := first_x
for _ in -half..=half {
total += grid[row + ix]
ix += 1
if ix == int(width) do ix = 0
}
iy += 1
if iy == int(height) do iy = 0
}
return total * reciprocal
}
// nutrient_sample maps one field-space point onto the nutrient mask and
// returns its strength in [0, 1]: the mask covers the same toroidal world,
// scaled proportionally, nearest cell selects the byte. Without a mask, or
// for a structurally impossible sample, it returns 0. Pure helper,
// unit-tested directly.
nutrient_sample :: proc(colony: ^Colony, x, y: f32) -> f32 {
if colony == nil || colony.nutrient == nil do return 0
w := colony.nutrient_width
h := colony.nutrient_height
if w < 1 || h < 1 || len(colony.nutrient) != int(w)*int(h) do return 0
fx := wrap_float(x, f32(colony.width))
fy := wrap_float(y, f32(colony.height))
nx := int(fx / f32(colony.width) * f32(w))
ny := int(fy / f32(colony.height) * f32(h))
if nx >= int(w) do nx = int(w) - 1
if ny >= int(h) do ny = int(h) - 1
return f32(colony.nutrient[ny*int(w)+nx]) * (1.0 / 255.0)
}
// colony_set_nutrient copies one bounded whole-mask snapshot in as the
// colony's nutrient input; the copy is Odin-owned from then on. Releasing
// any previous mask first gives replacing, resizing, and clearing one real
// cleanup path (the copy is a separate allocation, not arena backing).
// Dimension bounds follow the grid's own rules; len must equal width*height.
colony_set_nutrient :: proc(colony: ^Colony, mask: []u8, width, height: i32) -> bool {
if colony == nil || !colony.live do return false
if width < MIN_DIMENSION || width > MAX_DIMENSION do return false
if height < MIN_DIMENSION || height > MAX_DIMENSION do return false
if int(width) * int(height) > MAX_CELLS do return false
if len(mask) != int(width) * int(height) do return false
colony_clear_nutrient(colony)
snapshot, snapshot_error := mem.make_slice([]u8, int(width) * int(height), colony.allocator)
if snapshot_error != nil do return false
copy(snapshot, mask)
colony.nutrient = snapshot
colony.nutrient_width = width
colony.nutrient_height = height
return true
}
// colony_clear_nutrient releases the nutrient input; attraction stops
// applying on the next step. Clearing an absent mask is a no-op.
colony_clear_nutrient :: proc(colony: ^Colony) {
if colony == nil || colony.nutrient == nil do return
_ = mem.free(raw_data(colony.nutrient), colony.allocator)
colony.nutrient = nil
colony.nutrient_width = 0
colony.nutrient_height = 0
}
// colony_step_agents runs only phases 1-2 of colony_step — sense, steer,
// move, and deposit — leaving the field update (diffuse, decay) to the
// caller. The GPU comparison variant (DX-9) drives this between its own
// field-update dispatches; the agent math and its order are identical to
// colony_step's, so the split changes nothing about the canonical path.
colony_step_agents :: proc(colony: ^Colony, params: Params) -> bool {
if colony == nil || !colony.live || !params_valid(params) do return false
colony.params = params
width := colony.width
height := colony.height
for index in 0..<colony.agent_count {
px := colony.x[index]
py := colony.y[index]
h := colony.heading[index]
forward_x := px + math.cos(h) * params.sensor_distance
forward_y := py + math.sin(h) * params.sensor_distance
left_x := px + math.cos(h - params.sensor_angle) * params.sensor_distance
left_y := py + math.sin(h - params.sensor_angle) * params.sensor_distance
right_x := px + math.cos(h + params.sensor_angle) * params.sensor_distance
right_y := py + math.sin(h + params.sensor_angle) * params.sensor_distance
forward := sense_at(colony.grid, width, height, forward_x, forward_y, params.sensor_size)
left := sense_at(colony.grid, width, height, left_x, left_y, params.sensor_size)
right := sense_at(colony.grid, width, height, right_x, right_y, params.sensor_size)
if params.attraction > 0 && colony.nutrient != nil {
forward += params.attraction * nutrient_sample(colony, forward_x, forward_y)
left += params.attraction * nutrient_sample(colony, left_x, left_y)
right += params.attraction * nutrient_sample(colony, right_x, right_y)
}
jitter_unit := stream_unit(stream_mix(colony.seed, index, colony.steps_done))
if left > forward && left > right {
h -= params.turn_speed * (0.5 + jitter_unit)
} else if right > forward && right > left {
h += params.turn_speed * (0.5 + jitter_unit)
}
h += (2.0 * jitter_unit - 1.0) * params.jitter
px += math.cos(h) * params.move_speed
py += math.sin(h) * params.move_speed
colony.x[index] = wrap_float(px, f32(width))
colony.y[index] = wrap_float(py, f32(height))
colony.heading[index] = h
}
for index in 0..<colony.agent_count {
ix := int(colony.x[index])
iy := int(colony.y[index])
colony.grid[iy * int(width) + ix] += params.deposit
}
colony.steps_done += 1
return true
}
// diffuse_pass applies one separable 3-tap box blur to the trail field: the
// horizontal pass reads `grid` and writes `scratch`, the vertical pass reads
// `scratch` and writes `grid`. Both wrap toroidally. This is a pure helper,
// unit-tested directly; colony_step drives it with the colony's own scratch.
diffuse_pass :: proc(grid, scratch: []f32, width, height: i32) -> bool {
cells := int(width) * int(height)
if len(grid) < cells || len(scratch) < cells do return false
third: f32 = 1.0 / 3.0
for iy in 0..<int(height) {
row := iy * int(width)
for ix in 0..<int(width) {
left := row + (ix - 1 if ix > 0 else int(width) - 1)
right := row + (ix + 1 if ix + 1 < int(width) else 0)
scratch[row + ix] = (grid[left] + grid[row + ix] + grid[right]) * third
}
}
for iy in 0..<int(height) {
up := (iy - 1 if iy > 0 else int(height) - 1) * int(width)
down := (iy + 1 if iy + 1 < int(height) else 0) * int(width)
row := iy * int(width)
for ix in 0..<int(width) {
grid[row + ix] = (scratch[up + ix] + scratch[row + ix] + scratch[down + ix]) * third
}
}
return true
}
// apply_decay multiplies the whole field by (1 - decay). This is a pure
// helper, unit-tested directly.
apply_decay :: proc(grid: []f32, decay: f32) -> bool {
if !finite_range(decay, 0, 1) do return false
keep := 1.0 - decay
for i in 0..<len(grid) {
grid[i] *= keep
}
return true
}
// colony_step advances the colony one fixed step in three index-ordered
// phases, all reading and writing only the colony's own arena storage:
//
// 1. Sense, steer, move. Every agent samples three forward sensors at
// heading and heading +/- sensor_angle, sensor_distance ahead, turns
// toward the strongest (an exact tie keeps the heading and applies the
// seeded jitter), then moves move_speed along its heading with toroidal
// wrap.
// 2. Deposit. Every agent adds `deposit` at its new position, in index
// order. All movement has already completed; deposits affect the next
// step's sensing, not other agents' movement within this step.
// 3. Diffuse and evaporate. One separable box blur through the preallocated
// scratch, then multiplicative decay.
//
// Invalid parameters are refused before any state changes. The procedure
// allocates nothing and never reads a clock.
colony_step :: proc(colony: ^Colony, params: Params) -> bool {
if colony == nil || !colony.live || !params_valid(params) do return false
colony.params = params
width := colony.width
height := colony.height
// Phase 1: sense, steer, move — index-ordered, sensing reads last frame's
// deposited-and-decayed field.
for index in 0..<colony.agent_count {
px := colony.x[index]
py := colony.y[index]
h := colony.heading[index]
forward_x := px + math.cos(h) * params.sensor_distance
forward_y := py + math.sin(h) * params.sensor_distance
left_x := px + math.cos(h - params.sensor_angle) * params.sensor_distance
left_y := py + math.sin(h - params.sensor_angle) * params.sensor_distance
right_x := px + math.cos(h + params.sensor_angle) * params.sensor_distance
right_y := py + math.sin(h + params.sensor_angle) * params.sensor_distance
forward := sense_at(colony.grid, width, height, forward_x, forward_y, params.sensor_size)
left := sense_at(colony.grid, width, height, left_x, left_y, params.sensor_size)
right := sense_at(colony.grid, width, height, right_x, right_y, params.sensor_size)
// The nutrient mask (EX-SLIME) adds its weighted strength to the same
// three comparisons; with no mask or zero attraction this never runs,
// and the canonical workload is untouched.
if params.attraction > 0 && colony.nutrient != nil {
forward += params.attraction * nutrient_sample(colony, forward_x, forward_y)
left += params.attraction * nutrient_sample(colony, left_x, left_y)
right += params.attraction * nutrient_sample(colony, right_x, right_y)
}
// This agent's seeded unit for the step drives BOTH the turn
// magnitude and the wander: two agents on the same gradient turn by
// different amounts, so a strong trail spreads its followers across
// a branching mesh instead of aligning them into one single-file
// river. The stream is a pure function of seed, index, and step
// count, so runs replay exactly.
jitter_unit := stream_unit(stream_mix(colony.seed, index, colony.steps_done))
if left > forward && left > right {
h -= params.turn_speed * (0.5 + jitter_unit)
} else if right > forward && right > left {
h += params.turn_speed * (0.5 + jitter_unit)
}
// And every agent applies the always-on random walk on top of any
// turn — the stochasticity that keeps clumps from freezing in place
// and keeps trails weaving into each other.
h += (2.0 * jitter_unit - 1.0) * params.jitter
px += math.cos(h) * params.move_speed
py += math.sin(h) * params.move_speed
colony.x[index] = wrap_float(px, f32(width))
colony.y[index] = wrap_float(py, f32(height))
colony.heading[index] = h
}
// Phase 2: deposit at the new positions, in index order.
for index in 0..<colony.agent_count {
// Movement has already wrapped these finite positions into [0, extent).
// Truncation therefore selects the same cell as floor followed by wrap.
ix := int(colony.x[index])
iy := int(colony.y[index])
colony.grid[iy * int(width) + ix] += params.deposit
}
// Phase 3: diffuse through the preallocated scratch, then evaporate.
if !diffuse_pass(colony.grid, colony.scratch, width, height) do return false
if !apply_decay(colony.grid, params.decay) do return false
colony.steps_done += 1
return true
}
// saturate_u8 clamps one scaled trail value to the u8 display range.
saturate_u8 :: proc(value: f32) -> u8 {
if value <= 0 do return 0
if value >= 255 do return 255
return u8(value)
}
// colony_render writes the display bytes for the current trail field into
// `sink`: each cell scaled by the params gain and saturating-clamped to u8.
// The texture stays single-channel; the Godot-side palette shader owns all
// color. The procedure allocates nothing.
colony_render :: proc(colony: ^Colony, sink: []u8) -> bool {
if colony == nil || !colony.live do return false
cells := int(colony.width) * int(colony.height)
if len(sink) < cells do return false
gain := colony.params.gain
for i in 0..<cells {
sink[i] = saturate_u8(colony.grid[i] * gain)
}
return true
}
// colony_display exposes the colony's own arena-backed display buffer, valid
// until the colony is destroyed. The authored class renders into it with
// colony_render and hands the span to the engine-side transport.
colony_display :: proc(colony: ^Colony) -> []u8 {
if colony == nil || !colony.live do return nil
return colony.display
}
// state_digest folds the trail field and the agent arrays into one stable
// 64-bit value, so a frame's state can be compared across runs, colonies, and
// transports without holding the bytes.
state_digest :: proc(colony: ^Colony) -> u64 {
if colony == nil || !colony.live do return 0
hash := u64(0xcbf29ce484222325)
for value in colony.grid {
hash = fold_bits(hash, transmute(u32) value)
}
for value in colony.x {
hash = fold_bits(hash, transmute(u32) value)
}
for value in colony.y {
hash = fold_bits(hash, transmute(u32) value)
}
for value in colony.heading {
hash = fold_bits(hash, transmute(u32) value)
}
return hash
}
fold_bits :: proc(hash: u64, bits: u32) -> u64 {
folded := hash
for shift in 0..<4 {
folded = (folded ~ u64((bits >> (u32(shift) * 8)) & 0xFF)) * 0x100000001b3
}
return folded
}
// colony_clone deep-copies a colony into a fresh allocation with the same
// layout and state. It is a test-only tool: oracle_frame uses it to recompute
// a step without touching the observed colony.
colony_clone :: proc(source: ^Colony) -> ^Colony {
if source == nil || !source.live do return nil
block, block_error := mem.alloc(size_of(Colony) + int(source.storage_bytes), mem.DEFAULT_ALIGNMENT, source.allocator)
if block_error != nil || block == nil do return nil
replica := cast(^Colony)block
replica^ = source^
backing := (cast([^]byte)block)[size_of(Colony):size_of(Colony)+int(source.storage_bytes)]
mem.arena_init(&replica.arena, backing)
if !colony_layout(replica) {
_ = mem.free(block, source.allocator)
return nil
}
copy(replica.grid, source.grid)
copy(replica.scratch, source.scratch)
copy(replica.x, source.x)
copy(replica.y, source.y)
copy(replica.heading, source.heading)
copy(replica.display, source.display)
// The nutrient is a separate owner, not arena backing: aliasing it here
// would let one replica's teardown free the source's mask, so a masked
// clone deep-copies it.
replica.nutrient = nil
replica.nutrient_width = 0
replica.nutrient_height = 0
if source.nutrient != nil {
if !colony_set_nutrient(replica, source.nutrient, source.nutrient_width, source.nutrient_height) {
_ = mem.free(block, source.allocator)
return nil
}
}
return replica
}
// oracle_frame recomputes ONE full colony_step from a clone of `source`'s
// pre-step state into explicit outputs and compares them elementwise against
// the caller's observed post-step buffers: `observed_grid` must hold the
// stepped field and the three agent arrays the stepped positions and
// headings. This is same-implementation replay, not an independent oracle;
// it allocates a full replica per call and is never a warm-path operation.
oracle_frame :: proc(
source: ^Colony,
observed_grid: []f32,
observed_x: []f32,
observed_y: []f32,
observed_heading: []f32,
) -> bool {
if source == nil || !source.live do return false
cells := int(source.width) * int(source.height)
agents := int(source.agent_count)
if len(observed_grid) < cells do return false
if len(observed_x) < agents || len(observed_y) < agents || len(observed_heading) < agents do return false
replica := colony_clone(source)
if replica == nil do return false
defer slime_destroy(&replica)
if !colony_step(replica, source.params) do return false
for i in 0..<cells {
if replica.grid[i] != observed_grid[i] do return false
}
for i in 0..<agents {
if replica.x[i] != observed_x[i] do return false
if replica.y[i] != observed_y[i] do return false
if replica.heading[i] != observed_heading[i] do return false
}
return true
}