wire sim refactor

This commit is contained in:
minjaesong
2026-01-08 19:06:20 +09:00
parent e14e689dce
commit aa22fe69ff
9 changed files with 900 additions and 17 deletions

View File

@@ -1,5 +1,6 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="RIGHT_MARGIN" value="999" />
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>

1
.idea/vcs.xml generated
View File

@@ -2,5 +2,6 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/Terrarum.wiki" vcs="Git" />
</component>
</project>

View File

@@ -97,7 +97,8 @@ class WireActor : ActorWithBody, NoSerialise, InternalActor {
// signal wires?
if (WireCodex.wireProps[wireID]?.accepts == "digital_bit") {
val strength = world?.getWireEmitStateOf(worldX, worldY, wireID)?.x ?: 0.0
// Use parametric brightness from logical wire graph for efficiency
val strength = world?.getWireBrightness(worldX, worldY, wireID) ?: 0.0
// draw base (unlit) sprite
batch.color = Color.WHITE

View File

@@ -122,6 +122,13 @@ open class GameWorld(
public val wirings = HashedWirings()
private val wiringGraph = HashedWiringGraph()
/**
* Logical wire graph for efficient signal propagation.
* Contains coarse graph with logical nodes (fixtures, junctions) and segments.
* Rebuilt when wires are placed/removed; used for signal simulation and brightness lookup.
*/
val logicalWireGraph: LogicalWireGraph by lazy { LogicalWireGraph(this) }
@Transient private val WIRE_POS_MAP = intArrayOf(1,2,4,8)
@Transient private val WIRE_ANTIPOS_MAP = intArrayOf(4,8,1,2)
@@ -540,6 +547,10 @@ open class GameWorld(
// scratch-that-i'll-figure-it-out wire placement
setWireGraphOfUnsafe(blockAddr, tile, connection)
// Note: Do NOT rebuild logical wire graph here - connectivity is set up
// separately by BlockBase.setConnectivity() AFTER this call.
// The graph is rebuilt at the end of wire placement in BlockBase.kt
}
fun setTileOnLayerUnsafe(layer: Int, x: Int, y: Int, tile: Int) {
@@ -569,6 +580,9 @@ open class GameWorld(
// remove wire from this tile
wiringGraph[blockAddr]!!.remove(tile)
wirings[blockAddr]!!.ws.remove(tile)
// Rebuild logical wire graph for this wire type
logicalWireGraph.rebuild(tile)
}
}
@@ -586,6 +600,9 @@ open class GameWorld(
// remove wire from this tile
wiringGraph[blockAddr]!!.remove(tile)
wirings[blockAddr]!!.ws.remove(tile)
// Rebuild logical wire graph for this wire type
logicalWireGraph.rebuild(tile)
}
}
@@ -609,6 +626,27 @@ open class GameWorld(
return wiringGraph[blockAddr]?.get(itemID)?.emt
}
/**
* Get wire brightness using parametric evaluation from the logical wire graph.
* This is more efficient than per-tile signal storage as it derives brightness
* from segment properties rather than storing it per-tile.
*
* @param x World tile X coordinate
* @param y World tile Y coordinate
* @param wireType The wire item ID
* @return Signal strength at this position (0.0 to 1.0+), or 0.0 if no wire/signal
*/
fun getWireBrightness(x: Int, y: Int, wireType: ItemID): Double {
val (cx, cy) = coerceXY(x, y)
val blockAddr = LandUtil.getBlockAddr(this, cx, cy)
val segment = logicalWireGraph.getGraph(wireType)
?.positionToSegment?.get(blockAddr) ?: return 0.0
val offset = segment.getOffsetForPosition(Point2i(cx, cy)) ?: return 0.0
return segment.getBrightnessAtOffset(offset)
}
fun getWireReceptionStateOf(x: Int, y: Int, itemID: ItemID): ArrayList<WireReceptionState>? {
val (x, y) = coerceXY(x, y)
val blockAddr = LandUtil.getBlockAddr(this, x, y)

View File

@@ -0,0 +1,491 @@
package net.torvald.terrarum.gameworld
import net.torvald.terrarum.INGAME
import net.torvald.terrarum.Point2i
import net.torvald.terrarum.WireCodex
import net.torvald.terrarum.gameitems.ItemID
import net.torvald.terrarum.modulebasegame.TerrarumIngame.Companion.inUpdateRange
import net.torvald.terrarum.modulebasegame.gameactors.Electric
import net.torvald.terrarum.modulebasegame.gameactors.WireEmissionType
import net.torvald.terrarum.realestate.LandUtil
import org.dyn4j.geometry.Vector2
import kotlin.math.pow
/**
* Two-layer wire simulation model: Logical graph for signal propagation.
*
* The logical graph separates "what changes per tile" (visual brightness) from
* "what doesn't" (connectivity topology, signal evaluation). This reduces
* simulation complexity from O(wire_tiles) to O(logical_nodes).
*
* Logical nodes are:
* - Signal sources (fixtures with wireEmitterTypes)
* - Signal sinks (fixtures with wireSinkTypes)
* - Junctions (wire tiles with 3+ connections)
*
* Wire segments connect logical nodes. Signal propagates along segments with
* decay calculated per-segment, not per-tile.
*
* Created for Terrarum wire simulation refactoring.
*
* Created by minjaesong and Claude on 2026-01-08.
*/
typealias BlockBoxIndex = Int
/**
* Represents a node in the logical wire graph.
* Nodes are: signal sources, signal sinks, and junctions (3+ connections).
*/
sealed class LogicalWireNode {
abstract val position: Point2i
abstract val wireType: ItemID
abstract var signalStrength: Vector2
/** Connected segments (populated during graph building) */
@Transient
val connectedSegments: MutableList<WireSegment> = mutableListOf()
/**
* Fixture-backed node (source or sink).
* References the Electric fixture that emits or receives signals.
*/
data class FixtureNode(
override val position: Point2i,
override val wireType: ItemID,
val fixtureRef: Electric,
val blockBoxIndex: BlockBoxIndex,
val isEmitter: Boolean,
val emissionType: WireEmissionType
) : LogicalWireNode() {
override var signalStrength: Vector2 = Vector2(0.0, 0.0)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is FixtureNode) return false
return position == other.position && wireType == other.wireType &&
blockBoxIndex == other.blockBoxIndex && isEmitter == other.isEmitter
}
override fun hashCode(): Int {
var result = position.hashCode()
result = 31 * result + wireType.hashCode()
result = 31 * result + blockBoxIndex
result = 31 * result + isEmitter.hashCode()
return result
}
}
/**
* Junction node where 3+ wire segments meet.
* Acts as a signal relay point with potential splitting/merging.
*/
data class JunctionNode(
override val position: Point2i,
override val wireType: ItemID,
val connectionCount: Int // 3 for T-junction, 4 for cross
) : LogicalWireNode() {
override var signalStrength: Vector2 = Vector2(0.0, 0.0)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is JunctionNode) return false
return position == other.position && wireType == other.wireType
}
override fun hashCode(): Int {
var result = position.hashCode()
result = 31 * result + wireType.hashCode()
return result
}
}
}
/**
* Represents a wire segment connecting two logical nodes.
* Signal decays along the segment based on length.
*
* Replaces N tile nodes with a single edge, enabling O(1) signal propagation
* across an entire wire run.
*/
data class WireSegment(
val wireType: ItemID,
var startNode: LogicalWireNode,
var endNode: LogicalWireNode,
val length: Int,
val tilePositions: List<Point2i>,
var startStrength: Double = 0.0,
var endStrength: Double = 0.0,
val decayConstant: Double = 1.0
) {
/**
* Parametric brightness evaluation - O(1) per tile.
* @param offset Distance from start node (0 to length)
* @return Signal strength at that offset
*/
fun getBrightnessAtOffset(offset: Int): Double {
if (offset < 0 || offset > length) return 0.0
// Calculate from whichever end has the stronger signal
val fromStart = startStrength * decayConstant.pow(offset.toDouble())
val fromEnd = endStrength * decayConstant.pow((length - offset).toDouble())
return maxOf(fromStart, fromEnd).coerceAtLeast(0.0)
}
/**
* Get the offset of a tile position within this segment.
* @return Offset index, or null if position not in segment
*/
fun getOffsetForPosition(pos: Point2i): Int? {
val idx = tilePositions.indexOfFirst { it.x == pos.x && it.y == pos.y }
return if (idx >= 0) idx else null
}
/**
* Get the node at the other end of this segment.
*/
fun getOtherEnd(node: LogicalWireNode): LogicalWireNode {
return if (node === startNode || node == startNode) endNode else startNode
}
}
/**
* Complete logical wire graph for a world.
* Stored per wire-type for efficiency.
*/
class LogicalWireGraph(private val world: GameWorld) {
companion object {
private const val RIGHT = 1
private const val DOWN = 2
private const val LEFT = 4
private const val UP = 8
private val DIRECTIONS = intArrayOf(RIGHT, DOWN, LEFT, UP)
private val DIRECTION_OFFSETS = mapOf(
RIGHT to Point2i(1, 0),
DOWN to Point2i(0, 1),
LEFT to Point2i(-1, 0),
UP to Point2i(0, -1)
)
private fun Int.wireNodeMirror(): Int = when (this) {
RIGHT -> LEFT
DOWN -> UP
LEFT -> RIGHT
UP -> DOWN
else -> 0
}
}
/**
* Graph data for a single wire type.
*/
data class WireTypeGraph(
val nodes: MutableList<LogicalWireNode> = mutableListOf(),
val segments: MutableList<WireSegment> = mutableListOf(),
val positionToSegment: HashMap<BlockAddress, WireSegment> = HashMap(),
val positionToNode: HashMap<BlockAddress, LogicalWireNode> = HashMap(),
var dirty: Boolean = true,
var structureDirty: Boolean = true // Set when fixtures are added/removed; requires full rebuild
) {
fun clear() {
nodes.forEach { it.connectedSegments.clear() }
nodes.clear()
segments.clear()
positionToSegment.clear()
positionToNode.clear()
dirty = true
structureDirty = false // We're about to rebuild, so structure will be fresh
}
}
private val graphs = HashMap<ItemID, WireTypeGraph>()
fun getGraph(wireType: ItemID): WireTypeGraph? = graphs[wireType]
fun getOrCreateGraph(wireType: ItemID): WireTypeGraph {
return graphs.getOrPut(wireType) { WireTypeGraph() }
}
fun markDirty(wireType: ItemID) {
graphs[wireType]?.dirty = true
}
fun markAllDirty() {
graphs.values.forEach { it.dirty = true }
}
/**
* Mark all graphs as needing structural rebuild.
* Called when Electric fixtures are spawned or despawned.
*/
fun markAllStructureDirty() {
graphs.values.forEach { it.structureDirty = true }
}
/**
* Rebuild the logical graph for a specific wire type.
* Scans all wire tiles and fixtures to construct nodes and segments.
*/
fun rebuild(wireType: ItemID) {
val graph = getOrCreateGraph(wireType)
graph.clear()
val emissionType = WireCodex[wireType].accepts
val decayConstant = WireCodex.wireDecays[wireType] ?: 1.0
// Step 1: Find all logical nodes
// 1a: Fixture nodes (emitters and sinks)
INGAME.actorContainerActive.filterIsInstance<Electric>().forEach { fixture ->
if (!fixture.inUpdateRange(world)) return@forEach
// Emitter ports
fixture.wireEmitterTypes.forEach { (bbi, wireEmissionType) ->
if (wireEmissionType == emissionType) {
val pos = fixture.worldBlockPos!! + fixture.blockBoxIndexToPoint2i(bbi)
// Only add if there's actually a wire at this position
if (hasWireAt(pos.x, pos.y, wireType)) {
val node = LogicalWireNode.FixtureNode(
position = pos,
wireType = wireType,
fixtureRef = fixture,
blockBoxIndex = bbi,
isEmitter = true,
emissionType = wireEmissionType
)
graph.nodes.add(node)
graph.positionToNode[LandUtil.getBlockAddr(world, pos.x, pos.y)] = node
}
}
}
// Sink ports
fixture.wireSinkTypes.forEach { (bbi, wireSinkType) ->
if (wireSinkType == emissionType) {
val pos = fixture.worldBlockPos!! + fixture.blockBoxIndexToPoint2i(bbi)
if (hasWireAt(pos.x, pos.y, wireType)) {
val blockAddr = LandUtil.getBlockAddr(world, pos.x, pos.y)
// Don't add duplicate node if already an emitter at same position
if (!graph.positionToNode.containsKey(blockAddr)) {
val node = LogicalWireNode.FixtureNode(
position = pos,
wireType = wireType,
fixtureRef = fixture,
blockBoxIndex = bbi,
isEmitter = false,
emissionType = wireSinkType
)
graph.nodes.add(node)
graph.positionToNode[blockAddr] = node
}
}
}
}
}
// 1b: Junction nodes (tiles with 3+ connections)
world.wirings.forEach { (blockAddr, wiringNode) ->
if (wiringNode.ws.contains(wireType)) {
val cnx = world.getWireGraphUnsafe(blockAddr, wireType) ?: 0
val connectionCount = Integer.bitCount(cnx)
if (connectionCount >= 3) {
val x = (blockAddr % world.width).toInt()
val y = (blockAddr / world.width).toInt()
// Don't create junction if there's already a fixture node here
if (!graph.positionToNode.containsKey(blockAddr)) {
val node = LogicalWireNode.JunctionNode(
position = Point2i(x, y),
wireType = wireType,
connectionCount = connectionCount
)
graph.nodes.add(node)
graph.positionToNode[blockAddr] = node
}
}
}
}
// Step 2: Trace segments between nodes
val visitedEdges = HashSet<Pair<BlockAddress, Int>>() // (position, direction)
graph.nodes.forEach { startNode ->
val startAddr = LandUtil.getBlockAddr(world, startNode.position.x, startNode.position.y)
val cnx = world.getWireGraphUnsafe(startAddr, wireType) ?: 0
for (dir in DIRECTIONS) {
if (cnx and dir != 0) {
val edgeKey = startAddr to dir
if (visitedEdges.contains(edgeKey)) continue
// Trace path in this direction
val segment = tracePath(startNode, dir, wireType, decayConstant, graph.positionToNode)
if (segment != null) {
graph.segments.add(segment)
startNode.connectedSegments.add(segment)
segment.endNode.connectedSegments.add(segment)
// Mark all tiles in segment
segment.tilePositions.forEach { pos ->
val addr = LandUtil.getBlockAddr(world, pos.x, pos.y)
graph.positionToSegment[addr] = segment
}
// Mark both directions as visited
val endAddr = LandUtil.getBlockAddr(world, segment.endNode.position.x, segment.endNode.position.y)
visitedEdges.add(startAddr to dir)
visitedEdges.add(endAddr to dir.wireNodeMirror())
}
}
}
}
// Handle orphan wire tiles (connected to nothing) - create single-tile segments
world.wirings.forEach { (blockAddr, wiringNode) ->
if (wiringNode.ws.contains(wireType) && !graph.positionToSegment.containsKey(blockAddr)) {
val x = (blockAddr % world.width).toInt()
val y = (blockAddr / world.width).toInt()
val pos = Point2i(x, y)
// Create a degenerate segment for orphan tiles
val orphanNode = LogicalWireNode.JunctionNode(pos, wireType, 0)
val segment = WireSegment(
wireType = wireType,
startNode = orphanNode,
endNode = orphanNode,
length = 0,
tilePositions = listOf(pos),
decayConstant = decayConstant
)
graph.segments.add(segment)
graph.positionToSegment[blockAddr] = segment
}
}
graph.dirty = true
}
/**
* Trace a wire path from a node in a given direction until hitting another node.
*/
private fun tracePath(
startNode: LogicalWireNode,
initialDirection: Int,
wireType: ItemID,
decayConstant: Double,
positionToNode: HashMap<BlockAddress, LogicalWireNode>
): WireSegment? {
val path = mutableListOf<Point2i>()
path.add(startNode.position)
var currentPos = startNode.position
var direction = initialDirection
// Move to first tile in the direction
val offset = DIRECTION_OFFSETS[direction] ?: return null
currentPos = Point2i(currentPos.x + offset.x, currentPos.y + offset.y)
val maxIterations = 100000 // Safety limit
var iterations = 0
while (iterations++ < maxIterations) {
val blockAddr = LandUtil.getBlockAddr(world, currentPos.x, currentPos.y)
// Check if we've reached another logical node
val endNode = positionToNode[blockAddr]
if (endNode != null && endNode !== startNode) {
path.add(currentPos)
return WireSegment(
wireType = wireType,
startNode = startNode,
endNode = endNode,
length = path.size - 1, // Length is number of edges, not vertices
tilePositions = path.toList(),
decayConstant = decayConstant
)
}
// Check if wire exists here
val cnx = world.getWireGraphUnsafe(blockAddr, wireType)
if (cnx == null || cnx == 0) {
// Dead end - create endpoint node
val deadEndNode = LogicalWireNode.JunctionNode(
position = Point2i(currentPos.x, currentPos.y),
wireType = wireType,
connectionCount = 1
)
// Note: we don't add dead end nodes to the main node list
return WireSegment(
wireType = wireType,
startNode = startNode,
endNode = deadEndNode,
length = path.size,
tilePositions = path.toList() + listOf(currentPos),
decayConstant = decayConstant
)
}
path.add(currentPos)
// Find the exit direction (not the direction we came from)
val entryDir = direction.wireNodeMirror()
var exitDir: Int? = null
for (dir in DIRECTIONS) {
if (dir != entryDir && (cnx and dir) != 0) {
// Check if target is connected back
val nextOffset = DIRECTION_OFFSETS[dir] ?: continue
val nextPos = Point2i(currentPos.x + nextOffset.x, currentPos.y + nextOffset.y)
val nextCnx = world.getWireGraphOf(nextPos.x, nextPos.y, wireType) ?: 0
if ((nextCnx and dir.wireNodeMirror()) != 0) {
exitDir = dir
break
}
}
}
if (exitDir == null) {
// Dead end
return WireSegment(
wireType = wireType,
startNode = startNode,
endNode = LogicalWireNode.JunctionNode(currentPos, wireType, 1),
length = path.size - 1,
tilePositions = path.toList(),
decayConstant = decayConstant
)
}
// Move to next tile
val nextOffset = DIRECTION_OFFSETS[exitDir]!!
currentPos = Point2i(currentPos.x + nextOffset.x, currentPos.y + nextOffset.y)
direction = exitDir
}
return null // Safety: exceeded max iterations
}
private fun hasWireAt(x: Int, y: Int, wireType: ItemID): Boolean {
val blockAddr = LandUtil.getBlockAddr(world, x, y)
return world.wirings[blockAddr]?.ws?.contains(wireType) == true
}
/**
* Rebuild graphs for all wire types present in the world.
*/
fun rebuildAll() {
// Collect all wire types
val wireTypes = HashSet<ItemID>()
world.wirings.values.forEach { wiringNode ->
wireTypes.addAll(wiringNode.ws)
}
wireTypes.forEach { wireType ->
rebuild(wireType)
}
}
}

View File

@@ -12,6 +12,8 @@ import net.torvald.terrarum.gameactors.Controllable
import net.torvald.terrarum.gameitems.ItemID
import net.torvald.terrarum.gameworld.GameWorld
import net.torvald.terrarum.gameworld.GameWorld.Companion.FLUID
import net.torvald.terrarum.gameworld.LogicalWireNode
import net.torvald.terrarum.gameworld.WireSegment
import net.torvald.terrarum.modulebasegame.TerrarumIngame.Companion.inUpdateRange
import net.torvald.terrarum.modulebasegame.gameactors.*
import net.torvald.terrarum.modulebasegame.gameitems.AxeCore
@@ -525,37 +527,155 @@ object WorldSimulator {
it.inUpdateRange(world) && it.wireEmitterTypes.isNotEmpty()
}
// Keep for backwards compatibility - used by oldTraversedNodes cleanup
private val wireSimMarked = HashSet<Long>()
private val wireSimPoints = Queue<WireGraphCursor>()
private val oldTraversedNodes = ArrayList<WireGraphCursor>()
private val fixtureCache = HashMap<Point2i, Pair<Electric, WireEmissionType>>() // also instance of Electric
/**
* Simulates wire signal propagation using the two-layer logical wire graph.
*
* This implementation operates on logical nodes (fixtures, junctions) and segments
* rather than individual tiles, reducing complexity from O(wire_tiles) to O(logical_nodes).
*
* Signal strength is calculated per-segment and stored for parametric brightness evaluation.
* Per-tile emission states are also updated for backwards compatibility with existing code.
*/
private fun simulateWires(delta: Float) {
// unset old wires before we begin
// Clear old per-tile emission states for backwards compatibility
oldTraversedNodes.forEach { (x, y, _, _, wire) ->
world.getAllWiringGraph(x, y)?.get(wire)?.emt?.set(0.0, 0.0)
}
oldTraversedNodes.clear()
fixtureCache.clear()
wiresimGetSourceBlocks().let { sources ->
// signal-emitting fixtures must set emitState of its own tiles via update()
sources.forEach {
it.wireEmitterTypes.forEach { (bbi, wireType) ->
val startingPoint = it.worldBlockPos!! + it.blockBoxIndexToPoint2i(bbi)
val signal = it.wireEmission[bbi] ?: Vector2(0.0, 0.0)
world.getAllWiringGraph(startingPoint.x, startingPoint.y)?.keys?.filter { WireCodex[it].accepts == wireType }?.forEach { wire ->
val simStartingPoint = WireGraphCursor(startingPoint, wire)
wireSimMarked.clear()
wireSimPoints.clear()
traverseWireGraph(world, wire, simStartingPoint, signal, wireType)
// Collect all wire types that have active emitters
val activeWireTypes = HashSet<ItemID>()
wiresimGetSourceBlocks().forEach { fixture ->
fixture.wireEmitterTypes.forEach { (bbi, wireEmissionType) ->
val pos = fixture.worldBlockPos!! + fixture.blockBoxIndexToPoint2i(bbi)
world.getAllWiringGraph(pos.x, pos.y)?.keys?.forEach { wireType ->
if (WireCodex[wireType].accepts == wireEmissionType) {
activeWireTypes.add(wireType)
}
}
}
}
// Process each wire type
activeWireTypes.forEach { wireType ->
simulateWireType(wireType)
}
}
/**
* Simulate signal propagation for a specific wire type using the logical graph.
*/
private fun simulateWireType(wireType: ItemID) {
val graph = world.logicalWireGraph.getGraph(wireType)
// Rebuild graph if it doesn't exist, is empty, or structure is dirty (fixtures added/removed)
if (graph == null || graph.nodes.isEmpty() || graph.structureDirty) {
world.logicalWireGraph.rebuild(wireType)
}
val currentGraph = world.logicalWireGraph.getGraph(wireType) ?: return
if (currentGraph.nodes.isEmpty()) return
val decayConstant = WireCodex.wireDecays[wireType] ?: 1.0
val emissionType = WireCodex[wireType].accepts
// Step 1: Reset all node signal strengths and segment strengths
currentGraph.nodes.forEach { node ->
node.signalStrength = Vector2(0.0, 0.0)
}
currentGraph.segments.forEach { segment ->
segment.startStrength = 0.0
segment.endStrength = 0.0
}
// Step 2: Get signals from emitter fixture nodes
val emitterNodes = currentGraph.nodes.filterIsInstance<LogicalWireNode.FixtureNode>()
.filter { it.isEmitter && it.fixtureRef.inUpdateRange(world) }
emitterNodes.forEach { emitterNode ->
val signal = emitterNode.fixtureRef.wireEmission[emitterNode.blockBoxIndex] ?: Vector2(0.0, 0.0)
emitterNode.signalStrength = signal.copy()
}
// Step 3: BFS propagation on logical graph
val visited = HashSet<LogicalWireNode>()
val queue = ArrayDeque<LogicalWireNode>()
// Start from all emitter nodes
emitterNodes.forEach { node ->
if (node.signalStrength.x > 0.0 || node.signalStrength.y > 0.0) {
queue.add(node)
}
}
while (queue.isNotEmpty()) {
val node = queue.removeFirst()
if (visited.contains(node)) continue
visited.add(node)
// Propagate through connected segments
node.connectedSegments.forEach { segment ->
val otherNode = segment.getOtherEnd(node)
// Calculate decayed signal at the other end
val decayedSignal = node.signalStrength.copy()
decayedSignal.x *= decayConstant.pow(segment.length.toDouble())
decayedSignal.y *= decayConstant.pow(segment.length.toDouble())
// Update segment strengths for brightness calculation
if (node === segment.startNode || node == segment.startNode) {
segment.startStrength = maxOf(segment.startStrength, node.signalStrength.x)
} else {
segment.endStrength = maxOf(segment.endStrength, node.signalStrength.x)
}
// Update other node with max signal (handles multiple paths)
if (decayedSignal.x > otherNode.signalStrength.x) {
otherNode.signalStrength.x = decayedSignal.x
}
if (decayedSignal.y > otherNode.signalStrength.y) {
otherNode.signalStrength.y = decayedSignal.y
}
// Add to queue for further propagation
if (!visited.contains(otherNode)) {
queue.add(otherNode)
}
}
}
// Step 4: Update per-tile emission states for backwards compatibility
// This allows getWireEmitStateOf() to work alongside the new getWireBrightness()
currentGraph.segments.forEach { segment ->
segment.tilePositions.forEachIndexed { index, pos ->
val brightness = segment.getBrightnessAtOffset(index)
val emitState = Vector2(brightness, 0.0)
world.setWireEmitStateOf(pos.x, pos.y, wireType, emitState)
// Track for cleanup on next tick
oldTraversedNodes.add(WireGraphCursor(pos, wireType))
}
}
// Step 5: Notify sink fixtures
currentGraph.nodes.filterIsInstance<LogicalWireNode.FixtureNode>()
.filter { !it.isEmitter && it.fixtureRef.inUpdateRange(world) }
.forEach { sinkNode ->
if (sinkNode.signalStrength.x > 0.0 || sinkNode.signalStrength.y > 0.0) {
val offsetX = sinkNode.blockBoxIndex % sinkNode.fixtureRef.blockBox.width
val offsetY = sinkNode.blockBoxIndex / sinkNode.fixtureRef.blockBox.width
sinkNode.fixtureRef.updateOnWireGraphTraversal(offsetX, offsetY, sinkNode.emissionType)
}
}
currentGraph.dirty = false
}
private fun calculateDecay(signal: Vector2, dist: Int, wire: ItemID, signalType: WireEmissionType): Vector2 {
@@ -563,7 +683,9 @@ object WorldSimulator {
return signal * d.pow(dist.toDouble())
}
private fun traverseWireGraph(world: GameWorld, wire: ItemID, startingPoint: WireGraphCursor, signal: Vector2, signalType: WireEmissionType) {
// Keep old traversal method for reference/fallback (can be removed later)
@Deprecated("Use simulateWireType() with logical graph instead")
private fun traverseWireGraphLegacy(world: GameWorld, wire: ItemID, startingPoint: WireGraphCursor, signal: Vector2, signalType: WireEmissionType) {
val emissionType = WireCodex[wire].accepts

View File

@@ -57,6 +57,30 @@ open class Electric : FixtureBase {
const val ELECTRIC_EPSILON_GENERIC = 1.0 / 1024.0
}
/**
* When an Electric fixture is spawned, mark all wire graphs as structurally dirty
* so they rebuild to include this fixture's emitter/sink nodes.
*/
override fun onSpawn(tx: Int, ty: Int) {
super.onSpawn(tx, ty)
// Mark wire graphs as needing structural rebuild to include this fixture
if (wireEmitterTypes.isNotEmpty() || wireSinkTypes.isNotEmpty()) {
INGAME.world.logicalWireGraph.markAllStructureDirty()
}
}
/**
* When an Electric fixture is despawned, mark all wire graphs as structurally dirty
* so they rebuild without this fixture's nodes.
*/
override fun despawn() {
// Mark wire graphs as needing structural rebuild before this fixture is removed
if (wireEmitterTypes.isNotEmpty() || wireSinkTypes.isNotEmpty()) {
INGAME.world.logicalWireGraph.markAllStructureDirty()
}
super.despawn()
}
fun getWireEmitterAt(blockBoxIndex: BlockBoxIndex) = this.wireEmitterTypes[blockBoxIndex]
fun getWireEmitterAt(point: Point2i) = this.wireEmitterTypes[pointToBlockBoxIndex(point)]
fun getWireEmitterAt(x: Int, y: Int) = this.wireEmitterTypes[pointToBlockBoxIndex(x, y)]

View File

@@ -236,6 +236,11 @@ object BlockBase {
oldTileX = mtx
oldTileY = mty
// Rebuild logical wire graph after connectivity is fully established
if (ret >= 0) {
ingame.world.logicalWireGraph.rebuild(itemID)
}
ret
}

View File

@@ -0,0 +1,200 @@
import kotlin.math.ceil
object Random {
fun uniformRand(low: Int, high: Int) = (Math.random() * (high + 1)).toInt()
fun triangularRand(low: Float, high: Float): Float {
val a = (Math.random() + Math.random()) / 2.0
return ((high - low) * a + low).toFloat()
}
fun gaussianRand(avg: Float, stddev: Float): Float {
// Box-Muller transform to generate random numbers with standard normal distribution
// This implementation uses the polar form for better efficiency
// We need two uniform random values between 0 and 1
val random = kotlin.random.Random
// Using the polar form of the Box-Muller transformation
var u: Double
var v: Double
var s: Double
do {
// Generate two uniform random numbers between -1 and 1
u = Math.random() * 2 - 1
v = Math.random() * 2 - 1
// Calculate sum of squares
s = u * u + v * v
} while (s >= 1 || s == 0.0)
// Calculate polar transformation
val multiplier = kotlin.math.sqrt(-2.0 * kotlin.math.ln(s) / s)
// Transform to the desired mean and standard deviation
// We only use one of the two generated values here
return (avg + stddev * u * multiplier).toFloat()
}
}
sealed class SeekSimulator {
abstract fun computeSeekTime(currentSector: Int, targetSector: Int): Float
class Tape(
val totalSectors: Int,
val tapeLengthMeters: Float = 200f,
val baseSeekTime: Float = 0.5f, // seconds base inertia
val tapeSpeedMetersPerSec: Float = 2.0f, // normal speed
) : SeekSimulator() {
override fun computeSeekTime(currentSector: Int, targetSector: Int): Float {
val posCurrent = (currentSector.toFloat() / totalSectors) * tapeLengthMeters
val posTarget = (targetSector.toFloat() / totalSectors) * tapeLengthMeters
val distance = kotlin.math.abs(posTarget - posCurrent)
// Inject random tape jitter
val effectiveSpeed = tapeSpeedMetersPerSec * Random.triangularRand(0.9f, 1.1f)
return baseSeekTime + (distance / effectiveSpeed)
}
}
class Disc(
val totalTracks: Int,
val armSeekBaseTime: Float = 0.005f, // fast seek, seconds
val armSeekMultiplier: Float = 0.002f, // slower for bigger jumps
val rotationLatencyAvg: Float = 0.008f, // seconds (half-rotation average)
) : SeekSimulator() {
override fun computeSeekTime(currentSector: Int, targetSector: Int): Float {
val cylCurrent = sectorToTrack(currentSector)
val cylTarget = sectorToTrack(targetSector)
val deltaTracks = kotlin.math.abs(cylTarget - cylCurrent)
val armSeek = armSeekBaseTime + (armSeekMultiplier * kotlin.math.sqrt(deltaTracks.toFloat()))
val rotationLatency = Random.gaussianRand(rotationLatencyAvg, rotationLatencyAvg * 0.2f)
return armSeek + rotationLatency
}
private fun sectorToTrack(sector: Int): Int {
// Simplistic assumption: sector layout maps 1:1 to track at this level
return sector % totalTracks
}
}
class Drum(
val rpm: Float = 3000f
) : SeekSimulator() {
override fun computeSeekTime(currentSector: Int, targetSector: Int): Float {
val degreesPerSector = 360.0f / 10000.0f // Assume 10k sectors per drum circumference
val angleCurrent = currentSector * degreesPerSector
val angleTarget = targetSector * degreesPerSector
val deltaAngle = kotlin.math.abs(angleTarget - angleCurrent) % 360f
val rotationLatencySeconds = (deltaAngle / 360f) * (60f / rpm)
// Add a little mechanical jitter
val jitteredLatency = rotationLatencySeconds * Random.triangularRand(0.95f, 1.05f)
return jitteredLatency
}
}
}
class SeekLatencySampler(
val simulator: SeekSimulator,
val totalSectors: Int,
val sampleCount: Int = 10000
) {
data class Sample(val fromSector: Int, val toSector: Int, val latency: Float)
val samples = mutableListOf<Sample>()
fun runSampling() {
samples.clear()
var lastSector = Random.uniformRand(0, totalSectors - 1)
repeat(sampleCount) {
val nextSector = Random.uniformRand(0, totalSectors - 1)
val latency = simulator.computeSeekTime(lastSector, nextSector)
samples.add(Sample(lastSector, nextSector, latency))
lastSector = nextSector
}
}
fun analyzeAndPrint() {
if (samples.isEmpty()) {
println("No samples generated. Run runSampling() first.")
return
}
val latencies = samples.map { it.latency }
val minLatency = latencies.minOrNull() ?: 0f
val maxLatency = latencies.maxOrNull() ?: 0f
val avgLatency = latencies.average().toFloat()
val stddevLatency = kotlin.math.sqrt(latencies.map { (it - avgLatency).let { diff -> diff * diff } }.average()).toFloat()
println("=== Seek Latency Stats ===")
println("Samples: $sampleCount")
println("Min: ${"%.4f".format(minLatency)} s")
println("Max: ${"%.4f".format(maxLatency)} s")
println("Avg: ${"%.4f".format(avgLatency)} s")
println("Stddev: ${"%.4f".format(stddevLatency)} s")
printSimpleHistogram(latencies)
}
private fun printSimpleHistogram(latencies: List<Float>, bins: Int = 30) {
val min = latencies.minOrNull() ?: return
val max = latencies.maxOrNull() ?: return
val binSize = (max - min) / bins
val histogram = IntArray(bins) { 0 }
latencies.forEach { latency ->
val bin = kotlin.math.min(((latency - min) / binSize).toInt(), bins - 1)
histogram[bin]++
}
println("--- Latency Distribution ---")
histogram.forEachIndexed { index, count ->
val lower = min + binSize * index
val upper = lower + binSize
val bar = "#".repeat(count / (sampleCount / 200)) // Scale bar length
println("${"%.4f".format(lower)} - ${"%.4f".format(upper)} s: $bar")
}
}
}
fun main() {
val tapeSimulator = SeekSimulator.Tape(
totalSectors = 100000,
tapeLengthMeters = 200f,
baseSeekTime = 0.2f,
tapeSpeedMetersPerSec = 5.0f
)
val discSimulator = SeekSimulator.Disc(
totalTracks = 3810,
armSeekBaseTime = 0.005f,
armSeekMultiplier = 0.002f,
rotationLatencyAvg = 0.008f
)
val drumSimulator = SeekSimulator.Drum(
rpm = 3000f
)
listOf(tapeSimulator, discSimulator, drumSimulator).forEach { sim ->
SeekLatencySampler(
simulator = sim,
totalSectors = 100000,
sampleCount = 5000
).also {
it.runSampling()
it.analyzeAndPrint()
}
}
}