import { fnv1a64 } from "./replay.mjs"; export const PLC_SCHEMA = "sim_plc.v1"; const MAX_RUNG_COUNT = 256; const MAX_REGISTER_COUNT = 256; const MAX_SAMPLE_CAPACITY = 1024; export function parseClassicLadder(source) { if (typeof source !== "string" || source.length === 0) return { status: "error", code: "clp_source_invalid" }; const lines = source.split(/\r?\n/); const rungs = []; let active = null; for (let index = 0; index < lines.length; index += 1) { const line = lines[index].trim(); const start = /^_FILE-rung_(\d+)\.csv$/.exec(line); if (start) { active = { number: Number(start[1]), cells: [] }; rungs.push(active); continue; } if (line.startsWith("_/FILE-rung_")) { active = null; continue; } if (!active || !line || line.startsWith("#")) continue; const cells = line.split(",").map((cell) => cell.trim()).filter(Boolean); const parsed = []; for (const cell of cells) { const match = /^(\d+)-(\d+)-(\d+)\/(\d+)$/.exec(cell); if (!match) return { status: "error", code: "clp_cell_invalid", line: index + 1, text: line }; parsed.push({ type: Number(match[1]), negated: Number(match[2]), value: Number(match[3]), index: Number(match[4]) }); } active.cells.push(parsed); } if (rungs.length === 0) return { status: "error", code: "clp_rung_missing" }; const supported = new Set([0, 1, 2, 9, 10, 20, 50, 51, 52, 53, 55, 60, 99, 103]); for (const rung of rungs) for (const row of rung.cells) for (const cell of row) if (!supported.has(cell.type)) return { status: "error", code: "clp_cell_unsupported", type: cell.type, rung: rung.number }; return { status: "verified", schema: "classicladder-clp.v1", rungs: rungs.map((rung) => ({ number: rung.number, rows: rung.cells.length, cells: rung.cells.reduce((count, row) => count + row.length, 0) })), cell_count: rungs.reduce((count, rung) => count + rung.cells.reduce((n, row) => n + row.length, 0), 0) }; } function clone(value) { return structuredClone(value); } function finite(value) { return typeof value === "number" && Number.isFinite(value); } function name(value) { return typeof value === "string" && /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/.test(value) ? value : null; } export class PlcRuntime { constructor() { this.reset(); } reset() { this.state = { schema: PLC_SCHEMA, tick: 0, scan_count: 0, registers: {}, timers: {}, counters: {}, rungs: [], sampler: { capacity: 0, policy: "drop_newest", samples: [], dropped: 0, triggered: 0 }, last_error: null }; return this.export(); } export() { const value = clone(this.state); return { ...value, state_hash: fnv1a64(value) }; } hash() { return this.export().state_hash; } fail(code, detail = null) { this.state.last_error = code; return { status: "error", code, ...(detail ? { detail } : {}), state: this.export() }; } ok(command) { this.state.last_error = null; return { status: "verified", command, state: this.export() }; } configure({ rungs = [], sample_capacity = 0, backpressure = "drop_newest" } = {}) { if (!Array.isArray(rungs) || rungs.length > MAX_RUNG_COUNT) return this.fail("plc_rung_capacity"); if (!Number.isSafeInteger(sample_capacity) || sample_capacity < 0 || sample_capacity > MAX_SAMPLE_CAPACITY) return this.fail("plc_sampler_capacity"); if (!["drop_newest", "drop_oldest", "block"].includes(backpressure)) return this.fail("plc_backpressure_policy"); const normalized = []; for (const rung of rungs) { if (!rung || typeof rung !== "object" || !["SET", "ADD", "AND", "OR", "NOT", "TIMER", "COUNTER"].includes(rung.op)) return this.fail("plc_rung_unsupported"); if (!name(rung.target)) return this.fail("plc_register_name"); if (rung.source !== undefined && !name(rung.source)) return this.fail("plc_register_name"); normalized.push({ op: rung.op, target: rung.target, ...(rung.source ? { source: rung.source } : {}), ...(rung.value !== undefined ? { value: rung.value } : {}), ...(rung.preset !== undefined ? { preset: rung.preset } : {}) }); if (!(rung.target in this.state.registers)) this.state.registers[rung.target] = 0; } this.state.rungs = normalized; this.state.sampler = { capacity: sample_capacity, policy: backpressure, samples: [], dropped: 0, triggered: 0 }; return this.ok("PLC_CONFIG"); } setRegister(register, value) { if (!name(register) || !finite(value)) return this.fail("plc_register_value"); if (!(register in this.state.registers) && Object.keys(this.state.registers).length >= MAX_REGISTER_COUNT) return this.fail("plc_register_capacity"); this.state.registers[register] = value; return this.ok("PLC_SET"); } scan(tick) { if (!Number.isSafeInteger(tick) || tick < this.state.tick) return this.fail("plc_tick_regression"); this.state.tick = tick; for (const rung of this.state.rungs) { const source = rung.source ? (this.state.registers[rung.source] ?? 0) : (rung.value ?? 0); const current = this.state.registers[rung.target] ?? 0; if (rung.op === "SET") this.state.registers[rung.target] = source; else if (rung.op === "ADD") this.state.registers[rung.target] = current + source; else if (rung.op === "AND") this.state.registers[rung.target] = current !== 0 && source !== 0 ? 1 : 0; else if (rung.op === "OR") this.state.registers[rung.target] = current !== 0 || source !== 0 ? 1 : 0; else if (rung.op === "NOT") this.state.registers[rung.target] = current === 0 ? 1 : 0; else if (rung.op === "TIMER") { const timer = this.state.timers[rung.target] ?? { elapsed: 0, preset: Number.isSafeInteger(rung.preset) ? rung.preset : 1, done: false }; if (source !== 0) timer.elapsed += 1; else timer.elapsed = 0; timer.done = timer.elapsed >= timer.preset; this.state.timers[rung.target] = timer; this.state.registers[rung.target] = timer.done ? 1 : 0; } else if (rung.op === "COUNTER") { const counter = this.state.counters[rung.target] ?? { count: 0, preset: Number.isSafeInteger(rung.preset) ? rung.preset : 1, done: false }; if (source !== 0) counter.count += 1; counter.done = counter.count >= counter.preset; this.state.counters[rung.target] = counter; this.state.registers[rung.target] = counter.done ? 1 : 0; } if (!finite(this.state.registers[rung.target])) return this.fail("plc_nonfinite"); } this.state.scan_count += 1; return this.capture(false); } capture(triggered = true) { const sampler = this.state.sampler; if (sampler.capacity === 0) return this.ok(triggered ? "PLC_TRIGGER" : "PLC_SCAN"); const sample = { tick: this.state.tick, registers: clone(this.state.registers), scan_count: this.state.scan_count }; if (sampler.samples.length >= sampler.capacity) { if (sampler.policy === "block") return this.fail("plc_backpressure"); sampler.dropped += 1; if (sampler.policy === "drop_newest") return this.ok(triggered ? "PLC_TRIGGER" : "PLC_SCAN"); sampler.samples.shift(); } sampler.samples.push(sample); if (triggered) sampler.triggered += 1; return this.ok(triggered ? "PLC_TRIGGER" : "PLC_SCAN"); } trigger() { return this.capture(true); } restore(value) { if (!value || value.schema !== PLC_SCHEMA) throw new Error("plc_restore_schema"); const unsigned = { ...value }; delete unsigned.state_hash; if (value.state_hash !== fnv1a64(unsigned)) throw new Error("plc_restore_hash"); this.state = clone(unsigned); return this.export(); } }