// 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
}
