From 60342a6d1c83f93eb9117516b208c39f7ff4e58d Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 05:43:42 +0530 Subject: [PATCH 01/44] Add shared block infrastructure to the register allocator and static module Register allocator: - release a block's registers after its last data consumer, including values produced outside a loop and read inside it - keep pre-initialized loop values in their own register and support explicit loop accumulator bindings and exported loop results - route each consumer input from the output port it is wired to and refuse connections to ports that own no register - record register-pool exhaustion per block so validation can name it - reserve user-typed registers that blocks emit verbatim and report overlapping ones - give multi-result macros one destination register per output - keep 64-bit (two register) sources and outputs Static module and templates: - report allocation failures, pinned-register conflicts, unconnected required inputs, invalid port connections, loop/container boundary violations and macro arity mismatches at validation time - program CTPPR0 so c28 points at shared RAM when a block uses c28, and drive SPI controller CS pins to their idle level, at every entry into generated code - warn in the GUI when a GPO/GPI pad is shared or a GPO has two drivers - gate blocks that need a hardware widget on the device capability table - define TEMP_REG3/TEMP_REG4 (R26/R27) for macros - expand multi-line instructions in the simulator path New shared scripts: pru_reserved_names.js (clpru reserved identifiers), pru_spi_timing.js (SPI delay limits and IRAM budget), pru_device_caps.js (per-SoC hardware widgets), block_control_flow_data.js. --- .../common/block_control_flow_data.js | 33 + .../common/pru_blocks_static_module.syscfg.js | 767 +++++++++++++++- .../pru_blocks/common/pru_device_caps.js | 144 +++ .../pru_blocks/common/pru_reserved_names.js | 161 ++++ .../.meta/pru_blocks/common/pru_spi_timing.js | 153 ++++ .../pru_blocks/common/pru_syscfg.asm.xdt | 16 + .../pru_blocks/common/pru_syscfg.inc.xdt | 2 + .../pru_register_allocator.js | 834 +++++++++++++++++- 8 files changed, 2046 insertions(+), 64 deletions(-) create mode 100644 .metadata/sysconfig/.meta/pru_blocks/common/block_control_flow_data.js create mode 100644 .metadata/sysconfig/.meta/pru_blocks/common/pru_device_caps.js create mode 100644 .metadata/sysconfig/.meta/pru_blocks/common/pru_reserved_names.js create mode 100644 .metadata/sysconfig/.meta/pru_blocks/common/pru_spi_timing.js diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/block_control_flow_data.js b/.metadata/sysconfig/.meta/pru_blocks/common/block_control_flow_data.js new file mode 100644 index 0000000..daaef9c --- /dev/null +++ b/.metadata/sysconfig/.meta/pru_blocks/common/block_control_flow_data.js @@ -0,0 +1,33 @@ +// Control-flow class ("sequential" or "continuous") of each block, used by the GUI pin-sharing check in +// pru_blocks_static_module: instances of one sequential module may share a two-way pad, and a GPO driven by +// two instances is flagged when either block is continuous. A block that is not listed is treated as +// continuous (the check fails closed). blockControlFlowBound names a config that makes a continuous block +// sequential when it is > 0. +exports = { + blockControlFlow: { + "access_look_up_table": "sequential", + "arithmetic_block": "sequential", + "bitwise_block": "sequential", + "conditional_block": "continuous", + "crc_block": "sequential", + "delay_block": "continuous", + "flow_control_block": "continuous", + "group_block": "continuous", + "label": "continuous", + "load_constant_block": "sequential", + "look_up_table": "continuous", + "loop_block": "continuous", + "memory_access_block": "continuous", + "memory_variable_block": "continuous", + "pru_gpi_block": "sequential", + "pru_gpo_block": "sequential", + "pru_spi_read": "sequential", + "pru_spi_transfer": "sequential", + "pru_spi_write": "sequential", + }, + blockControlFlowBound: { + } +}; +if (typeof module !== 'undefined') { + module.exports = exports; +} diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js index 21a84f5..f0adacd 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js @@ -2,6 +2,13 @@ const pruRegisterAllocator = system.getScript("/pru_blocks/common/register_alloc const pruDMEM0 = system.getScript("/pru_blocks/common/simulation/pru_dmem0.js"); const pruSMEM = system.getScript("/pru_blocks/common/simulation/pru_smem.js"); const pruSimulator = system.getScript("/pru_blocks/common/simulation/pru_core.js"); +// single-source device capability table (TRM SPRUIM2J §6.4.1.1 / SPRUJB6 §7.3.1.1) +let pruDeviceCaps = null; +try { + pruDeviceCaps = system.getScript("/pru_blocks/common/pru_device_caps.js"); +} catch (e) { + try { pruDeviceCaps = system.getScript("/pru_blocks/common/pru_device_caps"); } catch (e2) { /* capsule unavailable in test stub */ } +} const simulationData = {cycleCount : [], r30Bits : [], r31Bits : []}; // ============================================================================ @@ -553,9 +560,19 @@ function expandMacros(pruInstructionsWithMacros, pruInstructionsLabelWithMacros) macroCount++; } else { - // No macro definition, use instruction as is - pruInstructions.push(pruInstructionsWithMacros[i]); - pruInstructionsLabels.push(pruInstructionsLabelWithMacros[i]); + // No macro definition, use instruction as is (expanding multi-line instructions if present) + if (typeof pruInstructionsWithMacros[i] === "string" && pruInstructionsWithMacros[i].includes("\n")) { + let lines = pruInstructionsWithMacros[i].split("\n").map(l => l.trim()).filter(l => l && !l.startsWith(";")); + pruInstructions.push(0); + pruInstructionsLabels.push(pruInstructionsLabelWithMacros[i]); + for (let line of lines) { + pruInstructions.push(line); + pruInstructionsLabels.push(0); + } + } else { + pruInstructions.push(pruInstructionsWithMacros[i]); + pruInstructionsLabels.push(pruInstructionsLabelWithMacros[i]); + } } } @@ -574,10 +591,12 @@ function replaceTemporaryRegisters(instructions) { return instruction; } - // Replace TEMP_REG1 with R28 and TEMP_REG2 with R29 + // Replace TEMP_REG1 with R28, TEMP_REG2 with R29, TEMP_REG3 with R26, TEMP_REG4 with R27 return instruction .replace(/TEMP_REG1(\.\w+)?/g, 'R28$1') - .replace(/TEMP_REG2(\.\w+)?/g, 'R29$1'); + .replace(/TEMP_REG2(\.\w+)?/g, 'R29$1') + .replace(/TEMP_REG3(\.\w+)?/g, 'R26$1') + .replace(/TEMP_REG4(\.\w+)?/g, 'R27$1'); }); } @@ -681,7 +700,7 @@ function getDataSectionContents() */ function getUsectLinkerDirectives() { - let usectDirectives = []; + let usectDirectives = new Set(); let systemModules = system.modules; // Look for Memory Reserve module @@ -691,20 +710,20 @@ function getUsectLinkerDirectives() if (instance["labelName"] && instance["labelName"] !== "") { // Call the module's getUsectDirective function if available if (typeof memReserveModule.getUsectDirective === 'function') { - usectDirectives.push(memReserveModule.getUsectDirective(instance)); + usectDirectives.add(memReserveModule.getUsectDirective(instance)); } else { // Fallback: construct directive directly (always 4-byte aligned) const labelName = instance.labelName; const sizeInBytes = instance.sizeInBytes; const sectionName = instance.sectionName ; - usectDirectives.push(`${labelName}\t.usect "${sectionName}", ${sizeInBytes}, 1`); + usectDirectives.add(`${labelName}\t.usect "${sectionName}", ${sizeInBytes}, 1`); } } } } - return usectDirectives; + return Array.from(usectDirectives); } /** @@ -743,6 +762,126 @@ function getInitSectDirectives() { return assembly; } +// ============================================================================ +// constant-table pointer c28 (PRU shared RAM) +// ============================================================================ +// c28 is only partially fixed: TRM SPRUIM2J Table 6-65 (PRU_ICSSG) +// gives c28 = 00nn_nn00h with nnnn = CTPPR0.C28_POINTER[15:0]. CTPPR0 sits at +// offset 28h of the core's own PRU_CTRL block and resets to 0 +// (SPRUIM2J 14.3.6.1.1), so until firmware writes it every LBCO/SBCO through +// c28 hits local 0x00000000 -- the core's own data RAM -- not shared RAM. +// The design-wide fix lives here, once, rather than in each block: one write +// at every entry point into the generated code serves all c28 users and costs +// the per-call blocks (mailbox, gateway) nothing. + +// PRU_CTRL base of each SysConfig core context (core-local addresses). +// SPRUIM2J Table 6-65 entry 11 and the CTPPR0 instance table; SPRUJB6 confirms only the c28 pointer formula and the local control bases; +// the AM261x CTPPR0 offset 0x28 / reset 0 is INFERRED from the shared PRU-ICSS IP, not +// documented in SPRUJB6. +const PRU_CTRL_BASE_BY_CORE = [ + [/tx_pru(\d)$/, n => 0x25000 + 0x800 * n], + [/rtu_pru(\d)$/, n => 0x23000 + 0x800 * n], + [/pru(\d)$/, n => 0x22000 + 0x2000 * n], +]; +const PRU_CTRL_CTPPR0 = 0x28; +const SHARED_RAM_LOCAL = 0x00010000; // PRU-local view of shared RAM + +function pruCtrlBase(coreName) { + for (const [re, base] of PRU_CTRL_BASE_BY_CORE) { + const m = (coreName || "").match(re); + if (m) return base(parseInt(m[1])); + } + return null; +} + +/** True when any instantiated block's macro addresses memory through c28. */ +function designUsesC28(modules) { + for (const moduleName in modules) { + const module = modules[moduleName]; + if (!module || typeof module.getMacro !== "function" || !module.$instances) continue; + for (const inst of module.$instances) { + if (!inst || !inst.opCode) continue; + if (/\bc28\b/i.test(module.getMacro("", inst.opCode))) return true; + } + } + return false; +} + +/** + * Assembly that programs CTPPR0 so c28 = shared RAM (0x00010000). Empty when + * no block in the design uses c28. Clobbers TEMP_REG1/TEMP_REG2 only. + * C29_POINTER is written 0, its reset value; nothing in the library uses c29. + */ +function getCtppr0Setup(coreName, modules) { + if (!designUsesC28(modules || system.modules)) return ""; + if (coreName === undefined) coreName = system.getScript("/common").getSelfSysCfgCoreName(); + const base = pruCtrlBase(coreName); + if (base === null) { + throw new Error(`No PRU_CTRL base known for core context '${coreName}'; ` + + "cannot program CTPPR0 for c28"); + } + const ptr = SHARED_RAM_LOCAL >> 8; + const hex = v => "0x" + v.toString(16).toUpperCase(); + return [ + ` ; c28 = CTPPR0.C28_POINTER << 8 and CTPPR0 resets to 0 (TRM SPRUIM2J`, + ` ; Table 6-65, 14.3.6.1.1). Point c28 at shared RAM ${hex(SHARED_RAM_LOCAL)} for ${coreName}.`, + ` ldi TEMP_REG1, ${hex(ptr)}`, + ` ldi32 TEMP_REG2, ${hex(base + PRU_CTRL_CTPPR0)}`, + ` sbbo &TEMP_REG1, TEMP_REG2, 0, 4`, + ].join("\n"); +} + +// the pru_spi_* blocks' controller mode asserts CS with `clr r30, r30, ` and releases it with `set`, but R30 resets to 0, so +// with nothing driving the pin high first the first transaction has no assertion edge. The prologue drives each distinct controller CS +// pin to its idle (inactive, high) level once; a peripheral block samples CS on R31 and must not drive it. +const SPI_MODULE_PATHS = [ + "/pru_blocks/pru_io_blocks/pru_spi_read", + "/pru_blocks/pru_io_blocks/pru_spi_write", + "/pru_blocks/pru_io_blocks/pru_spi_transfer", +]; + +/** Sorted distinct CS pin numbers (R30 bits) of every controller-mode pru_spi_* instance. */ +function spiControllerCsPins(modules) { + const pins = new Set(); + for (const path of SPI_MODULE_PATHS) { + const module = (modules || system.modules)[path]; + if (!module || !module.$instances) continue; + for (const inst of module.$instances) { + if (inst && inst["Device Mode"] !== "peripheral") pins.add(Number(inst["CS Signal"])); + } + } + return Array.from(pins).filter(n => Number.isInteger(n) && n >= 0).sort((a, b) => a - b); +} + +function getSpiCsIdleSetup(modules) { + const pins = spiControllerCsPins(modules); + if (pins.length === 0) return ""; + return [` ; drive each SPI controller CS pin to its idle (high) level once; R30 resets to 0, which would hide the first assertion edge.`] + .concat(pins.map(n => ` set r30, r30, ${n}`)).join("\n"); +} + +/** + * Everything emitted by pru_syscfg.asm.xdt right after sysconfig_generated_start and every group start label: the CTPPR0 + * write (only when a block uses c28) and the CS idle level of the SPI controller blocks. Empty when neither applies. + */ +function getConstantTableSetup(coreName, modules) { + return [getCtppr0Setup(coreName, modules), getSpiCsIdleSetup(modules)].filter(Boolean).join("\n"); +} + +// Instructions the CTPPR0 write costs at each entry (ldi + ldi32 (2) + sbbo). Counted +// as 4 cycles; a store to the PRU_CTRL peripheral may stall longer on silicon. +const CTPPR0_SETUP_CYCLES = 4; + +/** Cycles of the SPI CS idle part of the prologue: one `set r30` per distinct controller CS pin. */ +function getSpiCsIdleSetupCycles(modules) { + return spiControllerCsPins(modules).length; +} + +/** Cycles getConstantTableSetup() adds to every entry into generated code: CTPPR0 write (c28 users) + one `set r30` per SPI CS pin. */ +function getConstantTableSetupCycles(modules) { + return (designUsesC28(modules || system.modules) ? CTPPR0_SETUP_CYCLES : 0) + getSpiCsIdleSetupCycles(modules); +} + function prepareR30DataForPlotting(r30ValueHistory) { // Clear existing data simulationData.cycleCount = []; @@ -819,6 +958,585 @@ function collectR31History() { return {}; } +/** + * register-pool exhaustion is an error naming the block and the budget; the instruction would otherwise be missing from the asm. + * Runs BEFORE the generic "-1" gate in the validate pass so loop-counter and group-return failures (which leave "-1" in the + * instructions) are named too. Returns the number of recorded failures reported. + */ +function reportRegisterExhaustion(inst, report) { + for (const f of pruRegisterAllocator.getAllocationFailures()) { + let failedInst = null; + for (let moduleName in system.modules) { + const mod = system.modules[moduleName]; + if (mod && mod.$instances) failedInst = mod.$instances.find(i => i.$name === f.block) || failedInst; + } + const message = f.contractFailure + ? "Accumulator binding error: block '" + f.block + "' cannot honor " + f.what + "." + : "Out of registers: block '" + f.block + "' needs " + f.bytes + " byte(s) for " + f.what + + " but only " + f.freeBytes + " of the " + f.budgetBytes + " allocatable bytes (R0-R" + (f.budgetRegisters - 1) + + ", " + f.budgetRegisters + " registers; R28/R29 are reserved) are free, so its instruction cannot be generated. " + + (f.pinnedBytes > 0 ? f.pinnedBytes + " byte(s) of the pool are pinned by registers typed into block fields (" + + f.pinnedBy.join(", ") + ") and are not available to the allocator. " : "") + + "Reduce the number of values alive at once, or store intermediate results to memory."; + report.logError(message, failedInst || inst); + } + return pruRegisterAllocator.getAllocationFailures().length; +} + +/** + * registers typed into block fields. Reports (a) a typed value that is not a usable register window (ERROR), + * (b) two fields that name overlapping registers - an ERROR when the user typed at least one of them, a WARNING when both + * still hold their default value (a saved design with two blocks left on defaults used to generate; it must not suddenly fail), + * (c) a hand-off reader that runs before its writer outside any common loop (WARNING). Returns the number of ERRORS. + */ +function reportPinnedRegisterConflicts(inst, report) { + const regName = (p) => p.token + " (" + p.bytes + " byte" + (p.bytes === 1 ? "" : "s") + ")"; + const instanceNamed = (name) => { + let target = null; + for (let moduleName in system.modules) { + const mod = system.modules[moduleName]; + if (mod && mod.$instances) target = mod.$instances.find(i => i.$name === name) || target; + } + return target || inst; + }; + let errors = 0; + for (const p of pruRegisterAllocator.getPinnedRegisterProblems()) { + report.logError("Invalid register: " + p.block + "." + p.field + " = '" + p.token + "' " + p.why + ".", instanceNamed(p.block)); + errors++; + } + for (const c of pruRegisterAllocator.getPinnedRegisterConflicts()) { + const text = "Register conflict: " + c.second.block + "." + c.second.field + " = " + regName(c.second) + + " overlaps " + c.first.block + "." + c.first.field + " = " + regName(c.first) + + ". Both blocks use these registers directly, so one result would overwrite the other. Give each block its own registers"; + if (c.harmful) { + report.logError(text + ": " + c.first.block + " runs first and " + c.second.block + " overwrites " + regName(c.first) + + " before any block reads it, so " + c.first.block + "'s value is lost (" + (c.first.atDefault && c.second.atDefault ? "both fields are still at their default value; " : "") + + "type a different register into " + c.second.block + "." + c.second.field + " or " + c.first.block + "." + c.first.field + ").", instanceNamed(c.second.block)); + errors++; + } else if (c.first.atDefault && c.second.atDefault) { + const free = pruRegisterAllocator.suggestFreeRegister(c.second.bytes); + report.logWarning(text + " (both fields are still at their default value: type a different register into " + c.second.block + "." + + c.second.field + " or " + c.first.block + "." + c.first.field + (free ? " - " + free + " is not used by any block" : "") + + " - to remove this warning).", instanceNamed(c.second.block)); + } else { + report.logError(text + ".", instanceNamed(c.second.block)); + errors++; + } + } + for (const o of pruRegisterAllocator.getPinnedHandOffOrderWarnings()) { + report.logWarning("Register hand-off order: " + o.reader.block + "." + o.reader.field + " = " + o.reader.token + " uses the register that " + + o.writer.block + "." + o.writer.field + " = " + o.writer.token + " fills, but " + o.reader.block + " runs BEFORE " + o.writer.block + + " and no loop contains both, so it reads the register before the value arrives. Connect " + o.writer.block + " first, or put both in one loop (inside one loop the reader sees the previous iteration's value: the first iteration reads the uninitialised register and the last value is never read).", + instanceNamed(o.reader.block)); + } + return errors; +} + +/** + * Validate PRU instructions for undefined symbols, arity mismatches, and unconnected ports. + * Runs right after register allocation. + */ +function validatePruInstructions(inst, report) { + let definedSymbols = new Set(); + let usects = getUsectLinkerDirectives(); + usects.forEach(u => { + let parts = u.split('\t'); + if (parts.length > 0) definedSymbols.add(parts[0].trim()); + }); + + let dataSects = getDataSectionContents(); + dataSects.forEach(d => { + if (d.symbolName) definedSymbols.add(d.symbolName.trim()); + }); + + let pruInstructionsWithMacros = pruRegisterAllocator.getPruRegisterAllocationSummary().pruInstructions; + let pruInstructionModules = pruRegisterAllocator.getPruRegisterAllocationSummary().pruInstructionModules; + let pruInstructionInstances = pruRegisterAllocator.getPruRegisterAllocationSummary().pruInstructionInstances; + + // 1. Scan all blocks for graph connection issues and symbol usages + for (let moduleName in system.modules) { + let mod = system.modules[moduleName]; + if (!mod || !mod.$instances) continue; + for (let b_inst of mod.$instances) { + // A. Undefined symbol checks + if (b_inst.symbolName && !definedSymbols.has(b_inst.symbolName)) { + report.logError("Undefined memory symbol referenced: " + b_inst.symbolName, b_inst, "symbolName"); + } + if (b_inst.variableName && !definedSymbols.has(b_inst.variableName)) { + report.logError("Undefined shared-memory variable referenced: " + b_inst.variableName, b_inst, "variableName"); + } + + // B. Required ports unconnected + if (b_inst.numOfInputPorts > 0) { + for (let i = 1; i <= b_inst.numOfInputPorts; i++) { + let portName = "input" + i; + if (!b_inst[portName] || b_inst[portName].length === 0 || !b_inst[portName][0].inst) { + report.logError("Required graph input port '" + portName + "' is unconnected", b_inst); + } + } + } + + // C. Invalid graph link targets + if (b_inst.numOfInputPorts > 0) { + for (let i = 1; i <= b_inst.numOfInputPorts; i++) { + let portName = "input" + i; + if (b_inst[portName] && b_inst[portName].length > 0 && b_inst[portName][0].inst) { + let sourceInst = b_inst[portName][0].inst; + if (sourceInst.numOfOutputPorts === 0 || sourceInst.numOfOutputPorts === undefined) { + report.logError("Invalid connection: source block '" + sourceInst.$name + "' does not produce an output for '" + portName + "'", b_inst); + } + } + } + } + } + } + + // 1b. A consumer wired to an output port that owns no register is refused; the allocator never reroutes it to output1 + for (const c of pruRegisterAllocator.getUnroutedConnections()) { + let consumerInst = null; + for (let moduleName in system.modules) { + const mod = system.modules[moduleName]; + if (mod && mod.$instances) consumerInst = mod.$instances.find(i => i.$name === c.consumer) || consumerInst; + } + report.logError("Invalid connection: block '" + c.source + "' port '" + c.sourcePort + "' has no register of its own, so '" + + c.consumer + "." + c.inputPort + "' cannot read it (the block's macro writes only output1 here)", consumerInst || inst); + } + + // 1c. Loop contents consumed outside loop/group + for (const v of pruRegisterAllocator.getLoopBoundaryViolations()) { + let consumerInst = null; + let loopInst = null; + for (let moduleName in system.modules) { + const mod = system.modules[moduleName]; + if (mod && mod.$instances) { + if (v.consumer !== "generator") { + consumerInst = consumerInst || mod.$instances.find(i => i.$name === v.consumer); + } + loopInst = loopInst || mod.$instances.find(i => i.$name === v.container); + } + } + if (v.consumer === "generator") { + report.logError("Invalid container contents: group '" + v.producer + "' cannot be placed inside container '" + + v.container + "'. Group blocks cannot be nested inside loops or other groups.", loopInst || inst); + } else { + // a loop may explicitly export a block's final-iteration output (loop_block.exportedBlocks). + // A consumer outside such a loop reading an exported block is the supported post-loop store, not a + // boundary violation. Anything not exported is still rejected below. + const isDownstream = (start, target) => { + const seen = new Set(); + const walk = (node) => { + if (!node || seen.has(node)) return false; + seen.add(node); + if (node.$name === target) return true; + return [].concat(node.next || [], node.T || [], node.F || []).some(c => walk(c && c.inst)); + }; + return walk(start); + }; + if (loopInst && Array.isArray(loopInst.exportedBlocks) && loopInst.exportedBlocks.includes(v.producer) && + isDownstream(loopInst, v.consumer)) { + continue; + } + if (loopInst && Array.isArray(loopInst.exportedBlocks) && loopInst.exportedBlocks.includes(v.producer)) { + report.logError("Invalid exported result: consumer '" + v.consumer + "' reads exported block '" + + v.producer + "' before or outside the downstream control flow of loop '" + v.container + + "'. Exported blocks may only be consumed downstream of their loop.", consumerInst || inst); + continue; + } + report.logError("Invalid connection: consumer '" + v.consumer + "' is outside container '" + v.container + + "' but attempts to read '" + v.producer + "' which is inside it (via port '" + v.port + "'). Container contents cannot be consumed from the outside.", consumerInst || inst); + if (loopInst) { + report.logError("Invalid container: contains '" + v.producer + "' which is consumed from the outside by '" + + v.consumer + "'. Container contents cannot be consumed from the outside.", loopInst); + } + } + } + + // 2. Macro arity mismatch + for (let i = 0; i < pruInstructionsWithMacros.length; i++) { + let call = pruInstructionsWithMacros[i]; + let moduleName = pruInstructionModules[i]; + let b_inst = pruInstructionInstances[i]; + + if (!call || call === 0 || call === "0" || !moduleName || !b_inst) continue; + let mod = system.modules[moduleName]; + + if (mod && typeof mod.getMacro === 'function') { + let callArgsStr = call.substring(call.indexOf(' ') + 1).trim(); + let callArgs = callArgsStr ? callArgsStr.split(',').map(s=>s.trim()).filter(s=>s!=="") : []; + let callArity = callArgs.length; + + if (callArgs.some(arg => arg === "undefined")) { + report.logError("Macro call contains 'undefined' argument. Call: " + call, b_inst); + } + + + // The arity check needs the macro DEFINITION, not the inline expansion. + // Blocks switch on an empty first argument to emit the `.macro` line + // (see uart_tx getMacroSingleShot, uart_rx getMacro), so calling with + // the call string returns a body that has no signature in it at all. + let opCode = call.trim().split(/\s+/)[0]; + let defBody = mod.getMacro("", opCode); + // Definition bodies may be preceded by guarded .set/.asg prologues + // (uart_rx prepends the ICSS_CFG block), so find the signature line + // rather than assuming it is line 1. + let defLine = defBody ? (defBody.split('\n').find(l => l.includes('.macro')) || "") : ""; + + if (defLine) { + let defArgsStr = defLine.substring(defLine.indexOf('.macro') + 6).trim(); + let defArgs = defArgsStr ? defArgsStr.split(',').map(s=>s.trim()).filter(s=>s!=="") : []; + let defArity = defArgs.length; + + if (callArity !== defArity) { + report.logError("Macro arity mismatch: generated call has " + callArity + " args but definition expects " + defArity + ". Call: " + call, b_inst); + } + } + } + } +} + +/** + * GUI pin warnings: shared GPO/GPI physical pads, duplicate GPO drivers, + * malformed getPinUsage results, and unavailable device/core pad data. + * + * scripts/check_pin_collisions.js (run by the suite and CI) fails a design where a two-way (bidirectional) + * pad is shared by blocks of DIFFERENT modules (e.g. two blocks of different modules reading and driving the same two-way pad). + * This validator emits a report.logWarning (not logError, so code generation still succeeds) to give + * the user immediate feedback in the SysConfig GUI before CI runs. + * + * Verdict uses the exact same data and exemptions as CI: + * - claims come from each block's moduleStatic pin usage query (inst); + * - exempt (no warning): + * (a) the same instance's own open-drain pair; + * (b) different instances of the SAME block module that classify SEQUENTIAL (sourced from + * scripts/block_control_flow.js via generated block_control_flow_data.js). + * - only pads that are actually shared between GPO and GPI on the device count (derived from + * SysConfig device data). + * + * Warning text names both blocks (instance names), the pad, and warns that CI's pin check will FAIL. + * Device scope: both read the design's own device and PRU core (CI from the @cliArgs --device/--context header, the GUI from system.deviceData and the core name), so they agree on every device. + */ +// SysConfig core name -> pad signal prefix, same mapping as scripts/check_pin_collisions.js corePrefix(): +// icss_g_[rtu_|tx_]pru -> PRG_PRU (ICSSG), icss_m_pru -> PR_PRU (ICSSM). +function padPrefixForCore(coreName) { + const m = /^icss_([gm])(\d)(?:_(?:rtu|tx))?_pru(\d)$/.exec(coreName || ''); + return m ? (m[1] === 'g' ? 'PRG' : 'PR') + m[2] + '_PRU' + m[3] : null; +} + +function getDevicePadSharing(deviceData, coreName) { + if (!deviceData) return null; + const prefix = padPrefixForCore(coreName); + if (!prefix) return null; + const sharedIndices = new Set(); + + // 1. SysConfig runtime interface pins (ICSSG devices) + const ifaceName = /^PRG(\d)_/.test(prefix) ? 'PRU_ICSSG' + prefix[3] + '_PRU' : null; + if (ifaceName && deviceData.interfaces && deviceData.interfaces[ifaceName] && deviceData.interfaces[ifaceName].interfacePins) { + const ifacePins = deviceData.interfaces[ifaceName].interfacePins; + for (let i = 0; i < 20; i++) { + const gpoName = `${prefix}_GPO${i}`; + const gpiName = `${prefix}_GPI${i}`; + const gpoPin = ifacePins[`GPO${i}`]; + if (!gpoPin || !gpoPin.pinMappings) continue; + for (const pm of gpoPin.pinMappings) { + if (pm.name !== gpoName) continue; + for (const dp of (pm.pinMappings || [])) { + if (dp && dp.mux && Array.isArray(dp.mux.muxSetting)) { + const signals = dp.mux.muxSetting.map(s => s.peripheralPin && s.peripheralPin.name); + if (signals.includes(gpoName) && signals.includes(gpiName)) { + sharedIndices.add(i); + } + } + } + } + } + return sharedIndices; + } + + // 1b. SysConfig runtime interface pins (ICSSM devices): interface PRU-ICSS holds one two-way + // PR_PRU_GPIO interface pin per pad that exists; GPO i and GPI i share it. + const icssmIface = /^PR(\d)_/.test(prefix) ? 'PRU-ICSS' + prefix[2] : null; + if (icssmIface && deviceData.interfaces && deviceData.interfaces[icssmIface] && deviceData.interfaces[icssmIface].interfacePins) { + const ifacePins = deviceData.interfaces[icssmIface].interfacePins; + for (let i = 0; i < 20; i++) { + const pin = ifacePins[`${prefix}_GPIO${i}`]; + if (pin && pin.pinMappings && pin.pinMappings.length) sharedIndices.add(i); + } + return sharedIndices; + } + + // 2. Raw JSON device data structure (ICSSM devices, stubs and test harnesses). ICSSG pads carry a + // PRGx_PRUy_GPOn + PRGx_PRUy_GPIn pair; ICSSM pads carry one two-way PRx_PRUy_GPIOn signal. + const infos = deviceData.pinCommonInfos || {}; + for (const info of Object.values(infos)) { + if (!info || !info.pinModeInfo) continue; + const sigs = new Set(info.pinModeInfo.map(m => m.signalName)); + for (let i = 0; i < 20; i++) { + if ((sigs.has(`${prefix}_GPO${i}`) && sigs.has(`${prefix}_GPI${i}`)) || sigs.has(`${prefix}_GPIO${i}`)) { + sharedIndices.add(i); + } + } + } + return sharedIndices; +} + +let bcfData = null; +function isSequentialBlock(blockName, instCfg) { + if (!bcfData && typeof system !== 'undefined' && typeof system.getScript === 'function') { + try { + bcfData = system.getScript("/pru_blocks/common/block_control_flow_data.js"); + } catch (e) { + bcfData = null; + } + } + if (bcfData && bcfData.blockControlFlow && bcfData.blockControlFlow[blockName] !== undefined) { + if (bcfData.blockControlFlow[blockName] === 'sequential') return true; + // continuous by default, sequential once the block's bound config is > 0 (pwm_block periodCount). + const bound = bcfData.blockControlFlowBound && bcfData.blockControlFlowBound[blockName]; + return !!(bound && instCfg && Number.isInteger(Number(instCfg[bound])) && Number(instCfg[bound]) > 0); + } + return false; // Fail closed if undecided or unknown +} + +function validateBidiPinCollisionsGlobal(inst, report) { + try { + // GUI pin warnings: shared GPO/GPI physical pads, duplicate GPO drivers + //, malformed pin-usage results, and unavailable device/core pad data. + // The GPO/GPI shared-pad rule needs device pad data; the duplicate-GPO rule and the + // 'could not evaluate' diagnostics do not, exactly as in CI's checkSyscfg. + const padSharing = getDevicePadSharing(system.deviceData, system.getScript("/common").getSelfSysCfgCoreName()); + const sharedIndices = padSharing || new Set(); + + const claims = []; + for (const modName in system.modules) { + const mod = system.modules[modName]; + if (!mod || !mod.$instances || mod.$instances.length === 0) continue; + for (let idx = 0; idx < mod.$instances.length; idx++) { + const bi = mod.$instances[idx]; + const moduleStatic = (bi.$module && bi.$module.moduleStatic) || mod.moduleStatic; + if (!moduleStatic || typeof moduleStatic.getPinUsage !== 'function') continue; + const iname = typeof bi.$name === 'string' ? bi.$name : `#${idx}`; + let pins = []; + try { + pins = moduleStatic.getPinUsage(bi); + } catch (e) { + const reason = (e && e.message) ? e.message : String(e); + report.logWarning( + `Pin-sharing check could not evaluate block '${iname}' (${modName}): ${reason}`, + inst + ); + continue; + } + const block = modName.split('/').pop(); + if (!Array.isArray(pins)) { + // CI's for-of over a non-iterable throws and fails the design. + report.logWarning( + `Pin-sharing check could not evaluate block '${iname}' (${modName}): its pin-usage function did not return an array; CI's pin check will FAIL this design.`, + inst + ); + continue; + } + for (const p of pins) { + if (!p || (p.dir !== "GPO" && p.dir !== "GPI")) { + report.logWarning( + `Pin-sharing check could not evaluate block '${iname}' (${modName}): its pin-usage function returned an entry without a GPO/GPI dir; CI's pin check will FAIL this design.`, + inst + ); + continue; + } + const pin = parseInt(p.pin); + if (isNaN(pin)) { + report.logWarning( + `Pin-sharing check could not evaluate block '${iname}' (${modName}): unparseable ${p.dir} pin "${String(p.pin)}"; CI's pin check will FAIL this design.`, + inst + ); + continue; + } + claims.push({ + inst: bi, + block: block, + iname: iname, + dir: p.dir, + pin: pin, + bidi: p.bidi === true, + cfg: bi + }); + } + } + } + + if (claims.length === 0) return; + + const gpoBits = claims.filter(c => c.dir === "GPO"); + const gpiBits = claims.filter(c => c.dir === "GPI"); + const warnedPairs = new Set(); + + if (padSharing === null && gpoBits.length && gpiBits.length) { + report.logWarning( + "GPO/GPI pad sharing could not be checked for this device/core; CI's pin check will FAIL this design.", + inst + ); + } + + if (padSharing !== null) { + for (const o of gpoBits) { + for (const i of gpiBits) { + if (o.pin !== i.pin || !sharedIndices.has(o.pin)) continue; + if (o.inst === i.inst && o.bidi && i.bidi) continue; // (a) same instance AND both bidi + if (o.inst !== i.inst && o.bidi && i.bidi && o.block === i.block && isSequentialBlock(o.block)) continue; // (b) different instances, both bidi, same block module, module classifies sequential + + const keyA = (o.inst && o.inst.$name) || o.iname; + const keyB = (i.inst && i.inst.$name) || i.iname; + const pairKey = o.inst === i.inst + ? `self:${keyA}:${o.pin}` + : (keyA < keyB ? `${keyA}:${keyB}:${o.pin}` : `${keyB}:${keyA}:${o.pin}`); + if (warnedPairs.has(pairKey)) continue; + warnedPairs.add(pairKey); + + report.logWarning( + `Pad ${o.pin} is driven (GPO) by '${o.iname}' (${o.block}) and read (GPI) by '${i.iname}' (${i.block}); CI's pin check will FAIL this design.`, + inst + ); + } + } + } + + // two different instances drive one GPO index and at least one block is CONTINUOUS + // (fail closed: a block missing from the control-flow table counts as continuous). Mirrors + // the duplicate-GPO rule in scripts/check_pin_collisions.js checkSyscfg. + for (let x = 0; x < gpoBits.length; x++) { + for (let y = x + 1; y < gpoBits.length; y++) { + const a = gpoBits[x], b = gpoBits[y]; + if (a.pin !== b.pin || a.inst === b.inst) continue; + const cont = [a, b] + .filter((c) => !isSequentialBlock(c.block, c.cfg)) + .map((c) => c.block) + .filter((blk, k, arr) => arr.indexOf(blk) === k); + if (!cont.length) continue; + report.logWarning( + `GPO ${a.pin} is driven by both '${a.iname}' (${a.block}) and '${b.iname}' (${b.block}); continuous: ${cont.join(', ')}; CI's pin check will FAIL this design (duplicate GPO drivers).`, + inst + ); + } + } + } catch (e) { + const reason = (e && e.message) ? e.message : String(e); + report.logWarning( + `Pin-sharing check could not evaluate the design: ${reason}`, + inst + ); + } +} + +// device-capability gating for hardware-widget blocks +// every failure path in this gate fails CLOSED or errors VISIBLY. +// No degradation (missing caps script, missing SoC, unknown SoC, capability +// lookup exception) may silently skip validation and leave the build clean. +function validateDeviceCapabilities(inst, report) { + // Only gate when at least one module actually declares widget requirements, + // so widget-free designs stay clean. + let widgetModules = []; + for (let moduleName in system.modules) { + let mod = system.modules[moduleName]; + if (!mod || !mod.requiresWidget) continue; + widgetModules.push([moduleName, mod]); + } + if (widgetModules.length === 0) return; + + // Failure path 1: capability table itself failed to load -> refuse to pass. + if (!pruDeviceCaps) { + report.logError( + "Device capability table (pru_device_caps.js) could not be loaded - cannot verify hardware-widget requirements. Failing closed.", + inst + ); + return; + } + + // Failure path 2: /common getScript failure -> visible error, not a silent return. + let socName = null; + let commonFailure = null; + try { + const common = system.getScript("/common"); + if (common && typeof common.getSocName === "function") { + socName = common.getSocName(); + } + } catch (e) { + commonFailure = String(e && e.message || e); + } + if (commonFailure) { + report.logError( + `Cannot load common module to determine SoC (${commonFailure}) - cannot verify hardware-widget requirements. Failing closed.`, + inst + ); + return; + } + + // Failure path 3: SoC missing entirely -> visible error. + if (!socName) { + report.logError( + "SoC name could not be determined - cannot verify hardware-widget requirements. Failing closed.", + inst + ); + return; + } + + let deviceName = socName; + try { + if (system.deviceData && system.deviceData.device) { + deviceName = system.deviceData.device; + } + } catch (e) { /* use socName */ } + + // Failure path 4: SoC not in the capability table -> visible error, never a + // silent default capability set. + let socKnown = false; + try { + socKnown = (typeof pruDeviceCaps.isKnownSoc === "function") + ? !!pruDeviceCaps.isKnownSoc(socName) + : Object.keys(pruDeviceCaps.DEVICE_CAPS || {}).some(function (k) { return k === String(socName).toLowerCase(); }); + } catch (e) { socKnown = false; } + if (!socKnown) { + report.logError( + `SoC '${socName}' (device '${deviceName}') is not in the device capability table - cannot verify hardware-widget requirements. Failing closed.`, + inst + ); + return; + } + + for (let [moduleName, mod] of widgetModules) { + let raw = mod.requiresWidget; + let widgets = Array.isArray(raw) ? raw : [raw]; + for (let w of widgets) { + if (!w) continue; + let available = false; + let widgetFailure = null; + try { + available = pruDeviceCaps.isWidgetAvailable(w, socName); + } catch (e) { + // Failure path 5: capability lookup threw -> treat widget as + // UNAVAILABLE and say so, instead of assuming available. + widgetFailure = String(e && e.message || e); + } + if (widgetFailure) { + let instances = (mod.$instances && mod.$instances.length) ? mod.$instances : [inst]; + for (let bInst of instances) { + report.logError( + `Widget availability check for '${w}' threw an exception (${widgetFailure}) - treating as unavailable on device '${deviceName}' (SoC '${socName}'). Failing closed.`, + bInst + ); + } + } else if (!available) { + let instances = (mod.$instances && mod.$instances.length) ? mod.$instances : [inst]; + for (let bInst of instances) { + report.logError( + `Block '${mod.displayName || moduleName}' requires widget '${w}' which is not available on device '${deviceName}' (SoC '${socName}')`, + bInst + ); + } + } + } + } +} + /** * Detects cycles in the control flow and data flow graph (prev/next/T/F and input/output ports). @@ -926,6 +1644,9 @@ function validate(inst, report) return; } + // Device-capability gate (must name both widget and device, ERROR not warning) + validateDeviceCapabilities(inst, report); + // Validate R31 simulation input configuration validateR31SimulationInput(inst, report); @@ -943,6 +1664,8 @@ function validate(inst, report) //allocating pru registers for all blocks at system level pruRegisterAllocator.allocatePruRegisters(); + // GUI warning for cross-module bidirectional pad sharing + validateBidiPinCollisionsGlobal(inst, report); //validating register allocation let pruInstructionsWithMacros = pruRegisterAllocator.getPruRegisterAllocationSummary().pruInstructions; let pruInstructionsLabelWithMacros = pruRegisterAllocator.getPruRegisterAllocationSummary().labels; @@ -951,11 +1674,18 @@ function validate(inst, report) instruction.includes("-1") ); + // named, budgeted errors first; the generic message stays as the fallback for any unrecorded -1 + const recordedExhaustion = reportRegisterExhaustion(inst, report); + // user-typed registers that overlap each other + reportPinnedRegisterConflicts(inst, report); + if (hasRegisterError) { - report.logError("Out of PRU registers, reduce number of PRU blocks added", inst); - } - /*prepare instructions and init dmem0 for simulation*/ - else { + if (recordedExhaustion === 0) report.logError("Out of PRU registers, reduce number of PRU blocks added", inst); + } else { + // --- Generate-time validation pass --- + validatePruInstructions(inst, report); + + /*prepare instructions and init dmem0 for simulation*/ // If UART blocks are present, skip simulation and return zeros if (hasUartBlock) { const cyclesToSimulate = inst["pruCyclesToSimulate"]; @@ -1245,5 +1975,12 @@ This allows you to test PRU blocks that read from input pins (like SPI Read's SD getDataSectionContents, getInitSectDirectives, getUsectLinkerDirectives, - simulationData -}; \ No newline at end of file + getConstantTableSetup, + getConstantTableSetupCycles, + getSpiCsIdleSetupCycles, + pruCtrlBase, + simulationData, + validateDeviceCapabilities, + pruDeviceCaps, + validateBidiPinCollisionsGlobal +}; diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_device_caps.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_device_caps.js new file mode 100644 index 0000000..a60827f --- /dev/null +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_device_caps.js @@ -0,0 +1,144 @@ +"use strict"; +/* + * pru_device_caps.js — single-source device capability table for. + * + * TRM evidence (authoritative, do not re-derive): + * - AM64x/AM243x carry PRU_ICSSG (SPRUIM2J §6.4.1.1) + * - AM261x carries PRU_ICSSM (SPRUJB6 §7.3.1.1) + * + * Widget listing derived from the table in the task: + * widget ICSSG (AM64x/AM243x) ICSSM (AM261x/AM263Px) + * MAC (multiply/acc) yes yes + * CRC16/32 yes yes + * SPAD banks 8 3 (widget present on both, bank count differs) + * BSWAP (byte swap) yes NO + * SUM32 yes NO + * Task Manager yes NO + * Broadside Stitch FIFO yes (PRU0/PRU1 only) NO + * Spinlock (XFR2SPIN) yes (64 flags, all 6) NO + * BS RAM 2KB per core yes NO + * RTU_PRU / TX_PRU cores yes NO + * IEP instances 2 1 + * PWM module yes (4 sets of 3 pairs) NO (SPRUIM2J 6.4.10; SPRUJB6 7.3.1.1 lists no PWM) + * + * AM263Px is ICSSM-class (-motor-control-sdk docs: SOC_AM263PX || SOC_AM261X -> PRU-ICSSM). + */ + +// Canonical widget identifiers — use these in requiresWidget declarations. +const WIDGETS = { + MAC: "mac", + CRC_HW: "crc_hw", + BSWAP: "bswap", + SUM32: "sum32", + TASK_MGR: "task_manager", + STITCH_FIFO: "stitch_fifo", + BS_RAM: "bs_ram", + RTU_PRU: "rtu_pru", + TX_PRU: "tx_pru", + IEP2: "iep2", // second IEP instance (IEP0 exists on all; IEP1 only on ICSSG) + SPAD: "spad", // scratchpad banks (present on both, but kept for completeness) + XFR2PSI: "xfr2psi", // XFRDMA / PSI-L packet streaming (ICSSG only, no PSI-L on ICSSM SPRUJB6 7.3.1.1) + XFR2TR: "xfr2tr", // RTU_PRU XFR2TR ring (SPRUIM2J 6.4.6.3.3 / Table 6-112). Attached to the + // RTU_PRU cores, which exist on ICSSG only (SPRUIM2J 6.4.1.1); ICSSM has no RTU_PRU. + FDB_GP: "fdb_gp", // FDB Generic RAM + compare (SPRUIM2J 6.4.11.2.3.7.4, Tables 6-103/6-105; + // broadside IDs 30/31/35). PRU0/PRU1 only, ICSSG only. + XFR2SPIN: "xfr2spin", // ICSSG hardware spinlock (SPRUIM2J Table 6-60 "XFR2SPIN 0x90 1 copy per + // PRU_ICSSG system"; 6.4.6.2.9 / Table 6-98). 64 ownership flags reachable from all + // six ICSSG cores. ICSSM (SPRUJB6 7.3.1.1) has no spinlock unit, and there is no + // software substitute for a cross-core atomic, so this is ICSSG-only. + QUEUE_EMPTY: "queue_empty", // ICSSG QUEUE_EMPTY status query (SPRUIM2J Table 6-60 BS ID 0xF0) + ICSSG_PWM: "icssg_pwm", // PRU_ICSSG PWM module driven by IEP compares (SPRUIM2J 6.4.10). ICSSM + // (SPRUJB6 7.3.1.1) has no PWM module; the R30 software PWM blocks are the fallback. +}; + +// ICSSG: full feature set +const ICSSG_CAPS = new Set([ + WIDGETS.MAC, + WIDGETS.CRC_HW, + WIDGETS.SPAD, + WIDGETS.BSWAP, + WIDGETS.SUM32, + WIDGETS.TASK_MGR, + WIDGETS.STITCH_FIFO, + WIDGETS.BS_RAM, + WIDGETS.RTU_PRU, + WIDGETS.TX_PRU, + WIDGETS.IEP2, + WIDGETS.XFR2PSI, + WIDGETS.XFR2TR, + WIDGETS.FDB_GP, + WIDGETS.XFR2SPIN, + WIDGETS.QUEUE_EMPTY, + WIDGETS.ICSSG_PWM, +]); + +// ICSSM (AM261x, AM263Px): smaller PRU-ICSS +const ICSSM_CAPS = new Set([ + WIDGETS.MAC, + WIDGETS.CRC_HW, + WIDGETS.SPAD, + // BSWAP, SUM32, TASK_MGR, STITCH_FIFO, BS_RAM, RTU_PRU, TX_PRU, IEP2 are absent +]); + +const DEVICE_CAPS = { + am64x: ICSSG_CAPS, + am243x: ICSSG_CAPS, + am261x: ICSSM_CAPS, + am263px: ICSSM_CAPS, + am263x: ICSSM_CAPS, +}; + +// fail closed. A missing or unknown SoC must never silently inherit +// a capability set — return an EMPTY set so every requiresWidget check reports +// the widget as missing (visible error) instead of quietly passing. +function isKnownSoc(socName) { + if (!socName) return false; + return Object.prototype.hasOwnProperty.call(DEVICE_CAPS, String(socName).toLowerCase()); +} + +function getCapsForSoc(socName) { + if (!socName) return new Set(); + const key = String(socName).toLowerCase(); + return DEVICE_CAPS[key] || new Set(); +} + +function isWidgetAvailable(widget, socName) { + const caps = getCapsForSoc(socName); + return caps.has(widget); +} + +function getMissingWidgets(requiresWidget, socName) { + if (!requiresWidget) return []; + const list = Array.isArray(requiresWidget) ? requiresWidget : [requiresWidget]; + const caps = getCapsForSoc(socName); + return list.filter(function (w) { return !caps.has(w); }); +} + +// All known device ids (sorted for docs) +const ALL_DEVICES = Object.keys(DEVICE_CAPS).sort(); + +// SysConfig export (system.getScript) +if (typeof exports !== "undefined") { + exports.WIDGETS = WIDGETS; + exports.ICSSG_CAPS = ICSSG_CAPS; + exports.ICSSM_CAPS = ICSSM_CAPS; + exports.DEVICE_CAPS = DEVICE_CAPS; + exports.getCapsForSoc = getCapsForSoc; + exports.isKnownSoc = isKnownSoc; + exports.isWidgetAvailable = isWidgetAvailable; + exports.getMissingWidgets = getMissingWidgets; + exports.ALL_DEVICES = ALL_DEVICES; +} + +// Node / test harness export +if (typeof module !== "undefined" && module.exports) { + module.exports.WIDGETS = WIDGETS; + module.exports.ICSSG_CAPS = ICSSG_CAPS; + module.exports.ICSSM_CAPS = ICSSM_CAPS; + module.exports.DEVICE_CAPS = DEVICE_CAPS; + module.exports.getCapsForSoc = getCapsForSoc; + module.exports.isKnownSoc = isKnownSoc; + module.exports.isWidgetAvailable = isWidgetAvailable; + module.exports.getMissingWidgets = getMissingWidgets; + module.exports.ALL_DEVICES = ALL_DEVICES; +} diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_reserved_names.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_reserved_names.js new file mode 100644 index 0000000..4acb872 --- /dev/null +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_reserved_names.js @@ -0,0 +1,161 @@ +"use strict"; +/* + * pru_reserved_names.js — ONE shared clpru reserved-identifier list. + * + * A SysConfig instance name the assembler rejects must be a SysConfig + * validation ERROR, not a build failure. Every block that emits its instance + * name (or a name derived from it) as an assembly label or symbol calls into + * this file from its validate(); the list lives HERE ONLY — do not copy it + * into blocks. + * + * Oracle: TI clpru 2.3.3 (ti-cgt-pru-linux). Each name below was probed as a + * data symbol (` .byte 1, 2, 3` in .initdmem — exactly what the CRC/LUT + * blocks emit) and as a code label (`:`); every one fails with + * [E0300] " is a reserved identifier". Any name that assembles was + * REMOVED from this list (kept red): SCAN, BROAD, IDLE, MULL, MUL, b4-b7, + * w0-w7, assembler directives (byte/ushort/uint/usect/text/sect/global/...), + * sp, pc, main, call, ret, TEMP_REG1 and ordinary names all assemble fine. + * + * Case (each claim probed with clpru itself, both as a data symbol and as + * a code label; see docs/ncp-268-report.md): register names r0-r31/c0-c31 + * are case-insensitive (R5, C0 fail) — ANY case is rejected. Mnemonics are + * rejected in exact-lowercase (add) and exact-ALL-UPPERCASE (ADD) only; a + * mixed-case form (Add, Ldi32) ASSEMBLES and must be accepted, because the + * brief's oracle says names that assemble are not reserved. Byte lanes are + * rejected lowercase only (b0 fails; B0 ASSEMBLES and is accepted). + * + * Loading: blocks reach this file through + * system.getScript("/pru_blocks/common/pru_reserved_names.js") + * the same way pru_spi_* blocks share pru_spi_timing.js. It needs no + * `system` object itself, so harnesses can load it for real in a bare vm. + */ + +// PRU general-purpose registers R0-R31 and constant-table registers C0-C31. +var REGISTER_NAMES = []; +(function () { + var i; + for (i = 0; i < 32; i++) { REGISTER_NAMES.push("r" + i); } + for (i = 0; i < 32; i++) { REGISTER_NAMES.push("c" + i); } +})(); + +// Byte-lane selectors the assembler reserves as identifiers (w0-w7 are NOT +// reserved — probed, they assemble — so only b0-b3 are listed). +var BYTE_LANE_NAMES = ["b0", "b1", "b2", "b3"]; + +// PRU instruction mnemonics clpru reserves (each probed lowercase and +// ALL-UPPERCASE; mixed-case forms assemble and are NOT rejected — see +// isReservedAsmName. SCAN/BROAD/IDLE/MULL/MUL assemble and are NOT listed). +var MNEMONIC_NAMES = [ + "add", "adc", "sub", "suc", "rsb", "rsc", + "and", "or", "xor", "not", "min", "max", "clr", "set", + "lsl", "lsr", "lmbd", + "mov", "ldi", "ldi32", "lbbo", "sbbo", "lbco", "sbco", "zero", "fill", + "mvib", "mviw", "mvid", + "jmp", "jal", + "qbgt", "qbge", "qblt", "qble", "qbeq", "qbne", "qba", "qbbc", "qbbs", + "loop", "halt", "slp", + "wbc", "wbs", + "xin", "xout", "xchg", "sxin", "sxout", "sxchg", "nop" +]; + +var RESERVED_ASM_NAMES = REGISTER_NAMES.concat(BYTE_LANE_NAMES, MNEMONIC_NAMES); + +var REGISTER_LOOKUP = {}; +REGISTER_NAMES.forEach(function (n) { REGISTER_LOOKUP[n] = true; }); + +var BYTE_LANE_LOOKUP = {}; +BYTE_LANE_NAMES.forEach(function (n) { BYTE_LANE_LOOKUP[n] = true; }); + +var MNEMONIC_LOOKUP = {}; +var MNEMONIC_UPPER_LOOKUP = {}; +MNEMONIC_NAMES.forEach(function (n) { + MNEMONIC_LOOKUP[n] = true; + MNEMONIC_UPPER_LOOKUP[n.toUpperCase()] = true; +}); + +/** + * True when `name` is a clpru reserved identifier, using the assembler's own + * case rules (see header): registers match case-insensitively; mnemonics + * match the exact-lowercase or exact-ALL-UPPERCASE spelling only (mixed-case + * assembles, so it is accepted); byte lanes match the exact-lowercase + * spelling only (B0 assembles, so it is accepted). + */ +function isReservedAsmName(name) { + if (name === undefined || name === null) return false; + var s = String(name); + if (REGISTER_LOOKUP[s.toLowerCase()] === true) return true; + if (BYTE_LANE_LOOKUP[s] === true) return true; + if (MNEMONIC_LOOKUP[s] === true) return true; + if (MNEMONIC_UPPER_LOOKUP[s] === true) return true; + return false; +} + +function reservedErrorFor(kind, value, detail) { + return "Instance " + kind + " \"" + value + "\" is a clpru reserved " + + "identifier ([E0300]; PRU register, constant-table register or " + + "instruction mnemonic) and cannot be used as an assembly label or " + + "symbol. Rename it." + (detail ? " " + detail : ""); +} + +/** + * Reject a reserved instance ($name). For blocks that emit the bare name + * (crc, look_up_table, modbus lutName). `detail` names the emission, e.g. + * "This block emits ' .byte ...' into .initdmem." + */ +function validateInstanceName(inst, report, detail) { + if (!inst || inst.$name === undefined || inst.$name === null) return; + if (isReservedAsmName(inst.$name)) { + report.logError(reservedErrorFor("name", inst.$name, detail), inst); + } +} + +/** + * Reject a reserved value in a derived assembly symbol. Some blocks never + * emit the raw name: conditional emits `_TRUE`/`_FALSE` branch + * labels, group emits `_start`, pid emits `_pid_state`. + * Those derived symbols assemble even for a reserved base (probed: c0_TRUE, + * c0_start, c0_pid_state and add_TRUE all build), so only the DERIVED form + * is checked — a reserved base whose derived symbols assemble is accepted. + * `field` is a config field name, or "$name" for the instance name. + */ +function validateDerivedSymbol(inst, report, field, suffixes, detail) { + if (!inst) return; + var value = (field === "$name") ? inst.$name : inst[field]; + if (value === undefined || value === null) return; + if (String(value).trim() === "") return; // emptiness is reported by the block itself + suffixes.forEach(function (suffix) { + var derived = String(value) + suffix; + if (isReservedAsmName(derived)) { + report.logError("Derived name \"" + derived + "\" (from " + field + ") is a clpru reserved " + + "identifier ([E0300]; PRU register, constant-table register or " + + "instruction mnemonic) and cannot be used as an assembly label or " + + "symbol. Rename it." + (detail ? " " + detail : ""), inst, field === "$name" ? undefined : field); + } + }); +} + +/** + * Reject a reserved value in a named config field that is emitted as an + * assembly label or symbol (groupName, labelName, variableName). + */ +function validateSymbolField(inst, report, field, detail) { + if (!inst) return; + var value = inst[field]; + if (value === undefined || value === null) return; + if (String(value).trim() === "") return; // emptiness is reported by the block itself + if (isReservedAsmName(value)) { + report.logError("Reserved name \"" + value + "\" (" + field + ") is a clpru reserved " + + "identifier ([E0300]; PRU register, constant-table register or " + + "instruction mnemonic) and cannot be used as an assembly label or " + + "symbol. Rename it." + (detail ? " " + detail : ""), inst, field); + } +} + +exports = { + RESERVED_ASM_NAMES: RESERVED_ASM_NAMES, + isReservedAsmName: isReservedAsmName, + validateInstanceName: validateInstanceName, + validateDerivedSymbol: validateDerivedSymbol, + validateSymbolField: validateSymbolField +}; +if (typeof module !== "undefined" && module && module.exports) { module.exports = exports; } diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_spi_timing.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_spi_timing.js new file mode 100644 index 0000000..ddfd21d --- /dev/null +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_spi_timing.js @@ -0,0 +1,153 @@ +"use strict"; +/* + * pru_spi_timing.js - shared timing checks of the pru_spi_read / pru_spi_write / pru_spi_transfer blocks: the + * "CS Setup Time" / "CS Hold Time" / "Data Setup Time" nanosecond settings as the PRU cycle counts their macros take. + * + * Facts this rests on (TI clpru 2.3.3, measured with --asm_listing): + * - `.loop N` / `nop` / `.endloop` is unrolled at assembly time into N `nop` instructions. A PRU instruction + * takes one cycle, so a macro formal of N costs exactly N cycles with no loop overhead. + * - `.loop 0` is accepted and emits nothing. + * - Every nop is a word of IRAM (4 bytes; a PRU core has 12 KB), so the count is capped (MAX_DELAY_CYCLES). + * + * The PRU core clock is the "PRU Clock Frequency" setting (pruClkFreq) of the Simulation Settings static module. + * When the static module is absent the helper falls back to that setting's default, DEFAULT_PRU_CLK_MHZ. + */ + +const DEFAULT_PRU_CLK_MHZ = 200; + +// Each delay site expands to this many nops at most (1 KB of the 12 KB PRU IRAM); a macro has up to two such sites. +const MAX_DELAY_CYCLES = 256; + +// The Simulation Settings option "333.333 MHz" is the AM64x/AM243x 1000/3 MHz core clock. +const CLK_333_OPTION_MHZ = 333.333; + +function pruClockMhz() { + let mhz = DEFAULT_PRU_CLK_MHZ; + const staticModule = system.modules["/pru_blocks/common/pru_blocks_static_module"]; + if (staticModule && staticModule.$static && staticModule.$static.pruClkFreq) { + mhz = Number(staticModule.$static.pruClkFreq); + } + return Math.abs(mhz - CLK_333_OPTION_MHZ) < 1e-3 ? 1000 / 3 : mhz; +} + +/** + * Setup and hold times are minimums, so the conversion rounds UP: ceil(ns * MHz / 1000). + * The tiny subtraction keeps an exact product (35 ns at 200 MHz = 7 cycles) from tipping to 8 on float noise. + * 0 ns (or less) is 0 cycles, which the macros turn into an empty `.loop 0`. + */ +function nsToCycles(ns, mhz) { + const t = Number(ns); + if (!(t > 0)) return 0; + return Math.ceil(t * mhz / 1000 - 1e-9); +} + +/** Cycle count for one nanosecond setting at the design's current PRU clock. */ +function csDelayCycles(ns) { + return nsToCycles(ns, pruClockMhz()); +} + +/** + * Report an error for each of CS Setup Time / CS Hold Time / Data Setup Time whose converted count exceeds + * MAX_DELAY_CYCLES. Peripheral mode does not use these settings. + */ +function validateCsTimes(inst, report) { + if (inst["Device Mode"] === "peripheral") return; + const mhz = pruClockMhz(); + ["CS Setup Time", "CS Hold Time", "Data Setup Time"].forEach(function (name) { + const cycles = nsToCycles(inst[name], mhz); + if (cycles > MAX_DELAY_CYCLES) { + report.logError(name + " of " + inst[name] + " ns is " + cycles + " PRU cycles at " + mhz.toFixed(3) + " MHz; the limit is " + + MAX_DELAY_CYCLES + " PRU cycles (each cycle is one nop in program memory). Lower the time or the PRU Clock Frequency.", + inst, name); + } + }); +} + +// program-memory budget check. Every delay cycle is one nop (4 bytes of IRAM), so one maxed block is ~2 KB and a few +// of them overflow the 12 KB a PRU core has. The linker already refuses an overflowing .text, but only after the whole design has +// been generated; this reports it at validation, naming the design total. +const IRAM_BUDGET_BYTES = 12 * 1024; +// Fixed instructions of one block's macro body, rounded UP from the clpru .text of single-block builds (controller and +// peripheral, 8-32 bit packets, MODE0-3: 0x44..0x64 bytes for the whole design incl. its data-load and CS-idle blocks). +const FIXED_BLOCK_INSTRUCTIONS = 28; +// Peripheral macros unroll `.loop CS_FILTER_CYCLES` at this many sites. +const FILTER_SITES = 2; +// CS Filter Cycles has range [1, 0xFFFFFFFF]: the smallest filter a peripheral block can be lowered to. +const MIN_CS_FILTER_CYCLES = 1; +const SPI_MODULES = [ + "/pru_blocks/pru_io_blocks/pru_spi_read", + "/pru_blocks/pru_io_blocks/pru_spi_write", + "/pru_blocks/pru_io_blocks/pru_spi_transfer" +]; + +/** + * Upper-bound estimate of the program memory one SPI block instance takes: 4 bytes per instruction. Controller mode: the fixed body + * plus every delay site (CS setup, CS hold, data setup and the two SCLK pulse compensations). Peripheral mode: the fixed body plus + * the CS glitch filter, `.loop CS_FILTER_CYCLES` at two sites of the slave macro (wait for CS high, wait for CS low), each unrolled + * into CS Filter Cycles copies of one qbbc/qbbs. Both modes' settings are hidden in the other mode. + */ +function estimateIramBytes(inst) { + let instructions = FIXED_BLOCK_INSTRUCTIONS; + if (inst["Device Mode"] !== "peripheral") { + instructions += csDelayCycles(inst["CS Setup Time"]) + csDelayCycles(inst["CS Hold Time"]) + + csDelayCycles(inst["Data Setup Time"]) + Number(inst["delay_component1"]) + Number(inst["delay_component2"]); + } else { + instructions += FILTER_SITES * Number(inst["CS Filter Cycles"]); + } + return 4 * instructions; +} + +/** + * Warn, once per design (on the first SPI instance), when the estimated IRAM of all pru_spi_* instances together exceeds the + * 12 KB of one PRU core. A warning and not an error: the estimate is an upper bound and the other blocks of the design are + * not counted, so it can neither prove a fit nor prove an overflow (the linker is the final word). The advice names the + * actual cause: when the peripheral CS Filter Cycles loops alone push the design over the budget, the warning + * says so instead of advising the controller-mode CS Setup / Hold, Data Setup and SCLK widths. "Alone" means the design would + * fit with every filter at its minimum of 1 cycle; a design that still overflows then gets the controller advice. + * Tightness (estimate / linked .text, 3 peripheral blocks): within 10% at CS Filter Cycles >= 100 (read 1.08x, write + * 1.07x, transfer 1.04x); loose at small filters because the fixed body is rounded up (read: 2.20x at 1, 2.04x at 2, 1.52x at 10, + * 1.23x at 30; write 2.05x / 1.92x / 1.47x / 1.21x; transfer 1.39x / 1.35x / 1.21x / 1.11x). Always conservative, never under. + */ +function validateIramBudget(inst, report) { + const all = []; + SPI_MODULES.forEach(function (name) { + const mod = system.modules[name]; + if (mod && mod.$instances) mod.$instances.forEach(function (i) { all.push(i); }); + }); + if (all.length === 0 || all[0] !== inst) return; + let total = 0, filterBytes = 0, minFilterBytes = 0; + all.forEach(function (i) { + total += estimateIramBytes(i); + if (i["Device Mode"] === "peripheral") { + filterBytes += 4 * FILTER_SITES * Number(i["CS Filter Cycles"]); + minFilterBytes += 4 * FILTER_SITES * MIN_CS_FILTER_CYCLES; + } + }); + if (total > IRAM_BUDGET_BYTES) { + let advice; + // Lowering the filter only helps while the design fits with every filter at its minimum (CS Filter Cycles >= 1, so a + // peripheral block never drops below 8 bytes of filter): otherwise the controller-side widths must shrink too. + if (filterBytes > 0 && total - filterBytes + minFilterBytes <= IRAM_BUDGET_BYTES) { + advice = "The CS Filter Cycles loops of the peripheral-mode block(s) push it over: lower the CS Filter Cycles"; + } else { + advice = "Shorten the CS Setup / Hold Time, Data Setup Time and SCLK pulse widths, or lower the PRU Clock Frequency"; + } + report.logWarning("The " + all.length + " SPI block(s) in this design need an estimated " + total + " bytes of program memory (IRAM), over the " + + IRAM_BUDGET_BYTES + " bytes of one PRU core (4 bytes per instruction; every delay cycle is a nop). " + advice + + "; the linker will refuse an overflowing .text." + + (filterBytes > 0 ? " The estimate is conservative: within 10% of the linked size for peripheral CS filters of 100 cycles or more, " + + "but up to about 2x over at small filters (the default filter of 2 cycles reads about 2x for read / write, 1.35x for transfer)." : ""), inst); + } +} + +exports = { + IRAM_BUDGET_BYTES: IRAM_BUDGET_BYTES, + estimateIramBytes: estimateIramBytes, + validateIramBudget: validateIramBudget, + DEFAULT_PRU_CLK_MHZ: DEFAULT_PRU_CLK_MHZ, + MAX_DELAY_CYCLES: MAX_DELAY_CYCLES, + pruClockMhz: pruClockMhz, + nsToCycles: nsToCycles, + csDelayCycles: csDelayCycles, + validateCsTimes: validateCsTimes +}; diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_syscfg.asm.xdt b/.metadata/sysconfig/.meta/pru_blocks/common/pru_syscfg.asm.xdt index 1f395e1..673040b 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_syscfg.asm.xdt +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_syscfg.asm.xdt @@ -1,6 +1,7 @@ %const pruRegisterAllocationSummary = system.getScript("/pru_blocks/common/register_allocation/pru_register_allocator.js").getPruRegisterAllocationSummary(); %const registerAllocator = system.getScript("/pru_blocks/common/register_allocation/pru_register_allocator.js"); %const systemModules = system.modules; +%const bindingModule = system.modules["/pru_blocks/common/pru_blocks_static_module"]; ; Copyright (C) 2024 Texas Instruments Incorporated - http://www.ti.com/ ; ; Redistribution and use in source and binary forms, with or without @@ -53,6 +54,12 @@ ;************************************* includes ************************************* .include "pru_syscfg.inc" +; Define temporary registers for macro usage + .asg R28, TEMP_REG1 + .asg R29, TEMP_REG2 + .asg R26, TEMP_REG3 + .asg R27, TEMP_REG4 + .retain ; Required for building .out with assembly file .retainrefs ; Required for building .out with assembly file @@ -98,10 +105,16 @@ % } % } %} +%// Program CTPPR0 (c28 -> shared RAM) and the SPI CS idle levels at every entry into generated code. +%let ctSetup = bindingModule && typeof bindingModule.getConstantTableSetup === "function" ? bindingModule.getConstantTableSetup() : ""; +%let groupStartLabelSet = new Set((groupMetadata || []).map(g => `${g.groupName}_start`)); ;************************************************************************************** ; PRU assembly code outside group block ;************************************************************************************** sysconfig_generated_start: +%if(ctSetup){ +`ctSetup` +%} %// First loop: Output ungrouped blocks (sysconfig_generated section) %for(let iterator = 0; iterator < groupStartIndex; iterator++) %{ @@ -128,6 +141,9 @@ sysconfig_generated_start: %if(typeof(labels[iterator]) == "string" && labels[iterator] !== "") %{ `labels[iterator]`: +%if(ctSetup && groupStartLabelSet.has(labels[iterator])){ +`ctSetup` +%} %} % if((!pruInstructions[iterator].includes("-1")) && (pruInstructions[iterator] != "0") && (!pruInstructions[iterator].includes("undefined"))) % { diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_syscfg.inc.xdt b/.metadata/sysconfig/.meta/pru_blocks/common/pru_syscfg.inc.xdt index 2da4cd3..ab90138 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_syscfg.inc.xdt +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_syscfg.inc.xdt @@ -72,6 +72,8 @@ SLICE`icssgNum` .set 1 ;************************************* register definitions ************************* .asg R28, TEMP_REG1 .asg R29, TEMP_REG2 + .asg R26, TEMP_REG3 + .asg R27, TEMP_REG4 % // Define RET_ADDR0 as the dynamically allocated group return address register % let groupMetadata = system.getScript("/pru_blocks/common/register_allocation/pru_register_allocator.js").getGroupMetadata(); % if (groupMetadata && groupMetadata.length > 0) { diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js b/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js index d224604..b443cfd 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js @@ -23,6 +23,256 @@ let cycleBudgetMap = new Map(); let groupMetadata; let loopCounterRegisters = {}; // Track loop counters: {instanceName: {byteOffset, numBytes}} let groupReturnAddrByteOffset = -1; // Single allocated return address register for all group blocks +let containerOf = {}; // child instance name -> name of the loop/group block that lists it in $groupContents +let deferredLoopReleases = {}; // loop instance name -> map entries to release when that loop's body has been emitted +let hoistedOutOf = {}; // child instance name -> loops that list it in preInitBlocks (it is emitted BEFORE them) +let hoistedInPlace = {}; // ALU instance name -> [{entry, container}] preInit inputs it released to accumulate in place +let accumulatorBindings = {}; // update block name -> {preInit, container} from loop_block.accumulators + +/** + * Block fields that name a PRU register typed by the user and emitted VERBATIM by the block's macro. The allocator must + * not hand such a register to another live value, or the macro would overwrite it. Every register named here is + * removed from the pool, for the whole design, before anything is allocated. + * Key: module path. Value: list of field descriptors: + * default : the field's default value in the block source; a conflict between two fields both still at default is a WARNING, not an error + * field : config name holding the register ("R4", "R5.b0", "R3.w1", or a bare register number) + * bytes : width of a bare register token (a .bN/.wN token carries its own width); a number or (inst) => number + * fullRegister : the macro does a fixed 4-byte access (`zero ®, 4`, `lbco ®, ..., 4`, `ldi32 REG`) whatever the token, so a + * sub-register token (R0.b0) still reserves max(token, 4) bytes from its first byte + * when : (inst) => bool, the field is only read/written in some modes + * role : what the macro does with the register: "w" writes a result there, "r" only reads it, "rm" reads it AND destroys + * it (shifts it out); a string or (inst) => string. Used for the overlap check only (see reservePinnedRegisters). + * Fields that exist but are never touched by the macro are deliberately absent: reserving them would move allocations for nothing. + */ +const PINNED_REGISTER_FIELDS = { + // Entries are added together with the blocks that declare such fields. +}; + +let pinnedRegisters = []; // {block, field, token, firstByte, bytes} for every user-typed register, in the pool or not +let pinnedRegisterConflicts = []; // two pins that claim the same byte, reported by validation as an ERROR +let pinnedProblems = []; // {block, field, token, why} typed registers that are not a usable register window (ERROR in validation) +let pinnedByteCount = 0; // bytes of the pool removed by pins +let poolAfterPins = []; // pruByteArray right after the architectural windows and the pins were removed (free-register suggestion) + +/** + * Parses a typed register ("R4", "R5.b0", "R3.w1", 7) into {firstByte, bytes} (windowBytes: the macro's window, not the token, sets the width of a sub-register token), or null when it is not a register token. + */ +function parsePinnedRegister(value, bareBytes, windowBytes) { + const m = /^\s*R(\d+)(?:\.([bw])([0-3]))?\s*$/i.exec(typeof value === "number" ? "R" + value : String(value)); + if (!m || (m[2] && m[2].toLowerCase() === "w" && m[3] === "3")) return null; // .w3 would straddle two registers + const reg = parseInt(m[1], 10); + if (!m[2]) return { firstByte: reg * numOfBytesPerRegister, bytes: bareBytes }; + const tokenBytes = m[2].toLowerCase() === "b" ? 1 : 2; + // a macro that moves a byte window starting at the token covers the window, not just the token + return { firstByte: reg * numOfBytesPerRegister + parseInt(m[3], 10), bytes: windowBytes ? Math.max(tokenBytes, bareBytes) : tokenBytes }; +} + +/** + * Two pins on the same bytes are fine only when one block hands a value to another through the register (one writes, + * the other only reads) or both only read. Two writers, a reader + * that destroys what another reads, and any two fields of ONE block would overwrite each other. + */ +function isIntendedHandOff(a, b) { + if (a.block === b.block) return false; + const pair = [a.role, b.role].sort().join("+"); + return pair === "r+w" || pair === "r+r" || pair === "rm+w"; +} + +/** + * Removes the user-pinned registers (all bytes they overlap) from the pool. Must run before anything is allocated. + * Pins that fall outside R0-R27 (R28/R29 temporaries, R30/R31) take nothing from the pool. + * + * A pin whose block only READS the register (role "r") cannot damage + * anything, so it is reserved only when another block's pin WRITES those bytes (a hand-off through + * the register). A read-only pin with no writer is fed by an allocator output that happens to sit in that + * register. Reserving it would + * take the register away from the very value it is meant to publish, so it stays in the pool until the block takes a data + * input and is flagged reserved:false in getPinnedRegisters(). + */ +function reservePinnedRegisters() { + pinnedRegisters = []; + pinnedRegisterConflicts = []; + pinnedProblems = []; + pinnedByteCount = 0; + for (const moduleName in PINNED_REGISTER_FIELDS) { + const instances = systemModules[moduleName]?.$instances || []; + for (const instance of instances) { + for (const spec of PINNED_REGISTER_FIELDS[moduleName]) { + if (spec.when && !spec.when(instance)) continue; + const raw = instance[spec.field]; + if (raw === undefined || raw === null || raw === "") continue; + const bareBytes = typeof spec.bytes === "function" ? spec.bytes(instance) : (spec.bytes || numOfBytesPerRegister); + const parsed = parsePinnedRegister(raw, bareBytes, typeof spec.bytes === "function" || spec.fullRegister === true); + const token = typeof raw === "number" ? "R" + raw : String(raw); + if (!parsed) { + pinnedProblems.push({ block: instance.$name, field: spec.field, token: token, + why: "is not a PRU register (write R0-R27, optionally with .b0-.b3 or .w0-.w2, e.g. R4 or R5.b0)" }); + continue; + } + // a field marked ownWindowCheck is range-checked by its own block validator; every other field is checked here + if (!spec.ownWindowCheck && parsed.firstByte + parsed.bytes > totalBytes) { + pinnedProblems.push({ block: instance.$name, field: spec.field, token: token, + why: "makes the block touch " + parsed.bytes + " byte(s) from byte " + parsed.firstByte + ", past R27 (R28/R29 are the compiler temporaries, R30/R31 the I/O registers); choose a register whose window ends at or before R27.b3" }); + } + const role = typeof spec.role === "function" ? spec.role(instance) : spec.role; + pinnedRegisters.push({ block: instance.$name, field: spec.field, role: role, token: token, + firstByte: parsed.firstByte, bytes: parsed.bytes, reserved: true, + alwaysReserve: Boolean(spec.alwaysReserve), + atDefault: String(raw).trim().toUpperCase() === String(spec.default).toUpperCase() }); + } + } + } + const overlaps = (p, q) => p.firstByte < q.firstByte + q.bytes && q.firstByte < p.firstByte + p.bytes; + for (const pin of pinnedRegisters) { + if (pin.role === "r" && !pin.alwaysReserve) pin.reserved = pinnedRegisters.some(q => q.role === "w" && q.block !== pin.block && overlaps(pin, q)); + } + const owners = Array.from({ length: totalBytes }, () => []); + const reported = {}; + for (const pin of pinnedRegisters) { + for (let b = pin.firstByte; b < pin.firstByte + pin.bytes && b < totalBytes; b++) { + for (const other of owners[b]) { + const key = other.block + "." + other.field + "|" + pin.block + "." + pin.field; + if (!reported[key] && !isIntendedHandOff(other, pin)) { + reported[key] = true; + pinnedRegisterConflicts.push({ first: other, second: pin }); + } + } + if (pin.reserved && pruByteArray[b] === 0) { pruByteArray[b] = 1; pinnedByteCount++; } + owners[b].push(pin); + } + } + poolAfterPins = Array.from(pruByteArray); +} + +/** + * emit position of each block (first instruction), after allocation. + */ +function pinnedEmitOrder() { + const order = {}; + (pruRegisterAllocationSummary.pruInstructionInstances || []).forEach((inst, i) => { + if (inst && inst.$name !== undefined && order[inst.$name] === undefined) order[inst.$name] = i; + }); + return order; +} + +function pinsOverlap(p, q) { + return p.firstByte < q.firstByte + q.bytes && q.firstByte < p.firstByte + p.bytes; +} + +const UNCONDITIONAL_EXIT_OPCODES = ["HALT", "JMP"]; // flow_control_block: HALT, or JMP to the end of the generated code + +/** the instance object of an allocated block, by name. */ +function allocatedInstanceNamed(name) { + const entry = moduleInstanceRegisterMap[name]; + return entry ? systemModules[entry.moduleName]?.$instances[entry.instanceNum] : undefined; +} + +/** names of the callable (non-loop) groups around a block, innermost first, as one comparable string. */ +function callableGroupChain(name) { + return enclosingContainers(name).filter(c => !(moduleInstanceRegisterMap[c] && moduleInstanceRegisterMap[c].groupIsLoop)).join("/"); +} + +/** + * true when EVERY control-flow path from `startName` (next, T and F ports) ends in an unconditional exit (a Flow Control + * HALT or JMP) without ever reaching `targetName`. A path that just ends (no successor, no exit) is NOT such a path: the emitted + * code falls through into whatever follows (an if/else whose T branch has no HALT runs on into the F branch). A conditional with its T or F port unconnected is likewise a non-exit (the missing branch is not provably an exit). + */ +function flowExitsBefore(startName, targetName) { + const seen = new Set(); + const walk = (name) => { + if (name === targetName) return false; + const inst = allocatedInstanceNamed(name); + if (!inst) return false; + if (/\/flow_control_block$/.test(moduleInstanceRegisterMap[name].moduleName) && UNCONDITIONAL_EXIT_OPCODES.includes(String(inst.opCode).toUpperCase())) return true; + if (/\/conditional_block$/.test(moduleInstanceRegisterMap[name].moduleName) && (!(inst.T || []).length || !(inst.F || []).length)) return false; + if (seen.has(name)) return true; + seen.add(name); + const successors = [].concat(inst.next || [], inst.T || [], inst.F || []).map(c => c?.inst?.$name).filter(Boolean); + return successors.length > 0 && successors.every(walk); + }; + return walk(startName); +} + +/** + * is the emit order of two blocks the order they run in? Only then can "no reader between them" prove a lost value. + * Not provable when the blocks sit in different callable groups (hand-written main.asm calls the groups in an order the generator + * cannot see), or when the earlier block's every path ends in HALT/JMP before the later block (mutually exclusive branches). + * Straight line, and the same loop body, stay provable. + */ +function executionOrderProvable(earlyName, lateName) { + return callableGroupChain(earlyName) === callableGroupChain(lateName) && !flowExitsBefore(earlyName, lateName); +} + +/** + * the pin conflicts, each marked `harmful` when the overlap provably destroys a value unread: two writers ('w') of + * overlapping bytes with NO reader ('r'/'rm') of them between the two in emit order (or two fields of one block). The first + * writer's result is then dead before anything uses it, whether the registers were typed or left at their defaults. + * Everything else (a reader between the writers, i.e. the register is reused one value at a time) is not provably harmful. + * "between" needs (a) the emit order to BE the execution order (executionOrderProvable) and (b) a reader whose window + * overlaps the bytes the second writer destroys (the overlap of the two writers), not merely the first writer's window. + * Must be called after allocation (needs the emit order). + */ +function getPinnedConflictsClassified() { + const order = pinnedEmitOrder(); + return pinnedRegisterConflicts.map(c => { + let harmful = false; + if (c.first.role === "w" && c.second.role === "w") { + if (c.first.block === c.second.block) { + harmful = true; + } else if (order[c.first.block] !== undefined && order[c.second.block] !== undefined) { + const early = order[c.first.block] < order[c.second.block] ? c.first : c.second; + const late = early === c.first ? c.second : c.first; + const lost = { firstByte: Math.max(early.firstByte, late.firstByte), + bytes: Math.min(early.firstByte + early.bytes, late.firstByte + late.bytes) - Math.max(early.firstByte, late.firstByte) }; + harmful = executionOrderProvable(early.block, late.block) && + !pinnedRegisters.some(r => (r.role === "r" || r.role === "rm") && pinsOverlap(r, lost) && + order[r.block] !== undefined && order[r.block] > order[early.block] && order[r.block] < order[late.block]); + c = { first: early, second: late }; + } + } + return { first: c.first, second: c.second, harmful: harmful }; + }); +} + +/** + * the first register window of `bytes` bytes (whole registers) that no pin (typed or default, reserved or not) covers and + * the architectural windows (XFR2PSI/SUM32) do not take, for the "type another register" advice; null when there is none. + */ +function suggestFreeRegister(bytes) { + const regs = Math.max(1, Math.ceil(bytes / numOfBytesPerRegister)); + for (let r = 0; r + regs <= numOfPruRegisters; r++) { + const win = { firstByte: r * numOfBytesPerRegister, bytes: regs * numOfBytesPerRegister }; + let free = true; + for (let b = win.firstByte; b < win.firstByte + win.bytes; b++) if (poolAfterPins[b] !== 0) free = false; + if (free && !pinnedRegisters.some(p => pinsOverlap(p, win))) return "R" + r; + } + return null; +} + +/** + * A hand-off through a register (one block writes it, another transmits/publishes/shifts it) only works when the + * writer runs first. A reader emitted before EVERY overlapping writer with no loop around both reads the register before it holds + * the value. A reader that an earlier writer + * already feeds is fine (consume A, publish A, consume B, publish B on one register), whatever runs after it. In a loop it is the + * previous iteration's value: a one-iteration lag (the first iteration reads the uninitialised register, the last value is + * never consumed), so it is exempt from the warning. Must be called after allocation (needs the emit order). + */ +function getPinnedHandOffOrderWarnings() { + const order = pinnedEmitOrder(); + const inLoop = (name) => enclosingContainers(name).filter(c => moduleInstanceRegisterMap[c] && moduleInstanceRegisterMap[c].groupIsLoop); + const out = []; + for (const r of pinnedRegisters) { + if ((r.role !== "r" && r.role !== "rm") || order[r.block] === undefined) continue; + const writers = pinnedRegisters.filter(w => w.role === "w" && w.block !== r.block && pinsOverlap(w, r) && order[w.block] !== undefined); + if (writers.some(w => order[w.block] < order[r.block])) continue; + for (const w of writers) { + const loopsOfW = inLoop(w.block); + if (inLoop(r.block).some(c => loopsOfW.includes(c))) continue; + out.push({ writer: w, reader: r }); + } + } + return out; +} /** * Returns PRU registers allocation summary @@ -78,6 +328,32 @@ function getFreeByteOffset(numOfBytes) { return -1; } +/** + * a request the pool could not satisfy is recorded here and reported by validation as an ERROR. + * Before, pushInstruction returned -1 and every caller dropped it, so the block's instruction vanished from the asm + * and the build stayed clean (a 24-stage running sum emitted 22 of its 24 ADDs). + * @param {Object} instance - block that asked for registers + * @param {number} numOfBytes - size of the request + * @param {string} what - what the registers were for + */ +function recordAllocationFailure(instance, numOfBytes, what, contractFailure) { + // a block that failed is revisited by every consumer; report each (block, request) once + if (allocationFailures.some(f => f.block === instance.$name && f.what === what)) return; + let freeBytes = 0; + for (let i = 0; i < totalBytes; i++) if (pruByteArray[i] === 0) freeBytes++; + allocationFailures.push({ + block: instance.$name, + bytes: numOfBytes, + what: what, + contractFailure: contractFailure === true, + freeBytes: freeBytes, + pinnedBytes: pinnedByteCount, + pinnedBy: pinnedRegisters.filter(p => p.reserved && p.firstByte < totalBytes).map(p => p.block + "." + p.field + "=" + p.token), + budgetRegisters: numOfPruRegisters, + budgetBytes: totalBytes + }); +} + /** * Converts byte offset to PRU register notation * @param {number} byteOffset - Byte offset in PRU memory @@ -129,6 +405,92 @@ function disallocatePruRegisters(byteOffset, numOfBytes) { } } +// a connection names the SOURCE PORT it leaves from (conn.port === "output2"), and every output port a block +// allocates has its own register (byteOffsetOfOutput / numOfBytesReqByOutput). The allocator used to read only output1, +// so a consumer wired to output2..N silently read output1's register. All consumer-side lookups go through these helpers. +let allocationFailures = []; // register-pool exhaustion, one record per request that could not be placed; reported by validation +let unroutedConnections = []; // consumers wired to an output port that owns no register; reported by validation, never rerouted to output1 +let loopBoundaryViolations = []; // loop contents consumed outside loop; reported by validation + +/** Number of output ports a block instance exposes (output1..outputN arrays), at least 1. */ +function outputPortCount(inst) { + let n = 1; + while (n < 32 && inst["output" + (n + 1)] !== undefined) n++; + return n; +} + +/** Total consumer connections over every output port of a block instance. */ +function outputConnectionCount(inst) { + let total = 0; + for (let n = 1; n <= outputPortCount(inst); n++) total += (inst["output" + n] || []).length; + return total; +} + +/** Every connection leaving any output port of a block instance. */ +function allOutputConnections(inst) { + let conns = []; + for (let n = 1; n <= outputPortCount(inst); n++) conns = conns.concat(inst["output" + n] || []); + return conns; +} + +/** Output port number a connection leaves from ("output3" -> 3). A missing or malformed port name is a loud + * Error naming the bad value (never a silent fallback to output1, which would read the wrong register). */ +function sourcePortNumber(conn, owner) { + const raw = conn && conn.port; + const m = /^output(\d+)$/.exec(raw || ""); + if (!m) throw new Error("pru_register_allocator: invalid source output port '" + String(raw) + "'" + (owner ? " on " + owner : "") + "; expected 'outputN'"); + return parseInt(m[1], 10); +} + +/** Registers (byteOffset, numBytes) allocated to output port N of a mapped block, or null when that port owns none. */ +function outputPortAllocation(entry, port) { + const off = entry["byteOffsetOfOutput" + port]; + const bytes = entry["numOfBytesReqByOutput" + port]; + return (off === undefined || off === -1 || !(bytes > 0)) ? null : { byteOffset: off, numBytes: bytes }; +} + +/** Releases every output port's registers of a mapped block. */ +function disallocateAllOutputPorts(entry) { + for (let port = 1; entry["byteOffsetOfOutput" + port] !== undefined; port++) { + const alloc = outputPortAllocation(entry, port); + if (alloc) disallocatePruRegisters(alloc.byteOffset, alloc.numBytes); + } +} + +/** Every allocated output register of a mapped block, in port order. */ +function outputRegisterNotations(entry) { + const regs = []; + for (let port = 1; entry["byteOffsetOfOutput" + port] !== undefined; port++) { + const alloc = outputPortAllocation(entry, port); + const notation = alloc ? getPruRegister(alloc.byteOffset, alloc.numBytes) : -1; + if (notation !== -1) regs.push(notation); + } + return regs; +} + +/** + * Register token a consumer reads for input wired to the source port named by `conn`. + * Returns the source output port's own register; a missing/malformed port name or a port that owns no register is + * recorded in unroutedConnections (so validation names the block and port) and yields a token that cannot assemble, + * because substituting output1's register is the silent wrong-code defect this replaces. + */ +function sourceRegisterToken(consumer, inputPort, conn) { + const source = conn.inst; + let port; + try { + port = sourcePortNumber(conn, "'" + source.$name + "' -> '" + consumer.$name + "." + inputPort + "'"); + } catch (err) { + unroutedConnections.push({ consumer: consumer.$name, inputPort: inputPort, source: source.$name, sourcePort: String(conn && conn.port) }); + return "UNROUTED_" + source.$name + "_" + String(conn && conn.port); + } + const alloc = outputPortAllocation(moduleInstanceRegisterMap[source.$name], port); + if (!alloc) { + unroutedConnections.push({ consumer: consumer.$name, inputPort: inputPort, source: source.$name, sourcePort: "output" + port }); + return "UNROUTED_" + source.$name + "_output" + port; + } + return getPruRegister(alloc.byteOffset, alloc.numBytes); +} + /** * Inverts PRU instruction opcode for conditional branching * @param {string} opCode - Original opcode @@ -238,6 +600,15 @@ function insertPreInitInstructions(insertIndex, preInitData) { function processLoopCounterGroup(instance) { let groupContents = JSON.parse(JSON.stringify(instance)).$groupContents; let maxCycles = 0; + // a preInit value is emitted in front of the loop and read on every pass, so it must own its register before + // any body block is allocated. Reached lazily (through its first reader) it could be handed a register an earlier body + // temporary had just released, and that temporary's next-pass load would overwrite it. Allocate them all first. + for (const name of groupContents) { + const mapped = (instance.preInitBlocks || []).includes(name) && moduleInstanceRegisterMap[name]; + if (!mapped || !('blockOutputCalculated' in mapped) || mapped.blockOutputCalculated === 1 || !('output1ArrayLength' in mapped)) continue; + pushInstruction(systemModules[mapped.moduleName]?.$instances[mapped.instanceNum], null); + if (mapped.output1ArrayLength !== -1) mapped.output1ArrayLength++; // that call was not a consumer read + } // First pass: Process program control block (without output1 port or T and F ports and next ports) for (const instanceName of groupContents) { const { moduleName, instanceNum } = moduleInstanceRegisterMap[instanceName]; @@ -285,7 +656,7 @@ function processLoopCounterGroup(instance) { // skip blocks which are in group blocks (eg: loop block) if (moduleInstanceRegisterMap[instanceName]?.output1ArrayLength === -1) { const { moduleName, instanceNum } = moduleInstanceRegisterMap[instanceName]; - const instance = systemModules[moduleName]?.$instances[instanceNum]; + const instance = systemModules[moduleName]?.$instances[instanceNum]; // Process the instruction chain starting from this result node pushInstruction(instance, null); // Mark as calculated @@ -300,7 +671,27 @@ function processLoopCounterGroup(instance) { maxCycles = Math.max(maxCycles, moduleInstanceRegisterMap[instanceName].peakCycles); } } - + + // exported blocks are the loop body's results for the outside world. A body whose only data + // sink sits outside the loop (a post-loop store reading an exported accumulator) has no in-loop result + // node, so without this pass the update block would be emitted after the loop instead of inside it. + // Runs last on purpose: anything reachable from an in-loop sink keeps its existing emission order, and + // pushInstruction skips whatever is already calculated. + for (const instanceName of (instance.exportedBlocks || [])) { + const entry = moduleInstanceRegisterMap[instanceName]; + if (!entry || entry.blockOutputCalculated === 1 || entry.conditionCalculated === 1) continue; + const { moduleName, instanceNum } = entry; + const childInstance = systemModules[moduleName]?.$instances[instanceNum]; + if (!childInstance) continue; + pushInstruction(childInstance, null); + if ("conditionCalculated" in moduleInstanceRegisterMap[instanceName]) { + moduleInstanceRegisterMap[instanceName].conditionCalculated = 1; + } else if ("blockOutputCalculated" in moduleInstanceRegisterMap[instanceName]) { + moduleInstanceRegisterMap[instanceName].blockOutputCalculated = 1; + } + maxCycles = Math.max(maxCycles, moduleInstanceRegisterMap[instanceName].peakCycles); + } + return maxCycles; } function processGroupBlock(instance){ @@ -312,6 +703,7 @@ function processGroupBlock(instance){ // Allocate return address register on first group block (2 bytes for word address) if (groupReturnAddrByteOffset === -1) { groupReturnAddrByteOffset = getFreeByteOffset(2); + if (groupReturnAddrByteOffset === -1) recordAllocationFailure(instance, 2, "the group return address"); } let label = `${groupName}_start`; @@ -394,10 +786,8 @@ function collectBranchChain(instance) { if (nextInst) queue.push(nextInst); // Forward: data flow to consumers - if (inst.output1) { - for (const conn of inst.output1) { - if (conn?.inst) queue.push(conn.inst); - } + for (const conn of allOutputConnections(inst)) { + if (conn?.inst) queue.push(conn.inst); } } @@ -456,6 +846,226 @@ function collectInputPredecessors(instance) { return collectInputDependencies(chain); } +/** + * may the registers of this block be released now? + * Only the exact counter decides. output1ArrayLength is decremented on EVERY pushInstruction visit - including the + * control-flow `prev` walk - so it is over-decremented and goes negative in a single-consumer chain, which made + * `A_k = A_(k-1) + C_k` never release (a 24-stage running sum exhausted the pool). dataConsumersRemaining is decremented + * once per data connection in the consumer's input loop, so 0 means every data consumer has read the value. + * Callers release only AFTER the consumer's destination is allocated (see releaseConsumedInputs): a value released + * earlier would be handed to the consumer as its own destination, and a macro that writes before it has finished + * reading its input (Lookup Table reads R0.b2 and writes R0.b3) would corrupt itself. + */ +function releaseSafe(entry) { + return entry.dataConsumersRemaining === 0; +} + +/** + * blocks whose whole instruction is ONE native PRU ALU instruction. The hardware reads every source before it + * writes the destination, so such a block may reuse the register of an input it is the last consumer of + * (`LSR R0, R0, R1.b0`), exactly as before. Every other block - every `m_*` macro and anything not listed - may write + * its result before it has finished reading its inputs (Lookup Table reads R0.b2 and writes R0.b3), so it must not be + * given a register released by its own inputs. The default is the safe side: an unknown opCode releases AFTER allocation. + */ +const IN_PLACE_SAFE_OPCODES = ["ADD", "ADC", "SUB", "SUC", "AND", "OR", "XOR", "NOT", "LSL", "LSR"]; + +function readsAllInputsBeforeWriting(instance) { + return typeof instance.opCode === "string" && IN_PLACE_SAFE_OPCODES.includes(instance.opCode.toUpperCase()); +} + +/** Names of every loop/group block that contains `name`, innermost first. */ +function enclosingContainers(name) { + const chain = []; + for (let c = containerOf[name]; c !== undefined && !chain.includes(c); c = containerOf[c]) chain.push(c); + return chain; +} + +/** checks if consumer reads producer from outside producer's loop/group */ +function checkBoundary(producerName, consumerName, portName) { + if (!producerName || !consumerName) return; + const pContainers = enclosingContainers(producerName); + const cContainers = enclosingContainers(consumerName); + for (const c of pContainers) { + if (!cContainers.includes(c)) { + loopBoundaryViolations.push({ + consumer: consumerName, + producer: producerName, + container: c, + port: portName + }); + } + } +} + +/** + * the loop body is emitted ONCE but runs loopCount times, so a value produced OUTSIDE a loop and read by a block + * INSIDE it is read again on every iteration. Releasing it at its single textual consumer let the next block in the body + * take the register, and iteration 2 read the clobbered byte. Returns the outermost container of `consumer` that does not + * also contain `producer` (null when both sit in the same containers, i.e. the release is safe where it stands). + */ +function outermostForeignContainer(producerName, consumerName) { + // a preInit block is listed in the loop's $groupContents but its instructions are moved in front of the loop + // (extractPreInitInstructions), so for release purposes it is OUTSIDE that loop: it runs once and every iteration reads it. + const hoisted = hoistedOutOf[producerName] || []; + const producerChain = enclosingContainers(producerName).filter(c => !hoisted.includes(c)); + const foreign = enclosingContainers(consumerName).filter(c => !producerChain.includes(c)); + return foreign.length === 0 ? null : foreign[foreign.length - 1]; +} + +/** Releases the registers of every input of `instance` whose last data consumer has now been allocated. */ +function releaseConsumedInputs(instance) { + for (let iterator1 = 1; iterator1 <= instance.numOfInputPorts; iterator1++) { + const inputInstanceName = instance["input" + iterator1]?.[0]?.inst?.$name; + const entry = inputInstanceName && moduleInstanceRegisterMap[inputInstanceName]; + // registersReleased: a second release would zero bytes a later block (possibly this one) has since been given. + if (entry && !entry.registersReleased && releaseSafe(entry)) { + // an accumulator that took over a preInit register already waits for the end of that loop + if (entry.holdUntilLoopEnd && enclosingContainers(instance.$name).includes(entry.holdUntilLoopEnd)) continue; + const container = outermostForeignContainer(inputInstanceName, instance.$name); + if (container === null) { + disallocateAllOutputPorts(entry); + entry.registersReleased = true; + } else if (moduleInstanceRegisterMap[container]?.groupIsLoop) { + if (entry.dataConsumersTotal === 1 && readsAllInputsBeforeWriting(instance) && (hoistedOutOf[inputInstanceName] || []).includes(container)) { + // incremental_spi: `ADC R0.b1, R0.b1, R0.b2` accumulates in the preInit register. Only when the ALU is the value's + // SOLE consumer (any other reader would see the accumulator on iteration 2). Released now so the + // ALU may take it over; settleHoistedInPlace() decides, once the destination is known, whether it did. + disallocateAllOutputPorts(entry); entry.registersReleased = true; + (hoistedInPlace[instance.$name] = hoistedInPlace[instance.$name] || []).push({ entry, container }); + } else { + // Live for the whole loop: released once the loop body has been emitted (releaseDeferredForLoop). + (deferredLoopReleases[container] = deferredLoopReleases[container] || []).push(entry); + } + } + // A callable group can run from any call site, so a value it reads from outside is never released. + } + } +} + +/** Byte indexes of every output register of a mapped block. */ +function outputBytes(entry) { + const bytes = []; + for (let port = 1; entry["byteOffsetOfOutput" + port] !== undefined; port++) { + const alloc = outputPortAllocation(entry, port); + if (alloc) for (let i = alloc.byteOffset; i < alloc.byteOffset + alloc.numBytes; i++) bytes.push(i); + } + return bytes; +} + +/** + * after an ALU block released a preInit input to accumulate in it, check the destination really landed on that + * register. If so the result is the loop's accumulator and nothing else in the body may take the register until the loop + * has ended (a later constant would clobber it on iteration 2). If not, the preInit value was never overwritten, so it is + * pinned again exactly as for any other consumer: a later block must not be given its register. + */ +function settleHoistedInPlace(instance) { + const pending = hoistedInPlace[instance.$name]; + if (!pending) return; + delete hoistedInPlace[instance.$name]; + const own = moduleInstanceRegisterMap[instance.$name]; + const ownBytes = own ? outputBytes(own) : []; + for (const { entry, container } of pending) { + const list = (deferredLoopReleases[container] = deferredLoopReleases[container] || []); + if (outputBytes(entry).some(b => ownBytes.includes(b))) { + own.holdUntilLoopEnd = container; + list.push(own); + } else { + for (const b of outputBytes(entry)) pruByteArray[b] = 1; + entry.registersReleased = false; + list.push(entry); + } + } +} + +/** [{preInit, update}] from a loop's accumulators (an array, or a JSON string of one). + * Malformed entries are ignored here; loop_block.validate() rejects them. */ +function parseAccumulatorBindings(value) { + let list = value; + if (list === undefined || list === null) return []; + if (typeof list === "string") { + if (list.trim() === "") return []; + try { + list = JSON.parse(list); + } catch (e) { + return []; + } + } + if (!Array.isArray(list)) return []; + const out = []; + for (const entry of list) { + if (entry && typeof entry.preInit === "string" && typeof entry.update === "string") { + out.push({ preInit: entry.preInit, update: entry.update }); + } + } + return out; +} + +/** + * explicit accumulator binding (loop_block.accumulators). The update block's destination IS the preInit + * block's register, so the update reads and writes the same bytes on every iteration (zero extra cycles), instead + * of the allocator handing it a fresh register that recomputes preInit + x each pass. Anything less than an exact, + * already-released, same-size preInit register is a loud allocation failure, never a silent fallback to 0 + x. + */ +function coalesceAccumulator(instance, binding, numBytes) { + const fail = (why) => { + recordAllocationFailure(instance, numBytes, "its accumulator register (accumulators: '" + + binding.preInit + "' -> '" + instance.$name + "'): " + why, true); + return -1; + }; + const pre = moduleInstanceRegisterMap[binding.preInit]; + if (!pre) return fail("preInit block '" + binding.preInit + "' is not allocated"); + const alloc = outputPortAllocation(pre, 1); + if (!alloc) return fail("preInit block '" + binding.preInit + "' owns no output register"); + if (alloc.numBytes !== numBytes) { + return fail("preInit register is " + alloc.numBytes + " byte(s) but the update needs " + numBytes); + } + for (let b = alloc.byteOffset; b < alloc.byteOffset + alloc.numBytes; b++) { + if (pruByteArray[b] !== 0) { + return fail("preInit register byte " + b + " is still live (the preInit value has another consumer, so '" + + instance.$name + "' cannot take it over)"); + } + } + for (let b = alloc.byteOffset; b < alloc.byteOffset + alloc.numBytes; b++) pruByteArray[b] = 1; + return alloc.byteOffset; +} + +/** + * an ALU result is the loop's accumulator only in the incremental_spi shape: its LOW byte is the first byte of the + * preInit value it took over, and it covers no byte of any other released preInit value. Any other overlap (a carry byte + * landing on another preInit register, or a result starting inside one) would overwrite that value on every pass, so every + * released preInit value is pinned again and the result is placed elsewhere. Returns the (possibly re-chosen) offset. + */ +function repinUnlessAccumulatorShape(instance, byteOffset, numBytes) { + const pending = hoistedInPlace[instance.$name]; + if (!pending || byteOffset === -1) return byteOffset; + const result = []; + for (let b = byteOffset; b < byteOffset + numBytes; b++) result.push(b); + const accumulates = pending.some(({ entry }) => { + const own = outputBytes(entry); + if (own.length === 0 || result[0] !== Math.min(...own)) return false; + return pending.every(other => other.entry === entry || !outputBytes(other.entry).some(b => result.includes(b))); + }); + if (accumulates || !pending.some(({ entry }) => outputBytes(entry).some(b => result.includes(b)))) return byteOffset; + for (const b of result) pruByteArray[b] = 0; + for (const { entry, container } of pending) { + for (const b of outputBytes(entry)) pruByteArray[b] = 1; + entry.registersReleased = false; + (deferredLoopReleases[container] = deferredLoopReleases[container] || []).push(entry); + } + delete hoistedInPlace[instance.$name]; + return getFreeByteOffset(numBytes); +} + +/** Releases the values a loop body only read, now that the loop has been emitted and none of them can be read again. */ +function releaseDeferredForLoop(loopName) { + for (const entry of deferredLoopReleases[loopName] || []) { + if (entry.registersReleased || !releaseSafe(entry)) continue; + disallocateAllOutputPorts(entry); + entry.registersReleased = true; + } + deferredLoopReleases[loopName] = []; +} + /** * Gets PRU Instructions from blocks connected in the system * @param {Object} instance - The current block instance @@ -488,6 +1098,7 @@ function pushInstruction(instance, parentInstance) { let label; if (instance?.["prev"] && instance?.["prev"][0]?.["inst"]) { const prevPortInstanceName = instance["prev"][0]?.["inst"]?.$name; + checkBoundary(prevPortInstanceName, instanceName, "prev"); // Process the previous block label = pushInstruction(instance["prev"][0]["inst"], instance); moduleInstanceRegisterMap[instanceName].connectedNodesInPeakCyclePath = @@ -533,12 +1144,31 @@ function pushInstruction(instance, parentInstance) { // Process each input port for (let iterator1 = 1; iterator1 <= instance.numOfInputPorts; iterator1++) { - const inputInstance = instance[inputPort + iterator1]?.[0]?.inst; + const inputConn = instance[inputPort + iterator1]?.[0]; + const inputInstance = inputConn?.inst; const inputInstanceName = inputInstance?.$name; if (inputInstanceName) { + checkBoundary(inputInstanceName, instanceName, inputPort + iterator1); // Recursively process connected input - const noOfBytesUsedByInput = pushInstruction(inputInstance, instance); + let noOfBytesUsedByInput = pushInstruction(inputInstance, instance); + // this connection is one data read of the source, counted exactly once, here. + if (moduleInstanceRegisterMap[inputInstanceName].dataConsumersRemaining > 0) { + moduleInstanceRegisterMap[inputInstanceName].dataConsumersRemaining--; + } + // the width that matters is the source PORT's, not output1's. + // a missing/malformed source port leaves sourcePort null (never silent output1); the token + // lookup below records it in unroutedConnections so validation names the block and port. + let sourcePort = null; + try { + sourcePort = sourcePortNumber(inputConn, "'" + inputInstanceName + "' -> '" + instanceName + ".input" + iterator1 + "'"); + } catch (err) { + // Recorded once by sourceRegisterToken below; keep null so the output1 fast path is never taken + // for a connection whose source port name SysConfig did not provide. + } + if (sourcePort !== null && sourcePort !== 1) { + noOfBytesUsedByInput = moduleInstanceRegisterMap[inputInstanceName]["numOfBytesReqByOutput" + sourcePort] || 0; + } moduleInstanceRegisterMap[instanceName].connectedNodesInPeakCyclePath = new Set([ ...moduleInstanceRegisterMap[instanceName].connectedNodesInPeakCyclePath, @@ -548,17 +1178,25 @@ function pushInstruction(instance, parentInstance) { // Update max bytes used maxBytesUsed = noOfBytesUsedByInput > maxBytesUsed ? noOfBytesUsedByInput : maxBytesUsed; - // Get register notation for this input's output - const _srcByteOffset = moduleInstanceRegisterMap[inputInstanceName].byteOffsetOfOutput1; - const _srcNumBytes = moduleInstanceRegisterMap[inputInstanceName].numOfBytesReqByOutput1; - if (_srcNumBytes > 4) { - // 64-bit source: emit two consecutive registers (lo, hi) - const _loReg = getPruRegister(_srcByteOffset, 4); - const _hiReg = getPruRegister(_srcByteOffset + 4, 4); - inputValues = inputValues + _loReg + " , " + _hiReg + " , "; + if (sourcePort === 1) { + // Get register notation for this input's output + const _srcByteOffset = moduleInstanceRegisterMap[inputInstanceName].byteOffsetOfOutput1; + const _srcNumBytes = moduleInstanceRegisterMap[inputInstanceName].numOfBytesReqByOutput1; + if (_srcNumBytes > 4) { + // 64-bit source: emit two consecutive registers (lo, hi) + const _loReg = getPruRegister(_srcByteOffset, 4); + const _hiReg = getPruRegister(_srcByteOffset + 4, 4); + inputValues = inputValues + _loReg + " , " + _hiReg + " , "; + } else { + inputValues = inputValues + getPruRegister(_srcByteOffset, _srcNumBytes) + " , "; + } } else { - inputValues = inputValues + getPruRegister(_srcByteOffset, _srcNumBytes) + " , "; + // Register notation for the output PORT this input is wired to (output2 and up) + inputValues = inputValues + sourceRegisterToken(instance, inputPort + iterator1, inputConn) + " , "; } + } else { + // Unconnected input: provide dummy register to preserve macro argument positions + inputValues = inputValues + "R0 , "; } } @@ -586,21 +1224,12 @@ function pushInstruction(instance, parentInstance) { inputValues = inputValues + instance["constant" + iterator1] + " , "; } - // Disallocate registers for inputInstance ports that are no longer needed - for (let iterator1 = 1; iterator1 <= instance.numOfInputPorts; iterator1++) { - const inputInstance = instance[inputPort + iterator1]?.[0]?.inst; - const inputInstanceName = inputInstance?.$name; - if (inputInstanceName) { - // Skip disallocation for inputs that are still needed by other blocks - if (moduleInstanceRegisterMap[inputInstanceName].output1ArrayLength === 0) { - disallocatePruRegisters( - moduleInstanceRegisterMap[inputInstanceName].byteOffsetOfOutput1, - moduleInstanceRegisterMap[inputInstanceName].numOfBytesReqByOutput1 - ); - } - } + // a single native ALU instruction reads all sources before it writes, so it may take over the register of an + // input it was the last consumer of; every other block releases only after its destination is allocated (below). + if (readsAllInputsBeforeWriting(instance)) { + releaseConsumedInputs(instance); } - + let instruction; // Process loop blocks if (instance.$groupContents) { @@ -612,6 +1241,7 @@ function pushInstruction(instance, parentInstance) { //push instruction to init loop count const byteOffset = getFreeByteOffset(instance.loopCountRegSize); + if (byteOffset === -1) recordAllocationFailure(instance, instance.loopCountRegSize, "its loop counter"); // Track loop counter register allocation loopCounterRegisters[instanceName] = { byteOffset: byteOffset, @@ -625,6 +1255,7 @@ function pushInstruction(instance, parentInstance) { addToPruRegisterAllocationSummary(label, instruction, instance, instanceName, 0); //push all the blocks inside loop block let loopBlockCycleBudget = processLoopCounterGroup(instance); + releaseDeferredForLoop(instanceName); // Handle pre-init blocks: extract their instructions and move before loop counter // Note: preInitBlocks is an array of block name strings (from multi-select options) @@ -654,6 +1285,7 @@ function pushInstruction(instance, parentInstance) { addToPruRegisterAllocationSummary(label, instruction, instance, instanceName, 0); //push all the blocks inside loop block let loopBlockCycleBudget = processLoopCounterGroup(instance); + releaseDeferredForLoop(instanceName); // Handle pre-init blocks: extract their instructions and move before startloop label // Note: preInitBlocks is an array of block name strings (from multi-select options) @@ -716,17 +1348,31 @@ function pushInstruction(instance, parentInstance) { } else { - //create Instruction - instruction = `${instance.opCode} ${outputRegister}, ${inputValues}`; + if (inputValues === "") { + instruction = `${instance.opCode} ${outputRegister}`; + } else { + instruction = `${instance.opCode} ${outputRegister}, ${inputValues}`; + } } } // Generate PRU instruction for regular blocks else { // Allocate registers for output - const byteOffset = getFreeByteOffset(maxBytesUsed); - + // an explicitly bound accumulator reuses its preInit register instead of taking a fresh + // one. repinUnlessAccumulatorShape still runs below: the coalesced bytes trivially satisfy its + // accumulator shape, so the preInit settle logic keeps working unchanged. + let byteOffset; + if (accumulatorBindings[instanceName] !== undefined) { + byteOffset = coalesceAccumulator(instance, accumulatorBindings[instanceName], maxBytesUsed); + if (byteOffset === -1) return -1; // specific failure already recorded (validation reports it) + } else { + byteOffset = getFreeByteOffset(maxBytesUsed); + } + byteOffset = repinUnlessAccumulatorShape(instance, byteOffset, maxBytesUsed); + if (byteOffset === -1) { - return -1; // Failed to allocate registers + recordAllocationFailure(instance, maxBytesUsed, "its result"); + return -1; // Failed to allocate registers (validation reports it as an error) } // Update register map with allocation info @@ -743,15 +1389,42 @@ function pushInstruction(instance, parentInstance) { } else { outputRegister = getPruRegister(byteOffset, maxBytesUsed); } + // Blocks with a multi-result macro need distinct allocator-owned destinations + // before their input and constant arguments. + const macroOutputRegisters = [outputRegister]; + if (instance.multiOutputMacroArgs) { + for (let output = 2; output <= instance.numOfOutputPorts; output++) { + const width = parseInt(instance["output" + output + "Size"] || maxBytesUsed); + const extraOffset = getFreeByteOffset(width); + if (extraOffset === -1) { + recordAllocationFailure(instance, width, "result output" + output); + return -1; + } + moduleInstanceRegisterMap[instanceName]["byteOffsetOfOutput" + output] = extraOffset; + moduleInstanceRegisterMap[instanceName]["numOfBytesReqByOutput" + output] = width; + macroOutputRegisters.push(getPruRegister(extraOffset, width)); + } + } // Clean up input values string (remove trailing comma and space) if (inputValues.endsWith(" , ")) { inputValues = inputValues.slice(0, -3); } //create Instruction - instruction = `${instance.opCode} ${outputRegister}, ${inputValues}`; + const instMod = systemModules[moduleInstanceRegisterMap[instanceName].moduleName]; + if (typeof instMod?.getInstruction === "function") { + instruction = instMod.getInstruction(instance, outputRegister); + } else if (inputValues === "") { + instruction = `${instance.opCode} ${macroOutputRegisters.join(", ")}`; + } else { + instruction = `${instance.opCode} ${macroOutputRegisters.join(", ")}, ${inputValues}`; + } } } + // the destination (if any) is allocated now, so inputs whose last data consumer this was can be released + // (a no-op for the ALU blocks that already released them above). + releaseConsumedInputs(instance); + settleHoistedInPlace(instance); if("conditionCalculated" in moduleInstanceRegisterMap[instanceName]) { moduleInstanceRegisterMap[instanceName].conditionCalculated = 1; @@ -790,9 +1463,35 @@ function allocatePruRegisters() { }; moduleInstanceRegisterMap = {}; + unroutedConnections = []; + allocationFailures = []; + loopBoundaryViolations = []; pruByteArray = new Array(totalBytes).fill(0); + + // user-typed registers the blocks emit verbatim leave the pool before anything is allocated + reservePinnedRegisters(); loopCounterRegisters = {}; // Reset loop counter tracking groupReturnAddrByteOffset = -1; // Reset group return address register + containerOf = {}; + deferredLoopReleases = {}; + hoistedOutOf = {}; + hoistedInPlace = {}; + accumulatorBindings = {}; + for (const moduleName in systemModules) { + for (const instance of systemModules[moduleName]?.$instances || []) { + if (instance.$name && instance.$groupContents) { + // $groupContents holds instance objects; the JSON clone turns them into names, as everywhere else here. + for (const child of JSON.parse(JSON.stringify(instance)).$groupContents) containerOf[child] = instance.$name; + for (const pre of JSON.parse(JSON.stringify(instance)).preInitBlocks || []) (hoistedOutOf[pre] = hoistedOutOf[pre] || []).push(instance.$name); + // explicit accumulator bindings live on loops (infiniteLoop marks a loop container). + if ('infiniteLoop' in instance) { + for (const b of parseAccumulatorBindings(instance.accumulators)) { + accumulatorBindings[b.update] = { preInit: b.preInit, container: instance.$name }; + } + } + } + } + } let loopBlockInstanceNames = []; let groupBlockInstanceNames = []; groupMetadata = []; @@ -824,12 +1523,17 @@ function allocatePruRegisters() { // Handle blocks with output1 port if (instance.output1) { - // TODO: Currently assuming each block has only one output port - const outputArrayLength = instance.output1.length === 0 ? -1 : instance.output1.length; + // output1ArrayLength counts the consumer connections of EVERY output port (the name is historical): + // a block whose only consumer hangs off output2 is not a result node, and its registers are released after the last one. + const outputConnections = outputConnectionCount(instance); + const outputArrayLength = outputConnections === 0 ? -1 : outputConnections; moduleInstanceRegisterMap[instanceName] = { "blockOutputCalculated": 0, "output1ArrayLength": outputArrayLength, + // exact count of data consumers that have not read this block yet (see releaseSafe) + "dataConsumersRemaining": outputConnections, + "dataConsumersTotal": outputConnections, "byteOffsetOfOutput1": -1, "numOfBytesReqByOutput1": 0, "peakCycles": 0, @@ -869,6 +1573,7 @@ function allocatePruRegisters() { if(instance.$groupContents && ('infiniteLoop' in instance)){ let groupContents = JSON.parse(JSON.stringify(instance)).$groupContents loopBlockInstanceNames = loopBlockInstanceNames.concat(groupContents); + moduleInstanceRegisterMap[instanceName].groupIsLoop = true; } } //Handle program end block instances(without output1 port or T and F ports or next ports) @@ -889,6 +1594,7 @@ function allocatePruRegisters() { if(instance.$groupContents && ('infiniteLoop' in instance)){ let groupContents = JSON.parse(JSON.stringify(instance)).$groupContents loopBlockInstanceNames = loopBlockInstanceNames.concat(groupContents); + moduleInstanceRegisterMap[instanceName].groupIsLoop = true; } if(instance.$groupContents && !('infiniteLoop' in instance)){ let groupContents = JSON.parse(JSON.stringify(instance)).$groupContents @@ -899,6 +1605,17 @@ function allocatePruRegisters() { instanceName : instanceName, groupContents : groupContents }); + + // A group listed inside a loop's $groupContents is emitted outside the loop + // and never called. We reject this. + if (containerOf[instanceName]) { + loopBoundaryViolations.push({ + consumer: "generator", + producer: instanceName, + container: containerOf[instanceName], + port: "$groupContents" + }); + } } } } @@ -1007,10 +1724,11 @@ function getRegisterAllocationComments() { if (entry.byteOffsetOfOutput1 !== -1 && entry.numOfBytesReqByOutput1 > 0 && entry.conditionCalculated === undefined) { - const regNotation = getPruRegister(entry.byteOffsetOfOutput1, entry.numOfBytesReqByOutput1); - if (regNotation !== -1) { + // every output port that owns a register is listed, not just output1 + const regNotations = outputRegisterNotations(entry); + if (regNotations.length > 0) { allocationMap[instanceName] = { - registers: [regNotation], + registers: regNotations, peakCycles: entry.peakCycles }; } @@ -1086,6 +1804,18 @@ function getRegisterAllocationComments() { } } + // the CTPPR0 write runs once at sysconfig_generated_start and on every group + // CALL, ahead of the first block, so it adds to the worst case of either path. The + // per-block peakCycles in the allocation table do not include it. + const staticModule = systemModules["/pru_blocks/common/pru_blocks_static_module"]; + const ctSetupCycles = (staticModule && typeof staticModule.getConstantTableSetupCycles === "function") + ? staticModule.getConstantTableSetupCycles() : 0; + // getConstantTableSetupCycles() also counts the SPI CS idle `set r30` per pin; split it so the CTPPR0 wording stays exact. + const csIdleCycles = (staticModule && typeof staticModule.getSpiCsIdleSetupCycles === "function") + ? staticModule.getSpiCsIdleSetupCycles() : 0; + const ctppr0Cycles = ctSetupCycles - csIdleCycles; + overallPeakCycles += ctSetupCycles; + // 6. Add reserved registers and overall peak cycles summary if (sortedNames.length > 0) { comments.push(";"); @@ -1099,7 +1829,7 @@ function getRegisterAllocationComments() { if (hasInfiniteLoop) { comments.push("; OVERALL PEAK CYCLES: Unknown - Infinite loop detected in configuration"); } else { - comments.push(`; OVERALL PEAK CYCLES: ${overallPeakCycles + 1} cycles (worst-case timing) including group and non-group code`); + comments.push(`; OVERALL PEAK CYCLES: ${overallPeakCycles + 1} cycles (worst-case timing) including group and non-group code${ctSetupCycles ? ` (includes ${[ctppr0Cycles ? `${ctppr0Cycles} cycles of CTPPR0 setup for c28` : "", csIdleCycles ? `${csIdleCycles} cycles of SPI CS idle-level setup` : ""].filter(Boolean).join(" and ")}, repeated on every group call; per-block cycle counts exclude it)` : ""}`); } comments.push(";--------------------------------------------------------------------------------------"); @@ -1125,10 +1855,7 @@ function getBlockRegisterAllocation(instanceName) { // Check for output register allocation if (entry.byteOffsetOfOutput1 !== -1 && entry.numOfBytesReqByOutput1 > 0) { - const regNotation = getPruRegister(entry.byteOffsetOfOutput1, entry.numOfBytesReqByOutput1); - if (regNotation !== -1) { - registers.push(regNotation); - } + registers.push(...outputRegisterNotations(entry)); } // Check for loop counter register @@ -1156,5 +1883,14 @@ exports = { allocatePruRegisters, getGroupMetadata: () => groupMetadata, getRegisterAllocationComments, - getBlockRegisterAllocation -} \ No newline at end of file + getBlockRegisterAllocation, + sourcePortNumber, + getUnroutedConnections: () => unroutedConnections, + getAllocationFailures: () => allocationFailures, + getPinnedRegisters: () => pinnedRegisters, + getPinnedRegisterConflicts: getPinnedConflictsClassified, + suggestFreeRegister, + getPinnedRegisterProblems: () => pinnedProblems, + getPinnedHandOffOrderWarnings, + getLoopBoundaryViolations: () => loopBoundaryViolations +} From cb331948c667b7db7ab8d4c2da37a41f09935643 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 05:43:42 +0530 Subject: [PATCH 02/44] Fix existing blocks: validation, cycle counts and register use - CRC16/CRC32: scale the table index to the entry size before the load (the byte offset does not fit the LBBO offset operand); 13 cycles - Access Lookup Table: scale the index for ushort/uint tables (7 cycles) - PRU GPI: build the bit mask in the result register so pins above 7 work; 3 cycles, 4-byte output - PRU SPI Read/Transfer: clear the result register before sampling so short packets are zero-extended; reserve the whole register for 17-24 bit packets; one extra overhead cycle - PRU SPI Read/Write/Transfer: refuse CS/data delays above 256 cycles, warn when the SPI blocks exceed the PRU IRAM budget - If/Else: an unconnected true/false branch is an error (the generated code would reference an undefined label) - Load Constant: optional explicit output width - Loop: reject a hardware LOOP nested in a finite loop body; add exported result blocks and accumulator bindings - Delay: document the exact cycle count and the nesting restriction - Memory Access: warn when two blocks share one auto-created Memory Variable - CRC, Lookup Table, Memory Variable, Group, If/Else: reject symbols that clash with clpru reserved identifiers - PRU GPI/GPO and SPI blocks: declare the pins they use (getPinUsage) --- .../application_specific/crc_block.syscfg.js | 57 +++++-- .../data_handling/arithmetic_block.syscfg.js | 2 +- .../data_handling/bitwise_block.syscfg.js | 2 +- .../load_constant_block.syscfg.js | 40 ++++- .../conditional_block.syscfg.js | 15 +- .../flow_control_block.syscfg.js | 2 +- .../program_control/group_block.syscfg.js | 9 +- .../program_control/loop_block.syscfg.js | 146 +++++++++++++++++- .../pru_io_blocks/pru_gpi_block.syscfg.js | 38 +++-- .../pru_io_blocks/pru_gpo_block.syscfg.js | 8 +- .../pru_io_blocks/pru_spi_read.syscfg.js | 57 +++++-- .../pru_io_blocks/pru_spi_transfer.syscfg.js | 49 +++++- .../pru_io_blocks/pru_spi_write.syscfg.js | 20 ++- .../utils/access_look_up_table.syscfg.js | 42 ++++- .../pru_blocks/utils/delay_block.syscfg.js | 57 +++---- .../pru_blocks/utils/look_up_table.syscfg.js | 9 +- .../utils/memory_access_block.syscfg.js | 55 +++++++ .../utils/memory_variable_block.syscfg.js | 7 + 18 files changed, 504 insertions(+), 111 deletions(-) diff --git a/.metadata/sysconfig/.meta/pru_blocks/application_specific/crc_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/application_specific/crc_block.syscfg.js index de0dd4b..5719f00 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/application_specific/crc_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/application_specific/crc_block.syscfg.js @@ -61,6 +61,13 @@ function getLookUptable(instance) } function validate(inst, report) { + // $name is emitted as the CRC lookup-table symbol (" .byte/.ushort/.uint ..." in .initdmem); reject clpru reserved identifiers via the shared helper. + if (typeof system !== "undefined" && system.getScript) { + var reservedNames = system.getScript("/pru_blocks/common/pru_reserved_names.js"); + if (reservedNames && typeof reservedNames.validateInstanceName === "function") { + reservedNames.validateInstanceName(inst, report, "This block emits the instance name as the CRC lookup-table symbol in .initdmem."); + } + } if(inst["input1"].length == 0) { report.logWarning("crc is not intialized",inst) @@ -153,17 +160,33 @@ function getMacro(pruInstructionMacro, opCode) } if (macroName && macroName.trim().startsWith("m_calculate_crc16")) { + // LUT entries are 2 bytes: the byte offset is index*2, which does not + // fit in a .b0 operand, so the scaled offset is added into the base + // and the LBBO offset register is zeroed (LDI TEMP_REG2, 0) first. The + // LDI is required on every model: without it the .b0 offset left in + // TEMP_REG2 is added a second time on top of the scaled base. macroBody += ` LDI32 TEMP_REG1, ${lutAddress} LSR TEMP_REG2.b0, ${crcInit}, 8 XOR TEMP_REG2.b0, TEMP_REG2.b0, ${dataByte} + AND TEMP_REG2, TEMP_REG2, 0xFF + LSL TEMP_REG2, TEMP_REG2, 1 + ADD TEMP_REG1, TEMP_REG1, TEMP_REG2 + LDI TEMP_REG2, 0 LBBO &${crcResult}, TEMP_REG1, TEMP_REG2.b0, 2 LSL TEMP_REG2, ${crcInit}, 8 XOR ${crcResult}, TEMP_REG2.w0, ${crcResult}`; } if (macroName && macroName.trim().startsWith("m_calculate_crc32")) { + // LUT entries are 4 bytes: the byte offset is index*4 (up to 1020), + // which does not fit in a .b0 operand, so the scaled offset is added + // into the base and the LBBO offset register is zeroed first. macroBody += ` LDI32 TEMP_REG1, ${lutAddress} LSR TEMP_REG2.b0, ${crcInit}, 24 XOR TEMP_REG2.b0, TEMP_REG2.b0, ${dataByte} + AND TEMP_REG2, TEMP_REG2, 0xFF + LSL TEMP_REG2, TEMP_REG2, 2 + ADD TEMP_REG1, TEMP_REG1, TEMP_REG2 + LDI TEMP_REG2, 0 LBBO &${crcResult}, TEMP_REG1, TEMP_REG2.b0, 4 LSL TEMP_REG2, ${crcInit}, 8 XOR ${crcResult}, TEMP_REG2, ${crcResult}`; @@ -302,22 +325,30 @@ Calculates CRC checksums for error detection in data transmission and storage. C - LDI32 TEMP_REG1, CRC_LUT_address ; Load LUT address - LSR TEMP_REG2.b0, init, 8 ; Extract high byte - XOR TEMP_REG2.b0, TEMP_REG2.b0, dataByte ; XOR with data +- AND TEMP_REG2, TEMP_REG2, 0xFF ; Zero-extend the table index +- LSL TEMP_REG2, TEMP_REG2, 1 ; Scale: entries are 2 bytes (index*2) +- ADD TEMP_REG1, TEMP_REG1, TEMP_REG2 ; Base += scaled byte offset +- LDI TEMP_REG2, 0 ; Zero the LBBO offset register - LBBO &result, TEMP_REG1, TEMP_REG2.b0, 2 ; Lookup - LSL TEMP_REG2, init, 8 ; Shift init left -- XOR result, TEMP_REG2.w0, result ; XOR with lookup (9 cycles) +- XOR result, TEMP_REG2.w0, result ; XOR with lookup (13 cycles) **Generated Assembly** (CRC32 example): - LDI32 TEMP_REG1, CRC_LUT_address ; Load LUT address - LSR TEMP_REG2.b0, init, 24 ; Extract high byte - XOR TEMP_REG2.b0, TEMP_REG2.b0, dataByte ; XOR with data +- AND TEMP_REG2, TEMP_REG2, 0xFF ; Zero-extend the table index +- LSL TEMP_REG2, TEMP_REG2, 2 ; Scale: entries are 4 bytes (index*4) +- ADD TEMP_REG1, TEMP_REG1, TEMP_REG2 ; Base += scaled byte offset +- LDI TEMP_REG2, 0 ; Zero the LBBO offset register - LBBO &result, TEMP_REG1, TEMP_REG2.b0, 4 ; Lookup - LSL TEMP_REG2, init, 8 ; Shift init left -- XOR result, TEMP_REG2, result ; XOR with lookup (9 cycles) +- XOR result, TEMP_REG2, result ; XOR with lookup (13 cycles) **Performance**: - CRC8: 6 PRU cycles per byte -- CRC16: 9 PRU cycles per byte -- CRC32: 9 PRU cycles per byte +- CRC16: 13 PRU cycles per byte +- CRC32: 13 PRU cycles per byte **Lookup Table**: Automatically generated 256-entry table based on polynomial - CRC8: 256 bytes (256 × 1 byte) @@ -589,14 +620,14 @@ exports = { { return 6 } - if(inst["opCode"] == "m_calculate_crc16") - { - return 9 - } - if(inst["opCode"] == "m_calculate_crc32") - { - return 9 - } + if(inst["opCode"] == "m_calculate_crc16") + { + return 13 + } + if(inst["opCode"] == "m_calculate_crc32") + { + return 13 + } } } ], @@ -627,4 +658,4 @@ exports = { getMacro, getDataSize, getLookUptable -} \ No newline at end of file +} diff --git a/.metadata/sysconfig/.meta/pru_blocks/data_handling/arithmetic_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/data_handling/arithmetic_block.syscfg.js index bbcbf94..8bf7d1e 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/data_handling/arithmetic_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/data_handling/arithmetic_block.syscfg.js @@ -270,4 +270,4 @@ exports = { }, }, getAIContext -} \ No newline at end of file +} diff --git a/.metadata/sysconfig/.meta/pru_blocks/data_handling/bitwise_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/data_handling/bitwise_block.syscfg.js index dede553..9319fc1 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/data_handling/bitwise_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/data_handling/bitwise_block.syscfg.js @@ -327,4 +327,4 @@ exports = { }, }, getAIContext -} \ No newline at end of file +} diff --git a/.metadata/sysconfig/.meta/pru_blocks/data_handling/load_constant_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/data_handling/load_constant_block.syscfg.js index bae4c7c..3645de4 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/data_handling/load_constant_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/data_handling/load_constant_block.syscfg.js @@ -5,7 +5,13 @@ function validate(inst, report) { { report.logWarning("input"+iterator.toString()+" port not connected to output port",inst) } - } + } + // an explicit output width must be able to hold the constant + const width = inst["outputWidth"]; + if(width !== "auto" && getNumOfBytes(inst["constant1"]) > parseInt(width)) + { + report.logError("Constant " + inst["constant1"] + " needs " + getNumOfBytes(inst["constant1"]) + " byte(s) but Output Width is " + width + " byte(s). Choose a wider Output Width or Auto.", inst, "outputWidth"); + } } function getNumOfBytes(value) { @@ -44,6 +50,7 @@ const ldi1 = load_constant_block.addInstance(); | Parameter | Type | Valid Values | Default | Description | |-----------|------|--------------|---------|-------------| | constant1 | Integer | 0 to 0xFFFFFFFF | 0 | Constant value to load | +| outputWidth | String | "auto", "1", "2", "4" | "auto" | Register bytes reserved for the output (auto sizes from the value) | ### Example Configurations @@ -71,6 +78,13 @@ ldi1.$name = "Bit_Mask"; ldi1.constant1 = 0xFF00FF00; // Alternating byte mask \\\`\\\`\\\` +**Load a small value into its own full 32-bit register (never packed with another constant):** +\\\`\\\`\\\`javascript +ldi1.$name = "Coeff_0"; +ldi1.constant1 = 0x1000; +ldi1.outputWidth = "4"; // default "auto" sizes the register from the value +\\\`\\\`\\\` + **Load zero:** \\\`\\\`\\\`javascript ldi1.$name = "Zero_Value"; @@ -99,7 +113,9 @@ scripting.connect(ldi1, "next", next_block, "prev"); - 2 bytes for values 256-65535 - 4 bytes for values > 65535 -4. **Hexadecimal**: Values can be specified in hex (0x prefix) or decimal. +4. **Output Width**: outputWidth = "auto" (default) sizes the register from the value as above. "1", "2" or "4" reserves exactly that many bytes, so a small constant set to "4" gets a whole 32-bit register and is never packed with another constant. A width smaller than the value needs is a SysConfig error. The instruction is still LDI for values <= 0xFFFF (it zero-extends into the full register) and LDI32 above. + +5. **Hexadecimal**: Values can be specified in hex (0x prefix) or decimal. `; } @@ -119,6 +135,7 @@ Loads an immediate constant value into a register. This is typically the startin - **Constant Value**: Enter any value from 0 to 4,294,967,295 (0xFFFFFFFF) - Can be entered in decimal or hexadecimal format - Block automatically determines optimal instruction +- **Output Width**: Auto (default), 1, 2 or 4 bytes. Auto sizes the register from the value; 4 reserves a full 32-bit register even for a small value ### Technical Details (Additional Information) **Generated Assembly** (auto-selected based on value size): @@ -200,6 +217,18 @@ exports = { } } }, + { + name: "outputWidth", + displayName: "Output Width", + description: "Register bytes reserved for the output. Auto sizes from the constant value.", + default: "auto", + options: [ + { name: "auto", displayName: "Auto (from value)" }, + { name: "1", displayName: "One byte" }, + { name: "2", displayName: "Two bytes" }, + { name: "4", displayName: "Four bytes (full register)" }, + ], + }, { name: "opCode", default: "LDI", @@ -211,6 +240,11 @@ exports = { hidden: true, getValue: (inst) => { let bytes = getNumOfBytes(inst["constant1"]); + if(inst["outputWidth"] !== "auto") + { + // explicit width; a value that does not fit is reported by validate() + return Math.max(bytes == 3 ? 4 : bytes, parseInt(inst["outputWidth"])); + } if(bytes == 3) { //storing in 4 continous bytes to number of instructions to access @@ -271,4 +305,4 @@ exports = { }] }, }, -} \ No newline at end of file +} diff --git a/.metadata/sysconfig/.meta/pru_blocks/program_control/conditional_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/program_control/conditional_block.syscfg.js index e93f8ae..8adb492 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/program_control/conditional_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/program_control/conditional_block.syscfg.js @@ -1,4 +1,11 @@ function validate(inst, report) { + // "_TRUE"/"_FALSE" branch labels are derived from $name; only the derived forms are checked (a reserved base such as c0 still assembles as c0_TRUE — probed with clpru). + if (typeof system !== "undefined" && system.getScript) { + var reservedNames = system.getScript("/pru_blocks/common/pru_reserved_names.js"); + if (reservedNames && typeof reservedNames.validateDerivedSymbol === "function") { + reservedNames.validateDerivedSymbol(inst, report, "$name", ["_TRUE", "_FALSE"], "This block emits '_TRUE' and '_FALSE' branch labels derived from the instance name."); + } + } for(let iterator = 1; iterator <= inst["numOfInputPorts"]; iterator++) { if(inst["input"+iterator.toString()].length == 0) @@ -9,12 +16,12 @@ function validate(inst, report) { //verifying if true port is connected true/false port(conditional input) are not if((inst["T"].length == 0)) { - report.logWarning("t_next port is not connected",inst); + report.logError("t_next port is not connected: the generated code branches to _TRUE, which would be an undefined symbol",inst); } //verifying if false port is connected true/false port(conditional input) are not if((inst["F"].length == 0)) { - report.logWarning("f_next port is not connected",inst); + report.logError("f_next port is not connected: the generated code falls through to _FALSE, which would be an undefined symbol",inst); } } @@ -165,7 +172,7 @@ Implements conditional logic (IF/ELSE statements) to control program flow based ### Usage Notes - Both input ports must be connected for conditional operation -- Both t_next and f_next ports should be connected to avoid warnings +- Both t_next and f_next ports must be connected: an unconnected branch is a validation error (it would otherwise assemble to an undefined cmp_TRUE/cmp_FALSE label) - Comparisons are unsigned - values treated as positive integers - The conditional check happens instantly (1 cycle) - Code on both branches is generated, only one path executes at runtime @@ -311,4 +318,4 @@ exports = { }] }, }, -} \ No newline at end of file +} diff --git a/.metadata/sysconfig/.meta/pru_blocks/program_control/flow_control_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/program_control/flow_control_block.syscfg.js index 37fc906..2083910 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/program_control/flow_control_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/program_control/flow_control_block.syscfg.js @@ -235,4 +235,4 @@ exports = { }] }, }, -} \ No newline at end of file +} diff --git a/.metadata/sysconfig/.meta/pru_blocks/program_control/group_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/program_control/group_block.syscfg.js index bb6aa85..5343596 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/program_control/group_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/program_control/group_block.syscfg.js @@ -1,4 +1,11 @@ function validate(inst, report) { + // the "_start" entry label is derived from groupName; only the derived form is checked (a reserved base such as c0 still assembles as c0_start — probed with clpru). + if (typeof system !== "undefined" && system.getScript) { + var reservedNames = system.getScript("/pru_blocks/common/pru_reserved_names.js"); + if (reservedNames && typeof reservedNames.validateDerivedSymbol === "function") { + reservedNames.validateDerivedSymbol(inst, report, "groupName", ["_start"], "The group entry label '_start' is derived from this name."); + } + } // Validate that group name is non-empty if (!inst.groupName || inst.groupName.trim() === "") { report.logError("Group name cannot be empty", inst); @@ -428,4 +435,4 @@ exports = { }] }, }, -} \ No newline at end of file +} diff --git a/.metadata/sysconfig/.meta/pru_blocks/program_control/loop_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/program_control/loop_block.syscfg.js index 4d428dd..3db5b88 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/program_control/loop_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/program_control/loop_block.syscfg.js @@ -1,3 +1,30 @@ +// normalize loop_block.accumulators to {pairs, error}. Accepts an +// array of {preInit, update} (or a JSON string of one); anything else is a +// validation error. Malformed entries are ignored by the register allocator; +// validate() below rejects them so a typo can never silently disable the +// accumulator. +function parseLoopAccumulators(value) { + let list = value; + if (list === undefined || list === null) return { pairs: [], error: null }; + if (typeof list === "string") { + if (list.trim() === "") return { pairs: [], error: null }; + try { + list = JSON.parse(list); + } catch (e) { + return { pairs: [], error: "is not valid JSON (" + e.message + ")" }; + } + } + if (!Array.isArray(list)) return { pairs: [], error: "must be an array of {preInit, update} objects" }; + const pairs = []; + for (const entry of list) { + if (!entry || typeof entry.preInit !== "string" || typeof entry.update !== "string") { + return { pairs: [], error: "every entry must be a {preInit: \"\", update: \"\"} object" }; + } + pairs.push({ preInit: entry.preInit, update: entry.update }); + } + return { pairs: pairs, error: null }; +} + function validate(inst, report) { for(let iterator = 1; iterator <= inst["numOfInputPorts"]; iterator++) { @@ -10,7 +37,66 @@ function validate(inst, report) { // Validate loop count - PRU LOOP instruction supports up to 16-bit counters (max 65535) if(inst["infiniteLoop"] === false && inst["loopCount"] > 65535) { - report.logError("Loop count cannot exceed 65,535. Use nested Loop blocks for higher iteration counts.", inst, "loopCount"); + report.logError("Loop count cannot exceed 65,535. Split the work into sequential finite loops; PRU hardware LOOP instructions cannot nest.", inst, "loopCount"); + } + + /* + * The PRU has one hardware LOOP context. A LOOP emitted by a child + * overwrites its enclosing Loop's end address/counter, so the enclosing + * body falls out after its first pass. Walk through Group containers as + * well: a Delay hidden in a Group is still emitted physically in this + * Loop's body. + * + * Only a finite Loop emits LOOP (an infinite one emits QBA), and children + * listed in preInitBlocks are hoisted ahead of the LOOP, so both are exempt. + * + * Keep this capability-based rather than a name/path check. Delay marks + * itself below, and any future block that emits the hardware instruction + * can opt in with the same hidden configurable. + */ + function findNestedHardwareLoop(contents, seen, skipNames) { + for (const child of (contents || [])) { + if (!child || seen.indexOf(child) !== -1) continue; + if (skipNames && skipNames.has(child.$name)) continue; + seen.push(child); + if (child.emitsHardwareLoop === true) return child; + const nested = findNestedHardwareLoop(child.$groupContents, seen, skipNames); + if (nested) return nested; + } + return null; + } + // An infinite Loop emits QBA, not LOOP, and preInitBlocks are hoisted out + // of the body ahead of the LOOP, so neither can clobber the context. + const nestedHardwareLoop = inst["infiniteLoop"] === false + ? findNestedHardwareLoop(inst.$groupContents, [], new Set(inst["preInitBlocks"] || [])) + : null; + if (nestedHardwareLoop) { + report.logError( + `PRU hardware LOOP instructions cannot nest: ${nestedHardwareLoop.$name || "a block"} emits LOOP inside ${inst.$name || "this Loop"}. Move it outside the Loop or unroll the body.`, + inst, + "$groupContents"); + } + + // explicit accumulator bindings and exported result blocks. + const memberNames = new Set(((inst.$groupContents) || []).map((b) => (b && b.$name))); + for (const name of (inst.exportedBlocks || [])) { + if (!memberNames.has(name)) { + report.logError("Exported block '" + name + "' is not inside this loop. exportedBlocks must name blocks from this loop's contents.", inst, "exportedBlocks"); + } + } + const accumulators = parseLoopAccumulators(inst.accumulators); + if (accumulators.error) { + report.logError("Invalid accumulator bindings: " + accumulators.error + " Expected [{preInit: \"\", update: \"\"}].", inst, "accumulators"); + } + for (const pair of accumulators.pairs) { + if (!memberNames.has(pair.preInit)) { + report.logError("Accumulator preInit block '" + pair.preInit + "' is not inside this loop.", inst, "accumulators"); + } else if (!((inst.preInitBlocks) || []).includes(pair.preInit)) { + report.logError("Accumulator preInit block '" + pair.preInit + "' must also be listed in Pre-Initialization Blocks so its value is set once before the loop instead of on every iteration.", inst, "accumulators"); + } + if (!memberNames.has(pair.update)) { + report.logError("Accumulator update block '" + pair.update + "' is not inside this loop.", inst, "accumulators"); + } } } @@ -53,6 +139,8 @@ const loop_block1 = loop_block.addInstance(); | infiniteLoop | Boolean | true, false | false | Enable infinite loop mode | | loopCount | Integer | 1-65535 (0x1-0xFFFF) | 1 | Number of iterations (hidden if infiniteLoop=true) | | preInitBlocks | Array | Block $name values inside loop | [] | Blocks to execute once before loop starts | +| exportedBlocks | Array | Block $name values inside loop | [] | Blocks whose final-iteration output may be read outside the loop | +| accumulators | String (JSON) | [{"preInit": "k", "update": "a"}] | "" | Accumulator bindings coalescing each update block with its preInit register | | $size | Array | [width, height] | [500, 250] | Size of the loop container in pixels | ### Example Configurations @@ -80,6 +168,20 @@ loop_block1.loopCount = 10; loop_block1.preInitBlocks = ["If_Else_0", "Load_Constant_1", "PRU_GPI_0"]; \\\`\\\`\\\` +|**Loop-carried accumulator (repeat-add):** +\\\`\\\`\\\`javascript +// k_acc is set once before the loop; acc adds x into the SAME register each pass +loop_block1.$name = "Loop_0"; +loop_block1.loopCount = 10; +loop_block1.$groupContents = [k_acc, acc]; +loop_block1.preInitBlocks = ["k_acc"]; +loop_block1.accumulators = "[{\\"preInit\\": \\"k_acc\\", \\"update\\": \\"acc\\"}]"; +// The store sits AFTER the loop and reads the final sum +loop_block1.exportedBlocks = ["acc"]; +scripting.connect(wr, "input1", acc, "output1"); +scripting.connect(lp, "next", wr, "prev"); +\\\`\\\`\\\` + ### Adding Blocks Inside the Loop \\\`\\\`\\\`javascript @@ -156,7 +258,7 @@ scripting.connect(loop_block1, "next", next_block, "prev"); 1. **Container Block**: Loop is a container - use $groupContents to add blocks inside. -2. **Max Iterations**: Maximum loop count is 65,535 (16-bit limit). Use nested loops for more. +2. **Max Iterations**: Maximum loop count is 65,535 (16-bit limit). For more work, use sequential finite loops or host scheduling; PRU hardware loops cannot nest. 3. **Infinite Loop**: When infiniteLoop=true, the next port is hidden and loop never exits. @@ -164,7 +266,7 @@ scripting.connect(loop_block1, "next", next_block, "prev"); 5. **Loop Overhead**: Fixed loops add 2-3 cycles overhead plus 2 cycles per iteration. -6. **Nested Loops**: Place a Loop block inside another Loop for nested iteration. +6. **No hardware-loop nesting**: A Loop body must not contain another hardware Loop or a Delay whose count is greater than one. SysConfig rejects this because the PRU has only one hardware LOOP context. `; } @@ -198,6 +300,9 @@ Repeats a sequence of blocks multiple times. This is a container block that exec - Select blocks whose instructions should execute ONCE before the loop starts, not on every iteration - Useful for initializing accumulators, counters, or one-time setup operations - Selected blocks are still inside the loop visually, but their code is moved outside +- A pre-initialized value keeps its value on every iteration. It changes only in the accumulator pattern: the value is updated in place only when it has exactly ONE data reader, that reader is a single-instruction Arithmetic or Bitwise block, and the allocator places that block's result starting at the value's own register byte, so the result is stored back there and counts up each pass. In every other case (more than one reader, a different kind of reader, or a result placed anywhere else) the pre-initialized value stays constant for the whole loop. +- Declare that pattern explicitly with Accumulator Bindings (\`accumulators = "[{\\"preInit\\": \\"k_acc\\", \\"update\\": \\"acc\\"}]"\`): this is a declared, checked contract that requires the update to reuse the preInit register, so the update reads and writes the same register on every pass (for example \`ADD R0.w2, R0.w2, R0.w0\`) with zero extra cycles per iteration. The underlying single-reader coalescing is the same one used for pre-initialized values; the explicit binding additionally rejects invalid shapes loudly. The preInit block must also be listed in Pre-Initialization Blocks, both blocks must sit inside this loop, and the preInit value must have exactly one data reader (the update block); anything else is a build error, never a silent recompute. +- Let blocks after the loop read a final-iteration result with Exported Result Blocks (\`exportedBlocks = ["acc"]\`): a consumer downstream of the loop may read an exported block (for example a memory write storing the accumulated sum once, after the loop). A consumer outside the loop reading a block inside it that is NOT exported, or reading an export before the loop, is a validation error. **Common Use Cases for Pre-Initialization**: 1. **Accumulator Pattern**: Initialize a variable to 0 before accumulating values across iterations @@ -279,7 +384,7 @@ Blocks execute in the order they are connected via prev/next ports within the lo - Infinite loops are useful for continuous monitoring or periodic tasks - Be careful with infinite loops - they never exit - Loop counter uses a register - this register is reserved during loop execution -- Nested loops are possible (place a LOOP block inside another LOOP block) +- PRU hardware loops cannot nest. Do not place a fixed-count Loop or a Delay (count > 1) in a fixed-count Loop body; unroll the body or split it into sequential loops. An infinite Loop compiles to a branch rather than LOOP, so it may contain either, and blocks listed in preInitBlocks run once before the LOOP and are exempt. - Loop overhead is minimal (2-3 cycles setup, 2 cycles per iteration) ### Performance Calculation @@ -305,7 +410,7 @@ Where: - **Loop body**: The code/blocks executed each iteration - **Infinite loop**: Loop that never terminates (runs forever) - **Container block**: Block that contains other blocks (like a group) -- **Nested loop**: Loop inside another loop +- **Hardware-loop context**: PRU state used by the fixed-count \`LOOP\` instruction; only one is available, so fixed loops cannot nest - **Loop overhead**: Extra cycles required for loop management ---`; @@ -358,6 +463,14 @@ exports = { default: "loop", hidden : true }, + { + // Consumed by an enclosing Loop.validate(). Infinite loops use QBA, + // while finite loops consume the PRU's sole hardware LOOP context. + name: "emitsHardwareLoop", + default: true, + hidden: true, + getValue: (inst) => inst["infiniteLoop"] === false + }, { name : "loopCount", displayName: "Loop Count", @@ -379,6 +492,27 @@ exports = { })); } }, + { + name: "exportedBlocks", + displayName: "Exported Result Blocks", + description: "Blocks inside this loop whose final-iteration output may be read by blocks outside the loop (for example a memory write storing the accumulated sum once, after the loop). Without an entry here, a consumer outside the loop reading a block inside it is a container-boundary error.", + default: [], + minSelections: 0, + options: (inst) => { + // Return blocks that are inside this loop's $groupContents + const groupContents = inst.$groupContents || []; + return groupContents.map(blockInstance => ({ + name: blockInstance.$name, + displayName: blockInstance.$name + })); + } + }, + { + name: "accumulators", + displayName: "Accumulator Bindings", + description: "Loop-carried accumulator bindings as a JSON string '[{\\\"preInit\\\": \\\"\\\", \\\"update\\\": \\\"\\\"}]': the update (Arithmetic/Bitwise) block's destination register is coalesced with its preInit block's register, so the update reads and writes the same register on every pass with zero extra cycles per iteration. The preInit block must also be listed in Pre-Initialization Blocks, both blocks must sit inside this loop, and the preInit value must have exactly one data reader (the update block).", + default: "", + }, { name : "loopCountRegSize", default: 1, @@ -454,4 +588,4 @@ exports = { }] }, }, -} \ No newline at end of file +} diff --git a/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_gpi_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_gpi_block.syscfg.js index 581800c..8900185 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_gpi_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_gpi_block.syscfg.js @@ -115,7 +115,7 @@ scripting.connect(gpi1, "next", next_block, "prev"); 3. **Read-Only**: R31 is a read-only register that reflects current pin states. -4. **Single Cycle**: Reading takes only 1 PRU cycle. +4. **Cycle Count**: Reading takes 3 PRU cycles. `; } @@ -138,9 +138,11 @@ Reads digital input signals from PRU input pins and outputs the pin state to the ### Technical Details (Additional Information) **Generated Assembly**: -- AND result, R31, (1 << pin_number) ; Mask the specific bit (1 cycle) +- ZERO &result, 4 ; Clear destination register (1 cycle) +- SET result, result, pin_number ; Create single-bit mask (1 cycle) +- AND result, R31, result ; Mask the specific bit (1 cycle) -**Performance**: 1 PRU cycle +**Performance**: 3 PRU cycles **Register**: R31 is the PRU's input register - Read-only register @@ -230,11 +232,19 @@ R31 Bit 10 - Timestamp Mode: `; } +function getInstruction(inst, outputRegister) { + const reg = outputRegister.replace(/\.[bw]\d+$/, ""); + const match = String(inst.constant1).match(/1\s*<<\s*(\d+)/); + const pin = match ? parseInt(match[1]) : 0; + return `zero &${reg}, 4\n set ${reg}, ${reg}, ${pin}\n and ${reg}, R31, ${reg}`; +} + exports = { displayName: "PRU GPI", defaultInstanceName: `${PRU_USED}_GPI_INSTANCE_`, longDescription: getLongDescription(), getAIContext: getAIContext, + getInstruction: getInstruction, uiView: "graph", templates: { //need to check what can be passed as argument to template file, right now no argument is required @@ -285,22 +295,16 @@ exports = { name: "noOfCycles", displayName: "Number Of Cycles", getValue: (inst) => { - return 1; + return 3; }, - default : 1 + default : 3 }, { name: "output1Size", - default: 1, + default: 4, hidden: true, getValue: (inst) => { - - let bytes = getNumOfBytes(eval(inst["constant1"].split(",")[1].trim())); - if(bytes == 3) - { - return bytes + 1; - } - return bytes + return 4; } }, ], @@ -320,6 +324,12 @@ exports = { }, validate, moduleStatic: { + + getPinUsage: function(inst) { + const match = String(inst.constant1).match(/1\s*<<\s*(\d+)/); + return match ? [{ pin: parseInt(match[1]), dir: "GPI" }] : []; + } + , modules: function(inst) { return [{ name: "pru_register_allocator_validator", @@ -327,4 +337,4 @@ exports = { }] }, }, -} \ No newline at end of file +} diff --git a/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_gpo_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_gpo_block.syscfg.js index 6ce7c6a..bffc347 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_gpo_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_gpo_block.syscfg.js @@ -364,6 +364,12 @@ exports = { }, validate, moduleStatic: { + + getPinUsage: function(inst) { + const match = String(inst.constant1).match(/R30,\s*(\d+)/); + return match ? [{ pin: parseInt(match[1]), dir: "GPO" }] : []; + } + , modules: function(inst) { return [{ name: "pru_register_allocator_validator", @@ -371,4 +377,4 @@ exports = { }] }, }, -} \ No newline at end of file +} diff --git a/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js index 81bd34c..6f8a098 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js @@ -18,7 +18,11 @@ function getPruNumberFromContext() { return "PRU1"; } const PRU_USED = getPruNumberFromContext(); +// CS Setup/Hold Time are nanoseconds; the macros take PRU cycles. One shared conversion. +const spiTiming = system.getScript("/pru_blocks/common/pru_spi_timing.js"); function validate(inst, report) { + spiTiming.validateCsTimes(inst, report); + spiTiming.validateIramBudget(inst, report); // Port connection warnings for(let iterator = 1; iterator <= inst["numOfInputPorts"]; iterator++) { @@ -110,13 +114,26 @@ function getNumOfBytes(value) { return 2; } - if(value <= 24) - { - return 3; - } + // The macro clears its result with one LDI. A 17..24-bit result must + // therefore own the whole register: allocating three bytes would leave b3 + // available for a live neighbour that LDI would clobber. return 4; } +// A full variant name (not the bare "m_read_packet_spi_" prefix that +// scripts/block_control_flow.js probes with) must always carry the insert point. +const READ_VARIANT_NAME = /^m_read_packet_spi_mode[0-3]_(slave_)?(msb|lsb)_/; + +function insertReadResultInitialization(macroBody, dataReg, macroName) +{ + const insertPoint = /(\n\tldi\s+[^,\n]+,\s+[^\n]+\nREAD_BIT_LOOP\?:)/; + const initialized = macroBody.replace(insertPoint, "\n\tldi " + dataReg + ", 0$1"); + if (initialized === macroBody && READ_VARIANT_NAME.test(macroName || "")) { + throw new Error("pru_spi_read: could not find READ_BIT_LOOP initialization point"); + } + return initialized; +} + /** * Returns the body of a specific macro based on the macro name with parameters replaced * @param {string} pruInstructionMacro - The macro instruction string that contains the macro name and parameters @@ -169,8 +186,8 @@ function getMacro(pruInstructionMacro, opCode) { ; DELAY_COMPEN_1 - High pulse delay compensation (PRU cycles) ; DELAY_COMPEN_2 - Low pulse delay compensation (PRU cycles) ; CS_PIN - Chip select output pin number (GPO) - ; CS_SETUP_TIME - Setup time after CS assertion (PRU cycles) - ; CS_HOLD_TIME - Hold time before CS deassertion (PRU cycles) + ; CS_SETUP_TIME - Setup time after CS assertion (PRU cycles; the block converts its nanosecond setting, rounding up) + ; CS_HOLD_TIME - Hold time before CS deassertion (PRU cycles; the block converts its nanosecond setting, rounding up) ; DATA_SETUP_TIME - Data setup time after clock edge (PRU cycles) `; } @@ -701,6 +718,13 @@ SKIP_BIT_ENTRY_0?: qbne READ_BIT_LOOP?, ${bitId}, ${PACKETSIZE}`; } + // A partial packet must replace the whole destination word. The bit loop + // only writes bits [PACKETSIZE - 1:0], so clear the register once before it + // starts instead of leaving the prior value in the high bits. + if (macroName.startsWith("m_read_packet_spi_")) { + macroBody = insertReadResultInitialization(macroBody, dataReg, macroName); + } + if(pruInstructionMacro == "") { macroBody += "\n" + " .endm"; @@ -893,6 +917,7 @@ Cycles per bit: **(2+d1) + (5+d2)** ### Technical Details (Additional Information) **Performance**: +- **Result initialization:** The destination register is cleared once before sampling, so packets shorter than 32 bits are zero-extended. This adds one cycle to the transaction overhead. - Cycles per bit ≈ (high_width + low_width + data_setup_time + overhead) - Total cycles ≈ CS_setup + (packet_size × cycles_per_bit) + CS_hold @@ -1419,11 +1444,11 @@ exports = { // Cycles per bit: fixed overhead + DATA_SETUP_TIME + delay_component1 + delay_component2 const cyclesPerBit = 7 + dataSetupCycles + inst["delay_component1"] + inst["delay_component2"]; - return 1 + csSetupCycles + 1 + + return 2 + csSetupCycles + 1 + (inst["packetSize"] * cyclesPerBit) + csHoldCycles + 1; } else { - return 4 + inst["delay_component1"] + (inst["packetSize"] - 1) * (6 + inst["delay_component1"] + inst["delay_component2"]); + return 5 + inst["delay_component1"] + (inst["packetSize"] - 1) * (6 + inst["delay_component1"] + inst["delay_component2"]); } }, default : 1 @@ -1445,6 +1470,16 @@ exports = { }, validate, moduleStatic: { + + getPinUsage: function(inst) { + const isCtrl = inst["Device Mode"] === "controller"; + const pins = []; + if (inst["SCLK Signal"] !== undefined) pins.push({ pin: parseInt(inst["SCLK Signal"]), dir: isCtrl ? "GPO" : "GPI" }); + if (inst["SDI Signal"] !== undefined) pins.push({ pin: parseInt(inst["SDI Signal"]), dir: "GPI" }); + if (inst["CS Signal"] !== undefined) pins.push({ pin: parseInt(inst["CS Signal"]), dir: isCtrl ? "GPO" : "GPI" }); + return pins; + } + , modules: function(inst) { return [{ name: "pru_register_allocator_validator", @@ -1452,7 +1487,9 @@ exports = { }] }, }, - getMacro + getMacro, + // Kept private to this module's regression harness; production callers use getMacro. + _testInsertReadResultInitialization: insertReadResultInitialization } /* @@ -1466,4 +1503,4 @@ notes: 3 + delay component2 + 1 to set 1 + delay component1 + 1 to clear -*/ \ No newline at end of file +*/ diff --git a/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_transfer.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_transfer.syscfg.js index 7b95e68..e38e104 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_transfer.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_transfer.syscfg.js @@ -18,7 +18,11 @@ function getPruNumberFromContext() { return "PRU1"; } const PRU_USED = getPruNumberFromContext(); +// CS Setup/Hold Time are nanoseconds; the macros take PRU cycles. One shared conversion. +const spiTiming = system.getScript("/pru_blocks/common/pru_spi_timing.js"); function validate(inst, report) { + spiTiming.validateCsTimes(inst, report); + spiTiming.validateIramBudget(inst, report); // Port connection warnings for(let iterator = 1; iterator <= inst["numOfInputPorts"]; iterator++) { @@ -136,13 +140,24 @@ function getNumOfBytes(value) { return 2; } - if(value <= 24) - { - return 3; - } + // r_dataReg is initialized by a whole-register LDI for packets wider than + // 16 bits, so reserve all four bytes rather than clobbering a live b3. return 4; } +function insertTransferResultInitialization(macroBody, r_dataReg, headerLength) +{ + // The clear goes INSIDE the macro: after the ".macro" header and parameter + // comments when the header form is requested, at the very start of the body + // for the inline form. headerLength is the number of characters that belong + // to the header (0 for the inline form). + // It cannot destroy the transmit word: the register allocator never gives a + // macro the register of an input it has not finished reading (macros are not + // in IN_PLACE_SAFE_OPCODES), so r_dataReg and s_dataReg are disjoint. + return macroBody.slice(0, headerLength) + "\tldi " + r_dataReg + ", 0\n" + + macroBody.slice(headerLength); +} + /** * Returns the body of a specific macro based on the macro name with parameters replaced * @param {string} pruInstructionMacro - The macro instruction string that contains the macro name and parameters @@ -201,13 +216,15 @@ function getMacro(pruInstructionMacro, opCode) { ; DELAY_COMPEN_1 - High pulse delay compensation (PRU cycles) ; DELAY_COMPEN_2 - Low pulse delay compensation (PRU cycles) ; CS_PIN - Chip select output pin number (GPO) - ; CS_SETUP_TIME - Setup time after CS assertion (PRU cycles) - ; CS_HOLD_TIME - Hold time before CS deassertion (PRU cycles) + ; CS_SETUP_TIME - Setup time after CS assertion (PRU cycles; the block converts its nanosecond setting, rounding up) + ; CS_HOLD_TIME - Hold time before CS deassertion (PRU cycles; the block converts its nanosecond setting, rounding up) ; DATA_SETUP_TIME - Data setup time after clock edge (PRU cycles) `; } } + const headerLength = macroBody.length; + // Controller MODE0 MSB (CPOL=0, CPHA=0) - Clock idles LOW, sample on rising, shift on falling if (macroName === "m_transfer_packet_spi_mode0_msb_gpo_sclk") { macroBody += ` clr r30, r30, ${CS_PIN} @@ -985,6 +1002,11 @@ wait_for_low_transition_loop2?: SEND_BIT_LOOP_END?:`; } + if (macroName.startsWith("m_transfer_packet_spi_") && macroBody.length > headerLength) + { + macroBody = insertTransferResultInitialization(macroBody, r_dataReg, headerLength); + } + if(pruInstructionMacro == "") { macroBody += "\n" + " .endm"; @@ -1675,12 +1697,12 @@ exports = { // Based on m_transfer_packet_spi_master_gpo_sclk: ~ (4+d1) + (6+d2) cycles per bit const cyclesPerBit = 10 + dataSetupCycles + inst["delay_component1"] + inst["delay_component2"]; - return 1 + csSetupCycles + 1 + + return 2 + csSetupCycles + 1 + (inst["packetSize"] * cyclesPerBit) + csHoldCycles + 1; } else { // Peripheral mode: 13 cycles per bit (6 shifting + 7 sampling) - return 6 + inst["CS Filter Cycles"] + (inst["packetSize"] * 13); + return 7 + inst["CS Filter Cycles"] + (inst["packetSize"] * 13); } }, default : 1 @@ -1702,6 +1724,17 @@ exports = { }, validate, moduleStatic: { + + getPinUsage: function(inst) { + const isCtrl = inst["Device Mode"] === "controller"; + const pins = []; + if (inst["SCLK Signal"] !== undefined) pins.push({ pin: parseInt(inst["SCLK Signal"]), dir: isCtrl ? "GPO" : "GPI" }); + if (inst["SDI Signal"] !== undefined) pins.push({ pin: parseInt(inst["SDI Signal"]), dir: "GPI" }); + if (inst["SDO Signal"] !== undefined) pins.push({ pin: parseInt(inst["SDO Signal"]), dir: "GPO" }); + if (inst["CS Signal"] !== undefined) pins.push({ pin: parseInt(inst["CS Signal"]), dir: isCtrl ? "GPO" : "GPI" }); + return pins; + } + , modules: function(inst) { return [{ name: "pru_register_allocator_validator", diff --git a/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_write.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_write.syscfg.js index c1ac2e8..3c52f05 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_write.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_write.syscfg.js @@ -18,7 +18,11 @@ function getPruNumberFromContext() { return "PRU1"; } const PRU_USED = getPruNumberFromContext(); +// CS Setup/Hold Time are nanoseconds; the macros take PRU cycles. One shared conversion. +const spiTiming = system.getScript("/pru_blocks/common/pru_spi_timing.js"); function validate(inst, report) { + spiTiming.validateCsTimes(inst, report); + spiTiming.validateIramBudget(inst, report); // Port connection warnings for(let iterator = 1; iterator <= inst["numOfInputPorts"]; iterator++) { @@ -165,8 +169,8 @@ function getMacro(pruInstructionMacro, opCode) { ; DELAY_COMPEN_1 - High pulse delay compensation (PRU cycles) ; DELAY_COMPEN_2 - Low pulse delay compensation (PRU cycles) ; CS_PIN - Chip select output pin number (GPO) - ; CS_SETUP_TIME - Setup time after CS assertion (PRU cycles) - ; CS_HOLD_TIME - Hold time before CS deassertion (PRU cycles) + ; CS_SETUP_TIME - Setup time after CS assertion (PRU cycles; the block converts its nanosecond setting, rounding up) + ; CS_HOLD_TIME - Hold time before CS deassertion (PRU cycles; the block converts its nanosecond setting, rounding up) ; DATA_SETUP_TIME - Data setup time after clock edge (PRU cycles) `; } @@ -1466,6 +1470,16 @@ exports = { }, validate, moduleStatic: { + + getPinUsage: function(inst) { + const isCtrl = inst["Device Mode"] === "controller"; + const pins = []; + if (inst["SCLK Signal"] !== undefined) pins.push({ pin: parseInt(inst["SCLK Signal"]), dir: isCtrl ? "GPO" : "GPI" }); + if (inst["SDO Signal"] !== undefined) pins.push({ pin: parseInt(inst["SDO Signal"]), dir: "GPO" }); + if (inst["CS Signal"] !== undefined) pins.push({ pin: parseInt(inst["CS Signal"]), dir: isCtrl ? "GPO" : "GPI" }); + return pins; + } + , modules: function(inst) { return [{ name: "pru_register_allocator_validator", @@ -1487,4 +1501,4 @@ notes: 2 + delay component2 + 1 to set 3 + delay component1 + 1 to clear -*/ \ No newline at end of file +*/ diff --git a/.metadata/sysconfig/.meta/pru_blocks/utils/access_look_up_table.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/utils/access_look_up_table.syscfg.js index 8e0e7b6..5865730 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/utils/access_look_up_table.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/utils/access_look_up_table.syscfg.js @@ -89,8 +89,23 @@ function getMacro(pruInstructionMacro, opCode) { `; } if (macroName && macroName.trim().startsWith("m_lookup_table")) { + // the index counts entries, the LBBO offset counts bytes. Entries of + // 2/4 bytes need the index scaled (<< 1 / << 2) before it is used, and the + // scaled offset (up to 4 * (tableSize - 1)) does not fit an LBBO register + // offset, so it is added into the base and the LBBO offset is 0. byteSize is + // a literal 1, 2 or 4 at expansion, so the .if picks the form at assembly time. macroBody += ` LDI32 TEMP_REG1, ${lutAddress} - LBBO &${result}, TEMP_REG1, ${index}, ${byteSize}`; + .if ${byteSize} == 1 + LBBO &${result}, TEMP_REG1, ${index}, ${byteSize} + .else + .if ${byteSize} == 2 + LSL TEMP_REG2, ${index}, 1 + .else + LSL TEMP_REG2, ${index}, 2 + .endif + ADD TEMP_REG1, TEMP_REG1, TEMP_REG2 + LBBO &${result}, TEMP_REG1, 0, ${byteSize} + .endif`; } if (pruInstructionMacro == "") { @@ -160,7 +175,7 @@ scripting.connect(access_lut1, "next", next_block, "prev"); 4. **Bounds Checking**: Validation warns if constant index is out of bounds. Runtime bounds checking is not performed. -5. **Performance**: 5 PRU cycles total (2 for LDI32 + 3 for LBBO). +5. **Performance**: byte table: 5 PRU cycles (2 for LDI32 + 3 for LBBO). ushort/uint table: 7 PRU cycles (2 LDI32 + 1 LSL index scale + 1 ADD + 3 LBBO). `; } @@ -185,10 +200,15 @@ Reads data from a lookup table stored in PRU Data Memory (DMEM) using an index v ### Technical Details (Additional Information) **Generated Assembly**: - LDI32 TEMP_REG1, \`LUT_BASE_ADDRESS\` ; Load table base address (2 cycles) -- LBBO &result, TEMP_REG1, index, size ; Load data from PRU DRAM (3 cycles for ≤4 bytes) - -**Performance**: 5 PRU cycles total (assuming word-aligned access to PRU DRAM) -- LDI32: 2 cycles +- byte table: LBBO &result, TEMP_REG1, index, 1 ; Load data from PRU DRAM (3 cycles) +- ushort/uint table: the index counts entries, so it is scaled to a byte offset first: + LSL TEMP_REG2, index, 1 (ushort) or 2 (uint) ; 1 cycle + ADD TEMP_REG1, TEMP_REG1, TEMP_REG2 ; 1 cycle + LBBO &result, TEMP_REG1, 0, 2 or 4 ; 3 cycles + +**Performance** (assuming word-aligned access to PRU DRAM) +- byte table: 5 PRU cycles (LDI32 2 + LBBO 3) +- ushort/uint table: 7 PRU cycles (LDI32 2 + LSL 1 + ADD 1 + LBBO 3) - LBBO: 3 cycles (2 + N where N=1 for reading ≤4 bytes) Note: Add +1 cycle if index results in non-word-aligned address @@ -314,8 +334,14 @@ exports = { // LDI32 takes 2 cycles (loads 32-bit value as two 16-bit moves) // LBBO from PRU DRAM takes 2 + N cycles where N is number of 4-byte words // For 1, 2, or 4 bytes (all ≤4 bytes): N = 1, so LBBO takes 2 + 1 = 3 cycles - // Total: 2 (LDI32) + 3 (LBBO) = 5 cycles + // Total: 2 (LDI32) + 3 (LBBO) = 5 cycles for a byte table + // ushort/uint tables also scale the index (LSL, 1 cycle) and add it + // into the base (ADD, 1 cycle) before the LBBO: 2 + 1 + 1 + 3 = 7 cycles. // Note: This assumes word-aligned access. Add +1 if non-aligned. + const lut = getReferencedLUT(inst); + if (lut && (lut["dataType"] == "ushort" || lut["dataType"] == "uint")) { + return 7; + } return 5; } } @@ -352,4 +378,4 @@ exports = { }, getMacro, validate, -} \ No newline at end of file +} diff --git a/.metadata/sysconfig/.meta/pru_blocks/utils/delay_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/utils/delay_block.syscfg.js index 864b2ad..db97065 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/utils/delay_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/utils/delay_block.syscfg.js @@ -98,7 +98,7 @@ scripting.connect(delay1, "next", next_block, "prev"); ### Important Notes -1. **Maximum Delay**: Single Delay block limited to 255 cycles. Use Loop block for longer delays. +1. **Maximum Delay**: Single Delay block limited to 255 cycles. Do not put Delay(count > 1) inside a Loop: both emit PRU hardware LOOP. Chain Delays or split work into sequential finite loops instead. 2. **Timing**: At 200MHz PRU clock, 1 cycle = 5ns. delayCount of 200 = 1 microsecond. @@ -138,53 +138,30 @@ At 200MHz PRU clock (default on most TI devices): ### Technical Details (Additional Information) **Generated Assembly**: -- loop endloop, count - 1 ; Setup loop counter (2 cycles overhead) +- loop endloop, count - 1 ; Setup loop counter (1 cycle) - NOP ; No operation (count-1 times) - endloop: ; Loop end label -**Total Cycles**: count + 2 (2 cycle overhead for loop setup) +**Total Cycles**: count (the block takes exactly count cycles, matching noOfCycles) ### Usage Notes - Maximum delay per block: 255 cycles (hardware limitation) -- Minimum delay is 1 cycle (+ 2 overhead = 3 total) +- Minimum delay is 1 cycle (single NOP) - This is a **pass-through block** - connects input to output without modification ### For Delays Greater Than 255 Cycles -Since the Delay block is limited to 255 cycles maximum, use the **Loop block** for longer delays: +One Delay block waits at most 255 cycles. For longer waits: -**Method 1: Loop with single Delay block** +- **Chain Delay blocks.** Delay(255) -> Delay(255) -> Delay(100) waits 610 cycles. 10 us at 200 MHz is 2,000 cycles, so 8 chained blocks; 100 us is about 79. +- **Put the chain in an infinite Loop** for a periodic task. An infinite Loop compiles to a branch, not the hardware LOOP instruction, so Delay blocks of any length are legal inside it. +- **Use a software counter loop** (an Arithmetic block that decrements a counter and a Conditional block that branches back while it is non-zero) for very long waits. -Loop (count: N) → Delay (255 cycles) - -Total delay = N × 255 cycles -Example: Loop count 10 with Delay 255 = 2,550 cycles = 12.75μs @ 200MHz - -**Method 2: Loop with multiple Delay blocks in sequence** - -Loop (count: N) → Delay (255 cycles) → Delay (255 cycles) → Delay (100 cycles) - -Total delay = N × (255 + 255 + 100) = N × 610 cycles -Example: Loop count 100 with 610 cycles = 61,000 cycles = 305μs @ 200MHz - -**Method 3: Nested loops for very long delays** - -Outer Loop (count: 1000) -└─ Inner Loop (count: 200) - └─ Delay (255 cycles) - -Total delay = 1000 × 200 × 255 = 51,000,000 cycles = 255ms @ 200MHz - -**Delay Examples**: -- **10 microseconds**: Loop(40) → Delay(50) = 2,000 cycles -- **100 microseconds**: Loop(100) → Delay(200) = 20,000 cycles -- **1 millisecond**: Loop(200) → Loop(5) → Delay(200) = 200,000 cycles -- **10 milliseconds**: Loop(1000) → Loop(10) → Delay(200) = 2,000,000 cycles +Do not wrap a Delay longer than one cycle in a finite Loop: both emit the PRU hardware LOOP instruction, and the PRU has only one hardware-loop context, so SysConfig rejects it. Delay(1) is a plain NOP and is allowed inside any Loop. **Important Notes**: -- Loop overhead adds 2-3 cycles per iteration -- For precise timing, account for loop setup overhead -- Inner loop body executes sequentially (prev to next connections) +- Each Delay(count > 1) uses PRU hardware LOOP setup (1 cycle) plus count - 1 NOP iterations, totaling exactly count cycles matching noOfCycles +- For precise timing, check the generated cycle budget - Use simulation to verify exact cycle counts ### Terminology @@ -192,7 +169,7 @@ Total delay = 1000 × 200 × 255 = 51,000,000 cycles = 255ms @ 200MHz - **Cycle**: One tick of the PRU clock - **NOP**: No Operation - instruction that does nothing but consume time - **Loop overhead**: Extra cycles required for loop setup/teardown -- **Nested loops**: Loop block inside another Loop block for longer delays +- **Hardware-loop context**: PRU state used by the \`LOOP\` instruction; only one is available, so it cannot nest ---`; } @@ -232,7 +209,7 @@ exports = { default: 1, range: [1, 255], }, - { + { name: "opCode", displayName: "m_wait_cycles", default: "m_wait_cycles", @@ -245,6 +222,14 @@ exports = { }, hidden: true }, + { + // Consumed by Loop.validate(). A one-cycle Delay is a NOP and does + // not allocate the PRU's single hardware LOOP context. + name: "emitsHardwareLoop", + default: false, + hidden: true, + getValue: (inst) => inst["delayCount"] > 1 + }, { name: "numOfInputPorts", default: 0, diff --git a/.metadata/sysconfig/.meta/pru_blocks/utils/look_up_table.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/utils/look_up_table.syscfg.js index c040dfe..61d37e5 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/utils/look_up_table.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/utils/look_up_table.syscfg.js @@ -23,6 +23,13 @@ function getDataSize(instance) { /*to do: figure out a way for the user to input large number of values without manually entering everything*/ function validate(inst, report) { + // $name is emitted as the table symbol (" .byte/.ushort/.uint ..." in .initdmem); reject clpru reserved identifiers via the shared helper. + if (typeof system !== "undefined" && system.getScript) { + var reservedNames = system.getScript("/pru_blocks/common/pru_reserved_names.js"); + if (reservedNames && typeof reservedNames.validateInstanceName === "function") { + reservedNames.validateInstanceName(inst, report, "This block emits the instance name as the lookup-table symbol in .initdmem."); + } + } if (inst["tableSize"] < 1 || inst["tableSize"] > 65536) { report.logError("Table size must be between 1 and 65536", inst); } @@ -840,4 +847,4 @@ Then click "Browse For JSON File" button above to select it.`; getMacro, validate, getLookUptable, -} \ No newline at end of file +} diff --git a/.metadata/sysconfig/.meta/pru_blocks/utils/memory_access_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/utils/memory_access_block.syscfg.js index 7b87d9a..3c4427c 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/utils/memory_access_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/utils/memory_access_block.syscfg.js @@ -57,6 +57,22 @@ function getMemoryReserveSize(symbolName) { return -1; // Symbol not found } +/** + * Other Memory Access instances that resolve to the SAME auto-created memVar + * object as this one (neither picks a different symbol via symbolSelect). + * Setting labelName through any one of them renames the variable for all of + * them.. + */ +function getSharedMemVarPeers(inst) { + if (!inst.memVar || (inst.symbolSelect && inst.symbolSelect !== "")) return []; + const accessModule = system.modules["/pru_blocks/utils/memory_access_block"]; + if (!accessModule || !accessModule.$instances) return []; + return accessModule.$instances.filter(other => + other !== inst && + other.memVar === inst.memVar && + !(other.symbolSelect && other.symbolSelect !== "")); +} + function validate(inst, report) { // Check input port connections for write mode if (inst["operationMode"] === "write") { @@ -71,6 +87,17 @@ function validate(inst, report) { return; } + // Two blocks on one shared memVar object cannot hold different names. + const sharedPeers = getSharedMemVarPeers(inst); + if (sharedPeers.length > 0) { + const names = sharedPeers.map(p => p.$name).join(", "); + report.logWarning( + `Shares one Memory Variable ('${referencedMemVar.labelName}') with ${names}. Setting labelName through any one block's memVar renames the variable for every block that shares it. Use separate Memory Variable instances and select them with symbolSelect`, + inst, + "symbolSelect" + ); + } + // Validate data size if (inst["dataSize"] < 1 || inst["dataSize"] > 8) { report.logError("Data size must be between 1 and 8 bytes", inst, "dataSize"); @@ -364,6 +391,34 @@ Result: Writes 16 bytes to largeBuffer[0-15] using multiple registers - Input 1: data (value to store) - Input 2: offset (only in Register Input mode) +### Two buffers: use explicit Memory Variable instances + +Each Memory Access block's nested memVar is ONE shared object. Setting labelName through one block renames it for every block sharing it, so the second assignment silently wins and the first buffer is never reserved: + +\\\`\\\`\\\`javascript +// WRONG: both blocks end up on out_buf, in_buf is never reserved +rd.memVar.labelName = "in_buf"; +wr.memVar.labelName = "out_buf"; +\\\`\\\`\\\` + +Create one Memory Variable instance per buffer and assign it by reference: + +\\\`\\\`\\\`javascript +const memory_reserve = scripting.addModule("/pru_blocks/utils/memory_variable_block", {}, false); +const in_buf = memory_reserve.addInstance(); +in_buf.$name = "In_Buf"; +in_buf.labelName = "in_buf"; +in_buf.sizeInBytes = 64; +const out_buf = memory_reserve.addInstance(); +out_buf.$name = "Out_Buf"; +out_buf.labelName = "out_buf"; +out_buf.sizeInBytes = 64; +rd.memVar = in_buf; +wr.memVar = out_buf; +\\\`\\\`\\\` + +The block raises a validation warning when two Memory Access blocks share one memVar. + ### Important Notes 1. **Symbol Resolution**: The linker resolves the actual memory address at link time. The symbol points to the memory location defined by the Memory Variable block. diff --git a/.metadata/sysconfig/.meta/pru_blocks/utils/memory_variable_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/utils/memory_variable_block.syscfg.js index 13c3721..d88ce01 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/utils/memory_variable_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/utils/memory_variable_block.syscfg.js @@ -6,6 +6,13 @@ */ function validate(inst, report) { + // labelName is emitted as a .usect reservation label; reject clpru reserved identifiers via the shared helper. + if (typeof system !== "undefined" && system.getScript) { + var reservedNames = system.getScript("/pru_blocks/common/pru_reserved_names.js"); + if (reservedNames && typeof reservedNames.validateSymbolField === "function") { + reservedNames.validateSymbolField(inst, report, "labelName", "This symbol is emitted as a .usect reservation label."); + } + } // Validate label name if (!inst.labelName || inst.labelName.trim() === "") { report.logError("Label name cannot be empty", inst, "labelName"); From e12969c90df105700beb7d23a3d0dac0c9cbf55b Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 05:43:43 +0530 Subject: [PATCH 03/44] Add SysConfig test harness and block validator runners - tests/test_syscfg_add_instance.py: every block module adds an instance through the SysConfig CLI (skipped when SysConfig is not installed) - tests/test_fail_closed_gating.py: the device-capability gate fails closed on every degraded path - tests/test_shared_memvar_rename.py, tests/test_conditional_unconnected_branch.py - scripts/static_module_validate_runner.js, scripts/block_validate_runner.js, scripts/shared_memvar_runner.js: run block validators under node --- scripts/block_validate_runner.js | 310 +++++++++++++++++++ scripts/shared_memvar_runner.js | 68 ++++ scripts/static_module_validate_runner.js | 136 ++++++++ tests/conftest.py | 7 + tests/test_conditional_unconnected_branch.py | 57 ++++ tests/test_fail_closed_gating.py | 126 ++++++++ tests/test_shared_memvar_rename.py | 53 ++++ tests/test_syscfg_add_instance.py | 86 +++++ 8 files changed, 843 insertions(+) create mode 100644 scripts/block_validate_runner.js create mode 100644 scripts/shared_memvar_runner.js create mode 100644 scripts/static_module_validate_runner.js create mode 100644 tests/conftest.py create mode 100644 tests/test_conditional_unconnected_branch.py create mode 100644 tests/test_fail_closed_gating.py create mode 100644 tests/test_shared_memvar_rename.py create mode 100644 tests/test_syscfg_add_instance.py diff --git a/scripts/block_validate_runner.js b/scripts/block_validate_runner.js new file mode 100644 index 0000000..fea62ff --- /dev/null +++ b/scripts/block_validate_runner.js @@ -0,0 +1,310 @@ +#!/usr/bin/env node +'use strict'; +/* + * block_validate_runner.js — reusable block-validator harness + * + * Generalizes scripts/static_module_validate_runner.js into a reusable + * harness that can exercise ANY block declaring validate(). + * + * That runner covers a single validator (validateDeviceCapabilities in + * pru_blocks_static_module.syscfg.js). This runner sweeps every block that + * declares validate() — see --sweep. + * + * Usage: + * node scripts/block_validate_runner.js '' + * node scripts/block_validate_runner.js --block --inst '' + * node scripts/block_validate_runner.js --sweep + * node scripts/block_validate_runner.js --report + * + * blockPath forms: + * pru_io_blocks/spi_adc_sequencer_block + * .metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/spi_adc_sequencer_block.syscfg.js + * pru_blocks_static_module (device-capability static module) + * + * instJson: JSON object for the block instance. Extra keys are ignored by + * validators that don't read them — keep red/control arms same-shape: + * only the field under test should differ. + * + * Output (single-block mode): JSON {errors:[], warnings:[], infos:[]} + * Each entry is {message, field} where field may be undefined. + * + * Output (--sweep): JSON array of {block, file, hasValidate, errorCount, warningCount} + * Output (--report): same as --sweep plus {validatorsFound, armedCount} summary + * armed = blocks whose error paths have a committed red/control pair + * in tests/test_block_validators.py + * + * Exit code 0 on success, 2 on harness error. + */ + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const ROOT = path.join(__dirname, '..'); +const BLOCKS_ROOT = path.join(ROOT, '.metadata', 'sysconfig', '.meta', 'pru_blocks'); + +// ------------------------------------------------------------------ helpers + +function makeStub() { + const fn = function () { return makeStub(); }; + return new Proxy(fn, { + get(_t, prop) { + if (prop === Symbol.toPrimitive || prop === 'toString' || prop === 'valueOf') return () => ''; + if (prop === Symbol.iterator) return undefined; + if (prop === 'then') return undefined; + return makeStub(); + }, + apply() { return makeStub(); } + }); +} + +function resolveBlockPath(blockPath) { + if (!blockPath) return null; + // Absolute or repo-relative .syscfg.js already + if (blockPath.endsWith('.syscfg.js') && fs.existsSync(blockPath)) { + return path.resolve(blockPath); + } + if (blockPath.endsWith('.syscfg.js') && fs.existsSync(path.join(ROOT, blockPath))) { + return path.join(ROOT, blockPath); + } + // Try under BLOCKS_ROOT + const candidates = [ + path.join(BLOCKS_ROOT, blockPath + '.syscfg.js'), + path.join(BLOCKS_ROOT, blockPath), + path.join(BLOCKS_ROOT, 'common', blockPath + '.syscfg.js'), + path.join(ROOT, blockPath), + ]; + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + // Fallback: search by basename + const base = path.basename(blockPath).replace(/\.syscfg\.js$/, ''); + const found = findAllBlockFiles().find(f => path.basename(f, '.syscfg.js') === base); + if (found) return found; + return null; +} + +function findAllBlockFiles() { + const out = []; + function walk(dir) { + if (!fs.existsSync(dir)) return; + for (const name of fs.readdirSync(dir)) { + const p = path.join(dir, name); + const st = fs.statSync(p); + if (st.isDirectory()) walk(p); + else if (p.endsWith('.syscfg.js')) out.push(p); + } + } + walk(BLOCKS_ROOT); + return out.sort(); +} + +function analyzeValidateSource(src) { + const hasValidate = /function\s+validate\s*\(|validate\s*:\s*validate|validate\s*\(/.test(src); + const errorCount = (src.match(/logError/g) || []).length; + const warningCount = (src.match(/logWarning/g) || []).length; + // Count distinct error branches — each logError is an error path + return { hasValidate, errorCount, warningCount }; +} + +// evaluate a pure shared helper (no `system` dependency) in a bare +// vm and return its `exports`, mirroring what SysConfig's getScript returns. +const _sharedHelperCache = {}; +function loadSharedHelper(filePath) { + if (_sharedHelperCache[filePath]) return _sharedHelperCache[filePath]; + const src = fs.readFileSync(filePath, 'utf8'); + const sandbox = { + exports: {}, + module: { exports: {} }, + console: { log() {}, error() {}, warn() {} }, + }; + sandbox.global = sandbox; + vm.createContext(sandbox); + vm.runInContext(src, sandbox, { filename: filePath, timeout: 5000 }); + const mod = (sandbox.exports && Object.keys(sandbox.exports).length) ? sandbox.exports + : (sandbox.module.exports && Object.keys(sandbox.module.exports).length) ? sandbox.module.exports + : sandbox.exports; + _sharedHelperCache[filePath] = mod; + return mod; +} + +function loadBlockModule(filePath) { + const src = fs.readFileSync(filePath, 'utf8'); + + // Mock system sufficient for pure field validators; for validators that + // inspect system.modules we provide an empty registry so duplicate-name + // checks see no collisions (the legal control passes). + const mockSystem = { + deviceData: { device: 'AM243x_ALV_beta', package: 'ALV' }, + modules: {}, + getOS: () => 'linux', // look_up_table.syscfg.js branches on this at load + getScript: (p) => { + if (p.includes('pru_device_caps')) { + try { return require(path.join(BLOCKS_ROOT, 'common', 'pru_device_caps.js')); } catch (_) { return makeStub(); } + } + // reserved-name helper is pure (no `system` use) — load + // it for real so block validate() calls exercise the real list. + if (String(p).includes('pru_reserved_names')) { + try { return loadSharedHelper(path.join(BLOCKS_ROOT, 'common', 'pru_reserved_names.js')); } catch (_) { return makeStub(); } + } + if (p === '/common' || p === 'common' || (typeof p === 'string' && p.includes('/common'))) { + return { getSocName: () => 'am243x', getSelfSysCfgCoreName: () => 'r5fss0-0' }; + } + return makeStub(); + }, + contexts: {}, + deviceData2: {}, + }; + + const sandbox = { + system: mockSystem, + exports: {}, + module: { exports: {} }, + console: { log() {}, error() {}, warn() {} }, + require: () => makeStub(), + }; + sandbox.global = sandbox; + vm.createContext(sandbox); + vm.runInContext(src, sandbox, { filename: filePath, timeout: 5000 }); + const mod = (sandbox.exports && Object.keys(sandbox.exports).length) ? sandbox.exports + : (sandbox.module.exports && Object.keys(sandbox.module.exports).length) ? sandbox.module.exports + : sandbox.exports; + return { mod, src, mockSystem }; +} + +function runValidate(blockPath, inst) { + const file = resolveBlockPath(blockPath); + if (!file) { + console.error(`Block not found: ${blockPath}`); + process.exit(2); + } + const { mod } = loadBlockModule(file); + // Support both exports.validate and exports being the module directly + const validate = mod.validate || (mod.exports && mod.exports.validate) || null; + if (!validate) { + // Check if it's the static module with validateDeviceCapabilities + if (mod.validateDeviceCapabilities) { + console.error(`Block ${blockPath} exposes validateDeviceCapabilities, not validate — use static_module_validate_runner.js or pass --device-cap`); + process.exit(2); + } + console.error(`validate not found in ${file}`); + process.exit(2); + } + const errors = []; + const warnings = []; + const infos = []; + const report = { + logError: (msg, _inst, field) => errors.push({ message: String(msg), field: field || null }), + logWarning: (msg, _inst, field) => warnings.push({ message: String(msg), field: field || null }), + logInfo: (msg, _inst, field) => infos.push({ message: String(msg), field: field || null }), + }; + // Inject system if validator expects it via closure — already mocked in sandbox, + // but some validators call system.modules directly via global system. + // The vm's global system is already set; for Node-side calls we also expose it. + const prevSystem = global.system; + // eslint-disable-next-line no-undef + try { global.system = loadBlockModule(file).mockSystem; } catch (_) {} + try { + validate(inst, report); + } catch (e) { + // Keep red results red — a validator that throws is a failure, not a silent pass + errors.push({ message: `validator threw: ${e.message}`, field: null }); + } finally { + if (prevSystem !== undefined) global.system = prevSystem; + else delete global.system; + } + return { file, errors, warnings, infos }; +} + +// ------------------------------------------------------------------ CLI + +function printSweep(asReport) { + const files = findAllBlockFiles(); + const rows = []; + for (const file of files) { + const src = fs.readFileSync(file, 'utf8'); + const a = analyzeValidateSource(src); + if (!a.hasValidate) continue; + const rel = path.relative(ROOT, file).replace(/\\/g, '/'); + const block = path.basename(file, '.syscfg.js'); + rows.push({ + block, + file: rel, + hasValidate: a.hasValidate, + errorCount: a.errorCount, + warningCount: a.warningCount, + }); + } + if (!asReport) { + console.log(JSON.stringify(rows, null, 2)); + return; + } + // For --report we also emit armed summary based on committed test arms + // The committed armed set lives in tests/test_block_validators.py:ARMED_BLOCKS + // We duplicate the list here so the runner can report without importing python. + // Keep in sync — if you arm a new block, add it to both places. + const ARMED_BLOCKS = [ + 'spi_adc_sequencer_block', // sample_period + ]; + const validatorsFound = rows.length; + const armedCount = rows.filter(r => ARMED_BLOCKS.includes(r.block)).length; + // Also count error paths found vs armed paths — for now 1 path per armed block starter + const errorPathsFound = rows.reduce((s, r) => s + r.errorCount, 0); + // Armed error paths: we know sample_period is 1 of 3 paths in spi_adc_sequencer_block + const ARMED_ERROR_PATHS = 1; + console.log(JSON.stringify({ + validators: rows, + summary: { + validatorsFound, + validatorsArmed: armedCount, + validatorsUnarmed: validatorsFound - armedCount, + errorPathsFound, + errorPathsArmed: ARMED_ERROR_PATHS, + armedBlocks: ARMED_BLOCKS, + } + }, null, 2)); +} + +function main() { + const args = process.argv.slice(2); + if (args.includes('--sweep')) { + printSweep(false); + return; + } + if (args.includes('--report')) { + printSweep(true); + return; + } + // Parse --block / --inst or positional + let blockPath = null; + let instJson = null; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--block' && i + 1 < args.length) blockPath = args[++i]; + else if (args[i] === '--inst' && i + 1 < args.length) instJson = args[++i]; + else if (!blockPath) blockPath = args[i]; + else if (!instJson) instJson = args[i]; + } + if (!blockPath) { + console.error('Usage: node scripts/block_validate_runner.js \'\' OR --sweep OR --report'); + console.error('Example: node scripts/block_validate_runner.js pru_io_blocks/spi_adc_sequencer_block \'{"sample_period":100,"noOfCycles":970}\''); + process.exit(2); + } + let inst = {}; + if (instJson) { + try { inst = JSON.parse(instJson); } catch (e) { + console.error(`Invalid inst JSON: ${e.message}`); + process.exit(2); + } + } else if (args.includes('--inst-file')) { + const idx = args.indexOf('--inst-file'); + const fp = args[idx + 1]; + inst = JSON.parse(fs.readFileSync(fp, 'utf8')); + } + const result = runValidate(blockPath, inst); + // Keep red results red: emit structured errors, exit 0 so caller can inspect red vs green + console.log(JSON.stringify({ file: path.relative(ROOT, result.file).replace(/\\/g, '/'), errors: result.errors, warnings: result.warnings, infos: result.infos })); +} + +if (require.main === module) main(); + +module.exports = { findAllBlockFiles, analyzeValidateSource, loadBlockModule, runValidate, resolveBlockPath }; diff --git a/scripts/shared_memvar_runner.js b/scripts/shared_memvar_runner.js new file mode 100644 index 0000000..acb9e56 --- /dev/null +++ b/scripts/shared_memvar_runner.js @@ -0,0 +1,68 @@ +"use strict"; + +/** + * Runner: Memory Access blocks that share one auto-created memVar. + * + * Loads the REAL memory_access_block.syscfg.js and runs its validate() over + * designs built from a shared / separate / symbolSelect'd memVar, emitting + * JSON on stdout: { cases: { : { warnings, errors } }, guidance }. + */ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); + +const root = path.resolve(__dirname, ".."); +const blockFile = path.join(root, ".metadata", "sysconfig", ".meta", "pru_blocks", "utils", "memory_access_block.syscfg.js"); + +const memVars = { + shared: { labelName: "out_buf", sizeInBytes: 64, memoryLocation: "dmem" }, + inBuf: { labelName: "in_buf", sizeInBytes: 64, memoryLocation: "dmem" }, + outBuf: { labelName: "out_buf", sizeInBytes: 64, memoryLocation: "dmem" } +}; +const accessInstances = []; +const sandbox = { + exports: {}, module: { exports: {} }, console, + system: { modules: { + "/pru_blocks/utils/memory_variable_block": { $instances: Object.values(memVars) }, + "/pru_blocks/utils/memory_access_block": { $instances: accessInstances } + } }, + JSON, Math, Number, String, Array, Object, RegExp, parseInt, parseFloat, isNaN +}; +sandbox.global = sandbox; +sandbox.globalThis = sandbox; +vm.runInNewContext(fs.readFileSync(blockFile, "utf8"), sandbox, { filename: path.basename(blockFile) }); +const block = sandbox.exports; + +function access(name, mode, memVar, symbolSelect) { + return { $name: name, operationMode: mode, memVar, symbolSelect: symbolSelect || "", + offsetValue: 0, dataSize: 4, input1: mode === "write" ? [{}] : [] }; +} + +function run(insts) { + accessInstances.length = 0; + insts.forEach(i => accessInstances.push(i)); + const out = {}; + for (const inst of insts) { + const warnings = [], errors = []; + block.validate(inst, { + logError: m => errors.push(String(m)), + logWarning: m => warnings.push(String(m)), + logInfo: () => {} + }); + out[inst.$name] = { warnings, errors }; + } + return out; +} + +const cases = { + // the defect: rd.memVar.labelName = in_buf; wr.memVar.labelName = out_buf on ONE object + sharedMemVar: run([access("rd", "read", memVars.shared), access("wr", "write", memVars.shared)]), + // the guided pattern: explicit instances assigned by reference + separateMemVars: run([access("rd", "read", memVars.inBuf), access("wr", "write", memVars.outBuf)]), + // both blocks pick the same named symbol deliberately: no rename hazard + symbolSelected: run([access("rd", "read", memVars.shared, "out_buf"), access("wr", "write", memVars.shared, "out_buf")]), + // a single block alone + single: run([access("rd", "read", memVars.shared)]) +}; + +process.stdout.write(JSON.stringify({ cases, guidance: block.longDescription })); diff --git a/scripts/static_module_validate_runner.js b/scripts/static_module_validate_runner.js new file mode 100644 index 0000000..3944c63 --- /dev/null +++ b/scripts/static_module_validate_runner.js @@ -0,0 +1,136 @@ +#!/usr/bin/env node +'use strict'; +/* + * Test runner: exercises validateDeviceCapabilities from the real + * pru_blocks_static_module.syscfg.js against a mocked SysConfig runtime. + * + * Usage: + * node scripts/static_module_validate_runner.js + * Example: + * node scripts/static_module_validate_runner.js AM261x_ZCZ am261x bswap + * node scripts/static_module_validate_runner.js AM243x_ALV_beta am243x bswap + * + * Degradation modes (prove the gate fails closed / errors visibly): + * --no-soc common.getSocName() returns null (SoC missing) + * --common-throws system.getScript("/common") throws + * --no-caps system.getScript(pru_device_caps) throws (table unloadable) + * --widget-throws caps.isWidgetAvailable() throws for the tested widget + * + * Prints JSON array of error messages on stdout. + */ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const MODES = ['--no-soc', '--common-throws', '--no-caps', '--widget-throws']; +const modeArgs = process.argv.slice(2).filter(a => MODES.includes(a)); +const posArgs = process.argv.slice(2).filter(a => !MODES.includes(a)); +const mode = modeArgs[0] || null; + +const device = posArgs[0] || 'AM261x_ZCZ'; +const soc = posArgs[1] || 'am261x'; +const widget = posArgs[2] || 'bswap'; + +const ROOT = path.join(__dirname, '..'); +const CAPS_PATH = path.join(ROOT, '.metadata', 'sysconfig', '.meta', 'pru_blocks', 'common', 'pru_device_caps.js'); +const STATIC_PATH = path.join(ROOT, '.metadata', 'sysconfig', '.meta', 'pru_blocks', 'common', 'pru_blocks_static_module.syscfg.js'); + +let caps; +try { + caps = require(CAPS_PATH); +} catch (e) { + console.error('Failed to load caps: ' + e.message); + process.exit(2); +} + +let errors = []; +const report = { + logError: (msg) => errors.push(String(msg)), + logWarning: () => {}, + logInfo: () => {}, +}; + +const mockCommon = { + getSocName: () => (mode === '--no-soc' ? null : soc), + getSelfSysCfgCoreName: () => 'r5fss0-0', +}; + +// proxy stub for unrelated system.getScript calls (register allocator etc.) +function makeStub() { + const fn = function () { return makeStub(); }; + return new Proxy(fn, { + get(_t, prop) { + if (prop === Symbol.toPrimitive || prop === 'toString' || prop === 'valueOf') return () => ''; + if (prop === Symbol.iterator) return undefined; + if (prop === 'then') return undefined; + return makeStub(); + }, + apply() { return makeStub(); }, + }); +} + +// The static module calls system.getScript("/common") during load (e.g. +// getPruNumberFromContext at line ~2056). Arm the --common-throws failure only +// AFTER the module has loaded so the degradation hits the validator's own +// failure path instead of blowing up module initialization. +let vmLoaded = false; + +const mockSystem = { + deviceData: { device: device, package: 'ZCZ' }, + modules: { + '/pru_blocks/test/fake_block': { + displayName: 'Fake Block', + requiresWidget: [widget], + $instances: [{ $name: 'FAKE_0' }], + }, + }, + getScript: (p) => { + if (p.includes('pru_device_caps')) { + if (mode === '--no-caps') throw new Error('pru_device_caps.js not found (simulated unloadable capability table)'); + if (mode === '--widget-throws') { + return Object.assign({}, caps, { + isWidgetAvailable: () => { throw new Error('capability lookup exploded (simulated)'); }, + }); + } + return caps; + } + if (p === '/common' || p === 'common') { + if (mode === '--common-throws' && vmLoaded) throw new Error('/common unavailable (simulated)'); + return mockCommon; + } + if (p.includes('/common')) { + // simulation scripts under /pru_blocks/common/ are unrelated to SoC lookup + return mockCommon; + } + return makeStub(); + }, + contexts: {}, + deviceData2: {}, +}; + +const src = fs.readFileSync(STATIC_PATH, 'utf8'); + +const sandbox = { + system: mockSystem, + exports: {}, + module: { exports: {} }, + console: { log() {}, error() {}, warn() {} }, + require: () => makeStub(), +}; +sandbox.global = sandbox; +vm.createContext(sandbox); +try { + vm.runInContext(src, sandbox, { filename: STATIC_PATH, timeout: 5000 }); +} catch (e) { + console.error('VM load failed: ' + e.stack); + process.exit(2); +} +vmLoaded = true; +const mod = sandbox.exports; +if (!mod.validateDeviceCapabilities) { + console.error('validateDeviceCapabilities not found in exports'); + process.exit(2); +} +// call validator with dummy inst +mod.validateDeviceCapabilities({}, report); +console.log(JSON.stringify(errors)); diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8c99498 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +import os +import sys + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +for p in (_REPO_ROOT, os.path.join(_REPO_ROOT, "scripts", "sim")): + if p not in sys.path: + sys.path.insert(0, p) diff --git a/tests/test_conditional_unconnected_branch.py b/tests/test_conditional_unconnected_branch.py new file mode 100644 index 0000000..f16c0ee --- /dev/null +++ b/tests/test_conditional_unconnected_branch.py @@ -0,0 +1,57 @@ +"""An unconnected T or F port on If/Else is a validate() ERROR. + +Problem (LORA-52 F4): SysConfig only warned 'f_next port is not connected', +then clpru failed with [E0300] undefined symbol cmp_FALSE. Fix: fail early, +at validate time, with a clear message naming the port. A fully connected +design must still validate without that error. + +The harness is the committed runner (scripts/block_validate_runner.js), the +same one the other block-validator tests use. Control and red arms differ only +in the T/F lists. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "block_validate_runner.js" +BLOCK = "program_control/conditional_block" + + +def _validate(t, f): + inst = {"$name": "If_Else_0", "numOfInputPorts": 2, + "input1": ["a"], "input2": ["b"], "T": t, "F": f} + proc = subprocess.run(["node", str(RUNNER), BLOCK, json.dumps(inst)], + capture_output=True, text=True, cwd=str(ROOT), timeout=30) + assert proc.returncode == 0, proc.stdout + proc.stderr + return json.loads(proc.stdout.strip()) + + +def _branch_errors(data, port): + return [e for e in data["errors"] if port in e["message"]] + + +def test_connected_design_has_no_branch_error(): + data = _validate(["x"], ["y"]) + assert data["errors"] == [], data + + +def test_unconnected_f_port_is_an_error(): + data = _validate(["x"], []) + errs = _branch_errors(data, "f_next") + assert errs, "unconnected F must be a validate ERROR, got %s" % data + + +def test_unconnected_t_port_is_an_error(): + data = _validate([], ["y"]) + errs = _branch_errors(data, "t_next") + assert errs, "unconnected T must be a validate ERROR, got %s" % data + + +@pytest.mark.parametrize("t,f,port", [(["x"], [], "f_next"), ([], ["y"], "t_next")]) +def test_error_not_just_warning(t, f, port): + data = _validate(t, f) + assert not [w for w in data["warnings"] if port in w["message"]], data diff --git a/tests/test_fail_closed_gating.py b/tests/test_fail_closed_gating.py new file mode 100644 index 0000000..a24f24d --- /dev/null +++ b/tests/test_fail_closed_gating.py @@ -0,0 +1,126 @@ +""" +The device-capability gate must fail CLOSED or error VISIBLY on every failure path. + +Failure paths covered (all previously failed OPEN — silently skipped validation +and left the build clean): + 1. getScript("/common") failure -> silent return (was: return) + 2. missing SoC (getSocName() -> null) -> silent return (was: return) + 3. isWidgetAvailable() exception -> available = true (was: true) + 4. null pruDeviceCaps (caps unloadable) -> silent return (was: return) + 5. unknown SoC -> default ICSSM caps (was: ICSSM default) + +Drives the REAL validator in +.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js +and the REAL capability table at +.metadata/sysconfig/.meta/pru_blocks/common/pru_device_caps.js +via scripts/static_module_validate_runner.js. + +Red-proof contract: if any fail-closed branch is reverted to its old fail-open +form, the corresponding test below goes RED because the runner then reports an +empty error list ("clean build") for that degraded mode. +""" +import json +import pathlib +import subprocess + +import pytest + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = REPO_ROOT / "scripts" / "static_module_validate_runner.js" +NODE = "node" + + +def run_validator(device, soc, widget, mode=None): + cmd = [NODE, str(RUNNER), device, soc, widget] + if mode: + cmd.append(mode) + proc = subprocess.run( + cmd, capture_output=True, text=True, cwd=str(REPO_ROOT), timeout=10 + ) + assert proc.returncode == 0, f"runner failed: {proc.stdout}\n{proc.stderr}" + return json.loads(proc.stdout.strip() or "[]") + + +def test_failure_path_getscript_common_errors_visibly(): + """Path 1: system.getScript('/common') throws -> ERROR, not a silent skip.""" + errors = run_validator("AM261x_ZCZ", "am261x", "mac", mode="--common-throws") + assert len(errors) == 1, "getScript('/common') failure silently skipped gating" + msg = " ".join(errors).lower() + assert "common" in msg and ("fail" in msg or "cannot" in msg) + + +def test_failure_path_missing_soc_errors_visibly(): + """Path 2: getSocName() returns null -> ERROR naming the missing SoC.""" + errors = run_validator("AM261x_ZCZ", "am261x", "mac", mode="--no-soc") + assert len(errors) == 1, "missing SoC silently skipped gating" + msg = " ".join(errors).lower() + assert "soc" in msg and ("could not be determined" in msg or "cannot" in msg) + + +def test_failure_path_widget_exception_fails_closed(): + """Path 3: isWidgetAvailable() throws -> widget treated UNAVAILABLE with a visible ERROR.""" + errors = run_validator("AM263x_beta", "am263x", "mac", mode="--widget-throws") + assert len(errors) == 1, "isWidgetAvailable exception silently treated widget as available" + msg = " ".join(errors).lower() + assert "mac" in msg and "unavailable" in msg + + +def test_failure_path_null_caps_errors_visibly(): + """Path 4: capability table unloadable (null pruDeviceCaps) -> ERROR; build is NOT clean.""" + errors = run_validator("AM243x_ALV_beta", "am243x", "mac", mode="--no-caps") + assert len(errors) == 1, "null pruDeviceCaps silently skipped gating" + msg = " ".join(errors).lower() + assert "capability table" in msg or "pru_device_caps" in msg + + +def test_failure_path_unknown_soc_errors_visibly(): + """Path 5: SoC not in the capability table -> ERROR naming the SoC; no silent default caps.""" + errors = run_validator("AM9999_fake", "am9999", "mac") + assert len(errors) == 1, "unknown SoC silently inherited a default capability set" + msg = " ".join(errors).lower() + assert "am9999" in msg + assert "capability table" in msg or "not in" in msg + + +def test_known_soc_positive_control_stays_clean(): + """Control: a known SoC with the widget present must still produce NO errors.""" + errors = run_validator("AM243x_ALV_beta", "am243x", "mac") + assert errors == [], f"known-good configuration must stay clean, got {errors}" + + +def test_caps_module_unknown_soc_has_no_widgets(): + """Unit-level: getCapsForSoc for unknown/missing SoC returns an EMPTY set (fail closed), + never a default capability set.""" + caps_path = REPO_ROOT / ".metadata" / "sysconfig" / ".meta" / "pru_blocks" / "common" / "pru_device_caps.js" + assert caps_path.exists() + proc = subprocess.run( + [NODE, "-e", + f"const c = require({json.dumps(str(caps_path))});" + "const unk = c.getCapsForSoc('am9999');" + "const nul = c.getCapsForSoc(null);" + "if (unk.size !== 0 || nul.size !== 0) {" + " console.error('FAIL size unk=' + unk.size + ' null=' + nul.size); process.exit(1); }" + "if (c.isKnownSoc('am9999') !== false || c.isKnownSoc('am64x') !== true) {" + " console.error('FAIL isKnownSoc'); process.exit(1); }" + "console.log('ok');"], + capture_output=True, text=True, cwd=str(REPO_ROOT), timeout=10 + ) + assert proc.returncode == 0, f"caps module fail-closed check failed: {proc.stdout}\n{proc.stderr}" + + +def test_validator_fail_closed_source_guards(): + """Lint only - NOT load-bearing coupling evidence. + + These are whitespace-literal substring checks. Re-introducing the exact + fail-open as `catch (e) { + available = true; +}` passes them both while + the behavioural arms above correctly go red. Reviewed 2026-09-09; kept as + cheap lint, but the proof that the gate fails closed is the five + run_validator arms, never this function. + """ + src = (REPO_ROOT / ".metadata" / "sysconfig" / ".meta" / "pru_blocks" / "common" + / "pru_blocks_static_module.syscfg.js").read_text(encoding="utf-8") + assert "available = true; }" not in src, "isWidgetAvailable exception must fail CLOSED, not available=true" + gating = src.split("function validateDeviceCapabilities")[1].split("\n}\n")[0] + assert "Failing closed." in gating, "visible fail-closed error messages must be present" diff --git a/tests/test_shared_memvar_rename.py b/tests/test_shared_memvar_rename.py new file mode 100644 index 0000000..39f3ef3 --- /dev/null +++ b/tests/test_shared_memvar_rename.py @@ -0,0 +1,53 @@ +"""Renaming one Memory Access block's memVar renames it for every sharing block. + +`rd.memVar.labelName = in_buf; wr.memVar.labelName = out_buf` leaves both blocks on out_buf +and never reserves in_buf. The block must warn when two Memory Access blocks share one memVar +object, and its AI guidance must show explicit Memory Variable instances assigned by reference. +Runs the REAL memory_access_block.syscfg.js through scripts/shared_memvar_runner.js. +""" +import json +import os +import shutil +import subprocess + +import pytest + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RUNNER = os.path.join(ROOT, "scripts", "shared_memvar_runner.js") + +needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node not installed") + + +@pytest.fixture(scope="module") +def result(): + res = subprocess.run(["node", RUNNER], capture_output=True, text=True) + assert res.stdout.strip(), res.stderr + return json.loads(res.stdout) + + +@needs_node +def test_two_blocks_sharing_one_memvar_warn_on_both(result): + for name in ("rd", "wr"): + entry = result["cases"]["sharedMemVar"][name] + assert entry["errors"] == [] + assert len(entry["warnings"]) == 1, entry + assert "Shares one Memory Variable" in entry["warnings"][0] + assert "renames the variable for every block" in entry["warnings"][0] + assert "wr" in result["cases"]["sharedMemVar"]["rd"]["warnings"][0] + assert "rd" in result["cases"]["sharedMemVar"]["wr"]["warnings"][0] + + +@needs_node +@pytest.mark.parametrize("case", ["separateMemVars", "symbolSelected", "single"]) +def test_no_warning_without_a_shared_rename_hazard(result, case): + for name, entry in result["cases"][case].items(): + assert entry["warnings"] == [], (case, name, entry) + assert entry["errors"] == [], (case, name, entry) + + +@needs_node +def test_guidance_shows_explicit_memory_variable_instances(result): + g = result["guidance"] + assert "WRONG" in g and "rd.memVar.labelName" in g + assert "rd.memVar = in_buf;" in g and "wr.memVar = out_buf;" in g + assert 'memory_reserve.addInstance()' in g diff --git a/tests/test_syscfg_add_instance.py b/tests/test_syscfg_add_instance.py new file mode 100644 index 0000000..c1991f2 --- /dev/null +++ b/tests/test_syscfg_add_instance.py @@ -0,0 +1,86 @@ +"""Exercise each block's real SysConfig 1.28 addInstance path when available.""" +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import tempfile + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SYS_CONFIG = Path(os.environ.get("SYSCONFIG_CLI", r"C:\ti\sysconfig_1.28.0\dist\cli.js")) +NODE = Path(os.environ.get("SYSCONFIG_NODE", r"C:\ti\sysconfig_1.28.0\nodejs\node.exe")) +PRODUCT = ROOT / ".metadata" / "product.json" +DEVICE = "AM243x_ALX_beta" +PACKAGE = "ALX" +CONTEXT = "icss_g0_pru0" + + +def _available() -> bool: + return NODE.is_file() and SYS_CONFIG.is_file() + + +pytestmark = pytest.mark.skipif(not _available(), reason="SysConfig 1.28 CLI is not installed") + + +def _modules() -> list[str]: + base = ROOT / ".metadata" / "sysconfig" / ".meta" / "pru_blocks" + return [ + "/" + p.relative_to(base.parent).as_posix().removesuffix(".syscfg.js") + for p in sorted(base.rglob("*.syscfg.js")) + if p.name != "pru_blocks_static_module.syscfg.js" + ] + + +def _run(module_path: str) -> subprocess.CompletedProcess[str]: + temp = Path(tempfile.mkdtemp(prefix="syscfg-add-instance-")) + script = temp / "probe.syscfg" + script.write_text( + "// @cliArgs --device AM243x_ALX_beta --package ALX --part ALX " + "--context icss_g0_pru0 --product PRU_NO_CODE_TOOL@01.00.01\n" + f'scripting.addModule("{module_path}", {{}}, false).addInstance();\n', + encoding="utf-8", + ) + env = os.environ.copy() + local = temp / "local" + roaming = temp / "roaming" + local.mkdir(exist_ok=True) + roaming.mkdir(exist_ok=True) + env["LOCALAPPDATA"] = str(local) + env["APPDATA"] = str(roaming) + return subprocess.run( + [str(NODE), str(SYS_CONFIG), "--product", str(PRODUCT), "--device", DEVICE, + "--package", PACKAGE, "--part", PACKAGE, "--script", str(script), + "--context", CONTEXT, "--output", str(temp / "out")], + cwd=ROOT, env=env, text=True, capture_output=True, timeout=60, + ) + + +def _assert_instantiated(module_path: str, result: subprocess.CompletedProcess[str]) -> None: + output = result.stdout + result.stderr + assert "Running script..." in output, f"SysConfig did not run {module_path}:\n{output}" + assert "Exception occurred calling addInstance()" not in output, ( + f"SysConfig failed to instantiate {module_path}:\n{output}" + ) + assert "Exception occurred calling scripting.addModule()" not in output, ( + f"SysConfig rejected module path {module_path}:\n{output}" + ) + + +def test_every_block_module_adds_an_instance() -> None: + modules = _modules() + assert modules, "no pru_blocks modules discovered" + for module_path in modules: + _assert_instantiated(module_path, _run(module_path)) + + +def test_red_arm_rejects_a_deliberately_invalid_module_path() -> None: + """Prove the add-instance assertion goes red when a design names no module.""" + result = _run("/pru_blocks/this_module_does_not_exist") + output = result.stdout + result.stderr + assert "Exception occurred calling scripting.addModule()" in output + # Same oracle used by the all-module test must reject this deliberately bad design. + with pytest.raises(AssertionError, match="rejected module path"): + _assert_instantiated("/pru_blocks/this_module_does_not_exist", result) From 7b21a2ef2f655d5e8189ab29bd0bfb43ecf5bb24 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 05:44:41 +0530 Subject: [PATCH 04/44] Remove internal references from comments; neutral tool-path defaults in tests --- .../.meta/pru_blocks/common/pru_reserved_names.js | 6 +++--- tests/test_conditional_unconnected_branch.py | 2 +- tests/test_syscfg_add_instance.py | 15 +++++---------- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_reserved_names.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_reserved_names.js index 4acb872..7d0e604 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_reserved_names.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_reserved_names.js @@ -17,11 +17,11 @@ * sp, pc, main, call, ret, TEMP_REG1 and ordinary names all assemble fine. * * Case (each claim probed with clpru itself, both as a data symbol and as - * a code label; see docs/ncp-268-report.md): register names r0-r31/c0-c31 + * a code label): register names r0-r31/c0-c31 * are case-insensitive (R5, C0 fail) — ANY case is rejected. Mnemonics are * rejected in exact-lowercase (add) and exact-ALL-UPPERCASE (ADD) only; a - * mixed-case form (Add, Ldi32) ASSEMBLES and must be accepted, because the - * brief's oracle says names that assemble are not reserved. Byte lanes are + * mixed-case form (Add, Ldi32) ASSEMBLES and must be accepted, because a + * name that assembles is not reserved. Byte lanes are * rejected lowercase only (b0 fails; B0 ASSEMBLES and is accepted). * * Loading: blocks reach this file through diff --git a/tests/test_conditional_unconnected_branch.py b/tests/test_conditional_unconnected_branch.py index f16c0ee..e4e8ec5 100644 --- a/tests/test_conditional_unconnected_branch.py +++ b/tests/test_conditional_unconnected_branch.py @@ -1,6 +1,6 @@ """An unconnected T or F port on If/Else is a validate() ERROR. -Problem (LORA-52 F4): SysConfig only warned 'f_next port is not connected', +Problem: SysConfig only warned 'f_next port is not connected', then clpru failed with [E0300] undefined symbol cmp_FALSE. Fix: fail early, at validate time, with a clear message naming the port. A fully connected design must still validate without that error. diff --git a/tests/test_syscfg_add_instance.py b/tests/test_syscfg_add_instance.py index c1991f2..46e6b4d 100644 --- a/tests/test_syscfg_add_instance.py +++ b/tests/test_syscfg_add_instance.py @@ -10,8 +10,10 @@ ROOT = Path(__file__).resolve().parents[1] -SYS_CONFIG = Path(os.environ.get("SYSCONFIG_CLI", r"C:\ti\sysconfig_1.28.0\dist\cli.js")) -NODE = Path(os.environ.get("SYSCONFIG_NODE", r"C:\ti\sysconfig_1.28.0\nodejs\node.exe")) +# Default install location of TI tools, as in imports.mak (TOOLS_PATH); override with SYSCONFIG_CLI / SYSCONFIG_NODE. +TOOLS = Path(os.environ.get("TOOLS_PATH", (os.environ.get("SystemDrive", "C:") + "/ti") if os.name == "nt" else Path.home() / "ti")) +SYS_CONFIG = Path(os.environ.get("SYSCONFIG_CLI", TOOLS / "sysconfig_1.28.0" / "dist" / "cli.js")) +NODE = Path(os.environ.get("SYSCONFIG_NODE", TOOLS / "sysconfig_1.28.0" / "nodejs" / ("node.exe" if os.name == "nt" else "node"))) PRODUCT = ROOT / ".metadata" / "product.json" DEVICE = "AM243x_ALX_beta" PACKAGE = "ALX" @@ -43,18 +45,11 @@ def _run(module_path: str) -> subprocess.CompletedProcess[str]: f'scripting.addModule("{module_path}", {{}}, false).addInstance();\n', encoding="utf-8", ) - env = os.environ.copy() - local = temp / "local" - roaming = temp / "roaming" - local.mkdir(exist_ok=True) - roaming.mkdir(exist_ok=True) - env["LOCALAPPDATA"] = str(local) - env["APPDATA"] = str(roaming) return subprocess.run( [str(NODE), str(SYS_CONFIG), "--product", str(PRODUCT), "--device", DEVICE, "--package", PACKAGE, "--part", PACKAGE, "--script", str(script), "--context", CONTEXT, "--output", str(temp / "out")], - cwd=ROOT, env=env, text=True, capture_output=True, timeout=60, + cwd=ROOT, text=True, capture_output=True, timeout=60, ) From cef2e674455e3666106900121c8364fc3ff32988 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 08:20:11 +0530 Subject: [PATCH 05/44] uart_crc example: read registers the allocator owns, and add firmware test The register allocator now releases a register once its last reader has run, so main.asm can no longer rely on a released register still holding its value. Name the registers once in main.asm, and keep the received frame live on the receiving core by reading it into an otherwise unused block output. Add a firmware test that runs the example on the simulator for both boards and both cores, checking the stored data byte and the CRC match/mismatch result. --- .../am243x-lp/icss_g0_pru1_fw/example.syscfg | 9 +- .../am261x-lp/icss_m1_pru1_fw/example.syscfg | 9 +- examples/uart_crc/firmware/main.asm | 27 ++- tests/test_uart_crc_firmware.py | 159 ++++++++++++++++++ 4 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 tests/test_uart_crc_firmware.py diff --git a/examples/uart_crc/firmware/am243x-lp/icss_g0_pru1_fw/example.syscfg b/examples/uart_crc/firmware/am243x-lp/icss_g0_pru1_fw/example.syscfg index 1bd7d2c..d6b0c1f 100644 --- a/examples/uart_crc/firmware/am243x-lp/icss_g0_pru1_fw/example.syscfg +++ b/examples/uart_crc/firmware/am243x-lp/icss_g0_pru1_fw/example.syscfg @@ -22,6 +22,7 @@ const uart_rx_op1 = uart_rx_op.addInstance(); const memory_access_block = scripting.addModule("/pru_blocks/utils/memory_access_block", {}, false); const memory_access_block1 = memory_access_block.addInstance(); const memory_access_block2 = memory_access_block.addInstance(); +const memory_access_block3 = memory_access_block.addInstance(); /** * Write custom configuration values to the imported modules. @@ -47,11 +48,15 @@ memory_access_block1.dataSize = 2; memory_access_block2.$name = "Memory_Access_1"; memory_access_block2.dataSize = 1; +memory_access_block3.$name = "Received_Frame"; +memory_access_block3.dataSize = 2; + const memory_variable_block = scripting.addModule("/pru_blocks/utils/memory_variable_block", {}, false); const memory_variable_block1 = memory_variable_block.addInstance({}, false); memory_variable_block1.$name = "Memory_Variable_0"; memory_access_block1.memVar = memory_variable_block1; memory_access_block2.memVar = memory_variable_block1; +memory_access_block3.memVar = memory_variable_block1; memory_variable_block1.sizeInBytes = 4; /** @@ -60,7 +65,8 @@ memory_variable_block1.sizeInBytes = 4; scripting.connect(crc_block1, "input1", load_constant_block1, "output1"); scripting.connect(crc_block1, "input2", memory_access_block2, "output1"); scripting.connect(crc_block1, "prev", memory_access_block2, "next"); -scripting.connect(crc_block1, "next", flow_control_block1, "prev"); +scripting.connect(crc_block1, "next", memory_access_block3, "prev"); +scripting.connect(memory_access_block3, "next", flow_control_block1, "prev"); scripting.connect(uart_rx_op1, "output1", memory_access_block1, "input1"); scripting.connect(uart_rx_op1, "prev", uart_config1, "next"); scripting.connect(uart_rx_op1, "next", memory_access_block1, "prev"); @@ -76,4 +82,5 @@ uart_rx_op1.$position = [-290,-80]; uart_config1.$position = [-410,-85]; memory_access_block1.$position = [-150,-65]; memory_access_block2.$position = [-10,-25]; +memory_access_block3.$position = [285,-25]; memory_variable_block1.$position = [-385,-250]; diff --git a/examples/uart_crc/firmware/am261x-lp/icss_m1_pru1_fw/example.syscfg b/examples/uart_crc/firmware/am261x-lp/icss_m1_pru1_fw/example.syscfg index a42173c..3aa35ca 100644 --- a/examples/uart_crc/firmware/am261x-lp/icss_m1_pru1_fw/example.syscfg +++ b/examples/uart_crc/firmware/am261x-lp/icss_m1_pru1_fw/example.syscfg @@ -22,6 +22,7 @@ const uart_rx_op1 = uart_rx_op.addInstance(); const memory_access_block = scripting.addModule("/pru_blocks/utils/memory_access_block", {}, false); const memory_access_block1 = memory_access_block.addInstance(); const memory_access_block2 = memory_access_block.addInstance(); +const memory_access_block3 = memory_access_block.addInstance(); /** * Write custom configuration values to the imported modules. @@ -47,11 +48,15 @@ memory_access_block1.dataSize = 2; memory_access_block2.$name = "Memory_Access_1"; memory_access_block2.dataSize = 1; +memory_access_block3.$name = "Received_Frame"; +memory_access_block3.dataSize = 2; + const memory_variable_block = scripting.addModule("/pru_blocks/utils/memory_variable_block", {}, false); const memory_variable_block1 = memory_variable_block.addInstance({}, false); memory_variable_block1.$name = "Memory_Variable_0"; memory_access_block1.memVar = memory_variable_block1; memory_access_block2.memVar = memory_variable_block1; +memory_access_block3.memVar = memory_variable_block1; memory_variable_block1.sizeInBytes = 4; /** @@ -60,7 +65,8 @@ memory_variable_block1.sizeInBytes = 4; scripting.connect(crc_block1, "input1", load_constant_block1, "output1"); scripting.connect(crc_block1, "input2", memory_access_block2, "output1"); scripting.connect(crc_block1, "prev", memory_access_block2, "next"); -scripting.connect(crc_block1, "next", flow_control_block1, "prev"); +scripting.connect(crc_block1, "next", memory_access_block3, "prev"); +scripting.connect(memory_access_block3, "next", flow_control_block1, "prev"); scripting.connect(uart_rx_op1, "output1", memory_access_block1, "input1"); scripting.connect(uart_rx_op1, "prev", uart_config1, "next"); scripting.connect(uart_rx_op1, "next", memory_access_block1, "prev"); @@ -76,4 +82,5 @@ uart_rx_op1.$position = [-290,-80]; uart_config1.$position = [-410,-85]; memory_access_block1.$position = [-150,-65]; memory_access_block2.$position = [-10,-25]; +memory_access_block3.$position = [285,-25]; memory_variable_block1.$position = [-385,-250]; diff --git a/examples/uart_crc/firmware/main.asm b/examples/uart_crc/firmware/main.asm index 5ab0d6e..85f3f2a 100644 --- a/examples/uart_crc/firmware/main.asm +++ b/examples/uart_crc/firmware/main.asm @@ -32,6 +32,20 @@ ;* MAIN * ;******** +; Registers this file reads after the generated code. The register allocator chooses them, so they are +; named once here. Only a block whose output no other block consumes keeps its register to the end (a +; consumed value is released and its register reused), which is why the pru1 design ends with the +; "Received_Frame" block. + .if $isdefed("PRU0") +; pru0: Memory_Access_3 loads the TX frame {data byte, CRC} from buffer; the UART TX block sends it + .asg R0.b0, TX_DATA_REG ; data byte of the frame that was sent (0xAA) + .elseif $isdefed("PRU1") +; pru1: CRC_0 = CRC computed over the received data byte; Received_Frame = {received data byte, received CRC} + .asg R0.b2, CRC_CALC_REG ; CRC computed by CRC_0 + .asg R0.b0, RX_DATA_REG ; received data byte + .asg R0.b1, CRC_RX_REG ; received CRC + .endif + main: .if $isdefed("PRU0") zero &r0, 120 ; Clear the register space @@ -43,7 +57,7 @@ sysconfig_generated_end: LDI32 R2, 0x48010000 .endif ; Store TX data sent at SMEM[4] using SBBO - SBBO &R0.b1, R2, 4, 1 ; Store R0.b1 at [smem + 0] + SBBO &TX_DATA_REG, R2, 4, 1 halt .elseif $isdefed("PRU1") zero &r0, 120 ; Clear the register space @@ -51,7 +65,7 @@ sysconfig_generated_end: sysconfig_generated_end: ; Store CRC values to SMEM using absolute address 0x30010000 - ; R0.b0 = RX calculated CRC, R0.b1 = TX received CRC + ; CRC_CALC_REG = CRC computed from the received data, CRC_RX_REG = CRC received with it ; Load SMEM base address into R2 .if $isdefed("SOC_AM243X") @@ -61,15 +75,16 @@ sysconfig_generated_end: .endif ; Store RX calculated CRC at SMEM[0] using SBBO - SBBO &R1.b1, R2, 0, 1 ; Store R0.b0 at [smem + 0] + SBBO &CRC_CALC_REG, R2, 0, 1 ; Store TX received CRC at SMEM[1] using SBBO - SBBO &R0.b1, R2, 1, 1 ; Store R0.b1 at [smem + 1] + SBBO &CRC_RX_REG, R2, 1, 1 - SBBO &R0.b0, R2, 3, 1 + ; Store the received data byte at SMEM[3] + SBBO &RX_DATA_REG, R2, 3, 1 ; Compare the two CRC values - QBEQ crc_match, R0.b1, R1.b1 + QBEQ crc_match, CRC_RX_REG, CRC_CALC_REG ; CRC mismatch - store 0xFF at SMEM[2] LDI R3.b0, 0xFF diff --git a/tests/test_uart_crc_firmware.py b/tests/test_uart_crc_firmware.py new file mode 100644 index 0000000..61a82f3 --- /dev/null +++ b/tests/test_uart_crc_firmware.py @@ -0,0 +1,159 @@ +"""examples/uart_crc/firmware/main.asm must read the registers the generated code really uses. + +main.asm runs after the generated code and reads block results straight from registers the register +allocator chose. For am243x-lp and am261x-lp, pru0 and pru1, this test generates the example's own +example.syscfg with SysConfig, links it with the example's main.asm and runs the clpru ELF on TI +pru-simulator: + * The SMEM base address in main.asm (not mapped in the simulator) becomes a DMEM address, and on pru1 + the UART RX macro, which waits for a frame on a pin nothing drives here, is replaced by + `LDI R0, `: R0 is the register that macro delivers its frame in. + * pru1, frame 0xC041 (data 0x41, CRC 0xC0 = CRC-8 poly 0x07 of 0x41): SMEM[2] == 0x01 (match), + SMEM[0] == SMEM[1] == 0xC0, SMEM[3] == 0x41. + * pru1, frame 0x1141 (data 0x41, wrong CRC 0x11): SMEM[2] == 0xFF (mismatch), SMEM[0] == 0xC0, + SMEM[1] == 0x11. + * pru0: SMEM[4] == the TX data byte 0xAA (its CRC is 0x5F). +Expected values come from a plain-Python CRC-8, never from the asm. +Skips when SysConfig, clpru or the pru-simulator checkout (PRU_SIMULATOR_PATH) is missing. +""" +import os +import re +import shutil +import struct +import subprocess +import sys +import tempfile + +import pytest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +EX = os.path.join(REPO_ROOT, "examples", "uart_crc", "firmware") +MAIN_ASM = os.path.join(EX, "main.asm") +PRODUCT_JSON = os.path.join(REPO_ROOT, ".metadata", "product.json") +SMEM_STUB = 0x1800 # DMEM address that stands in for the real SMEM base + +TI_DIR = os.path.join(os.environ.get("SystemDrive", "C:") + os.sep, "ti") +SYSCFG_DIR = os.environ.get("SYSCFG_PATH", os.path.join(TI_DIR, "sysconfig_1.28.0")) +SYSCFG_NODE = os.path.join(SYSCFG_DIR, "nodejs", "node.exe" if os.name == "nt" else "node") +SYSCFG_CLI = os.path.join(SYSCFG_DIR, "dist", "cli.js") +CGT_DIR = os.environ.get("PRU_CGT_PATH", os.path.join(TI_DIR, "ti-cgt-pru_2.3.3")) +CLPRU = os.path.join(CGT_DIR, "bin", "clpru.exe" if os.name == "nt" else "clpru") +SIMDIR = os.environ.get("PRU_SIMULATOR_PATH", "") + +HAVE_TOOLS = all(os.path.exists(p) for p in (SYSCFG_NODE, SYSCFG_CLI, CLPRU)) and \ + os.path.isfile(os.path.join(SIMDIR, "simulator.py")) +needs_tools = pytest.mark.skipif(not HAVE_TOOLS, reason="SysConfig, clpru or TI pru-simulator (PRU_SIMULATOR_PATH) missing") + +CORES = [ + # id, firmware dir, SysConfig --device/--part/--package/--context, -D flags, SMEM base in main.asm + ("am243x-pru0", "am243x-lp/icss_g0_pru0_fw", ("AM243x_ALX_beta", "ALX", "ALX", "icss_g0_pru0"), "PRU0", "SOC_AM243X", "0x30010000"), + ("am243x-pru1", "am243x-lp/icss_g0_pru1_fw", ("AM243x_ALX_beta", "ALX", "ALX", "icss_g0_pru1"), "PRU1", "SOC_AM243X", "0x30010000"), + ("am261x-pru0", "am261x-lp/icss_m1_pru0_fw", ("AM261x_ZFG", "AM2612", "ZFG", "icss_m1_pru0"), "PRU0", "SOC_AM261X", "0x48010000"), + ("am261x-pru1", "am261x-lp/icss_m1_pru1_fw", ("AM261x_ZFG", "AM2612", "ZFG", "icss_m1_pru1"), "PRU1", "SOC_AM261X", "0x48010000"), +] +PRU0_CORES = [c for c in CORES if c[3] == "PRU0"] +PRU1_CORES = [c for c in CORES if c[3] == "PRU1"] +RX_MACRO = re.compile(r"^\s*m_uart_rx\S*.*$", re.M) + + +def crc8(data, poly=0x07): + """CRC-8 (MSB first, init 0): the CRC the example's lookup table implements.""" + crc = 0 + for byte in data: + crc ^= byte + for _ in range(8): + crc = ((crc << 1) ^ poly) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF + return crc + + +def test_oracle_matches_the_documented_frames(): + assert crc8([0x41]) == 0xC0 + assert crc8([0x41]) != 0x11 + assert crc8([0xAA]) == 0x5F + + +def _run(cmd, cwd): + proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + assert proc.returncode == 0, "%s failed:\n%s\n%s" % (cmd[0], proc.stdout[-2000:], proc.stderr[-2000:]) + + +def build_and_run(core, patch_frame=None): + _id, fw, (device, part, package, context), pru, soc, smem = core + work = tempfile.mkdtemp(prefix="uart_crc_") + try: + _run([SYSCFG_NODE, SYSCFG_CLI, "--product", PRODUCT_JSON, "--device", device, "--context", context, + "--part", part, "--package", package, "--output", "syscfg", os.path.join(EX, fw, "example.syscfg")], work) + asm_path = os.path.join(work, "syscfg", "pru_syscfg.asm") + with open(asm_path, encoding="utf-8") as f: + asm = f.read() + if patch_frame is not None: + asm, n = RX_MACRO.subn(" LDI R0, %d" % patch_frame, asm) + assert n == 1, "the pru1 design must contain exactly one UART RX macro" + with open(asm_path, "w", encoding="utf-8") as f: + f.write(asm) + with open(MAIN_ASM, encoding="utf-8") as f: + main = f.read() + assert smem in main, "main.asm no longer loads the SMEM base %s" % smem + main_path = os.path.join(work, "main_dmem.asm") + with open(main_path, "w", encoding="utf-8") as f: + f.write(main.replace(smem, "0x%X" % SMEM_STUB)) + cflags = ["-v4", "-O2", "--include_path=" + os.path.join(CGT_DIR, "include"), "-D" + pru, "-DSLICE0", "-D" + soc, + "--include_path=" + os.path.dirname(asm_path)] + _run([CLPRU] + cflags + ["--output_file=main.obj", main_path], work) + _run([CLPRU] + cflags + ["--output_file=pru_syscfg.obj", asm_path], work) + _run([CLPRU, "-v4", "-O2", "-z", "main.obj", "pru_syscfg.obj", os.path.join(EX, fw, "ti-pru-cgt", "linker.cmd"), + "--warn_sections", "--reread_libs", "--ram_model", "--entry_point=main", "--disable_auto_rts", + "-o", "fw.out"], work) + with open(os.path.join(work, "fw.out"), "rb") as f: + elf = f.read() + finally: + shutil.rmtree(work, ignore_errors=True) + entry = struct.unpack_from(" Date: Wed, 21 Oct 2026 14:32:37 +0530 Subject: [PATCH 06/44] Ignore build_out/ test output and remove trailing whitespace --- .gitignore | 3 ++- .../common/pru_blocks_static_module.syscfg.js | 14 +++++++------- .../register_allocation/pru_register_allocator.js | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 3938248..65755ee 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,5 @@ mcusdk_tagfile __pycache__/ !*boardcfg_*.bin source/sysconfig/ -source/board/pmic/pmic_lld/ \ No newline at end of file +source/board/pmic/pmic_lld/ +build_out/ diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js index f0adacd..b4ac5d0 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js @@ -1043,7 +1043,7 @@ function validatePruInstructions(inst, report) { let parts = u.split('\t'); if (parts.length > 0) definedSymbols.add(parts[0].trim()); }); - + let dataSects = getDataSectionContents(); dataSects.forEach(d => { if (d.symbolName) definedSymbols.add(d.symbolName.trim()); @@ -1075,7 +1075,7 @@ function validatePruInstructions(inst, report) { } } } - + // C. Invalid graph link targets if (b_inst.numOfInputPorts > 0) { for (let i = 1; i <= b_inst.numOfInputPorts; i++) { @@ -1156,20 +1156,20 @@ function validatePruInstructions(inst, report) { let call = pruInstructionsWithMacros[i]; let moduleName = pruInstructionModules[i]; let b_inst = pruInstructionInstances[i]; - + if (!call || call === 0 || call === "0" || !moduleName || !b_inst) continue; let mod = system.modules[moduleName]; - + if (mod && typeof mod.getMacro === 'function') { let callArgsStr = call.substring(call.indexOf(' ') + 1).trim(); let callArgs = callArgsStr ? callArgsStr.split(',').map(s=>s.trim()).filter(s=>s!=="") : []; let callArity = callArgs.length; - + if (callArgs.some(arg => arg === "undefined")) { report.logError("Macro call contains 'undefined' argument. Call: " + call, b_inst); } - - + + // The arity check needs the macro DEFINITION, not the inline expansion. // Blocks switch on an empty first argument to emit the `.macro` line // (see uart_tx getMacroSingleShot, uart_rx getMacro), so calling with diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js b/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js index b443cfd..742d3a0 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js @@ -1605,7 +1605,7 @@ function allocatePruRegisters() { instanceName : instanceName, groupContents : groupContents }); - + // A group listed inside a loop's $groupContents is emitted outside the loop // and never called. We reject this. if (containerOf[instanceName]) { From e545b3026d02caca1c546dfcbb92200a3cead27f Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 14:34:18 +0530 Subject: [PATCH 07/44] Neutral comments; do not reference scripts and blocks added later --- .../common/pru_blocks_static_module.syscfg.js | 10 ++++---- .../pru_blocks/common/pru_device_caps.js | 6 ++--- .../pru_io_blocks/pru_spi_read.syscfg.js | 2 +- scripts/block_validate_runner.js | 25 +++++++++---------- tests/test_fail_closed_gating.py | 11 ++++---- 5 files changed, 26 insertions(+), 28 deletions(-) diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js index b4ac5d0..bada521 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js @@ -1198,24 +1198,24 @@ function validatePruInstructions(inst, report) { * GUI pin warnings: shared GPO/GPI physical pads, duplicate GPO drivers, * malformed getPinUsage results, and unavailable device/core pad data. * - * scripts/check_pin_collisions.js (run by the suite and CI) fails a design where a two-way (bidirectional) + * The command-line pin-collision check (run by the test suite and CI) fails a design where a two-way (bidirectional) * pad is shared by blocks of DIFFERENT modules (e.g. two blocks of different modules reading and driving the same two-way pad). * This validator emits a report.logWarning (not logError, so code generation still succeeds) to give * the user immediate feedback in the SysConfig GUI before CI runs. * - * Verdict uses the exact same data and exemptions as CI: + * The check uses the same data and exemptions as the command-line pin-collision check: * - claims come from each block's moduleStatic pin usage query (inst); * - exempt (no warning): * (a) the same instance's own open-drain pair; * (b) different instances of the SAME block module that classify SEQUENTIAL (sourced from - * scripts/block_control_flow.js via generated block_control_flow_data.js). + * the generated common/block_control_flow_data.js table). * - only pads that are actually shared between GPO and GPI on the device count (derived from * SysConfig device data). * * Warning text names both blocks (instance names), the pad, and warns that CI's pin check will FAIL. * Device scope: both read the design's own device and PRU core (CI from the @cliArgs --device/--context header, the GUI from system.deviceData and the core name), so they agree on every device. */ -// SysConfig core name -> pad signal prefix, same mapping as scripts/check_pin_collisions.js corePrefix(): +// SysConfig core name -> pad signal prefix, same mapping as the command-line pin-collision check: // icss_g_[rtu_|tx_]pru -> PRG_PRU (ICSSG), icss_m_pru -> PR_PRU (ICSSM). function padPrefixForCore(coreName) { const m = /^icss_([gm])(\d)(?:_(?:rtu|tx))?_pru(\d)$/.exec(coreName || ''); @@ -1402,7 +1402,7 @@ function validateBidiPinCollisionsGlobal(inst, report) { // two different instances drive one GPO index and at least one block is CONTINUOUS // (fail closed: a block missing from the control-flow table counts as continuous). Mirrors - // the duplicate-GPO rule in scripts/check_pin_collisions.js checkSyscfg. + // the duplicate-GPO rule of the command-line pin-collision check. for (let x = 0; x < gpoBits.length; x++) { for (let y = x + 1; y < gpoBits.length; y++) { const a = gpoBits[x], b = gpoBits[y]; diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_device_caps.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_device_caps.js index a60827f..0e874a4 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_device_caps.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_device_caps.js @@ -1,12 +1,12 @@ "use strict"; /* - * pru_device_caps.js — single-source device capability table for. + * pru_device_caps.js — single-source device capability table for the PRU blocks. * - * TRM evidence (authoritative, do not re-derive): + * TRM references: * - AM64x/AM243x carry PRU_ICSSG (SPRUIM2J §6.4.1.1) * - AM261x carries PRU_ICSSM (SPRUJB6 §7.3.1.1) * - * Widget listing derived from the table in the task: + * Widget availability per subsystem: * widget ICSSG (AM64x/AM243x) ICSSM (AM261x/AM263Px) * MAC (multiply/acc) yes yes * CRC16/32 yes yes diff --git a/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js index 6f8a098..d261a39 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js @@ -121,7 +121,7 @@ function getNumOfBytes(value) } // A full variant name (not the bare "m_read_packet_spi_" prefix that -// scripts/block_control_flow.js probes with) must always carry the insert point. +// the block control-flow classifier probes with) must always carry the insert point. const READ_VARIANT_NAME = /^m_read_packet_spi_mode[0-3]_(slave_)?(msb|lsb)_/; function insertReadResultInitialization(macroBody, dataReg, macroName) diff --git a/scripts/block_validate_runner.js b/scripts/block_validate_runner.js index fea62ff..2a07b12 100644 --- a/scripts/block_validate_runner.js +++ b/scripts/block_validate_runner.js @@ -17,8 +17,8 @@ * node scripts/block_validate_runner.js --report * * blockPath forms: - * pru_io_blocks/spi_adc_sequencer_block - * .metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/spi_adc_sequencer_block.syscfg.js + * pru_io_blocks/pru_spi_read + * .metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js * pru_blocks_static_module (device-capability static module) * * instJson: JSON object for the block instance. Extra keys are ignored by @@ -30,8 +30,8 @@ * * Output (--sweep): JSON array of {block, file, hasValidate, errorCount, warningCount} * Output (--report): same as --sweep plus {validatorsFound, armedCount} summary - * armed = blocks whose error paths have a committed red/control pair - * in tests/test_block_validators.py + * armed = blocks whose error paths have a committed red/control test + * pair (listed in ARMED_BLOCKS below) * * Exit code 0 on success, 2 on harness error. */ @@ -240,18 +240,17 @@ function printSweep(asReport) { return; } // For --report we also emit armed summary based on committed test arms - // The committed armed set lives in tests/test_block_validators.py:ARMED_BLOCKS - // We duplicate the list here so the runner can report without importing python. - // Keep in sync — if you arm a new block, add it to both places. - const ARMED_BLOCKS = [ - 'spi_adc_sequencer_block', // sample_period - ]; + // Blocks whose validator error paths have a committed red/control test pair, + // mapped to the number of error paths those tests cover. Kept here so the + // runner can report without importing Python; add a block when its + // validator test pair is added. + const ARMED_ERROR_PATHS_BY_BLOCK = { + }; + const ARMED_BLOCKS = Object.keys(ARMED_ERROR_PATHS_BY_BLOCK); const validatorsFound = rows.length; const armedCount = rows.filter(r => ARMED_BLOCKS.includes(r.block)).length; - // Also count error paths found vs armed paths — for now 1 path per armed block starter const errorPathsFound = rows.reduce((s, r) => s + r.errorCount, 0); - // Armed error paths: we know sample_period is 1 of 3 paths in spi_adc_sequencer_block - const ARMED_ERROR_PATHS = 1; + const ARMED_ERROR_PATHS = ARMED_BLOCKS.reduce((s, b) => s + ARMED_ERROR_PATHS_BY_BLOCK[b], 0); console.log(JSON.stringify({ validators: rows, summary: { diff --git a/tests/test_fail_closed_gating.py b/tests/test_fail_closed_gating.py index a24f24d..9a616f5 100644 --- a/tests/test_fail_closed_gating.py +++ b/tests/test_fail_closed_gating.py @@ -1,8 +1,8 @@ """ The device-capability gate must fail CLOSED or error VISIBLY on every failure path. -Failure paths covered (all previously failed OPEN — silently skipped validation -and left the build clean): +Failure paths covered (each must report an error instead of silently skipping +validation and leaving the build clean): 1. getScript("/common") failure -> silent return (was: return) 2. missing SoC (getSocName() -> null) -> silent return (was: return) 3. isWidgetAvailable() exception -> available = true (was: true) @@ -109,15 +109,14 @@ def test_caps_module_unknown_soc_has_no_widgets(): def test_validator_fail_closed_source_guards(): - """Lint only - NOT load-bearing coupling evidence. + """Lint only; the behavioural tests above are the real check. These are whitespace-literal substring checks. Re-introducing the exact fail-open as `catch (e) { available = true; }` passes them both while - the behavioural arms above correctly go red. Reviewed 2026-09-09; kept as - cheap lint, but the proof that the gate fails closed is the five - run_validator arms, never this function. + the behavioural arms above correctly go red. Kept as cheap lint; the + check that the gate fails closed is the five run_validator arms. """ src = (REPO_ROOT / ".metadata" / "sysconfig" / ".meta" / "pru_blocks" / "common" / "pru_blocks_static_module.syscfg.js").read_text(encoding="utf-8") From de6f812153719ed419571da5204ff15a760b67c9 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 15:29:46 +0530 Subject: [PATCH 08/44] Improve validation descriptions and test coverage --- .../pru_blocks/common/pru_blocks_static_module.syscfg.js | 4 ++-- .../.meta/pru_blocks/common/pru_reserved_names.js | 9 ++++----- .../common/register_allocation/pru_register_allocator.js | 4 ++-- .../pru_blocks/program_control/loop_block.syscfg.js | 6 ++++-- scripts/shared_memvar_runner.js | 2 +- tests/conftest.py | 2 +- tests/test_fail_closed_gating.py | 6 +++--- tests/test_syscfg_add_instance.py | 4 ++-- tests/test_uart_crc_firmware.py | 1 + 9 files changed, 20 insertions(+), 18 deletions(-) diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js index bada521..9b6919d 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js @@ -772,7 +772,7 @@ function getInitSectDirectives() { // c28 hits local 0x00000000 -- the core's own data RAM -- not shared RAM. // The design-wide fix lives here, once, rather than in each block: one write // at every entry point into the generated code serves all c28 users and costs -// the per-call blocks (mailbox, gateway) nothing. +// the per-call blocks that use c28 nothing. // PRU_CTRL base of each SysConfig core context (core-local addresses). // SPRUIM2J Table 6-65 entry 11 and the CTPPR0 instance table; SPRUJB6 confirms only the c28 pointer formula and the local control bases; @@ -1290,7 +1290,7 @@ function isSequentialBlock(blockName, instCfg) { } if (bcfData && bcfData.blockControlFlow && bcfData.blockControlFlow[blockName] !== undefined) { if (bcfData.blockControlFlow[blockName] === 'sequential') return true; - // continuous by default, sequential once the block's bound config is > 0 (pwm_block periodCount). + // continuous by default, sequential once the block's bound config is > 0 (e.g. a period count). const bound = bcfData.blockControlFlowBound && bcfData.blockControlFlowBound[blockName]; return !!(bound && instCfg && Number.isInteger(Number(instCfg[bound])) && Number(instCfg[bound]) > 0); } diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_reserved_names.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_reserved_names.js index 7d0e604..4ccbe75 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_reserved_names.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_reserved_names.js @@ -8,11 +8,10 @@ * this file from its validate(); the list lives HERE ONLY — do not copy it * into blocks. * - * Oracle: TI clpru 2.3.3 (ti-cgt-pru-linux). Each name below was probed as a - * data symbol (` .byte 1, 2, 3` in .initdmem — exactly what the CRC/LUT - * blocks emit) and as a code label (`:`); every one fails with - * [E0300] " is a reserved identifier". Any name that assembles was - * REMOVED from this list (kept red): SCAN, BROAD, IDLE, MULL, MUL, b4-b7, + * Checked against TI clpru 2.3.3: each name below, used as a data symbol + * (` .byte 1, 2, 3` in .initdmem — exactly what the CRC/LUT blocks emit) + * and as a code label (`:`), fails with [E0300] " is a reserved + * identifier". Names that assemble are not listed: SCAN, BROAD, IDLE, MULL, MUL, b4-b7, * w0-w7, assembler directives (byte/ushort/uint/usect/text/sect/global/...), * sp, pc, main, call, ret, TEMP_REG1 and ordinary names all assemble fine. * diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js b/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js index 742d3a0..8e1a95d 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js @@ -406,8 +406,8 @@ function disallocatePruRegisters(byteOffset, numOfBytes) { } // a connection names the SOURCE PORT it leaves from (conn.port === "output2"), and every output port a block -// allocates has its own register (byteOffsetOfOutput / numOfBytesReqByOutput). The allocator used to read only output1, -// so a consumer wired to output2..N silently read output1's register. All consumer-side lookups go through these helpers. +// allocates has its own register (byteOffsetOfOutput / numOfBytesReqByOutput). Reading only output1 would make +// a consumer wired to output2..N silently read output1's register. All consumer-side lookups go through these helpers. let allocationFailures = []; // register-pool exhaustion, one record per request that could not be placed; reported by validation let unroutedConnections = []; // consumers wired to an output port that owns no register; reported by validation, never rerouted to output1 let loopBoundaryViolations = []; // loop contents consumed outside loop; reported by validation diff --git a/.metadata/sysconfig/.meta/pru_blocks/program_control/loop_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/program_control/loop_block.syscfg.js index 3db5b88..388a231 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/program_control/loop_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/program_control/loop_block.syscfg.js @@ -495,7 +495,8 @@ exports = { { name: "exportedBlocks", displayName: "Exported Result Blocks", - description: "Blocks inside this loop whose final-iteration output may be read by blocks outside the loop (for example a memory write storing the accumulated sum once, after the loop). Without an entry here, a consumer outside the loop reading a block inside it is a container-boundary error.", + description: "Blocks inside this loop whose final-iteration output may be read by blocks outside the loop.", + longDescription: "Blocks inside this loop whose final-iteration output may be read by blocks outside the loop (for example a memory write storing the accumulated sum once, after the loop). Without an entry here, a consumer outside the loop reading a block inside it is a container-boundary error.", default: [], minSelections: 0, options: (inst) => { @@ -510,7 +511,8 @@ exports = { { name: "accumulators", displayName: "Accumulator Bindings", - description: "Loop-carried accumulator bindings as a JSON string '[{\\\"preInit\\\": \\\"\\\", \\\"update\\\": \\\"\\\"}]': the update (Arithmetic/Bitwise) block's destination register is coalesced with its preInit block's register, so the update reads and writes the same register on every pass with zero extra cycles per iteration. The preInit block must also be listed in Pre-Initialization Blocks, both blocks must sit inside this loop, and the preInit value must have exactly one data reader (the update block).", + description: "Loop-carried accumulator bindings (JSON): the update block reads and writes its preInit block's register on every pass.", + longDescription: "Loop-carried accumulator bindings as a JSON string '[{\\\"preInit\\\": \\\"BLOCK_NAME\\\", \\\"update\\\": \\\"BLOCK_NAME\\\"}]': the update (Arithmetic/Bitwise) block's destination register is coalesced with its preInit block's register, so the update reads and writes the same register on every pass with zero extra cycles per iteration. The preInit block must also be listed in Pre-Initialization Blocks, both blocks must sit inside this loop, and the preInit value must have exactly one data reader (the update block).", default: "", }, { diff --git a/scripts/shared_memvar_runner.js b/scripts/shared_memvar_runner.js index acb9e56..35eba76 100644 --- a/scripts/shared_memvar_runner.js +++ b/scripts/shared_memvar_runner.js @@ -55,7 +55,7 @@ function run(insts) { } const cases = { - // the defect: rd.memVar.labelName = in_buf; wr.memVar.labelName = out_buf on ONE object + // the rename hazard: rd.memVar.labelName = in_buf; wr.memVar.labelName = out_buf on ONE object sharedMemVar: run([access("rd", "read", memVars.shared), access("wr", "write", memVars.shared)]), // the guided pattern: explicit instances assigned by reference separateMemVars: run([access("rd", "read", memVars.inBuf), access("wr", "write", memVars.outBuf)]), diff --git a/tests/conftest.py b/tests/conftest.py index 8c99498..d7b4437 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,6 @@ import sys _REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -for p in (_REPO_ROOT, os.path.join(_REPO_ROOT, "scripts", "sim")): +for p in (_REPO_ROOT,): if p not in sys.path: sys.path.insert(0, p) diff --git a/tests/test_fail_closed_gating.py b/tests/test_fail_closed_gating.py index 9a616f5..36f7f9d 100644 --- a/tests/test_fail_closed_gating.py +++ b/tests/test_fail_closed_gating.py @@ -15,9 +15,9 @@ .metadata/sysconfig/.meta/pru_blocks/common/pru_device_caps.js via scripts/static_module_validate_runner.js. -Red-proof contract: if any fail-closed branch is reverted to its old fail-open -form, the corresponding test below goes RED because the runner then reports an -empty error list ("clean build") for that degraded mode. +If any fail-closed branch is changed to fail open, the corresponding test below +fails, because the runner then reports an empty error list ("clean build") for +that degraded mode. """ import json import pathlib diff --git a/tests/test_syscfg_add_instance.py b/tests/test_syscfg_add_instance.py index 46e6b4d..2239059 100644 --- a/tests/test_syscfg_add_instance.py +++ b/tests/test_syscfg_add_instance.py @@ -72,10 +72,10 @@ def test_every_block_module_adds_an_instance() -> None: def test_red_arm_rejects_a_deliberately_invalid_module_path() -> None: - """Prove the add-instance assertion goes red when a design names no module.""" + """The add-instance assertion fails when a design names no module.""" result = _run("/pru_blocks/this_module_does_not_exist") output = result.stdout + result.stderr assert "Exception occurred calling scripting.addModule()" in output - # Same oracle used by the all-module test must reject this deliberately bad design. + # The check used by the all-module test must reject this deliberately bad design. with pytest.raises(AssertionError, match="rejected module path"): _assert_instantiated("/pru_blocks/this_module_does_not_exist", result) diff --git a/tests/test_uart_crc_firmware.py b/tests/test_uart_crc_firmware.py index 61a82f3..2142cb0 100644 --- a/tests/test_uart_crc_firmware.py +++ b/tests/test_uart_crc_firmware.py @@ -149,6 +149,7 @@ def test_pru1_bad_crc_frame_reports_mismatch(core): assert smem[0] == crc8([0x41]), "SMEM[0] (computed CRC) = 0x%02X, want 0x%02X" % (smem[0], crc8([0x41])) assert smem[1] == 0x11, "SMEM[1] (received CRC) = 0x%02X, want 0x11" % smem[1] assert smem[2] == 0xFF, "SMEM[2] = 0x%02X: a frame with a wrong CRC must report mismatch (0xFF)" % smem[2] + assert smem[3] == 0x41, "SMEM[3] (received data) = 0x%02X, want 0x41" % smem[3] @needs_tools From 6bb6e1286d75eca3b40f49723b0384f58bd25fa7 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 16:24:53 +0530 Subject: [PATCH 09/44] Pin-sharing warnings describe the conflict instead of a check that does not exist; neutral test wording --- .../common/pru_blocks_static_module.syscfg.js | 35 +++++++++---------- .../pru_blocks/common/pru_reserved_names.js | 8 ++--- .../conditional_block.syscfg.js | 2 +- .../program_control/group_block.syscfg.js | 2 +- scripts/block_validate_runner.js | 6 ++-- tests/test_conditional_unconnected_branch.py | 5 ++- tests/test_fail_closed_gating.py | 11 +++--- tests/test_syscfg_add_instance.py | 2 +- tests/test_uart_crc_firmware.py | 2 +- 9 files changed, 36 insertions(+), 37 deletions(-) diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js index 9b6919d..fb2f0b6 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js @@ -1198,12 +1198,12 @@ function validatePruInstructions(inst, report) { * GUI pin warnings: shared GPO/GPI physical pads, duplicate GPO drivers, * malformed getPinUsage results, and unavailable device/core pad data. * - * The command-line pin-collision check (run by the test suite and CI) fails a design where a two-way (bidirectional) - * pad is shared by blocks of DIFFERENT modules (e.g. two blocks of different modules reading and driving the same two-way pad). - * This validator emits a report.logWarning (not logError, so code generation still succeeds) to give - * the user immediate feedback in the SysConfig GUI before CI runs. + * A two-way (bidirectional) pad shared by blocks of DIFFERENT modules (e.g. two blocks of different + * modules reading and driving the same two-way pad) makes the generated firmware contend on that pad. + * This validator emits a report.logWarning (not logError, so code generation still succeeds) so the + * user sees the conflict in the SysConfig GUI. * - * The check uses the same data and exemptions as the command-line pin-collision check: + * Rules and exemptions: * - claims come from each block's moduleStatic pin usage query (inst); * - exempt (no warning): * (a) the same instance's own open-drain pair; @@ -1212,10 +1212,10 @@ function validatePruInstructions(inst, report) { * - only pads that are actually shared between GPO and GPI on the device count (derived from * SysConfig device data). * - * Warning text names both blocks (instance names), the pad, and warns that CI's pin check will FAIL. - * Device scope: both read the design's own device and PRU core (CI from the @cliArgs --device/--context header, the GUI from system.deviceData and the core name), so they agree on every device. + * Warning text names both blocks (instance names) and the pad. + * Device scope: the design's own device and PRU core (system.deviceData and the core name). */ -// SysConfig core name -> pad signal prefix, same mapping as the command-line pin-collision check: +// SysConfig core name -> pad signal prefix: // icss_g_[rtu_|tx_]pru -> PRG_PRU (ICSSG), icss_m_pru -> PR_PRU (ICSSM). function padPrefixForCore(coreName) { const m = /^icss_([gm])(\d)(?:_(?:rtu|tx))?_pru(\d)$/.exec(coreName || ''); @@ -1302,7 +1302,7 @@ function validateBidiPinCollisionsGlobal(inst, report) { // GUI pin warnings: shared GPO/GPI physical pads, duplicate GPO drivers //, malformed pin-usage results, and unavailable device/core pad data. // The GPO/GPI shared-pad rule needs device pad data; the duplicate-GPO rule and the - // 'could not evaluate' diagnostics do not, exactly as in CI's checkSyscfg. + // 'could not evaluate' diagnostics do not. const padSharing = getDevicePadSharing(system.deviceData, system.getScript("/common").getSelfSysCfgCoreName()); const sharedIndices = padSharing || new Set(); @@ -1328,9 +1328,9 @@ function validateBidiPinCollisionsGlobal(inst, report) { } const block = modName.split('/').pop(); if (!Array.isArray(pins)) { - // CI's for-of over a non-iterable throws and fails the design. + // A non-array result cannot be checked; report it rather than skip it silently. report.logWarning( - `Pin-sharing check could not evaluate block '${iname}' (${modName}): its pin-usage function did not return an array; CI's pin check will FAIL this design.`, + `Pin-sharing check could not evaluate block '${iname}' (${modName}): its pin-usage function did not return an array, so its pins are not checked for sharing.`, inst ); continue; @@ -1338,7 +1338,7 @@ function validateBidiPinCollisionsGlobal(inst, report) { for (const p of pins) { if (!p || (p.dir !== "GPO" && p.dir !== "GPI")) { report.logWarning( - `Pin-sharing check could not evaluate block '${iname}' (${modName}): its pin-usage function returned an entry without a GPO/GPI dir; CI's pin check will FAIL this design.`, + `Pin-sharing check could not evaluate block '${iname}' (${modName}): its pin-usage function returned an entry without a GPO/GPI dir, so that entry is not checked for sharing.`, inst ); continue; @@ -1346,7 +1346,7 @@ function validateBidiPinCollisionsGlobal(inst, report) { const pin = parseInt(p.pin); if (isNaN(pin)) { report.logWarning( - `Pin-sharing check could not evaluate block '${iname}' (${modName}): unparseable ${p.dir} pin "${String(p.pin)}"; CI's pin check will FAIL this design.`, + `Pin-sharing check could not evaluate block '${iname}' (${modName}): unparseable ${p.dir} pin "${String(p.pin)}", so that pin is not checked for sharing.`, inst ); continue; @@ -1372,7 +1372,7 @@ function validateBidiPinCollisionsGlobal(inst, report) { if (padSharing === null && gpoBits.length && gpiBits.length) { report.logWarning( - "GPO/GPI pad sharing could not be checked for this device/core; CI's pin check will FAIL this design.", + "GPO/GPI pad sharing could not be checked for this device/core: no pad data is available, so shared GPO/GPI pads are not detected.", inst ); } @@ -1393,7 +1393,7 @@ function validateBidiPinCollisionsGlobal(inst, report) { warnedPairs.add(pairKey); report.logWarning( - `Pad ${o.pin} is driven (GPO) by '${o.iname}' (${o.block}) and read (GPI) by '${i.iname}' (${i.block}); CI's pin check will FAIL this design.`, + `Pad ${o.pin} is driven (GPO) by '${o.iname}' (${o.block}) and read (GPI) by '${i.iname}' (${i.block}); the generated firmware will contend on this pad.`, inst ); } @@ -1401,8 +1401,7 @@ function validateBidiPinCollisionsGlobal(inst, report) { } // two different instances drive one GPO index and at least one block is CONTINUOUS - // (fail closed: a block missing from the control-flow table counts as continuous). Mirrors - // the duplicate-GPO rule of the command-line pin-collision check. + // (fail closed: a block missing from the control-flow table counts as continuous). for (let x = 0; x < gpoBits.length; x++) { for (let y = x + 1; y < gpoBits.length; y++) { const a = gpoBits[x], b = gpoBits[y]; @@ -1413,7 +1412,7 @@ function validateBidiPinCollisionsGlobal(inst, report) { .filter((blk, k, arr) => arr.indexOf(blk) === k); if (!cont.length) continue; report.logWarning( - `GPO ${a.pin} is driven by both '${a.iname}' (${a.block}) and '${b.iname}' (${b.block}); continuous: ${cont.join(', ')}; CI's pin check will FAIL this design (duplicate GPO drivers).`, + `GPO ${a.pin} is driven by both '${a.iname}' (${a.block}) and '${b.iname}' (${b.block}); continuous: ${cont.join(', ')}; the drivers will overwrite each other on this pin (duplicate GPO drivers).`, inst ); } diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_reserved_names.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_reserved_names.js index 4ccbe75..4ba7328 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_reserved_names.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_reserved_names.js @@ -15,7 +15,7 @@ * w0-w7, assembler directives (byte/ushort/uint/usect/text/sect/global/...), * sp, pc, main, call, ret, TEMP_REG1 and ordinary names all assemble fine. * - * Case (each claim probed with clpru itself, both as a data symbol and as + * Case (each claim checked with clpru 2.3.3, both as a data symbol and as * a code label): register names r0-r31/c0-c31 * are case-insensitive (R5, C0 fail) — ANY case is rejected. Mnemonics are * rejected in exact-lowercase (add) and exact-ALL-UPPERCASE (ADD) only; a @@ -38,10 +38,10 @@ var REGISTER_NAMES = []; })(); // Byte-lane selectors the assembler reserves as identifiers (w0-w7 are NOT -// reserved — probed, they assemble — so only b0-b3 are listed). +// reserved — checked with clpru, they assemble — so only b0-b3 are listed). var BYTE_LANE_NAMES = ["b0", "b1", "b2", "b3"]; -// PRU instruction mnemonics clpru reserves (each probed lowercase and +// PRU instruction mnemonics clpru reserves (each checked lowercase and // ALL-UPPERCASE; mixed-case forms assemble and are NOT rejected — see // isReservedAsmName. SCAN/BROAD/IDLE/MULL/MUL assemble and are NOT listed). var MNEMONIC_NAMES = [ @@ -112,7 +112,7 @@ function validateInstanceName(inst, report, detail) { * Reject a reserved value in a derived assembly symbol. Some blocks never * emit the raw name: conditional emits `_TRUE`/`_FALSE` branch * labels, group emits `_start`, pid emits `_pid_state`. - * Those derived symbols assemble even for a reserved base (probed: c0_TRUE, + * Those derived symbols assemble even for a reserved base (checked with clpru: c0_TRUE, * c0_start, c0_pid_state and add_TRUE all build), so only the DERIVED form * is checked — a reserved base whose derived symbols assemble is accepted. * `field` is a config field name, or "$name" for the instance name. diff --git a/.metadata/sysconfig/.meta/pru_blocks/program_control/conditional_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/program_control/conditional_block.syscfg.js index 8adb492..147a73f 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/program_control/conditional_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/program_control/conditional_block.syscfg.js @@ -1,5 +1,5 @@ function validate(inst, report) { - // "_TRUE"/"_FALSE" branch labels are derived from $name; only the derived forms are checked (a reserved base such as c0 still assembles as c0_TRUE — probed with clpru). + // "_TRUE"/"_FALSE" branch labels are derived from $name; only the derived forms are checked (a reserved base such as c0 still assembles as c0_TRUE — checked with clpru 2.3.3). if (typeof system !== "undefined" && system.getScript) { var reservedNames = system.getScript("/pru_blocks/common/pru_reserved_names.js"); if (reservedNames && typeof reservedNames.validateDerivedSymbol === "function") { diff --git a/.metadata/sysconfig/.meta/pru_blocks/program_control/group_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/program_control/group_block.syscfg.js index 5343596..7e07e72 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/program_control/group_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/program_control/group_block.syscfg.js @@ -1,5 +1,5 @@ function validate(inst, report) { - // the "_start" entry label is derived from groupName; only the derived form is checked (a reserved base such as c0 still assembles as c0_start — probed with clpru). + // the "_start" entry label is derived from groupName; only the derived form is checked (a reserved base such as c0 still assembles as c0_start — checked with clpru 2.3.3). if (typeof system !== "undefined" && system.getScript) { var reservedNames = system.getScript("/pru_blocks/common/pru_reserved_names.js"); if (reservedNames && typeof reservedNames.validateDerivedSymbol === "function") { diff --git a/scripts/block_validate_runner.js b/scripts/block_validate_runner.js index 2a07b12..7b24881 100644 --- a/scripts/block_validate_runner.js +++ b/scripts/block_validate_runner.js @@ -207,7 +207,7 @@ function runValidate(blockPath, inst) { try { validate(inst, report); } catch (e) { - // Keep red results red — a validator that throws is a failure, not a silent pass + // A validator that throws is reported as an error, not as a clean result errors.push({ message: `validator threw: ${e.message}`, field: null }); } finally { if (prevSystem !== undefined) global.system = prevSystem; @@ -285,7 +285,7 @@ function main() { } if (!blockPath) { console.error('Usage: node scripts/block_validate_runner.js \'\' OR --sweep OR --report'); - console.error('Example: node scripts/block_validate_runner.js pru_io_blocks/spi_adc_sequencer_block \'{"sample_period":100,"noOfCycles":970}\''); + console.error('Example: node scripts/block_validate_runner.js utils/delay_block \'{"delayCount":100}\''); process.exit(2); } let inst = {}; @@ -300,7 +300,7 @@ function main() { inst = JSON.parse(fs.readFileSync(fp, 'utf8')); } const result = runValidate(blockPath, inst); - // Keep red results red: emit structured errors, exit 0 so caller can inspect red vs green + // Emit structured errors and exit 0 so the caller can inspect errors vs a clean result console.log(JSON.stringify({ file: path.relative(ROOT, result.file).replace(/\\/g, '/'), errors: result.errors, warnings: result.warnings, infos: result.infos })); } diff --git a/tests/test_conditional_unconnected_branch.py b/tests/test_conditional_unconnected_branch.py index e4e8ec5..2788274 100644 --- a/tests/test_conditional_unconnected_branch.py +++ b/tests/test_conditional_unconnected_branch.py @@ -5,9 +5,8 @@ at validate time, with a clear message naming the port. A fully connected design must still validate without that error. -The harness is the committed runner (scripts/block_validate_runner.js), the -same one the other block-validator tests use. Control and red arms differ only -in the T/F lists. +The harness is scripts/block_validate_runner.js. The connected and the +unconnected cases differ only in the T/F lists. """ import json diff --git a/tests/test_fail_closed_gating.py b/tests/test_fail_closed_gating.py index 36f7f9d..1e88d5f 100644 --- a/tests/test_fail_closed_gating.py +++ b/tests/test_fail_closed_gating.py @@ -3,11 +3,12 @@ Failure paths covered (each must report an error instead of silently skipping validation and leaving the build clean): - 1. getScript("/common") failure -> silent return (was: return) - 2. missing SoC (getSocName() -> null) -> silent return (was: return) - 3. isWidgetAvailable() exception -> available = true (was: true) - 4. null pruDeviceCaps (caps unloadable) -> silent return (was: return) - 5. unknown SoC -> default ICSSM caps (was: ICSSM default) + failure path fail-open behaviour this test rejects + 1. getScript("/common") failure -> silent return + 2. missing SoC (getSocName() -> null) -> silent return + 3. isWidgetAvailable() exception -> available = true + 4. null pruDeviceCaps (caps unloadable) -> silent return + 5. unknown SoC -> default ICSSM caps Drives the REAL validator in .metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js diff --git a/tests/test_syscfg_add_instance.py b/tests/test_syscfg_add_instance.py index 2239059..1a64f9e 100644 --- a/tests/test_syscfg_add_instance.py +++ b/tests/test_syscfg_add_instance.py @@ -71,7 +71,7 @@ def test_every_block_module_adds_an_instance() -> None: _assert_instantiated(module_path, _run(module_path)) -def test_red_arm_rejects_a_deliberately_invalid_module_path() -> None: +def test_an_invalid_module_path_is_rejected() -> None: """The add-instance assertion fails when a design names no module.""" result = _run("/pru_blocks/this_module_does_not_exist") output = result.stdout + result.stderr diff --git a/tests/test_uart_crc_firmware.py b/tests/test_uart_crc_firmware.py index 2142cb0..a80cb8a 100644 --- a/tests/test_uart_crc_firmware.py +++ b/tests/test_uart_crc_firmware.py @@ -65,7 +65,7 @@ def crc8(data, poly=0x07): return crc -def test_oracle_matches_the_documented_frames(): +def test_reference_crc_matches_the_documented_frames(): assert crc8([0x41]) == 0xC0 assert crc8([0x41]) != 0x11 assert crc8([0xAA]) == 0x5F From 469fc5356c6d793122fa2e092e763bbc757f040d Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 16:25:19 +0530 Subject: [PATCH 10/44] uart_crc firmware test: accept CGT_TI_PRU_PATH as used by imports.mak --- tests/test_uart_crc_firmware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_uart_crc_firmware.py b/tests/test_uart_crc_firmware.py index a80cb8a..305a06a 100644 --- a/tests/test_uart_crc_firmware.py +++ b/tests/test_uart_crc_firmware.py @@ -35,7 +35,7 @@ SYSCFG_DIR = os.environ.get("SYSCFG_PATH", os.path.join(TI_DIR, "sysconfig_1.28.0")) SYSCFG_NODE = os.path.join(SYSCFG_DIR, "nodejs", "node.exe" if os.name == "nt" else "node") SYSCFG_CLI = os.path.join(SYSCFG_DIR, "dist", "cli.js") -CGT_DIR = os.environ.get("PRU_CGT_PATH", os.path.join(TI_DIR, "ti-cgt-pru_2.3.3")) +CGT_DIR = os.environ.get("CGT_TI_PRU_PATH") or os.environ.get("PRU_CGT_PATH") or os.path.join(TI_DIR, "ti-cgt-pru_2.3.3") CLPRU = os.path.join(CGT_DIR, "bin", "clpru.exe" if os.name == "nt" else "clpru") SIMDIR = os.environ.get("PRU_SIMULATOR_PATH", "") From 7e4cb23c0e576420c333e5a0e3d2c5eb0c6dd8a4 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 16:26:53 +0530 Subject: [PATCH 11/44] Neutral comment wording in allocator, SPI read and validator runner --- .../common/register_allocation/pru_register_allocator.js | 2 +- .../.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js | 2 +- scripts/block_validate_runner.js | 6 +++--- tests/test_fail_closed_gating.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js b/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js index 8e1a95d..c0ae60e 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js @@ -472,7 +472,7 @@ function outputRegisterNotations(entry) { * Register token a consumer reads for input wired to the source port named by `conn`. * Returns the source output port's own register; a missing/malformed port name or a port that owns no register is * recorded in unroutedConnections (so validation names the block and port) and yields a token that cannot assemble, - * because substituting output1's register is the silent wrong-code defect this replaces. + * because silently substituting output1's register would assemble into wrong code. */ function sourceRegisterToken(consumer, inputPort, conn) { const source = conn.inst; diff --git a/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js index d261a39..112ab00 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js @@ -1488,7 +1488,7 @@ exports = { }, }, getMacro, - // Kept private to this module's regression harness; production callers use getMacro. + // Exposed for this module's tests; production callers use getMacro. _testInsertReadResultInitialization: insertReadResultInitialization } diff --git a/scripts/block_validate_runner.js b/scripts/block_validate_runner.js index 7b24881..afc4ce4 100644 --- a/scripts/block_validate_runner.js +++ b/scripts/block_validate_runner.js @@ -22,7 +22,7 @@ * pru_blocks_static_module (device-capability static module) * * instJson: JSON object for the block instance. Extra keys are ignored by - * validators that don't read them — keep red/control arms same-shape: + * validators that don't read them — keep error-case/control arms same-shape: * only the field under test should differ. * * Output (single-block mode): JSON {errors:[], warnings:[], infos:[]} @@ -30,7 +30,7 @@ * * Output (--sweep): JSON array of {block, file, hasValidate, errorCount, warningCount} * Output (--report): same as --sweep plus {validatorsFound, armedCount} summary - * armed = blocks whose error paths have a committed red/control test + * armed = blocks whose error paths have a committed error-case/control test * pair (listed in ARMED_BLOCKS below) * * Exit code 0 on success, 2 on harness error. @@ -240,7 +240,7 @@ function printSweep(asReport) { return; } // For --report we also emit armed summary based on committed test arms - // Blocks whose validator error paths have a committed red/control test pair, + // Blocks whose validator error paths have a committed error-case/control test pair, // mapped to the number of error paths those tests cover. Kept here so the // runner can report without importing Python; add a block when its // validator test pair is added. diff --git a/tests/test_fail_closed_gating.py b/tests/test_fail_closed_gating.py index 1e88d5f..b68c9c5 100644 --- a/tests/test_fail_closed_gating.py +++ b/tests/test_fail_closed_gating.py @@ -116,7 +116,7 @@ def test_validator_fail_closed_source_guards(): fail-open as `catch (e) { available = true; }` passes them both while - the behavioural arms above correctly go red. Kept as cheap lint; the + the behavioural arms above fail. Kept as cheap lint; the check that the gate fails closed is the five run_validator arms. """ src = (REPO_ROOT / ".metadata" / "sysconfig" / ".meta" / "pru_blocks" / "common" From 8058a628a333446e0b468dd01a4edcc25bc3ce3a Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 20:56:58 +0530 Subject: [PATCH 12/44] SPI read: MODE1 and MODE3 LSB SCLK halves match the configured widths The MODE1 LSB loop advanced its bit counter in the low half, where the delay formulas assume none, so SCLK ran one cycle short high and one cycle long low. The MODE3 LSB loop sampled a 1 bit one instruction slower than a 0 bit, so its high half grew by a cycle on every 1. Both variants now count in the half the MSB variant counts in, and both sample paths take two instructions. --- .../pru_io_blocks/pru_spi_read.syscfg.js | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js index 112ab00..34f01d8 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/pru_io_blocks/pru_spi_read.syscfg.js @@ -228,13 +228,17 @@ SKIP_BIT_ENTRY_0?: set r30, r30, ${CS_PIN}` } if (macroName === "m_read_packet_spi_mode1_lsb_gpo_sclk") { + // The bit counter advances in the high half, as in the MSB variant, so both halves + // match delay_component1/2. bitId is a byte register (TEMP_REG1.b0): it starts at + // 0xFF and wraps to 0 on the first add, and the loop ends after bit PACKETSIZE-1. macroBody += ` clr r30, r30, ${CS_PIN} .loop ${CS_SETUP_TIME} nop .endloop - ldi ${bitId}, 0 + ldi ${bitId}, 0xFF READ_BIT_LOOP?: set r30, r30, ${SCLK_PIN} + add ${bitId}, ${bitId}, 1 .loop ${DATA_SETUP_TIME} nop .endloop @@ -249,11 +253,10 @@ BIT_ENTRY_0?: clr ${dataReg}, ${dataReg}, ${bitId} nop SKIP_BIT_ENTRY_0?: - add ${bitId}, ${bitId}, 1 .loop ${DELAY_COMPEN_2} ; 0 nop .endloop - qbne READ_BIT_LOOP?, ${bitId}, ${PACKETSIZE} + qbne READ_BIT_LOOP?, ${bitId}, ${PACKETSIZE}-1 .loop ${CS_HOLD_TIME} nop .endloop @@ -361,12 +364,15 @@ SKIP_BIT_ENTRY_0?: set r30, r30, ${CS_PIN}`; } if (macroName === "m_read_packet_spi_mode3_lsb_gpo_sclk") { + // Both sample paths take two instructions, and the bit counter advances in the low half, + // as the MSB variant's sub does, so both halves match delay_component1/2. bitId is a + // byte register (TEMP_REG1.b0): it starts at 0xFF and wraps to 0 on the first add. macroBody += ` set r30, r30, ${SCLK_PIN} clr r30, r30, ${CS_PIN} .loop ${CS_SETUP_TIME} nop .endloop - ldi ${bitId}, 0 + ldi ${bitId}, 0xFF READ_BIT_LOOP?: clr r30, r30, ${SCLK_PIN} .loop ${DATA_SETUP_TIME} @@ -375,19 +381,19 @@ READ_BIT_LOOP?: .loop ${DELAY_COMPEN_1} nop .endloop - nop + add ${bitId}, ${bitId}, 1 set r30, r30, ${SCLK_PIN} qbbc BIT_ENTRY_0?, r31, ${SDI_PIN} set ${dataReg}, ${dataReg}, ${bitId} qba SKIP_BIT_ENTRY_0? BIT_ENTRY_0?: clr ${dataReg}, ${dataReg}, ${bitId} + nop SKIP_BIT_ENTRY_0?: - add ${bitId}, ${bitId}, 1 .loop ${DELAY_COMPEN_2} nop .endloop - qbne READ_BIT_LOOP?, ${bitId}, ${PACKETSIZE} + qbne READ_BIT_LOOP?, ${bitId}, ${PACKETSIZE}-1 .loop ${CS_HOLD_TIME} nop .endloop From e76b0e8063b4db3f5847a5514b57e386af1148df Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 20:57:57 +0530 Subject: [PATCH 13/44] Example linker files place .udmem and .usmem The Memory Variable block reserves buffers in .udmem (data RAM) and .usmem (shared RAM). With no rule for either section clpru placed them in instruction RAM after .text. Each example linker file now places .udmem in the data RAM its .bss uses and .usmem in PRU_SHAREDMEM, and a test checks every tracked linker file. --- .../icss_g0_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_m1_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_pru1_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_rtu_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_rtu_pru1_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_tx_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_tx_pru1_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_pru1_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_rtu_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_rtu_pru1_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_tx_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_tx_pru1_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_m0_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_m0_pru1_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_m1_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_m1_pru1_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_pru1_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_rtu_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_rtu_pru1_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_tx_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_tx_pru1_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_m1_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_m1_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_m1_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_m1_pru1_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_pru1_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_m1_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_m1_pru1_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_g0_pru1_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_m1_pru0_fw/ti-pru-cgt/linker.cmd | 2 + .../icss_m1_pru1_fw/ti-pru-cgt/linker.cmd | 2 + tests/test_linker_section_rules.py | 41 +++++++++++++++++++ 39 files changed, 117 insertions(+) create mode 100644 tests/test_linker_section_rules.py diff --git a/examples/conditional/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd b/examples/conditional/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd index 4a9418a..9c12a12 100644 --- a/examples/conditional/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/conditional/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd @@ -41,6 +41,8 @@ SECTIONS { .stack > PRU0_DMEM_0, PAGE 1 .bss > PRU0_DMEM_0, PAGE 1 .udmem > PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 + .udmem > PRU0_DMEM_0, PAGE 1 .initdmem > PRU0_DMEM_0, PAGE 1 .cio > PRU0_DMEM_0, PAGE 1 .data > PRU0_DMEM_0, PAGE 1 diff --git a/examples/conditional/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd b/examples/conditional/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd index 5d7910e..516c458 100644 --- a/examples/conditional/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/conditional/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd @@ -41,6 +41,8 @@ SECTIONS { .stack > PRU0_DMEM_0, PAGE 1 .bss > PRU0_DMEM_0, PAGE 1 .udmem > PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 + .udmem > PRU0_DMEM_0, PAGE 1 .initdmem > PRU0_DMEM_0, PAGE 1 .cio > PRU0_DMEM_0, PAGE 1 .data > PRU0_DMEM_0, PAGE 1 diff --git a/examples/empty/firmware/am243x-evm/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am243x-evm/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd index ee5e44a..dbaad76 100644 --- a/examples/empty/firmware/am243x-evm/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am243x-evm/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > PRU_IMEM, PAGE 0 .stack > PRU0_DMEM_0, PAGE 1 .bss > PRU0_DMEM_0, PAGE 1 + .udmem > PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > PRU0_DMEM_0, PAGE 1 .data > PRU0_DMEM_0, PAGE 1 .switch > PRU0_DMEM_0, PAGE 1 diff --git a/examples/empty/firmware/am243x-evm/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am243x-evm/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd index 47452ac..e157d56 100644 --- a/examples/empty/firmware/am243x-evm/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am243x-evm/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > PRU_IMEM, PAGE 0 .stack > PRU1_DMEM_1, PAGE 1 .bss > PRU1_DMEM_1, PAGE 1 + .udmem > PRU1_DMEM_1, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > PRU1_DMEM_1, PAGE 1 .data > PRU1_DMEM_1, PAGE 1 .switch > PRU1_DMEM_1, PAGE 1 diff --git a/examples/empty/firmware/am243x-evm/icss_g0_rtu_pru0_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am243x-evm/icss_g0_rtu_pru0_fw/ti-pru-cgt/linker.cmd index 8cc87ab..565f803 100644 --- a/examples/empty/firmware/am243x-evm/icss_g0_rtu_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am243x-evm/icss_g0_rtu_pru0_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > RTU_IMEM, PAGE 0 .stack > RTU0_DMEM_0, PAGE 1 .bss > RTU0_DMEM_0, PAGE 1 + .udmem > RTU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > RTU0_DMEM_0, PAGE 1 .data > RTU0_DMEM_0, PAGE 1 .switch > RTU0_DMEM_0, PAGE 1 diff --git a/examples/empty/firmware/am243x-evm/icss_g0_rtu_pru1_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am243x-evm/icss_g0_rtu_pru1_fw/ti-pru-cgt/linker.cmd index 7a0cbba..f9c36a6 100644 --- a/examples/empty/firmware/am243x-evm/icss_g0_rtu_pru1_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am243x-evm/icss_g0_rtu_pru1_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > RTU_IMEM, PAGE 0 .stack > RTU1_DMEM_1, PAGE 1 .bss > RTU1_DMEM_1, PAGE 1 + .udmem > RTU1_DMEM_1, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > RTU1_DMEM_1, PAGE 1 .data > RTU1_DMEM_1, PAGE 1 .switch > RTU1_DMEM_1, PAGE 1 diff --git a/examples/empty/firmware/am243x-evm/icss_g0_tx_pru0_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am243x-evm/icss_g0_tx_pru0_fw/ti-pru-cgt/linker.cmd index 9f5bb20..e080814 100644 --- a/examples/empty/firmware/am243x-evm/icss_g0_tx_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am243x-evm/icss_g0_tx_pru0_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > TX_PRU_IMEM, PAGE 0 .stack > TX_PRU0_DMEM_0, PAGE 1 .bss > TX_PRU0_DMEM_0, PAGE 1 + .udmem > TX_PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > TX_PRU0_DMEM_0, PAGE 1 .data > TX_PRU0_DMEM_0, PAGE 1 .switch > TX_PRU0_DMEM_0, PAGE 1 diff --git a/examples/empty/firmware/am243x-evm/icss_g0_tx_pru1_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am243x-evm/icss_g0_tx_pru1_fw/ti-pru-cgt/linker.cmd index 01e0e32..27780b9 100644 --- a/examples/empty/firmware/am243x-evm/icss_g0_tx_pru1_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am243x-evm/icss_g0_tx_pru1_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > TX_PRU_IMEM, PAGE 0 .stack > TX_PRU1_DMEM_1, PAGE 1 .bss > TX_PRU1_DMEM_1, PAGE 1 + .udmem > TX_PRU1_DMEM_1, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > TX_PRU1_DMEM_1, PAGE 1 .data > TX_PRU1_DMEM_1, PAGE 1 .switch > TX_PRU1_DMEM_1, PAGE 1 diff --git a/examples/empty/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd index ee5e44a..dbaad76 100644 --- a/examples/empty/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > PRU_IMEM, PAGE 0 .stack > PRU0_DMEM_0, PAGE 1 .bss > PRU0_DMEM_0, PAGE 1 + .udmem > PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > PRU0_DMEM_0, PAGE 1 .data > PRU0_DMEM_0, PAGE 1 .switch > PRU0_DMEM_0, PAGE 1 diff --git a/examples/empty/firmware/am243x-lp/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am243x-lp/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd index 47452ac..e157d56 100644 --- a/examples/empty/firmware/am243x-lp/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am243x-lp/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > PRU_IMEM, PAGE 0 .stack > PRU1_DMEM_1, PAGE 1 .bss > PRU1_DMEM_1, PAGE 1 + .udmem > PRU1_DMEM_1, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > PRU1_DMEM_1, PAGE 1 .data > PRU1_DMEM_1, PAGE 1 .switch > PRU1_DMEM_1, PAGE 1 diff --git a/examples/empty/firmware/am243x-lp/icss_g0_rtu_pru0_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am243x-lp/icss_g0_rtu_pru0_fw/ti-pru-cgt/linker.cmd index 8cc87ab..565f803 100644 --- a/examples/empty/firmware/am243x-lp/icss_g0_rtu_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am243x-lp/icss_g0_rtu_pru0_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > RTU_IMEM, PAGE 0 .stack > RTU0_DMEM_0, PAGE 1 .bss > RTU0_DMEM_0, PAGE 1 + .udmem > RTU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > RTU0_DMEM_0, PAGE 1 .data > RTU0_DMEM_0, PAGE 1 .switch > RTU0_DMEM_0, PAGE 1 diff --git a/examples/empty/firmware/am243x-lp/icss_g0_rtu_pru1_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am243x-lp/icss_g0_rtu_pru1_fw/ti-pru-cgt/linker.cmd index 7a0cbba..f9c36a6 100644 --- a/examples/empty/firmware/am243x-lp/icss_g0_rtu_pru1_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am243x-lp/icss_g0_rtu_pru1_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > RTU_IMEM, PAGE 0 .stack > RTU1_DMEM_1, PAGE 1 .bss > RTU1_DMEM_1, PAGE 1 + .udmem > RTU1_DMEM_1, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > RTU1_DMEM_1, PAGE 1 .data > RTU1_DMEM_1, PAGE 1 .switch > RTU1_DMEM_1, PAGE 1 diff --git a/examples/empty/firmware/am243x-lp/icss_g0_tx_pru0_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am243x-lp/icss_g0_tx_pru0_fw/ti-pru-cgt/linker.cmd index 9f5bb20..e080814 100644 --- a/examples/empty/firmware/am243x-lp/icss_g0_tx_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am243x-lp/icss_g0_tx_pru0_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > TX_PRU_IMEM, PAGE 0 .stack > TX_PRU0_DMEM_0, PAGE 1 .bss > TX_PRU0_DMEM_0, PAGE 1 + .udmem > TX_PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > TX_PRU0_DMEM_0, PAGE 1 .data > TX_PRU0_DMEM_0, PAGE 1 .switch > TX_PRU0_DMEM_0, PAGE 1 diff --git a/examples/empty/firmware/am243x-lp/icss_g0_tx_pru1_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am243x-lp/icss_g0_tx_pru1_fw/ti-pru-cgt/linker.cmd index 01e0e32..27780b9 100644 --- a/examples/empty/firmware/am243x-lp/icss_g0_tx_pru1_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am243x-lp/icss_g0_tx_pru1_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > TX_PRU_IMEM, PAGE 0 .stack > TX_PRU1_DMEM_1, PAGE 1 .bss > TX_PRU1_DMEM_1, PAGE 1 + .udmem > TX_PRU1_DMEM_1, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > TX_PRU1_DMEM_1, PAGE 1 .data > TX_PRU1_DMEM_1, PAGE 1 .switch > TX_PRU1_DMEM_1, PAGE 1 diff --git a/examples/empty/firmware/am261x-lp/icss_m0_pru0_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am261x-lp/icss_m0_pru0_fw/ti-pru-cgt/linker.cmd index ee5e44a..dbaad76 100644 --- a/examples/empty/firmware/am261x-lp/icss_m0_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am261x-lp/icss_m0_pru0_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > PRU_IMEM, PAGE 0 .stack > PRU0_DMEM_0, PAGE 1 .bss > PRU0_DMEM_0, PAGE 1 + .udmem > PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > PRU0_DMEM_0, PAGE 1 .data > PRU0_DMEM_0, PAGE 1 .switch > PRU0_DMEM_0, PAGE 1 diff --git a/examples/empty/firmware/am261x-lp/icss_m0_pru1_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am261x-lp/icss_m0_pru1_fw/ti-pru-cgt/linker.cmd index fbcb353..7a408fa 100644 --- a/examples/empty/firmware/am261x-lp/icss_m0_pru1_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am261x-lp/icss_m0_pru1_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > PRU_IMEM, PAGE 0 .stack > PRU1_DMEM_0, PAGE 1 .bss > PRU1_DMEM_0, PAGE 1 + .udmem > PRU1_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > PRU1_DMEM_0, PAGE 1 .data > PRU1_DMEM_0, PAGE 1 .switch > PRU1_DMEM_0, PAGE 1 diff --git a/examples/empty/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd index a6d3db5..21608dd 100644 --- a/examples/empty/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > PRU_IMEM, PAGE 0 .stack > PRU0_DMEM_0, PAGE 1 .bss > PRU0_DMEM_0, PAGE 1 + .udmem > PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > PRU0_DMEM_0, PAGE 1 .data > PRU0_DMEM_0, PAGE 1 .switch > PRU0_DMEM_0, PAGE 1 diff --git a/examples/empty/firmware/am261x-lp/icss_m1_pru1_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am261x-lp/icss_m1_pru1_fw/ti-pru-cgt/linker.cmd index fbcb353..7a408fa 100644 --- a/examples/empty/firmware/am261x-lp/icss_m1_pru1_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am261x-lp/icss_m1_pru1_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > PRU_IMEM, PAGE 0 .stack > PRU1_DMEM_0, PAGE 1 .bss > PRU1_DMEM_0, PAGE 1 + .udmem > PRU1_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > PRU1_DMEM_0, PAGE 1 .data > PRU1_DMEM_0, PAGE 1 .switch > PRU1_DMEM_0, PAGE 1 diff --git a/examples/empty/firmware/am64x-evm/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am64x-evm/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd index 38c6db7..c0fbaa7 100644 --- a/examples/empty/firmware/am64x-evm/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am64x-evm/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > PRU_IMEM, PAGE 0 .stack > PRU0_DMEM_0, PAGE 1 .bss > PRU0_DMEM_0, PAGE 1 + .udmem > PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > PRU0_DMEM_0, PAGE 1 .data > PRU0_DMEM_0, PAGE 1 .switch > PRU0_DMEM_0, PAGE 1 diff --git a/examples/empty/firmware/am64x-evm/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am64x-evm/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd index e2533c2..04ca530 100644 --- a/examples/empty/firmware/am64x-evm/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am64x-evm/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > PRU_IMEM, PAGE 0 .stack > PRU1_DMEM_1, PAGE 1 .bss > PRU1_DMEM_1, PAGE 1 + .udmem > PRU1_DMEM_1, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > PRU1_DMEM_1, PAGE 1 .data > PRU1_DMEM_1, PAGE 1 .switch > PRU1_DMEM_1, PAGE 1 diff --git a/examples/empty/firmware/am64x-evm/icss_g0_rtu_pru0_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am64x-evm/icss_g0_rtu_pru0_fw/ti-pru-cgt/linker.cmd index 01e4fd0..ccc9ffc 100644 --- a/examples/empty/firmware/am64x-evm/icss_g0_rtu_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am64x-evm/icss_g0_rtu_pru0_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > RTU_IMEM, PAGE 0 .stack > RTU0_DMEM_0, PAGE 1 .bss > RTU0_DMEM_0, PAGE 1 + .udmem > RTU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > RTU0_DMEM_0, PAGE 1 .data > RTU0_DMEM_0, PAGE 1 .switch > RTU0_DMEM_0, PAGE 1 diff --git a/examples/empty/firmware/am64x-evm/icss_g0_rtu_pru1_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am64x-evm/icss_g0_rtu_pru1_fw/ti-pru-cgt/linker.cmd index b525561..5b5c23a 100644 --- a/examples/empty/firmware/am64x-evm/icss_g0_rtu_pru1_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am64x-evm/icss_g0_rtu_pru1_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > RTU_IMEM, PAGE 0 .stack > RTU1_DMEM_1, PAGE 1 .bss > RTU1_DMEM_1, PAGE 1 + .udmem > RTU1_DMEM_1, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > RTU1_DMEM_1, PAGE 1 .data > RTU1_DMEM_1, PAGE 1 .switch > RTU1_DMEM_1, PAGE 1 diff --git a/examples/empty/firmware/am64x-evm/icss_g0_tx_pru0_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am64x-evm/icss_g0_tx_pru0_fw/ti-pru-cgt/linker.cmd index 0007da3..5db1ac8 100644 --- a/examples/empty/firmware/am64x-evm/icss_g0_tx_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am64x-evm/icss_g0_tx_pru0_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > TX_PRU_IMEM, PAGE 0 .stack > TX_PRU0_DMEM_0, PAGE 1 .bss > TX_PRU0_DMEM_0, PAGE 1 + .udmem > TX_PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > TX_PRU0_DMEM_0, PAGE 1 .data > TX_PRU0_DMEM_0, PAGE 1 .switch > TX_PRU0_DMEM_0, PAGE 1 diff --git a/examples/empty/firmware/am64x-evm/icss_g0_tx_pru1_fw/ti-pru-cgt/linker.cmd b/examples/empty/firmware/am64x-evm/icss_g0_tx_pru1_fw/ti-pru-cgt/linker.cmd index 87d2670..5b0238e 100644 --- a/examples/empty/firmware/am64x-evm/icss_g0_tx_pru1_fw/ti-pru-cgt/linker.cmd +++ b/examples/empty/firmware/am64x-evm/icss_g0_tx_pru1_fw/ti-pru-cgt/linker.cmd @@ -40,6 +40,8 @@ SECTIONS { .text > TX_PRU_IMEM, PAGE 0 .stack > TX_PRU1_DMEM_1, PAGE 1 .bss > TX_PRU1_DMEM_1, PAGE 1 + .udmem > TX_PRU1_DMEM_1, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .cio > TX_PRU1_DMEM_1, PAGE 1 .data > TX_PRU1_DMEM_1, PAGE 1 .switch > TX_PRU1_DMEM_1, PAGE 1 diff --git a/examples/gray_encoder_4_bit/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd b/examples/gray_encoder_4_bit/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd index 4a9418a..9c12a12 100644 --- a/examples/gray_encoder_4_bit/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/gray_encoder_4_bit/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd @@ -41,6 +41,8 @@ SECTIONS { .stack > PRU0_DMEM_0, PAGE 1 .bss > PRU0_DMEM_0, PAGE 1 .udmem > PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 + .udmem > PRU0_DMEM_0, PAGE 1 .initdmem > PRU0_DMEM_0, PAGE 1 .cio > PRU0_DMEM_0, PAGE 1 .data > PRU0_DMEM_0, PAGE 1 diff --git a/examples/gray_encoder_4_bit/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd b/examples/gray_encoder_4_bit/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd index 5d7910e..516c458 100644 --- a/examples/gray_encoder_4_bit/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/gray_encoder_4_bit/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd @@ -41,6 +41,8 @@ SECTIONS { .stack > PRU0_DMEM_0, PAGE 1 .bss > PRU0_DMEM_0, PAGE 1 .udmem > PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 + .udmem > PRU0_DMEM_0, PAGE 1 .initdmem > PRU0_DMEM_0, PAGE 1 .cio > PRU0_DMEM_0, PAGE 1 .data > PRU0_DMEM_0, PAGE 1 diff --git a/examples/incremental_spi/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd b/examples/incremental_spi/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd index 4a9418a..9c12a12 100644 --- a/examples/incremental_spi/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/incremental_spi/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd @@ -41,6 +41,8 @@ SECTIONS { .stack > PRU0_DMEM_0, PAGE 1 .bss > PRU0_DMEM_0, PAGE 1 .udmem > PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 + .udmem > PRU0_DMEM_0, PAGE 1 .initdmem > PRU0_DMEM_0, PAGE 1 .cio > PRU0_DMEM_0, PAGE 1 .data > PRU0_DMEM_0, PAGE 1 diff --git a/examples/incremental_spi/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd b/examples/incremental_spi/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd index 5d7910e..516c458 100644 --- a/examples/incremental_spi/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/incremental_spi/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd @@ -41,6 +41,8 @@ SECTIONS { .stack > PRU0_DMEM_0, PAGE 1 .bss > PRU0_DMEM_0, PAGE 1 .udmem > PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 + .udmem > PRU0_DMEM_0, PAGE 1 .initdmem > PRU0_DMEM_0, PAGE 1 .cio > PRU0_DMEM_0, PAGE 1 .data > PRU0_DMEM_0, PAGE 1 diff --git a/examples/spi_10mhz/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd b/examples/spi_10mhz/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd index 5d7910e..516c458 100644 --- a/examples/spi_10mhz/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/spi_10mhz/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd @@ -41,6 +41,8 @@ SECTIONS { .stack > PRU0_DMEM_0, PAGE 1 .bss > PRU0_DMEM_0, PAGE 1 .udmem > PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 + .udmem > PRU0_DMEM_0, PAGE 1 .initdmem > PRU0_DMEM_0, PAGE 1 .cio > PRU0_DMEM_0, PAGE 1 .data > PRU0_DMEM_0, PAGE 1 diff --git a/examples/spi_10mhz/firmware/am261x-lp/icss_m1_pru1_fw/ti-pru-cgt/linker.cmd b/examples/spi_10mhz/firmware/am261x-lp/icss_m1_pru1_fw/ti-pru-cgt/linker.cmd index 7340f73..d71396e 100644 --- a/examples/spi_10mhz/firmware/am261x-lp/icss_m1_pru1_fw/ti-pru-cgt/linker.cmd +++ b/examples/spi_10mhz/firmware/am261x-lp/icss_m1_pru1_fw/ti-pru-cgt/linker.cmd @@ -41,6 +41,8 @@ SECTIONS { .stack > PRU1_DMEM_0, PAGE 1 .bss > PRU1_DMEM_0, PAGE 1 .udmem > PRU1_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 + .udmem > PRU1_DMEM_0, PAGE 1 .initdmem > PRU1_DMEM_0, PAGE 1 .cio > PRU1_DMEM_0, PAGE 1 .data > PRU1_DMEM_0, PAGE 1 diff --git a/examples/spi_loopback/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd b/examples/spi_loopback/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd index 4a9418a..9c12a12 100644 --- a/examples/spi_loopback/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/spi_loopback/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd @@ -41,6 +41,8 @@ SECTIONS { .stack > PRU0_DMEM_0, PAGE 1 .bss > PRU0_DMEM_0, PAGE 1 .udmem > PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 + .udmem > PRU0_DMEM_0, PAGE 1 .initdmem > PRU0_DMEM_0, PAGE 1 .cio > PRU0_DMEM_0, PAGE 1 .data > PRU0_DMEM_0, PAGE 1 diff --git a/examples/spi_loopback/firmware/am243x-lp/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd b/examples/spi_loopback/firmware/am243x-lp/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd index f98cc9a..92f9306 100644 --- a/examples/spi_loopback/firmware/am243x-lp/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd +++ b/examples/spi_loopback/firmware/am243x-lp/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd @@ -41,6 +41,8 @@ SECTIONS { .stack > PRU1_DMEM_1, PAGE 1 .bss > PRU1_DMEM_1, PAGE 1 .udmem > PRU1_DMEM_1, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 + .udmem > PRU1_DMEM_1, PAGE 1 .initdmem > PRU1_DMEM_1, PAGE 1 .cio > PRU1_DMEM_1, PAGE 1 .data > PRU1_DMEM_1, PAGE 1 diff --git a/examples/spi_loopback/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd b/examples/spi_loopback/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd index 5d7910e..516c458 100644 --- a/examples/spi_loopback/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/spi_loopback/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd @@ -41,6 +41,8 @@ SECTIONS { .stack > PRU0_DMEM_0, PAGE 1 .bss > PRU0_DMEM_0, PAGE 1 .udmem > PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 + .udmem > PRU0_DMEM_0, PAGE 1 .initdmem > PRU0_DMEM_0, PAGE 1 .cio > PRU0_DMEM_0, PAGE 1 .data > PRU0_DMEM_0, PAGE 1 diff --git a/examples/spi_loopback/firmware/am261x-lp/icss_m1_pru1_fw/ti-pru-cgt/linker.cmd b/examples/spi_loopback/firmware/am261x-lp/icss_m1_pru1_fw/ti-pru-cgt/linker.cmd index 7340f73..d71396e 100644 --- a/examples/spi_loopback/firmware/am261x-lp/icss_m1_pru1_fw/ti-pru-cgt/linker.cmd +++ b/examples/spi_loopback/firmware/am261x-lp/icss_m1_pru1_fw/ti-pru-cgt/linker.cmd @@ -41,6 +41,8 @@ SECTIONS { .stack > PRU1_DMEM_0, PAGE 1 .bss > PRU1_DMEM_0, PAGE 1 .udmem > PRU1_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 + .udmem > PRU1_DMEM_0, PAGE 1 .initdmem > PRU1_DMEM_0, PAGE 1 .cio > PRU1_DMEM_0, PAGE 1 .data > PRU1_DMEM_0, PAGE 1 diff --git a/examples/uart_crc/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd b/examples/uart_crc/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd index 4a9418a..9c12a12 100644 --- a/examples/uart_crc/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/uart_crc/firmware/am243x-lp/icss_g0_pru0_fw/ti-pru-cgt/linker.cmd @@ -41,6 +41,8 @@ SECTIONS { .stack > PRU0_DMEM_0, PAGE 1 .bss > PRU0_DMEM_0, PAGE 1 .udmem > PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 + .udmem > PRU0_DMEM_0, PAGE 1 .initdmem > PRU0_DMEM_0, PAGE 1 .cio > PRU0_DMEM_0, PAGE 1 .data > PRU0_DMEM_0, PAGE 1 diff --git a/examples/uart_crc/firmware/am243x-lp/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd b/examples/uart_crc/firmware/am243x-lp/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd index f98cc9a..92f9306 100644 --- a/examples/uart_crc/firmware/am243x-lp/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd +++ b/examples/uart_crc/firmware/am243x-lp/icss_g0_pru1_fw/ti-pru-cgt/linker.cmd @@ -41,6 +41,8 @@ SECTIONS { .stack > PRU1_DMEM_1, PAGE 1 .bss > PRU1_DMEM_1, PAGE 1 .udmem > PRU1_DMEM_1, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 + .udmem > PRU1_DMEM_1, PAGE 1 .initdmem > PRU1_DMEM_1, PAGE 1 .cio > PRU1_DMEM_1, PAGE 1 .data > PRU1_DMEM_1, PAGE 1 diff --git a/examples/uart_crc/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd b/examples/uart_crc/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd index 5d7910e..516c458 100644 --- a/examples/uart_crc/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd +++ b/examples/uart_crc/firmware/am261x-lp/icss_m1_pru0_fw/ti-pru-cgt/linker.cmd @@ -41,6 +41,8 @@ SECTIONS { .stack > PRU0_DMEM_0, PAGE 1 .bss > PRU0_DMEM_0, PAGE 1 .udmem > PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 + .udmem > PRU0_DMEM_0, PAGE 1 .initdmem > PRU0_DMEM_0, PAGE 1 .cio > PRU0_DMEM_0, PAGE 1 .data > PRU0_DMEM_0, PAGE 1 diff --git a/examples/uart_crc/firmware/am261x-lp/icss_m1_pru1_fw/ti-pru-cgt/linker.cmd b/examples/uart_crc/firmware/am261x-lp/icss_m1_pru1_fw/ti-pru-cgt/linker.cmd index 7340f73..d71396e 100644 --- a/examples/uart_crc/firmware/am261x-lp/icss_m1_pru1_fw/ti-pru-cgt/linker.cmd +++ b/examples/uart_crc/firmware/am261x-lp/icss_m1_pru1_fw/ti-pru-cgt/linker.cmd @@ -41,6 +41,8 @@ SECTIONS { .stack > PRU1_DMEM_0, PAGE 1 .bss > PRU1_DMEM_0, PAGE 1 .udmem > PRU1_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 + .udmem > PRU1_DMEM_0, PAGE 1 .initdmem > PRU1_DMEM_0, PAGE 1 .cio > PRU1_DMEM_0, PAGE 1 .data > PRU1_DMEM_0, PAGE 1 diff --git a/tests/test_linker_section_rules.py b/tests/test_linker_section_rules.py new file mode 100644 index 0000000..e31e6e6 --- /dev/null +++ b/tests/test_linker_section_rules.py @@ -0,0 +1,41 @@ +"""Every shipped PRU linker command file places the Memory Variable block's sections. + +The Memory Variable block reserves its buffers with `.usect ".udmem"` (data RAM) +and `.usect ".usmem"` (shared RAM). A linker command file without a rule for a +section lets clpru put it in the first memory that fits, which is instruction +RAM directly after .text, so a "shared memory" variable silently lands where no +other core can reach it. Each file must place .udmem in the same data RAM as +its .bss and .usmem in PRU_SHAREDMEM. +""" + +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +def _linker_files(): + out = subprocess.run(["git", "ls-files", "*linker.cmd"], cwd=ROOT, capture_output=True, text=True, check=True).stdout + return sorted(out.split()) + + +def _placement(text, section): + m = re.search(r"^\s*%s\s*>\s*(\w+)\s*,\s*PAGE\s*(\d+)" % re.escape(section), text, re.M) + return m.groups() if m else None + + +def test_linker_files_are_found(): + assert len(_linker_files()) > 0 + + +@pytest.mark.parametrize("path", _linker_files()) +def test_memory_variable_sections_are_placed(path): + text = (ROOT / path).read_text(encoding="utf-8") + bss = _placement(text, ".bss") + assert bss, "%s has no .bss rule" % path + assert _placement(text, ".udmem") == bss, "%s: .udmem must go where .bss goes (%s, PAGE %s)" % (path, *bss) + assert re.search(r"^\s*PRU_SHAREDMEM\s*:", text, re.M), "%s does not declare PRU_SHAREDMEM" % path + assert _placement(text, ".usmem") == ("PRU_SHAREDMEM", "2"), "%s: .usmem must go to PRU_SHAREDMEM, PAGE 2" % path From 660066d4c5abfd8dde94774ed205aa8be94604c3 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 06:27:32 +0530 Subject: [PATCH 14/44] Add device models and simulator integration Behavioural peripheral models (perif/, core/device_model.py, pru_io/), the in-tool device bus and device models used by the Simulation Settings GPI "Attached Device Model" mode and the Device Model block, the device-binding manifest emitted into pru_syscfg.asm, and the TI pru-simulator run path (scripts/sim: ELF co-simulation, bound-device runner, Run on TI pru-simulator launcher). The static module gains the device-mode GPI fields, the device-bus preview, the pad check between attached devices and PRU outputs, and the TI pru-simulator result banner. --- .../pru_blocks/common/device_model_table.js | 624 +++++ .../common/pru_blocks_static_module.syscfg.js | 858 +++++- .../common/pru_device_bindings.json.xdt | 27 + .../pru_blocks/common/pru_syscfg.asm.xdt | 11 + .../.meta/pru_blocks/common/pru_ti_run.js | 287 ++ .../common/simulation/device_bus.js | 195 ++ .../common/simulation/device_models.js | 1628 +++++++++++ .../pru_blocks/common/simulation/pru_core.js | 373 ++- .../utils/device_model_block.syscfg.js | 266 ++ .../.meta/soc/pru_blocks_am243x.syscfg.js | 1 + .../.meta/soc/pru_blocks_am261x.syscfg.js | 1 + .../.meta/soc/pru_blocks_am64x.syscfg.js | 1 + core/__init__.py | 0 core/device_model.py | 15 + perif/__init__.py | 0 perif/bissline_device_model.py | 390 +++ perif/buscosim.py | 474 ++++ perif/hall_sensor.py | 256 ++ perif/mdio_phy_model.py | 278 ++ perif/ossd_emitter.py | 233 ++ perif/quadrature_index_encoder.py | 120 + perif/spi_adc.py | 140 + perif/ssi_endat_encoder.py | 282 ++ perif/step_dir_driver.py | 99 + perif/ws2812_string.py | 138 + pru_io/__init__.py | 7 + pru_io/waveform_player.py | 185 ++ scripts/asm_label.js | 31 + scripts/dual_oracle_harness.js | 683 +++++ scripts/sim/asm_probe.py | 87 + scripts/sim/crc_dut.js | 189 ++ scripts/sim/device_bindings.py | 548 ++++ scripts/sim/device_model_replay.js | 143 + scripts/sim/elf_cosim.py | 232 ++ scripts/sim/emit_block_asm.js | 68 + scripts/sim/extract_device_bindings.py | 23 + scripts/sim/gui_run.py | 668 +++++ scripts/sim/gui_run_launcher.js | 89 + scripts/sim/live_view.py | 467 ++++ scripts/sim/lut_cycles.js | 21 + scripts/sim/resolve_block_call.js | 62 + scripts/sim/run_bound_devices.py | 415 +++ scripts/simulator.js | 2488 +++++++++++++++++ tools/__init__.py | 0 tools/gpi_stimulus_runner.py | 245 ++ 45 files changed, 13333 insertions(+), 15 deletions(-) create mode 100644 .metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js create mode 100644 .metadata/sysconfig/.meta/pru_blocks/common/pru_device_bindings.json.xdt create mode 100644 .metadata/sysconfig/.meta/pru_blocks/common/pru_ti_run.js create mode 100644 .metadata/sysconfig/.meta/pru_blocks/common/simulation/device_bus.js create mode 100644 .metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js create mode 100644 .metadata/sysconfig/.meta/pru_blocks/utils/device_model_block.syscfg.js create mode 100644 core/__init__.py create mode 100644 core/device_model.py create mode 100644 perif/__init__.py create mode 100644 perif/bissline_device_model.py create mode 100644 perif/buscosim.py create mode 100644 perif/hall_sensor.py create mode 100644 perif/mdio_phy_model.py create mode 100644 perif/ossd_emitter.py create mode 100644 perif/quadrature_index_encoder.py create mode 100644 perif/spi_adc.py create mode 100644 perif/ssi_endat_encoder.py create mode 100644 perif/step_dir_driver.py create mode 100644 perif/ws2812_string.py create mode 100644 pru_io/__init__.py create mode 100644 pru_io/waveform_player.py create mode 100644 scripts/asm_label.js create mode 100644 scripts/dual_oracle_harness.js create mode 100644 scripts/sim/asm_probe.py create mode 100644 scripts/sim/crc_dut.js create mode 100644 scripts/sim/device_bindings.py create mode 100644 scripts/sim/device_model_replay.js create mode 100644 scripts/sim/elf_cosim.py create mode 100644 scripts/sim/emit_block_asm.js create mode 100644 scripts/sim/extract_device_bindings.py create mode 100644 scripts/sim/gui_run.py create mode 100644 scripts/sim/gui_run_launcher.js create mode 100644 scripts/sim/live_view.py create mode 100644 scripts/sim/lut_cycles.js create mode 100644 scripts/sim/resolve_block_call.js create mode 100644 scripts/sim/run_bound_devices.py create mode 100644 scripts/simulator.js create mode 100644 tools/__init__.py create mode 100644 tools/gpi_stimulus_runner.py diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js b/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js new file mode 100644 index 0000000..33d2603 --- /dev/null +++ b/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js @@ -0,0 +1,624 @@ +/** + * Device Model table (part A). + * + * One table, three consumers: the Device Model module builds its typed fields + * from it, the static module merges its instances into the device-binding + * manifest with it, and the tests read it to prove it still agrees with + * scripts/sim/device_bindings.py. + * + * WHAT A DEVICE MODEL INSTANCE IS + * An ADDITIONAL source of device bindings next to the GPI device fields in + * Simulation Settings (decision on, option 1). The GPI fields are + * untouched and keep working. Both sources feed the same manifest, in the same + * shape, so scripts/sim/device_bindings.py cannot tell them apart - which is + * the point: one consumer, one set of safety checks. + * + * WHY EVERY PARAMETER IS A REAL FIELD + * The GPI route takes a JSON string, so a typo in a key, a string where a + * number belongs, or an out-of-range value is only found when a model refuses + * it at run time. A typed field is range-checked in the GUI. A key here MUST be + * a keyword of the Python model constructor; test_device_model.py + * proves that against the real constructors, so renaming a model parameter + * breaks a test here instead of producing a model that is not the one asked for. + * + * PIN SLOTS + * `slot` is where the pin travels in the manifest: primaryPin / clockPin / + * indexPin, or `param:` for a model pin the manifest has no slot for + * (spi_adc cs_pin). `drives` says who drives the net: true when the DEVICE + * drives it back at the PRU, false when the PRU drives it and the device only + * listens. It is NOT guessable from the names and mirrors the table at the top + * of scripts/sim/device_bindings.py. + * + * Pure data and pure functions: no `system`, no I/O, so it loads outside the + * SysConfig runtime. + */ + +const PIN_MAX = 19; // PRU R30/R31 pin range accepted by scripts/sim/device_bindings.py + +// type: int | float | bool | bytes. `omit`: the value that means "not set" - +// the key is then left out of the manifest and the model takes its own default +// (the Python models take None there, which a typed int field cannot say). +const KINDS = { + quadrature: { + displayName: "Quadrature encoder (A/B/Z)", + sink: false, + pins: [ + { key: "a", label: "A channel", slot: "primaryPin", drives: true }, + { key: "b", label: "B channel", slot: "clockPin", drives: true }, + { key: "z", label: "Z (index) channel", slot: "indexPin", drives: true, optional: true } + ], + params: [ + { key: "counts_per_rev", label: "Counts per revolution", type: "int", def: 1024, min: 1 }, + { key: "cycles_per_count", label: "Cycles per count", type: "int", def: 50, min: 1 }, + { key: "forward", label: "Forward direction", type: "bool", def: true } + ] + }, + ssi: { + displayName: "SSI absolute encoder", + sink: false, + pins: [ + { key: "data", label: "DATA pin", slot: "primaryPin", drives: true }, + { key: "clk", label: "CLK pin", slot: "clockPin", drives: false } + ], + params: [ + { key: "bits", label: "Frame width (bits)", type: "int", def: 13, min: 1, max: 64 }, + { key: "position", label: "Position", type: "int", def: 0, min: 0, positionBitsKey: "bits" }, + { key: "cycles_per_count", label: "Cycles per count (0 = stationary)", type: "int", def: 0, min: 0, omit: 0 }, + { key: "min_clk_period_cycles", label: "Minimum clock period (cycles)", type: "int", def: 20, min: 1 }, + { key: "monoflop_cycles", label: "Monoflop time (cycles)", type: "int", def: 400, min: 1 }, + { key: "gray", label: "Gray-coded position", type: "bool", def: true } + ] + }, + bissc: { + displayName: "BiSS-C absolute encoder", + sink: false, + pins: [ + { key: "slo", label: "SLO pin", slot: "primaryPin", drives: true }, + { key: "ma", label: "MA (clock) pin", slot: "clockPin", drives: false } + ], + params: [ + { key: "position_bits", label: "Position bits", type: "int", def: 24, min: 1, max: 64 }, + { key: "tbusy_clocks", label: "Busy clocks", type: "int", def: 1, min: 0 }, + { key: "min_ma_period_cycles", label: "Minimum MA period (cycles)", type: "int", def: 2, min: 1 }, + { key: "idle_restart_cycles", label: "Idle restart (cycles)", type: "int", def: 40, min: 1 }, + { key: "position", label: "Position", type: "int", def: 0, min: 0, positionBitsKey: "position_bits" } + ] + }, + bissline: { + displayName: "BiSS Line absolute encoder", + sink: false, + pins: [ + { key: "slave", label: "SLAVE pin", slot: "primaryPin", drives: true }, + { key: "master", label: "MASTER (clock) pin", slot: "clockPin", drives: false } + ], + params: [ + { key: "cds_bit", label: "CDS bit", type: "int", def: 0, min: 0, max: 1 }, + { key: "fec_enabled", label: "FEC enabled", type: "bool", def: true }, + { key: "bit_period_cycles", label: "Bit period (cycles)", type: "int", def: 16, min: 1 }, + { key: "min_bit_period_cycles", label: "Minimum bit period (cycles)", type: "int", def: 8, min: 1 } + ] + }, + endat: { + displayName: "EnDat 2.2 absolute encoder", + sink: false, + pins: [ + { key: "data", label: "DATA pin", slot: "primaryPin", drives: true }, + { key: "clock", label: "CLOCK pin", slot: "clockPin", drives: false } + ], + params: [ + { key: "position_bits", label: "Position bits", type: "int", def: 24, min: 1, max: 64 }, + { key: "position", label: "Position", type: "int", def: 0, min: 0, positionBitsKey: "position_bits" }, + { key: "cycles_per_count", label: "Cycles per count (0 = stationary)", type: "int", def: 0, min: 0, omit: 0 }, + { key: "calc_cycles", label: "Calculation time (cycles)", type: "int", def: 50, min: 0 }, + { key: "lead_in_clocks", label: "Lead-in clocks", type: "int", def: 2, min: 0 }, + { key: "min_half_period_cycles", label: "Minimum half period (cycles)", type: "int", def: 0, min: 0 }, + { key: "recovery_cycles", label: "Recovery time (cycles)", type: "int", def: 0, min: 0 }, + { key: "reset_cycles", label: "Reset time (cycles)", type: "int", def: 2000, min: 0 }, + { key: "error1", label: "Error 1", type: "bool", def: false }, + { key: "error2", label: "Error 2", type: "bool", def: false } + ] + }, + tca9538: { + displayName: "TCA9538 I2C IO expander", + sink: false, + pins: [ + { key: "sda", label: "SDA pin", slot: "primaryPin", drives: true }, + // I2C SCL is open-drain: a slave may stretch the clock, so the device drives it too. + { key: "scl", label: "SCL pin", slot: "clockPin", drives: true } + ], + params: [ + { key: "address", label: "7-bit I2C address", type: "int", def: 35, min: 0, max: 127 } + ] + }, + uart: { + displayName: "UART transmitter", + sink: false, + pins: [ + { key: "tx", label: "TX pin (device drives)", slot: "primaryPin", drives: true } + ], + params: [ + { key: "payload", label: "Payload bytes (comma separated, 0-255; empty = model default)", type: "bytes", def: "", omit: "" }, + { key: "bit_period", label: "Bit period (cycles)", type: "int", def: 50, min: 1 }, + { key: "start_cycle", label: "Start cycle", type: "int", def: 0, min: 0 }, + { key: "idle_gap_bits", label: "Idle gap (bits)", type: "int", def: 2, min: 0 }, + { key: "frames", label: "Frames", type: "int", def: 1, min: 1 } + ] + }, + sigma_delta: { + displayName: "Sigma-delta modulator", + sink: false, + pins: [ + { key: "data", label: "DATA pin", slot: "primaryPin", drives: true }, + { key: "clk", label: "CLK pin", slot: "clockPin", drives: false } + ], + params: [ + { key: "min_period", label: "Minimum clock period (cycles)", type: "int", def: 4, min: 1 }, + { key: "saturation_threshold", label: "Saturation threshold", type: "float", def: 10, min: 0 }, + { key: "saturation_persist", label: "Saturation persist (cycles)", type: "int", def: 16, min: 1 }, + { key: "initial_input", label: "Initial input", type: "float", def: 0 } + ] + }, + spi_adc: { + displayName: "SPI ADC (SPI mode 0)", + sink: false, + pins: [ + { key: "sdi", label: "MISO pin (device drives; the model calls it sdi_pin)", slot: "primaryPin", drives: true }, + { key: "sclk", label: "SCLK pin", slot: "clockPin", drives: false }, + { key: "sdo", label: "MOSI pin (the model calls it sdo_pin)", slot: "indexPin", drives: false }, + { key: "cs", label: "CS pin", slot: "param:cs_pin", drives: false } + ], + params: [ + { key: "bits", label: "Word size (bits)", type: "int", def: 16, min: 1, max: 32 }, + { key: "conversion", label: "Conversion result", type: "int", def: 0, min: 0, positionBitsKey: "bits" }, + { key: "miso_idle", label: "MISO idle level", type: "int", def: 0, min: 0, max: 1 }, + { key: "msb_first", label: "MSB first", type: "bool", def: true } + ] + }, + step_dir: { + displayName: "STEP/DIR stepper driver", + sink: true, + pins: [ + { key: "step", label: "STEP pin", slot: "primaryPin", drives: false }, + { key: "dir", label: "DIR pin", slot: "clockPin", drives: false }, + { key: "en", label: "EN pin", slot: "indexPin", drives: false, optional: true } + ], + params: [ + { key: "min_pulse_cycles", label: "Minimum STEP pulse (cycles)", type: "int", def: 20, min: 1 }, + { key: "dir_setup_cycles", label: "DIR setup (cycles)", type: "int", def: 10, min: 0 }, + { key: "dir_positive", label: "DIR level for positive steps", type: "int", def: 1, min: 0, max: 1 } + ] + }, + ws2812: { + displayName: "WS2812 addressable LED string", + sink: true, + pins: [ + { key: "data", label: "DATA pin", slot: "primaryPin", drives: false } + ], + params: [ + { key: "num_leds", label: "LEDs in the string", type: "int", def: 1, min: 1 }, + { key: "ns_per_cycle", label: "ns per PRU cycle", type: "float", def: 5, min: 0.001 }, + { key: "t0h_ns", label: "T0H (ns)", type: "float", def: 400, min: 0 }, + { key: "t0l_ns", label: "T0L (ns)", type: "float", def: 850, min: 0 }, + { key: "t1h_ns", label: "T1H (ns)", type: "float", def: 800, min: 0 }, + { key: "t1l_ns", label: "T1L (ns)", type: "float", def: 450, min: 0 }, + { key: "tol_ns", label: "Timing tolerance (ns)", type: "float", def: 220, min: 0 }, + { key: "reset_ns", label: "Reset time (ns)", type: "float", def: 50000, min: 0 }, + { key: "max_bits", label: "Maximum bits", type: "int", def: 1536, min: 1 } + ] + }, + mdio_phy: { + displayName: "MDIO Clause 22 PHY", + sink: false, + pins: [ + { key: "mdio", label: "MDIO pin", slot: "primaryPin", drives: true }, + { key: "mdc", label: "MDC pin", slot: "clockPin", drives: false } + ], + params: [ + { key: "phy_addr", label: "PHY address", type: "int", def: 0, min: 0, max: 31 }, + { key: "min_half_period_cycles", label: "Minimum MDC half period (cycles)", type: "int", def: 40, min: 1 }, + { key: "preamble_bits", label: "Preamble bits", type: "int", def: 16, min: 0 }, + { key: "abort_gap_factor", label: "Abort gap factor", type: "int", def: 8, min: 1 } + ] + } +}; + +/** Name of the typed field carrying a pin or a parameter of `kind`. */ +function pinField(kind, pin) { return kind + "_" + pin.key + "Pin"; } +function paramField(kind, param) { + // snake_case model keyword -> camelCase field, prefixed by kind so the + // names stay unique in one flat config list. + return kind + "_" + param.key.replace(/_([a-z0-9])/g, function (_m, c) { return c.toUpperCase(); }); +} + +/** The (field, spec) pairs of a kind, for building and for reading back. */ +function pinFields(kind) { + return KINDS[kind].pins.map(function (p) { return { field: pinField(kind, p), spec: p }; }); +} +function paramFields(kind) { + return KINDS[kind].params.map(function (p) { return { field: paramField(kind, p), spec: p }; }); +} + +function parseBytes(text) { + const trimmed = String(text === undefined || text === null ? "" : text).trim(); + if (trimmed === "") return []; + const out = []; + for (const part of trimmed.split(",")) { + const t = part.trim(); + if (!/^(0x[0-9a-fA-F]{1,2}|\d{1,3})$/.test(t)) return null; + const v = Number(t); + if (v < 0 || v > 255) return null; + out.push(v); + } + return out; +} + +/** + * Check one instance. Returns [{field, message}] - empty when legal. + * Pure: pin-conflict checks against OTHER instances and the GPI fields need the + * rest of the design and live in collectPinClaims / findPinConflicts. + */ +function checkInstance(inst) { + const errors = []; + const spec = KINDS[inst.deviceKind]; + if (!spec) { + return [{ field: "deviceKind", message: "Unknown device kind '" + inst.deviceKind + "'" }]; + } + const used = {}; + for (const pf of pinFields(inst.deviceKind)) { + const value = inst[pf.field]; + if (value === -1 && pf.spec.optional) continue; + if (!Number.isInteger(value) || value < 0 || value > PIN_MAX) { + errors.push({ field: pf.field, message: pf.spec.label + " must be 0-" + PIN_MAX + (pf.spec.optional ? ", or -1 if unused" : "") + ", got " + value }); + continue; + } + if (Object.prototype.hasOwnProperty.call(used, value)) { + errors.push({ field: pf.field, message: pf.spec.label + " is pin " + value + ", which '" + used[value] + "' already uses. One device cannot put two signals on one pin." }); + } else { + used[value] = pf.spec.label; + } + } + for (const pf of paramFields(inst.deviceKind)) { + const p = pf.spec, v = inst[pf.field]; + if (p.type === "bool") continue; + if (p.type === "bytes") { + if (parseBytes(v) === null) { + errors.push({ field: pf.field, message: p.label + " must be comma-separated bytes 0-255, got '" + v + "'" }); + } + continue; + } + if (typeof v !== "number" || !isFinite(v) || (p.type === "int" && !Number.isInteger(v))) { + errors.push({ field: pf.field, message: p.label + " must be " + (p.type === "int" ? "an integer" : "a number") + ", got " + v }); + continue; + } + if (p.min !== undefined && v < p.min || p.max !== undefined && v > p.max) { + const range = (p.min !== undefined ? p.min : "-inf") + ".." + (p.max !== undefined ? p.max : "inf"); + errors.push({ field: pf.field, message: p.label + " must be in " + range + ", got " + v }); + continue; + } + if (p.positionBitsKey) { + const bitsParam = KINDS[inst.deviceKind].params.filter(function (q) { return q.key === p.positionBitsKey; })[0]; + const bits = inst[paramField(inst.deviceKind, bitsParam)]; + if (Number.isInteger(bits) && bits >= 1 && bits <= 53 && v > Math.pow(2, bits) - 1) { + errors.push({ field: pf.field, message: p.label + " " + v + " does not fit in " + bits + " bits (max " + (Math.pow(2, bits) - 1) + ")" }); + } + } + } + return errors; +} + +/** The pins an instance occupies: [{pin, label}], unused optional pins skipped. */ +function claimedPins(inst) { + const out = []; + for (const pf of pinFields(inst.deviceKind)) { + const v = inst[pf.field]; + if (v === -1 && pf.spec.optional) continue; + if (Number.isInteger(v)) out.push({ pin: v, label: pf.spec.label }); + } + return out; +} + +/** + * The pin carrying a PRU-driven clock (the kind's clockPin slot when the PRU, not the device, + * drives it), or null. Two models on one such pin is the "shared clock" case. + */ +function clockPin(inst) { + for (const pf of pinFields(inst.deviceKind)) { + if (pf.spec.slot === "clockPin" && pf.spec.drives === false && Number.isInteger(inst[pf.field])) { + return inst[pf.field]; + } + } + return null; +} + +/** A manifest binding, in exactly the shape buildDeviceBindings emits for a GPI pin. */ +function toBinding(inst) { + const spec = KINDS[inst.deviceKind]; + const binding = { primaryPin: null, kind: inst.deviceKind, clockPin: null, indexPin: null, params: {} }; + const extraPins = {}; + for (const pf of pinFields(inst.deviceKind)) { + const v = inst[pf.field]; + const pin = (v === -1 && pf.spec.optional) ? null : v; + if (pf.spec.slot.indexOf("param:") === 0) { + extraPins[pf.spec.slot.slice(6)] = pin; + } else { + binding[pf.spec.slot] = pin; + } + } + for (const key of Object.keys(extraPins)) { + if (extraPins[key] !== null) binding.params[key] = extraPins[key]; + } + for (const pf of paramFields(inst.deviceKind)) { + const p = pf.spec, v = inst[pf.field]; + if (p.type === "bytes") { + const bytes = parseBytes(v); + if (bytes && bytes.length) binding.params[p.key] = bytes; + continue; + } + if (Object.prototype.hasOwnProperty.call(p, "omit") && v === p.omit) continue; + binding.params[p.key] = v; + } + return binding; +} + +/** + * Which blocks read a bound device. Shared by the manifest's reader record + * (resolveDeviceReaders in the static module) and the Device Model's own + * display, so the two cannot disagree about which block reads a device. + */ +const READER_BLOCKS = { + bissc: [{ module: "/pru_blocks/application_specific/biss_c_initiator_block", dataPinField: "dataPin" }], + ssi: [{ module: "/pru_blocks/application_specific/ssi_initiator_block", dataPinField: "dataPin" }] +}; + +function findReaderInstances(kind, primaryPin, modules) { + const out = []; + for (const entry of READER_BLOCKS[kind] || []) { + const mod = modules ? modules[entry.module] : null; + for (const inst of (mod && mod.$instances) || []) { + if (Number(inst[entry.dataPinField]) === primaryPin) { + out.push({ inst: inst, entry: entry }); + } + } + } + return out; +} + +// Slot -> default pin, so a fresh instance of any kind starts on distinct pins. +const SLOT_DEFAULT_PIN = { primaryPin: 1, clockPin: 0, indexPin: 2 }; + +function defaultPin(pinSpec) { + if (pinSpec.optional) return -1; + if (pinSpec.slot === "primaryPin") return SLOT_DEFAULT_PIN.primaryPin; + if (pinSpec.slot === "clockPin") return SLOT_DEFAULT_PIN.clockPin; + if (pinSpec.slot === "indexPin") return SLOT_DEFAULT_PIN.indexPin; + return 3; // a pin carried in params (spi_adc cs) +} + +/** + * `inst` with every field of its kind that it does not carry set to the field's default - what a .syscfg + * that leaves a field unset means. SysConfig does this itself; scripts/check_pin_collisions.js (which has + * no SysConfig runtime) needs it spelled out. + */ +function withDefaults(inst) { + const out = { deviceKind: inst.deviceKind, $name: inst.$name }; + if (!KINDS[inst.deviceKind]) return out; + const isSet = function (v) { return typeof v === "number" || typeof v === "string" || typeof v === "boolean"; }; + for (const pf of pinFields(inst.deviceKind)) out[pf.field] = isSet(inst[pf.field]) ? inst[pf.field] : defaultPin(pf.spec); + for (const pf of paramFields(inst.deviceKind)) out[pf.field] = isSet(inst[pf.field]) ? inst[pf.field] : pf.spec.def; + return out; +} + +/** + * A GPO pad that feeds an attached device's input (SSI/BiSS-C/EnDat clock, SPI SCLK/MOSI/CS, STEP/DIR/EN, + * WS2812 DATA, MDIO's MDC, ...) may be driven only by the blocks that legitimately talk to that device. A second block on + * the pad (a SPI write whose SCLK is the same GPO) makes the device see its output as extra input edges; sequential + * hand-off of a pad is legal only while NO device listens on it, which is why the duplicate-GPO rule exempts sequential + * blocks and this one does not. Shared by the GUI validator and scripts/check_pin_collisions.js so the two cannot disagree. + * + * DRIVER_BLOCKS is the explicit list. Per kind, per PRU-driven pad (the device-pin `key` of KINDS), the block modules + * that legitimately drive that pad. A rule is one of + * { module, via: [[deviceKey, blockField], ...], padField } the block is matched to THE DEVICE: every named block field + * equals that pin of the device (the device-driven DATA pin for an encoder reader: the pairing the manifest `readers` + * record and the Device Model "Read by" field use). A second instance of the module that points at another data pin + * is NOT matched and is foreign, which is why a module wildcard is not used. `padField` is + * the block field that carries the pad itself; it is read only to say, in the message, that the matched block puts + * its pad somewhere other than where the device listens. + * { module } a module wildcard. Used only where the pad itself already identifies the instance (a WS2812 string has one + * pin, so any ws2812_block driving it is the string's driver) or where the bus is shared by design (SPI SCLK/MOSI are + * shared between chips; the chip is selected by CS, and CS is matched exactly). + * + * VERDICT per listened pad: a driver that is matched (or wildcard-legitimate) is never reported. Every other driver is an + * ERROR when a legitimate driver for the pad exists in the design (a matched block, or a wildcard block on the pad), because + * the intended driver is identifiable and the other block is a second one. When none exists, a block of one of the pad's + * rule modules is taken as the intended driver with a wrong pin (a wiring mistake the simulator reports) and is not flagged; + * any other driver then cannot be told from a hand-built generator, so it is a WARNING (severity "warning"), not an error. + * A pad whose rule list is empty (no block in this tool speaks the device, e.g. BiSS Line) can only be judged by count: + * one driver is a hand-built generator and clean, two or more are a WARNING. + * `frameBased` kinds (encoders that answer one frame at a time) also WARN when two blocks read the same device ( + * S-A): the second frame can start inside the encoder's monoflop/recovery time, which the simulator reports as clocks past + * the end of the word. + * + * devices: [{name, source, kind, matched: {padKey: [{name, driven}]}, listens: [{pin, label, key}]}] - `matched` comes from + * matchedDrivers; `listens` are the pins the PRU drives and the device only listens to. + * drivers: [{iname, block, pin}] - every GPO claim in the design. + * Returns [{device, pin, other, severity: "error"|"warning", message}]. + */ +const PRU_IO = "/pru_blocks/pru_io_blocks/"; +const SPI_BLOCKS = [PRU_IO + "pru_spi_transfer", PRU_IO + "pru_spi_read", PRU_IO + "pru_spi_write"]; +const DRIVER_BLOCKS = { + ssi: { noun: "reader", frameBased: true, pads: { + clk: { what: "extra clocks", rules: [{ module: "/pru_blocks/application_specific/ssi_initiator_block", via: [["data", "dataPin"]], padField: "clkPin" }] } } }, + bissc: { noun: "reader", frameBased: true, pads: { + ma: { what: "extra clocks", rules: [{ module: "/pru_blocks/application_specific/biss_c_initiator_block", via: [["slo", "dataPin"]], padField: "clkPin" }] } } }, + endat: { noun: "reader", frameBased: true, pads: { + clock: { what: "extra clocks", rules: [{ module: "/pru_blocks/application_specific/endat_initiator_block", via: [["data", "dataRxPin"]], padField: "clkPin" }] } } }, + sigma_delta: { noun: "reader", pads: { + clk: { what: "extra clocks", rules: [{ module: PRU_IO + "pdm_block", via: [["data", "dataPin"]], padField: "clkPin" }] } } }, + // No block in this tool speaks BiSS Line: its MASTER clock can only be a hand-built generator. + bissline: { noun: "driver", name: "BiSS Line", pads: { master: { what: "extra clocks", rules: [] } } }, + spi_adc: { noun: "driver", pads: { + sclk: { what: "extra clocks", rules: SPI_BLOCKS.map(function (m) { return { module: m }; }) }, + sdo: { what: "corrupted data bits", rules: [{ module: SPI_BLOCKS[0] }, { module: SPI_BLOCKS[2] }] }, + cs: { what: "a toggled chip select", rules: SPI_BLOCKS.map(function (m) { return { module: m, via: [["cs", "CS Signal"]], padField: "CS Signal" }; }) } } }, + step_dir: { noun: "driver", pads: { + step: { what: "extra steps", rules: [{ module: PRU_IO + "stepper_block" }, { module: PRU_IO + "multi_axis_motion_block" }] }, + dir: { what: "a flipped direction", rules: [{ module: PRU_IO + "stepper_block" }, { module: PRU_IO + "multi_axis_motion_block" }] }, + en: { what: "a toggled enable", rules: [{ module: PRU_IO + "multi_axis_motion_block" }] } } }, + ws2812: { noun: "driver", pads: { data: { what: "extra bits", rules: [{ module: PRU_IO + "ws2812_block" }] } } }, + mdio_phy: { noun: "reader", pads: { + mdc: { what: "extra clocks", rules: [{ module: PRU_IO + "mdio_master_block", via: [["mdio", "mdioBit"]], padField: "mdcBit" }] } } } +}; + +/** The device's pin for `key` (a KINDS pin key), from a manifest-shaped binding, or undefined. */ +function devicePin(kind, binding, key) { + for (const p of KINDS[kind].pins) { + if (p.key !== key) continue; + return p.slot.indexOf("param:") === 0 ? (binding.params || {})[p.slot.slice(6)] : binding[p.slot]; + } + return undefined; +} + +/** + * The blocks matched to a device by a `via` rule: {padKey: [{name, driven}]}, `driven` the pad the block's padField names + * (or null). Pure data lookup in `modules` (system.modules, or the CI's parsed design); the verdict is findDeviceInputConflicts. + */ +function matchedDrivers(kind, binding, modules) { + const out = {}; + const spec = DRIVER_BLOCKS[kind]; + if (!spec) return out; + for (const padKey of Object.keys(spec.pads)) { + out[padKey] = []; + for (const rule of spec.pads[padKey].rules) { + if (!rule.via) continue; + const mod = modules ? modules[rule.module] : null; + for (const inst of (mod && mod.$instances) || []) { + if (!rule.via.every(function (v) { return Number(inst[v[1]]) === devicePin(kind, binding, v[0]); })) continue; + if (out[padKey].some(function (m) { return m.name === inst.$name; })) continue; + const driven = Number(inst[rule.padField]); + out[padKey].push({ name: inst.$name, driven: Number.isInteger(driven) ? driven : null }); + } + } + } + return out; +} + +function findDeviceInputConflicts(devices, drivers) { + const out = []; + const base = function (module) { return module.split("/").pop(); }; + for (const d of devices) { + const spec = KINDS[d.kind], rules = DRIVER_BLOCKS[d.kind]; + if (!spec || !rules) continue; + const kindName = rules.name || spec.displayName.replace(/ .*$/, ""); + for (const l of d.listens) { + const pad = rules.pads[l.key] || { what: "extra input edges", rules: [] }; + const matched = (d.matched && d.matched[l.key]) || []; + const wild = pad.rules.filter(function (r) { return !r.via; }).map(function (r) { return base(r.module); }); + const ruleModules = pad.rules.map(function (r) { return base(r.module); }); + const onPad = []; + for (const drv of drivers) { + if (drv.pin === l.pin && !onPad.some(function (o) { return o.iname === drv.iname; })) onPad.push(drv); + } + const legitDrivers = onPad.filter(function (drv) { + return matched.some(function (m) { return m.name === drv.iname; }) || wild.indexOf(drv.block) !== -1; + }); + const hasLegit = matched.length > 0 || legitDrivers.length > 0; + const who = matched.map(function (m) { return m.name; }); + for (const drv of legitDrivers) if (who.indexOf(drv.iname) === -1) who.push(drv.iname); + const sees = " sees that block's output as extra input edges (" + pad.what + ")"; + if (!pad.rules.length) { + // Nothing in this tool speaks the device: judge by count. One driver is a hand-built generator. + if (onPad.length < 2) continue; + out.push({ + device: d.name, pin: l.pin, other: onPad.map(function (o) { return o.iname; }).join(", "), severity: "warning", + message: "GPO" + l.pin + " feeds the " + l.label + " of device '" + d.name + "' (" + d.kind + ", " + d.source + ") and " + + onPad.map(function (o) { return "'" + o.iname + "' (" + o.block + ")"; }).join(", ") + " all drive GPO" + l.pin + + ". No block in this tool speaks a " + kindName + " device, so the check cannot tell which one generates its " + + l.label + "; the device" + sees + " of any other. Keep one block on that GPO." + }); + continue; + } + for (const drv of onPad) { + if (legitDrivers.indexOf(drv) !== -1) continue; + // No legitimate driver: a block of the pad's rule modules is the intended one with a wrong pin; the simulator says so. + if (!hasLegit && ruleModules.indexOf(drv.block) !== -1) continue; + const note = (matched.length && !matched.some(function (m) { return m.driven === l.pin; })) + ? " Note: '" + matched[0].name + "' is matched to this device but puts its " + (pad.rules[0].padField || "pad") + " on GPO" + + matched[0].driven + ", not on the device's " + l.label + " (GPO" + l.pin + "); if that is the mistake, move '" + + matched[0].name + "' to GPO" + l.pin + " and leave '" + drv.iname + "' where it is." : ""; + out.push({ + device: d.name, pin: l.pin, other: drv.iname, severity: hasLegit ? "error" : "warning", + message: hasLegit ? ("GPO" + l.pin + " feeds the " + l.label + " of device '" + d.name + "' (" + d.kind + ", " + + (rules.noun === "reader" ? "read by " : "driven by ") + who.join(", ") + ", " + d.source + ") but '" + drv.iname + + "' (" + drv.block + ") also drives GPO" + l.pin + ": the " + kindName + " device" + sees + + ". Move '" + drv.iname + "' to a free GPO." + note) : ("No " + rules.noun + " block was found for device '" + d.name + + "' (" + d.kind + ", " + d.source + "), whose " + l.label + " is on GPO" + l.pin + ", but '" + drv.iname + "' (" + drv.block + + ") also drives GPO" + l.pin + ". If that block is not generating the device's " + l.label + ", the " + kindName + + " device sees its output as extra input edges (" + pad.what + "): move it to a free GPO.") + }); + } + } + if (rules.frameBased) { + const readers = []; + for (const padKey of Object.keys(d.matched || {})) { + for (const m of d.matched[padKey]) if (readers.indexOf(m.name) === -1) readers.push(m.name); + } + if (readers.length > 1) { + out.push({ + device: d.name, pin: d.listens.length ? d.listens[0].pin : -1, other: readers.slice(1).join(", "), severity: "warning", + message: readers.length + " blocks (" + readers.join(", ") + ") read device '" + d.name + "' (" + d.kind + ", " + d.source + + "). The " + kindName + " encoder answers one frame at a time: a second read that starts before the first frame's " + + "monoflop/recovery time has passed is read as extra clocks of the first (TI pru-simulator reports it as clocks past the end of the word). " + + "Put a Delay longer than the encoder's recovery time between them, or read once and reuse the value." + }); + } + } + } + return out; +} + +/** Pins of `kind` the PRU drives, from a manifest-shaped {primaryPin, clockPin, indexPin, params}: [{pin, label, key}]. */ +function listenedPins(kind, binding) { + const out = []; + if (!KINDS[kind]) return out; + for (const p of KINDS[kind].pins) { + const v = p.slot.indexOf("param:") === 0 ? (binding.params || {})[p.slot.slice(6)] : binding[p.slot]; + if (p.drives === false && Number.isInteger(v) && v >= 0) out.push({ pin: v, label: p.label, key: p.key }); + } + return out; +} + +/** The params object of a Simulation Settings GPI device field (JSON text), or {} when it is not an object. */ +function paramsOf(text) { + try { + const v = JSON.parse(String(text || "{}")); + return v && typeof v === "object" && !Array.isArray(v) ? v : {}; + } catch (e) { + return {}; + } +} + +exports = { + PIN_MAX, + KINDS, + READER_BLOCKS, + DRIVER_BLOCKS, + pinField, + paramField, + pinFields, + paramFields, + parseBytes, + checkInstance, + claimedPins, + clockPin, + toBinding, + findReaderInstances, + findDeviceInputConflicts, + matchedDrivers, + paramsOf, + listenedPins, + defaultPin, + withDefaults +}; +if (typeof module !== "undefined") { + module.exports = exports; +} diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js index fb2f0b6..86ec56d 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js @@ -2,14 +2,20 @@ const pruRegisterAllocator = system.getScript("/pru_blocks/common/register_alloc const pruDMEM0 = system.getScript("/pru_blocks/common/simulation/pru_dmem0.js"); const pruSMEM = system.getScript("/pru_blocks/common/simulation/pru_smem.js"); const pruSimulator = system.getScript("/pru_blocks/common/simulation/pru_core.js"); -// single-source device capability table (TRM SPRUIM2J §6.4.1.1 / SPRUJB6 §7.3.1.1) +const deviceBusScript = system.getScript("/pru_blocks/common/simulation/device_bus.js"); +const deviceModelsScript = system.getScript("/pru_blocks/common/simulation/device_models.js"); +// Kinds, typed fields and manifest conversion of the Device Model block +const deviceModelTable = system.getScript("/pru_blocks/common/device_model_table.js"); +// Launcher glue for the "Run on TI pru-simulator" button +const tiRun = system.getScript("/pru_blocks/common/pru_ti_run.js"); +// Single-source device capability table (TRM SPRUIM2J §6.4.1.1 / SPRUJB6 §7.3.1.1) let pruDeviceCaps = null; try { pruDeviceCaps = system.getScript("/pru_blocks/common/pru_device_caps.js"); } catch (e) { try { pruDeviceCaps = system.getScript("/pru_blocks/common/pru_device_caps"); } catch (e2) { /* capsule unavailable in test stub */ } } -const simulationData = {cycleCount : [], r30Bits : [], r31Bits : []}; +const simulationData = {cycleCount : [], r30Bits : [], r31Bits : [], deviceResults : []}; // ============================================================================ // R31 Simulation Input Functions @@ -150,6 +156,135 @@ function validatePatternInput(inst, report, pinNum) { return true; } +/** + * Helper function to validate device-mode input for a specific GPI pin. + * + * Device mode is the one input mode where the user is not authoring the + * waveform, so the failure it must guard against is different: a device + * configured onto the wrong pins, or with a parameter the model will silently + * drop. Both produce a simulation that runs happily and proves nothing, which + * is exactly the outcome attaching a real model is supposed to prevent. + */ +function validateDeviceInput(inst, report, pinNum) { + const kindField = `gpi${pinNum}_deviceKind`; + const clockField = `gpi${pinNum}_deviceClockPin`; + const indexField = `gpi${pinNum}_deviceIndexPin`; + const paramsField = `gpi${pinNum}_deviceParams`; + + const clockPin = inst[clockField]; + const indexPin = inst[indexField]; + + for (const [field, value, label] of [[clockField, clockPin, "Clock/B"], + [indexField, indexPin, "Index/Z"]]) { + if (value !== -1 && (!Number.isInteger(value) || value < 0 || value > 19)) { + report.logError(`GPI${pinNum} Device ${label} Pin must be 0-19, or -1 if unused`, inst, field); + return false; + } + if (value === pinNum) { + report.logError(`GPI${pinNum} Device ${label} Pin cannot be the same pin as the device's primary pin (${pinNum})`, inst, field); + return false; + } + } + + if (clockPin === -1 && indexPin !== -1) { + report.logError(`GPI${pinNum} Device Index/Z Pin is set but Clock/B Pin is not. A device cannot have a third pin without a second.`, inst, indexField); + return false; + } + + if (clockPin !== -1 && clockPin === indexPin) { + report.logError(`GPI${pinNum} Device Clock/B and Index/Z cannot be the same pin (${clockPin})`, inst, indexField); + return false; + } + + // Devices that cannot work on a single pin. Caught here rather than at run + // time, because a clocked slave with no clock produces an idle trace that + // looks like a firmware that never ran. + const MULTI_PIN = ["ssi", "bissc", "bissline", "endat", "tca9538", "sigma_delta"]; + if (MULTI_PIN.indexOf(inst[kindField]) !== -1 && clockPin === -1) { + report.logError(`Device "${inst[kindField]}" is a clocked device and needs a Clock pin. Set GPI${pinNum} Device Clock/B Pin.`, inst, clockField); + return false; + } + + let params; + try { + params = JSON.parse(inst[paramsField]); + } catch(e) { + report.logError(`Invalid GPI${pinNum} Device Parameters. Use JSON object syntax like {"bits": 13}`, inst, paramsField); + return false; + } + + if (params === null || typeof params !== "object" || Array.isArray(params)) { + report.logError(`GPI${pinNum} Device Parameters must be a JSON object. Example: {"bits": 13}`, inst, paramsField); + return false; + } + + // Pin assignments belong in the pin fields above. Accepting them here too + // would give two sources of truth for the same fact, and they would drift. + for (const reserved of ["clk_pin", "data_pin", "a_pin", "b_pin", "z_pin", "pin"]) { + if (Object.prototype.hasOwnProperty.call(params, reserved)) { + report.logError(`GPI${pinNum} Device Parameters must not set "${reserved}" - pins come from the pin fields above`, inst, paramsField); + return false; + } + } + + return true; +} + +/** + * Default a device binding's companion pins from the bound block's own pin + * configuration. + * + * The per-pin idiom makes the user name companions by hand, but the design + * already says where the device lives: an SSI initiator block carries clkPin + * and dataPin, a quadrature block carries aBit/bBit/zBit. When the block is + * present and its data/A pin is the binding's primary pin, the companions + * default from the block. Anything the user set explicitly always wins - + * a default that overrode an explicit choice would be a second source of + * truth. + * + * Returns {clockPin, indexPin}: the EFFECTIVE companion pins for pinNum. + */ +function devicePinDefaults(inst, pinNum, kind) { + let clockPin = inst[`gpi${pinNum}_deviceClockPin`]; + let indexPin = inst[`gpi${pinNum}_deviceIndexPin`]; + + if (typeof system === "undefined" || !system.modules) { + return { clockPin, indexPin }; + } + + const firstPinOf = (moduleName, pinFields, matchField) => { + const mod = system.modules[moduleName]; + if (!mod || !mod.$instances) return null; + for (const blockInst of mod.$instances) { + if (Number(blockInst[matchField]) === pinNum) { + return pinFields.map(f => Number(blockInst[f])); + } + } + return null; + }; + + if (kind === "ssi" && clockPin === -1) { + const pins = firstPinOf("/pru_blocks/application_specific/ssi_initiator_block", + ["clkPin", "dataPin"], "dataPin"); + if (pins && Number.isInteger(pins[0]) && pins[0] >= 0) { + clockPin = pins[0]; + } + } else if (kind === "quadrature") { + const pins = firstPinOf("/pru_blocks/pru_io_blocks/quadrature_block", + ["bBit", "zBit"], "aBit"); + if (pins) { + if (clockPin === -1 && Number.isInteger(pins[0]) && pins[0] >= 0) { + clockPin = pins[0]; + } + if (indexPin === -1 && Number.isInteger(pins[1]) && pins[1] >= 0) { + indexPin = pins[1]; + } + } + } + + return { clockPin, indexPin }; +} + /** * Validate R31 simulation input configuration */ @@ -171,11 +306,216 @@ function validateR31SimulationInput(inst, report) { if (!validatePatternInput(inst, report, pinNum)) { return false; } + } else if (inputMode === "device") { + // Validate against the EFFECTIVE pins (user fields, defaulted + // from the bound block's own pin config where left unset), so a + // clocked device whose clock comes from the block is not refused + // for a field the design already answers. + const kind = inst[`gpi${pinNum}_deviceKind`]; + const defaults = devicePinDefaults(inst, pinNum, kind); + const effectiveInst = Object.assign({}, inst, { + [`gpi${pinNum}_deviceClockPin`]: defaults.clockPin, + [`gpi${pinNum}_deviceIndexPin`]: defaults.indexPin + }); + if (!validateDeviceInput(effectiveInst, report, pinNum)) { + return false; + } } } return true; } +/** + * Collect every device-mode binding from the static module config. + * + * Returns { bindings, errors }. Each binding is { pinNum, kind, clockPin, + * indexPin, params } with companions already defaulted from the bound + * block's pin config (devicePinDefaults). Each error is { message, pinNum, + * field } - a binding that cannot be constructed is a CONFIG error, never a + * silently skipped device: an attached device that quietly did nothing + * would produce a run that proves nothing while looking like a pass. + */ +function collectDeviceBindings(inst) { + const bindings = []; + const errors = []; + const selectedPins = inst.signalsToDisplayR31 || []; + + for (const pinNumStr of selectedPins) { + const pinNum = parseInt(pinNumStr); + if (inst[`gpi${pinNum}_inputMode`] !== "device") { + continue; + } + + const kindField = `gpi${pinNum}_deviceKind`; + const paramsField = `gpi${pinNum}_deviceParams`; + const kind = inst[kindField]; + const defaults = devicePinDefaults(inst, pinNum, kind); + + let params; + try { + params = JSON.parse(inst[paramsField]); + } catch (e) { + // Already refused by validateDeviceInput; do not double-report. + continue; + } + if (params === null || typeof params !== "object" || Array.isArray(params)) { + continue; + } + + try { + const device = deviceModelsScript.createDeviceModel(kind, { + primary: pinNum, + clock: defaults.clockPin, + index: defaults.indexPin + }, params); + bindings.push({ + pinNum, + kind, + clockPin: defaults.clockPin, + indexPin: defaults.indexPin, + params, + device + }); + } catch (e) { + // Factory refusals (unknown kind, kind not yet ported to the + // in-tool simulator, unknown or reserved parameter keys, + // out-of-range constructor values) are config errors. The field + // is named so the error lands on the control that caused it. + const isKindProblem = /unknown device kind|not available in the in-tool/.test(e.message); + errors.push({ + message: `GPI${pinNum} attached device: ${e.message}`, + pinNum, + field: isKindProblem ? kindField : paramsField + }); + } + } + + return { bindings, errors }; +} + +/** + * Measure the clock the firmware actually produced, from the R30 trace. + * + * Returns { observedMinPeriod, observedMinHigh, observedMinLow } in cycles, + * or nulls when the clock never toggled. This is a MEASUREMENT on the + * waveform, not a restatement of the block's configured timing - the two + * differ exactly when the firmware is wrong, which is when the number + * matters. (lesson: a margin read from the config instead of the + * trace is a formula standing in for a measurement.) + */ +function measureClockWaveform(r30ValueHistory, clkPin) { + const bit = 1 << clkPin; + let prev = null; + let lastChange = null; + const periods = []; + const pulses = []; + let lastFall = null; + + for (let cycle = 1; cycle < r30ValueHistory.length; cycle++) { + const level = (r30ValueHistory[cycle] & bit) ? 1 : 0; + if (prev === null) { + prev = level; + lastChange = 1; + if (level === 0) lastFall = 1; + continue; + } + if (level !== prev) { + pulses.push(cycle - lastChange); + if (level === 0) { // falling edge + if (lastFall !== null) { + periods.push(cycle - lastFall); + } + lastFall = cycle; + } + lastChange = cycle; + prev = level; + } + } + + const min = (arr) => arr.length ? Math.min.apply(null, arr) : null; + return { + observedMinPeriod: min(periods), + observedMinPulse: min(pulses), + edges: periods.length + 1 + }; +} + +/** + * Build the per-device run result: protocol decode, faults, verdict, margin. + * + * Verdict rules are fail-closed: + * PASS the device observed no protocol violation AND at least one + * protocol event was decoded. An attached device that never spoke + * proves nothing - that run is a FAIL, not a pass. + * FAIL any fault, or silence. + * + * The margin is the measured slack on the device's tightest timing limit: + * for a clocked slave, the observed minimum clock period minus the model's + * minimum. Reported in cycles, measured from the R30 trace. + */ +function buildDeviceRunResults(bus, bindings, r30ValueHistory, pruState) { + const results = []; + const skippedMacros = (pruState && pruState.skippedMacroCount > 0 && pruState.skippedMacros && pruState.skippedMacros.length > 0) + ? pruState.skippedMacros + : []; + for (const binding of bindings) { + const device = binding.device; + const events = device.events ? device.events() : []; + const faults = device.faults ? device.faults() : []; + + let margin = null; + if (binding.clockPin !== -1 && device.minClkPeriodCycles !== undefined) { + const measured = measureClockWaveform(r30ValueHistory, binding.clockPin); + if (measured.observedMinPeriod !== null) { + margin = { + observedMinPeriodCycles: measured.observedMinPeriod, + requiredMinPeriodCycles: device.minClkPeriodCycles, + slackCycles: measured.observedMinPeriod - device.minClkPeriodCycles + }; + } + } + + const verdict = (faults.length === 0 && events.length > 0) ? "PASS" : "FAIL"; + let summary; + if (verdict === "PASS") { + summary = `PASS - ${events.length} protocol event(s) decoded` + + (margin ? `, clock margin ${margin.slackCycles} cycle(s) ` + + `(measured min period ${margin.observedMinPeriodCycles}, ` + + `device limit ${margin.requiredMinPeriodCycles})` : ""); + } else { + const reasons = []; + if (events.length === 0) { + // When the JS preview skipped one or more macro calls, explain + // that macro blocks cannot be executed in the preview instead of falsely + // claiming that the device was silent. + if (skippedMacros.length > 0) { + reasons.push(`the GUI preview cannot execute macro blocks (${skippedMacros.join(", ")}); ` + + `use Run on TI pru-simulator / gmake sim`); + } else { + reasons.push("no protocol activity decoded - the device never spoke, " + + "so this run proves nothing"); + } + } + if (faults.length > 0) { + reasons.push(`${faults.length} protocol violation(s), first: ${faults[0]}`); + } + summary = `FAIL - ${reasons.join("; ")}`; + } + + results.push({ + device: device.name, + kind: binding.kind, + pins: { primary: binding.pinNum, clock: binding.clockPin, index: binding.indexPin }, + decode: events, + faults, + verdict, + margin, + summary + }); + } + return results; +} + /** * Generate history for a single GPI pin */ @@ -184,6 +524,16 @@ function generatePinHistory(inst, pinNum) { const inputMode = inst[inputModeField]; const pinHistory = {}; + // Device mode drives this pin from a behavioural model at run time, so it + // has no statically authored history. Returning {} explicitly matters: the + // fall-through below is TIMESTAMP mode, and a device pin landing there + // reads empty inputCycles/inputValues and yields a flat idle pin - which + // looks exactly like a firmware that never ran. Fail visibly as nothing + // rather than invisibly as zero. + if (inputMode === "device") { + return {}; + } + if (inputMode === "pattern") { // Pattern mode const patternField = `gpi${pinNum}_bitPattern`; @@ -268,6 +618,223 @@ function generatePinHistory(inst, pinNum) { return pinHistory; } +/** + * Build the device-binding manifest for the generated output. + * + * WHY THIS IS A FILE AND NOT A GUI FEATURE + * Per decision C on, the Python device models on TI pru-simulator are + * the oracle; the SysConfig GUI can only execute JavaScript and must never + * become the authority. So the binding the user configures here is EMITTED, + * and the build runs the real model against it. This function produces that + * emission and nothing else - it does not simulate. + * + * The pin fields in the GUI are generic on purpose (primary / clock-B / + * index-Z) because one selector has to cover eight different devices. The + * translation from those generic names to each model's own argument names is + * deliberately NOT done here: it lives in scripts/sim/device_bindings.py, + * next to the models it must agree with, so a model that renames a pin breaks + * a Python test rather than silently producing an idle trace. What is emitted + * here is the user's intent in the GUI's own vocabulary. + * + * Pure: takes the static instance, returns an object. No I/O, no globals, so + * it can be tested outside the SysConfig runtime - which matters, because + * SysConfig cannot be loaded outside its own runtime. + */ +function buildDeviceBindings(inst) { + const selectedPins = inst.signalsToDisplayR31 || []; + const bindings = []; + + for (const pinNumStr of selectedPins) { + const pinNum = parseInt(pinNumStr); + if (inst[`gpi${pinNum}_inputMode`] !== "device") { + continue; + } + + // Validation has already run by the time templates are expanded, so a + // malformed params string cannot reach here through the GUI. Guard + // anyway and emit null rather than crashing the whole generation: a + // template that throws takes every other generated file down with it. + let params = null; + try { + params = JSON.parse(inst[`gpi${pinNum}_deviceParams`]); + } catch (e) { + params = null; + } + + const clockPin = inst[`gpi${pinNum}_deviceClockPin`]; + const indexPin = inst[`gpi${pinNum}_deviceIndexPin`]; + + bindings.push({ + primaryPin: pinNum, + kind: inst[`gpi${pinNum}_deviceKind`], + // -1 is the GUI's "unused"; null carries that across JSON without + // the consumer having to know the sentinel. + clockPin: (clockPin === -1 || clockPin === undefined) ? null : clockPin, + indexPin: (indexPin === -1 || indexPin === undefined) ? null : indexPin, + params: (params === null || typeof params !== "object" || Array.isArray(params)) ? {} : params + }); + } + + // Sorted so the manifest is stable across GUI selection order. An emitted + // file that reorders itself between runs shows up as a spurious diff. + bindings.sort(function (a, b) { return a.primaryPin - b.primaryPin; }); + + return { + // Bumped when the SHAPE changes, so the Python consumer can refuse a + // manifest it does not understand instead of half-reading it. + version: 1, + pruClkFreqMhz: inst.pruClkFreq, + cyclesToSimulate: inst.pruCyclesToSimulate, + bindings: bindings + }; +} + +/** + * The pins every GPI device binding occupies, for the Device Model block's + * conflict check. Returns [{pinNum, kind, pins: [{pin, label}]}]. + * Uses the EFFECTIVE companion pins (devicePinDefaults), the same pins the + * device would really be wired to, not just the raw fields. + * BuildDeviceBindings emits the RAW fields (an unset companion pin is null in the + * manifest); this check claims the effective pin on purpose - the device is wired there either + * way, so a Device Model on that pin is a real short even though the manifest omits it. + */ +function collectGpiDeviceClaims(inst) { + const claims = []; + for (const pinNumStr of inst.signalsToDisplayR31 || []) { + const pinNum = parseInt(pinNumStr); + if (inst[`gpi${pinNum}_inputMode`] !== "device") { + continue; + } + const kind = inst[`gpi${pinNum}_deviceKind`]; + const eff = devicePinDefaults(inst, pinNum, kind); + const pins = [{ pin: pinNum, label: "primary pin" }]; + if (eff.clockPin !== -1 && eff.clockPin !== undefined) pins.push({ pin: eff.clockPin, label: "Clock/B pin" }); + if (eff.indexPin !== -1 && eff.indexPin !== undefined) pins.push({ pin: eff.indexPin, label: "Index/Z pin" }); + claims.push({ pinNum, kind, pins }); + } + return claims; +} + +/** + * The device-binding manifest from BOTH sources: the GPI device + * fields (buildDeviceBindings, unchanged) plus every Device Model block + * instance. Same shape either way, so the consumer cannot tell them apart. + * With no Device Model instance this returns exactly buildDeviceBindings(inst). + */ +function buildDeviceBindingsWithModels(inst, modules) { + const manifest = buildDeviceBindings(inst); + const dm = modules ? modules["/pru_blocks/utils/device_model_block"] : null; + let added = false; + for (const mi of (dm && dm.$instances) || []) { + if (!deviceModelTable.KINDS[mi.deviceKind]) { + continue; + } + manifest.bindings.push(deviceModelTable.toBinding(mi)); + added = true; + } + if (added) { + manifest.bindings.sort(function (a, b) { return a.primaryPin - b.primaryPin; }); + } + return manifest; +} + +/** + * Record, on each binding, which block reads the bound device and which + * register the allocator gave that block's result. + * + * WHY: `gmake sim` compares the firmware's decoded value with the device + * model. The register holding that value is not a property of the block - the + * allocator chooses it per design (a value live across the read pushes a BiSS + * read from R0 to R1). A checker that assumed a fixed register reported a + * false FAIL on correct GUI firmware. The allocator's own answer is the only + * source of truth, so it is emitted here, next to the binding it belongs to. + * + * A reader is an instance of a block known to decode the device kind whose + * DATA pin is the binding's primary (device-driven) pin. For each reader: + * resultRegister the allocator's notation for output1 ("R1", "R0.w0"), + * or null if the block got no output register + * laterWriters blocks emitted AFTER the reader whose allocated register + * overlaps it; the value left at HALT is then not the read + * + * Kinds absent from the table get no `readers` key: the runner has no + * decoder for them and says NOT CHECKED. A kind in the table with no matching + * block gets `readers: []`, which the runner reports instead of guessing. + * + * Pure: the module map and the allocator are passed in, so the function can be + * evaluated outside the SysConfig runtime (scripts/sim/test_device_bindings.js). + * + * @param manifest buildDeviceBindings() output; annotated in place and returned + * @param modules system.modules + * @param allocation { registerOf(name) -> allocator notation or "-", + * order: instance names in emitted-instruction order } + */ +function resolveDeviceReaders(manifest, modules, allocation) { + const READER_BLOCKS = { + bissc: [{ module: "/pru_blocks/application_specific/biss_c_initiator_block", dataPinField: "dataPin" }], + ssi: [{ module: "/pru_blocks/application_specific/ssi_initiator_block", dataPinField: "dataPin" }] + }; + // Byte ranges [start, end) a notation such as "R1", "R0.b2", "R3.w1" covers. + function byteRanges(notation) { + const ranges = []; + const re = /R(\d+)(?:\.(b|w)(\d))?/g; + let m; + while ((m = re.exec(String(notation))) !== null) { + const base = Number(m[1]) * 4; + if (m[2] === "b") { + ranges.push([base + Number(m[3]), base + Number(m[3]) + 1]); + } else if (m[2] === "w") { + ranges.push([base + Number(m[3]), base + Number(m[3]) + 2]); + } else { + ranges.push([base, base + 4]); + } + } + return ranges; + } + function overlaps(a, b) { + return a.some(function (x) { + return b.some(function (y) { return x[0] < y[1] && y[0] < x[1]; }); + }); + } + + const order = (allocation && allocation.order) || []; + for (const binding of manifest.bindings || []) { + const table = READER_BLOCKS[binding.kind]; + if (!table) { + continue; + } + const readers = []; + for (const entry of table) { + const mod = modules ? modules[entry.module] : null; + for (const inst of (mod && mod.$instances) || []) { + if (Number(inst[entry.dataPinField]) !== binding.primaryPin) { + continue; + } + const notation = allocation.registerOf(inst.$name); + const own = (notation && notation !== "-") ? notation.split(",")[0].trim() : null; + const laterWriters = []; + const at = order.lastIndexOf(inst.$name); + if (own && at !== -1) { + const mine = byteRanges(own); + for (const other of order.slice(at + 1)) { + if (other === inst.$name || laterWriters.indexOf(other) !== -1) { + continue; + } + const theirs = allocation.registerOf(other); + if (theirs && theirs !== "-" && overlaps(mine, byteRanges(theirs))) { + laterWriters.push(other); + } + } + } + readers.push({ block: inst.$name, module: entry.module, + resultRegister: own, laterWriters: laterWriters }); + } + } + readers.sort(function (a, b) { return a.block < b.block ? -1 : a.block > b.block ? 1 : 0; }); + binding.readers = readers; + } + return manifest; +} + /** * Main function to generate R31 history by combining all enabled GPI pins */ @@ -322,6 +889,7 @@ function onSignalsToDisplayR31Change(inst, ui) { const inputMode = inst[`gpi${pinNum}_inputMode`]; const isTimestamp = (inputMode === "timestamp"); const isPattern = (inputMode === "pattern"); + const isDevice = (inputMode === "device"); // Show/hide separator if (ui[`gpi${pinNum}_separator`]) { @@ -354,6 +922,20 @@ function onSignalsToDisplayR31Change(inst, ui) { if (ui[`gpi${pinNum}_patternRepeatCount`]) { ui[`gpi${pinNum}_patternRepeatCount`].hidden = !(isSelected && isPattern); } + + // Show/hide device fields + if (ui[`gpi${pinNum}_deviceKind`]) { + ui[`gpi${pinNum}_deviceKind`].hidden = !(isSelected && isDevice); + } + if (ui[`gpi${pinNum}_deviceClockPin`]) { + ui[`gpi${pinNum}_deviceClockPin`].hidden = !(isSelected && isDevice); + } + if (ui[`gpi${pinNum}_deviceIndexPin`]) { + ui[`gpi${pinNum}_deviceIndexPin`].hidden = !(isSelected && isDevice); + } + if (ui[`gpi${pinNum}_deviceParams`]) { + ui[`gpi${pinNum}_deviceParams`].hidden = !(isSelected && isDevice); + } } } @@ -367,6 +949,7 @@ function createInputModeOnChange(pinNum) { const inputMode = inst[`gpi${pinNum}_inputMode`]; const isTimestamp = (inputMode === "timestamp"); const isPattern = (inputMode === "pattern"); + const isDevice = (inputMode === "device"); // Show/hide timestamp fields if (ui[`gpi${pinNum}_inputCycles`]) { @@ -389,6 +972,20 @@ function createInputModeOnChange(pinNum) { if (ui[`gpi${pinNum}_patternRepeatCount`]) { ui[`gpi${pinNum}_patternRepeatCount`].hidden = !(isSelected && isPattern); } + + // Show/hide device fields + if (ui[`gpi${pinNum}_deviceKind`]) { + ui[`gpi${pinNum}_deviceKind`].hidden = !(isSelected && isDevice); + } + if (ui[`gpi${pinNum}_deviceClockPin`]) { + ui[`gpi${pinNum}_deviceClockPin`].hidden = !(isSelected && isDevice); + } + if (ui[`gpi${pinNum}_deviceIndexPin`]) { + ui[`gpi${pinNum}_deviceIndexPin`].hidden = !(isSelected && isDevice); + } + if (ui[`gpi${pinNum}_deviceParams`]) { + ui[`gpi${pinNum}_deviceParams`].hidden = !(isSelected && isDevice); + } }; } @@ -419,11 +1016,85 @@ function generateR31SimulationInputConfigs() { hidden: true, options: [ { name: "timestamp", displayName: "Timestamp Mode" }, - { name: "pattern", displayName: "Pattern Mode" } + { name: "pattern", displayName: "Pattern Mode" }, + { name: "device", displayName: "Attached Device Model" } ], onChange: createInputModeOnChange(pinNum) }); + // Device mode fields. + // + // Timestamp and Pattern mode both make the user hand-author the + // waveform they expect a device to produce - which means the test + // encodes what they BELIEVE the device does. If that belief is wrong, + // the firmware passes against a fiction. Device mode replaces the + // hand-authored bits with a behavioural model that also REJECTS + // illegal traffic, so a wrong firmware fails instead of being + // faithfully replayed against. + configs.push({ + name: `gpi${pinNum}_deviceKind`, + displayName: `GPI${pinNum} Device`, + description: "Behavioural device model driving this pin", + longDescription: `Attaches a simulated device to GPI${pinNum}. Unlike ` + + `Timestamp and Pattern mode, the waveform is produced by a model ` + + `of the real part rather than transcribed by hand, and the model ` + + `reports protocol violations it observes.`, + default: "quadrature", + hidden: true, + options: [ + { name: "quadrature", displayName: "Quadrature encoder (A/B/Z)" }, + { name: "ssi", displayName: "SSI absolute encoder" }, + { name: "bissc", displayName: "BiSS-C absolute encoder" }, + { name: "bissline", displayName: "BiSS Line absolute encoder" }, + { name: "endat", displayName: "EnDat 2.2 absolute encoder" }, + { name: "tca9538", displayName: "TCA9538 I2C IO expander" }, + { name: "uart", displayName: "UART transmitter" }, + { name: "sigma_delta", displayName: "Sigma-delta modulator" }, + { name: "spi_adc", displayName: "SPI ADC (ADS8688-class SPI mode 0)" }, + { name: "step_dir", displayName: "STEP/DIR stepper driver" }, + { name: "ws2812", displayName: "WS2812 addressable LED string" }, + { name: "mdio_phy", displayName: "MDIO Clause 22 PHY" } + ] + }); + + // A device spans more than one pin, and SysConfig's per-pin config + // cannot express that on its own. The pin selected above is the + // device's PRIMARY pin (data/A); the companions are named here. + // + // This is deliberately the minimal shape that fits the existing + // per-pin idiom. The fuller SysConfig answer is an instance-array + // module, one instance per attached device, which is how TI models + // multi-pin peripherals elsewhere. That is a larger change to the + // static module's data flow and is recorded in + // docs/simulation/device_binding.md rather than chosen silently here. + configs.push({ + name: `gpi${pinNum}_deviceClockPin`, + displayName: `GPI${pinNum} Device Clock/B Pin`, + description: "Second pin: clock for clocked devices, B channel for quadrature. -1 if unused.", + default: -1, + hidden: true + }); + + configs.push({ + name: `gpi${pinNum}_deviceIndexPin`, + displayName: `GPI${pinNum} Device Index/Z Pin`, + description: "Third pin: index/Z for incremental encoders. -1 if unused.", + default: -1, + hidden: true + }); + + configs.push({ + name: `gpi${pinNum}_deviceParams`, + displayName: `GPI${pinNum} Device Parameters`, + description: 'JSON passed to the model constructor. Example: {"bits": 13, "position": 1024}', + longDescription: "Model-specific configuration. Left empty, the model's " + + "own defaults apply. Unknown keys are reported rather than ignored, " + + "because a silently dropped parameter produces a model that is not " + + "the one the user asked for.", + default: "{}", + hidden: true + }); + // Timestamp mode fields configs.push({ name: `gpi${pinNum}_inputCycles`, @@ -1297,6 +1968,37 @@ function isSequentialBlock(blockName, instCfg) { return false; // Fail closed if undecided or unknown } +// The attached devices (Device Model blocks and Simulation Settings GPI device fields) whose +// PRU-driven input pads another block also drives. Verdict logic is deviceModelTable.findDeviceInputConflicts, +// shared with scripts/check_pin_collisions.js. +function reportDeviceInputDrivers(inst, gpoBits, report) { + const modules = system.modules; + const devices = []; + const dm = modules["/pru_blocks/utils/device_model_block"]; + for (const mi of (dm && dm.$instances) || []) { + if (!deviceModelTable.KINDS[mi.deviceKind]) continue; + const b = deviceModelTable.toBinding(mi); + devices.push({ + name: mi.$name, source: "Device Model block", kind: mi.deviceKind, + matched: deviceModelTable.matchedDrivers(mi.deviceKind, b, modules), + listens: deviceModelTable.listenedPins(mi.deviceKind, b) + }); + } + const slotOfLabel = { "primary pin": "primaryPin", "Clock/B pin": "clockPin", "Index/Z pin": "indexPin" }; + for (const claim of collectGpiDeviceClaims(inst)) { + const b = { params: deviceModelTable.paramsOf(inst[`gpi${claim.pinNum}_deviceParams`]) }; + for (const p of claim.pins) b[slotOfLabel[p.label]] = p.pin; + devices.push({ + name: `GPI${claim.pinNum} device`, source: "Simulation Settings GPI device", kind: claim.kind, + matched: deviceModelTable.matchedDrivers(claim.kind, b, modules), + listens: deviceModelTable.listenedPins(claim.kind, b) + }); + } + for (const c of deviceModelTable.findDeviceInputConflicts(devices, gpoBits)) { + if (c.severity === "warning") report.logWarning(c.message, inst); else report.logError(c.message, inst); + } +} + function validateBidiPinCollisionsGlobal(inst, report) { try { // GUI pin warnings: shared GPO/GPI physical pads, duplicate GPO drivers @@ -1417,6 +2119,8 @@ function validateBidiPinCollisionsGlobal(inst, report) { ); } } + // A GPO that feeds an attached device's input may be driven only by that device's reader. + reportDeviceInputDrivers(inst, gpoBits, report); } catch (e) { const reason = (e && e.message) ? e.message : String(e); report.logWarning( @@ -1757,7 +2461,68 @@ function validate(inst, report) // Then replace SMEM symbols pruInstructions = pruSMEM.replaceSymbolReferences(pruInstructions); const r31HistoryMap = collectR31History(); - let {pruState, r30ValueHistory, r31ValueHistory} = pruSimulator.simulatePruInstructions(pruInstructions, pruInstructionsLabels, inst["pruCyclesToSimulate"], r31HistoryMap); + + // Attached Device Model mode: close the loop. Timestamp and + // Pattern mode play a pre-authored history; a device model ANSWERS + // the firmware, and rejects traffic the real part would reject. The + // user configures a device and presses Run - the bus, the models and + // the margin measurement all happen here, in JavaScript, inside the + // tool; no path, no build directory, no external invocation is ever + // shown. + const { bindings, errors: bindingErrors } = collectDeviceBindings(inst); + let deviceBus = null; + if (bindings.length > 0) { + deviceBus = new deviceBusScript.DeviceBus(); + for (const binding of bindings) { + deviceBus.attach(binding.device); + } + // Pins a device drives are INPUTS from the PRU's side. Leaving + // them in the PRU drive mask would record contention on every + // cycle - a false fault on a correct design, which is worse than + // no check at all because it teaches the user to ignore faults. + let pruDriveMask = deviceBusScript.MASK_20; + for (const binding of bindings) { + for (const pin of Object.keys(binding.device.nets || {})) { + pruDriveMask &= ~(1 << Number(pin)); + } + } + deviceBus.setPruDriveMask(pruDriveMask); + } + for (const err of bindingErrors) { + report.logError(err.message, inst, err.field); + } + + let {pruState, r30ValueHistory, r31ValueHistory} = pruSimulator.simulatePruInstructions(pruInstructions, pruInstructionsLabels, inst["pruCyclesToSimulate"], r31HistoryMap, {}, deviceBus); + + if (deviceBus) { + const results = buildDeviceRunResults(deviceBus, bindings, r30ValueHistory, pruState); + simulationData.deviceResults = results; + for (const result of results) { + // A FAIL here is a simulation RESULT, not a config error: the + // run happened, the device refused the firmware, and the + // user must see that verdict. A warning carries it without + // blocking code generation; the full decode and faults are + // on simulationData.deviceResults. + // Labelled as the in-GUI JavaScript preview. The CLI + // build prints this same warning, and next to a `gmake sim` + // run on TI pru-simulator an unlabelled "FAIL" reads as the + // verdict. It is not evidence; `gmake sim` is. The preview's own + // PASS/FAIL word is dropped from the message for that reason; + // the full verdict stays on simulationData.deviceResults. + const previewDetail = result.summary.replace(/^(PASS|FAIL) - /, ""); + // Once a TI pru-simulator run exists for THIS design, + // its verdict replaces the preview banner. A stale one never does. + const realBanner = tiRunBanner(inst); + if (realBanner) { + report.logWarning(realBanner, inst); + continue; + } + report.logWarning(`GUI JS preview only, not evidence - gmake sim gives the TI pru-simulator verdict. ` + + `Preview of attached device "${result.device}" on GPI${result.pins.primary}: ${previewDetail}`, inst); + } + } else { + simulationData.deviceResults = []; + } prepareR30DataForPlotting(r30ValueHistory); prepareR31DataForPlotting(r31ValueHistory); @@ -1766,6 +2531,25 @@ function validate(inst, report) } } +/** + * The banner for a real TI pru-simulator verdict on the design now + * shown, or null (keep the JS preview banner). The result is the JSON the + * wrapper wrote to $comFile; see pru_ti_run.js for the fail-closed rules. + */ +function tiRunBanner(inst) { + if (!inst.tiRunResult) { + return null; + } + return tiRun.bannerLine(tiRun.parseResult(inst.tiRunResult), tiRun.designDigest(system.modules, system)); +} + +function tiRunSummary(inst) { + if (!inst.tiRunResult) { + return "No TI pru-simulator run yet. Save the design, then press Run on TI pru-simulator."; + } + return tiRun.describe(tiRun.parseResult(inst.tiRunResult), tiRun.designDigest(system.modules, system)).text; +} + function getAIContext() { return getLongDescription(); } @@ -1889,6 +2673,12 @@ exports = { longDescription: getLongDescription(), getAIContext: getAIContext, moduleStatic: { + // The GUI may use this direct template. SysConfig 1.28 CLI does not + // execute moduleStatic.templates, so its always-run assembly template + // embeds the same buildDeviceBindings output for extraction by make. + templates: { + "/pru_blocks/common/pru_device_bindings.json.xdt": "/pru_device_bindings.json" + }, validate, config: [ { @@ -1932,6 +2722,62 @@ If the frequencies don't match, timing-sensitive operations (SPI, ADC) may not w description: "Total number of PRU clock cycles to execute during simulation", default: 2000, }, + { + name: "tiRunButton", + displayName: "Real Simulation", + description: "Generate, build with clpru and run this design on TI pru-simulator against the attached device models (the same path as gmake sim). Save the design first: it runs the saved .syscfg.", + buttonText: "Run on TI pru-simulator", + nonSerializable: true, + onLaunch: (inst) => tiRun.launchSpec(system, inst, tiRun.designDigest(system.modules, system)), + onComplete: (inst, _ui, result) => { + inst.tiRunResult = (result && typeof result.data === "string") ? result.data : ""; + } + }, + { + name: "tiRunLiveView", + displayName: "Open live web view after the run", + description: "After the run, open TI pru-simulator's dashboard in your browser on the ELF the button just built, with the bound devices attached. It does not change the verdict, and it is not started when the run was refused or the build failed. The view is simulator-only evidence, not hardware.", + default: false, + onChange: (inst, ui) => { + ui.tiRunLivePort.hidden = !inst.tiRunLiveView; + } + }, + { + name: "tiRunLivePort", + displayName: "Live web view port", + description: "Local port for the live web view (127.0.0.1 only). A view from an earlier Run is replaced; a port held by anything else is reported, not taken.", + default: 8765, + hidden: true + }, + { + name: "tiRunSummary", + displayName: "TI pru-simulator Result", + description: "Verdict from the last Run on TI pru-simulator. Taken from the run's exit code only; NOT CHECKED is never a pass; a result for an earlier design is shown as STALE.", + default: "", + readOnly: true, + multiline: true, + nonSerializable: true, + getValue: (inst) => tiRunSummary(inst) + }, + { + name: "tiRunResult", + displayName: "", + default: "", + hidden: true, + nonSerializable: true + }, + { + name: "tiRunProject", + displayName: "TI Run Project (optional)", + description: "Path to the saved example.syscfg (or its folder). Leave empty to use the open file; set it when the GUI does not report the file path.", + default: "" + }, + { + name: "tiRunRepoRoot", + displayName: "TI Run Tool Root (optional)", + description: "Folder holding scripts/sim/gui_run.py. Leave empty to find it from the PRU No-Code Tool product.", + default: "" + }, { name: "signalsToDisplay", displayName : "Select Output Signals To Display", @@ -1979,6 +2825,10 @@ This allows you to test PRU blocks that read from input pins (like SPI Read's SD getSpiCsIdleSetupCycles, pruCtrlBase, simulationData, + buildDeviceBindings, + buildDeviceBindingsWithModels, + collectGpiDeviceClaims, + resolveDeviceReaders, validateDeviceCapabilities, pruDeviceCaps, validateBidiPinCollisionsGlobal diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_device_bindings.json.xdt b/.metadata/sysconfig/.meta/pru_blocks/common/pru_device_bindings.json.xdt new file mode 100644 index 0000000..2091a57 --- /dev/null +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_device_bindings.json.xdt @@ -0,0 +1,27 @@ +%%{ +/* + * pru_device_bindings.json - the device-binding manifest. + * + * Emitted next to pru_syscfg.inc so the build, not the GUI, runs the oracle. + * Decision C on the Python device models on TI pru-simulator are the + * authority. This file is how the user's GUI selection reaches them without + * the user naming a path or invoking Python. + * + * Always emitted, even with no devices attached. An absent file and an empty + * bindings list mean different things - "generation did not run" versus "the + * user attached nothing" - and the consumer fails closed on the first. + */ +%%} +% const sm = system.modules["/pru_blocks/common/pru_blocks_static_module"]; +% let manifest = { version: 1, pruClkFreqMhz: null, cyclesToSimulate: null, bindings: [] }; +% if (sm && sm.$static && typeof sm.buildDeviceBindingsWithModels === "function") { +% manifest = sm.buildDeviceBindingsWithModels(sm.$static, system.modules); +% // Same reader/result-register record as the asm-embedded copy. +% if (typeof sm.resolveDeviceReaders === "function") { +% const alloc = system.getScript("/pru_blocks/common/register_allocation/pru_register_allocator.js"); +% const summary = alloc.getPruRegisterAllocationSummary(); +% const order = ((summary && summary.pruInstructionInstances) || []).map(i => i && i.$name); +% sm.resolveDeviceReaders(manifest, system.modules, { registerOf: alloc.getBlockRegisterAllocation, order: order }); +% } +% } +`JSON.stringify(manifest, null, 2)` diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_syscfg.asm.xdt b/.metadata/sysconfig/.meta/pru_blocks/common/pru_syscfg.asm.xdt index 673040b..4a7e206 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_syscfg.asm.xdt +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_syscfg.asm.xdt @@ -2,6 +2,17 @@ %const registerAllocator = system.getScript("/pru_blocks/common/register_allocation/pru_register_allocator.js"); %const systemModules = system.modules; %const bindingModule = system.modules["/pru_blocks/common/pru_blocks_static_module"]; +%const bindingManifest = bindingModule && bindingModule.$static ? bindingModule.buildDeviceBindingsWithModels(bindingModule.$static, systemModules) : {version:1, bindings:[]}; +%bindingManifest.core = system.getScript("/common").getSelfSysCfgCoreName(); +%// Carry the allocator's result register of the block reading each device. +%if (bindingModule && typeof bindingModule.resolveDeviceReaders === "function") { +% const readerOrder = ((pruRegisterAllocationSummary && pruRegisterAllocationSummary.pruInstructionInstances) || []).map(i => i && i.$name); +% bindingModule.resolveDeviceReaders(bindingManifest, systemModules, { registerOf: registerAllocator.getBlockRegisterAllocation, order: readerOrder }); +%} +; PRU_DEVICE_BINDINGS_BEGIN +; `JSON.stringify(bindingManifest)` +; PRU_DEVICE_BINDINGS_END +; PRU_DESIGN_DIGEST `system.getScript("/pru_blocks/common/pru_ti_run.js").designDigest(system.modules, system)` ; Copyright (C) 2024 Texas Instruments Incorporated - http://www.ti.com/ ; ; Redistribution and use in source and binary forms, with or without diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_ti_run.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_ti_run.js new file mode 100644 index 0000000..78a69f5 --- /dev/null +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_ti_run.js @@ -0,0 +1,287 @@ +/** + * The "Run on TI pru-simulator" button, GUI side. + * + * The GUI can only execute JavaScript, and its JS simulator is a preview, not + * evidence. This script is the glue that lets the Simulation Settings panel + * LAUNCH the real chain (scripts/sim/gui_run_launcher.js -> gui_run.py: generate, + * clpru build, scripts/sim/run_bound_devices.py) and SHOW what it returned. + * It decides nothing: the verdict is the exit code of run_bound_devices.py, + * carried through the wrapper's JSON. Everything here is pure so it can be run + * under node (scripts/sim/test_ti_run_gui.js) - SysConfig cannot be. + * + * Fail-closed rules, all enforced in parseResult/describe: + * - unreadable, missing or unknown verdict -> NOT CHECKED + * - PASS without exitCode 0 -> NOT CHECKED + * - a result for a different design than shown -> STALE, never a verdict + */ + +const VERDICTS = ["PASS", "FAIL", "REFUSED", "NOT CHECKED"]; +const DEFAULT_LIVE_PORT = 8765; + +// Written to $comFile before the wrapper starts. If the wrapper never runs or +// dies without writing, onComplete reads this back - which must not look like a +// pass. +const INITIAL_DATA = JSON.stringify({ + schema: 1, + verdict: "NOT CHECKED", + exitCode: null, + reason: "the TI pru-simulator wrapper did not write a result" +}); + +function fnv1a(text, seed) { + let h = seed >>> 0; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return ("00000000" + h.toString(16)).slice(-8); +} + +function snapshot(obj) { + const out = {}; + for (const key of Object.keys(obj).sort()) { + // $position and friends are layout, not design. tiRun* is this + // feature's own result state; including it would make every run stale. + if ((key.charAt(0) === "$" && key !== "$name") || key.indexOf("tiRun") === 0) { + continue; + } + const value = obj[key]; + if (typeof value === "function" || value === undefined) { + continue; + } + // Load Constant's outputWidth at its default "auto" is not part of the digest, so a design that does + // not use the option keeps the digest (and the pru_syscfg.asm header) it had before the option existed. + if (key === "outputWidth" && value === "auto") { + continue; + } + out[key] = value; + } + return out; +} + +/** + * A digest of the design as the GUI holds it: every module's static config and + * every instance's config, minus layout. The CLI embeds the same digest in the + * generated pru_syscfg.asm, so the wrapper can tell whether the design on disk + * (which it regenerates and builds) is the design the user is looking at. + */ +function executionContext(env) { + // system.context is the SysConfig context name (icss_g0_pru0 / icss_g0_pru1); + // system.getScript() is NOT a "current script" accessor, it takes a module path. + const data = (env && env.deviceData) || {}; + return { + context: (env && env.context) || "", + core: (data.core && data.core.name) || "", + device: data.device || "", + part: data.part || "", + package: data.package || "" + }; +} + +function designDigest(modules, env) { + const record = []; + for (const name of Object.keys(modules || {}).sort()) { + const mod = modules[name]; + if (!mod) { + continue; + } + const entry = { module: name }; + if (mod.$static) { + entry.static = snapshot(mod.$static); + } + const instances = (mod.$instances || []).map(snapshot); + instances.sort(function (a, b) { return a.$name < b.$name ? -1 : a.$name > b.$name ? 1 : 0; }); + if (instances.length) { + entry.instances = instances; + } + if (entry.static || entry.instances) { + record.push(entry); + } + } + // Do not digest makefiles/imports/block sources: the GUI cannot snapshot + // those files, and pretending it can would reject every click. They remain + // governed by the per-project lock and the generator/build exit guards. + const text = JSON.stringify({ design: record, execution: executionContext(env) }); + return fnv1a(text, 0x811c9dc5) + fnv1a(text, 0x9747b28c); +} + +function dirName(p) { + return String(p).replace(/[\\/][^\\/]*$/, ""); +} + +/** + * What onLaunch returns. `env` is the SysConfig `system` object (or a stub). + * + * Project: the open .syscfg (system.getCurrentScriptPath(), which SysConfig + * only exposes while a launcher is being built), else the "TI run project" + * config field. Repo root: the PRU_NO_CODE_TOOL product's product.json sits in + * /.metadata, else the "TI run repo root" field. The interpreter is + * resolved by the launcher, not here: the GUI process's PATH is not a shell's. + */ +function launchSpec(env, settings, digest) { + settings = settings || {}; + let repoRoot = settings.tiRunRepoRoot || ""; + if (!repoRoot) { + try { + for (const product of env.getProducts()) { + if (product && product.name === "PRU_NO_CODE_TOOL" && product.path) { + repoRoot = dirName(dirName(product.path)); + } + } + } catch (e) { /* path not visible: fall through to the explicit field */ } + } + let project = ""; + try { + // The open file is authoritative. A copied .syscfg must not inherit a + // stale tiRunProject path from its source design. + project = env.getCurrentScriptPath() || ""; + } catch (e) { /* fall through to explicit setting */ } + if (!project) { + try { + project = settings.tiRunProject || ""; + } catch (e) { /* leave empty */ } + } + const sep = repoRoot.indexOf("\\") !== -1 ? "\\" : "/"; + const args = [ + repoRoot + sep + "scripts" + sep + "sim" + sep + "gui_run_launcher.js", + "--com", "$comFile", + "--digest", digest, + "--repo", repoRoot, + "--project", project + ]; + // TiRunLiveView / tiRunLivePort are tiRun* so the digest ignores + // them; they only add arguments, they never change what is judged. + // The port goes along even when the box is unticked, so a run that + // did not ask for a view can still say a different design's view is serving there. + const port = Number(settings.tiRunLivePort); + const livePort = String(Number.isInteger(port) && port >= 1 && port <= 65535 ? port : DEFAULT_LIVE_PORT); + if (settings.tiRunLiveView === true) { + args.push("--live"); + } + args.push("--live-port", livePort); + return { + command: env.getNodePath(), + args: args, + initialData: INITIAL_DATA, + inSystemPath: true + }; +} + +function notChecked(reason) { + return { schema: 1, verdict: "NOT CHECKED", exitCode: null, reason: reason, devices: [] }; +} + +/** Parse $comFile content. Never throws, never upgrades a bad input to PASS. */ +function parseResult(text) { + let data; + try { + data = JSON.parse(text); + } catch (e) { + return notChecked("the wrapper result was not valid JSON"); + } + if (!data || typeof data !== "object" || VERDICTS.indexOf(data.verdict) === -1) { + return notChecked("the wrapper result carried no recognised verdict"); + } + if (data.verdict === "PASS" && data.exitCode !== 0) { + return notChecked("a PASS verdict without run_bound_devices.py exit code 0 is not accepted"); + } + if (data.verdict !== "PASS" && data.exitCode === 0) { + return notChecked("exit code 0 with verdict " + data.verdict + " is inconsistent"); + } + if (!Array.isArray(data.devices)) { + data.devices = []; + } + return data; +} + +/** + * One panel line for the optional live web view. Display only - it is + * never read by the verdict logic, and anything malformed is simply not shown. + */ +function oneLine(text) { + // Newlines and other control characters would add fake lines (e.g. a + // "verdict: PASS") to the panel; fold them to single spaces. + return String(text).replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ").trim(); +} + +function liveViewLine(live) { + if (!live || typeof live !== "object" || Array.isArray(live)) { + return null; + } + if (live.status === "started" && typeof live.url === "string" && oneLine(live.url)) { + return "Live web view: " + oneLine(live.url) + " (opened in your browser" + + (Number.isInteger(live.pid) ? "; pid " + live.pid : "") + ")"; + } + if (live.status === "other design") { + const why = typeof live.reason === "string" && oneLine(live.reason) ? oneLine(live.reason) : "no reason recorded"; + return "Live web view: " + why; + } + if (live.status === "not started" || live.status === "failed") { + const why = typeof live.reason === "string" && oneLine(live.reason) ? oneLine(live.reason) : "no reason recorded"; + return "Live web view: " + live.status + " - " + why + + (live.status === "failed" && typeof live.log === "string" && oneLine(live.log) ? " (log: " + oneLine(live.log) + ")" : ""); + } + return null; +} + +/** + * The text the panel shows. `fresh` is false when the result was produced for + * a different design than the one now on screen; then it is shown as STALE and + * `verdict` is null so no caller can treat it as current. + */ +function describe(result, currentDigest) { + if (!result) { + return { fresh: false, verdict: null, text: "" }; + } + const fresh = !!result.designDigest && result.designDigest === currentDigest; + const lines = []; + if (!fresh) { + lines.push("STALE - this TI pru-simulator run was for an earlier design (was " + + result.verdict + "); run it again"); + } else { + lines.push("TI pru-simulator verdict: " + result.verdict + + (result.verdict === "NOT CHECKED" ? " (not a pass)" : "")); + } + if (result.reason) { + lines.push("Reason: " + result.reason); + } + for (const device of result.devices || []) { + const where = device.pin === undefined ? "" : " on GPI" + device.pin; + if (device.expected !== undefined && device.decoded !== undefined) { + lines.push(device.kind + where + ": expected " + device.expected + + ", firmware decoded " + device.decoded); + } else { + lines.push(device.kind + where + ": " + (device.note || "no decoded value")); + } + } + if (result.cycles !== undefined && result.cycles !== null) { + lines.push("Cycles run: " + result.cycles); + } + if (result.vcd) { + lines.push("VCD: " + result.vcd); + } + const live = fresh ? liveViewLine(result.liveView) : null; + if (live) { + lines.push(live); + } + return { fresh: fresh, verdict: fresh ? result.verdict : null, text: lines.join("\n") }; +} + +/** + * The line that replaces the "GUI JS preview only" banner once a real verdict + * for THIS design exists. Null when there is none, so the preview banner stays. + */ +function bannerLine(result, currentDigest) { + const d = describe(result, currentDigest); + if (!d.fresh) { + return null; + } + return d.text.split("\n")[0] + (result.reason ? " - " + result.reason : ""); +} + +const api = { VERDICTS, DEFAULT_LIVE_PORT, liveViewLine, INITIAL_DATA, designDigest, executionContext, launchSpec, parseResult, describe, bannerLine }; +if (typeof module !== "undefined" && module.exports) { + module.exports = api; +} else { + exports = api; +} diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_bus.js b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_bus.js new file mode 100644 index 0000000..d8fa5cd --- /dev/null +++ b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_bus.js @@ -0,0 +1,195 @@ +/** + * device_bus.js - the wire between the simulated PRU and attached device models. + * + * This is a JavaScript port of the DeviceBus contract in the TI pru-simulator + * checkout (pru_io/device_model.py, built under and extended through + *). The semantics are intended to be IDENTICAL, and + * scripts/sim/test_device_model_crosscheck.js drives both implementations with + * the same stimulus and diffs them so a drift between the two is a red test, + * not an opinion. If you change the behaviour here, change it there too, in + * the same commit series - one bus, two languages. + * + * WHY A BUS AT ALL + * pru_core.js models the PRU core and, until now, took its R31 input from a + * fixed, pre-authored history. That is open-loop: the "device" on the other + * end cannot react to what the firmware does, so a wrong firmware is replayed + * against a recording instead of being answered by the part. A DeviceBus + * closes the loop: every cycle the PRU's R30 is resolved against whatever the + * attached devices drive, and the resolved value is what R31 samples. + * + * ELECTRICAL MODEL + * A device's tick() returns what it DRIVES as a (driveMask, driveValues) + * pair; the bus resolves all drivers per net. Two net types: + * + * OPEN_DRAIN a device may only pull low; the net is the wired-AND of every + * driver, with a pull-up supplying the idle high. + * PUSH_PULL one driver at a time. Two drivers disagreeing is contention, + * recorded as a fault rather than silently resolved - that is a + * real short on real hardware, and a model that quietly picks a + * winner hides a design error. + * + * ORDERING (same two-phase settle as the Python original) + * 1. Resolve the PRU's CURRENT output against every device's PREVIOUS + * drive, and show that to the devices - a device must see what the PRU + * is doing this cycle, not a cycle late. + * 2. Re-resolve with the drives the devices just produced; that is what + * R31 samples. + * So a device sees the PRU immediately but never the result of its own drive + * within the same cycle - which is the part real silicon cannot do either. + */ + +const OPEN_DRAIN = "open_drain"; +const PUSH_PULL = "push_pull"; + +const MASK_20 = (1 << 20) - 1; + +class DeviceBus { + constructor() { + this.devices = []; + this.contentions = []; + this._lastDrives = []; // [{device, mask, values}] + this._bus = MASK_20; // idle: pull-ups high + this._pruDriveMask = MASK_20; + } + + /** + * Attach a device. Never enabled by default anywhere else - the caller + * (Simulation Settings device mode) decides. + */ + attach(device) { + this.devices.push(device); + return device; + } + + detachAll() { + this.devices = []; + } + + /** + * Which pins the PRU is actually driving (the rest are inputs). Defaults + * to all 20. A design that configures pins as inputs should narrow this, + * otherwise the PRU fights every device on those pins. + */ + setPruDriveMask(mask) { + this._pruDriveMask = mask & MASK_20; + } + + _netType(pin) { + // A pin is open-drain if ANY attached device declares it so - one + // open-drain device makes the whole net open-drain, which is how a + // real bus behaves. + for (const dev of this.devices) { + if (dev.nets && dev.nets[pin] === OPEN_DRAIN) { + return OPEN_DRAIN; + } + } + return PUSH_PULL; + } + + _resolve(cycle, gpo, drives, recordContention) { + let bus = MASK_20; + for (let pin = 0; pin < 20; pin++) { + const bit = 1 << pin; + const net = this._netType(pin); + const drivers = []; + if (this._pruDriveMask & bit) { + drivers.push({ name: "PRU", value: (gpo >> pin) & 1 }); + } + for (const d of drives) { + if (d.mask & bit) { + drivers.push({ name: d.device.name, value: (d.values >> pin) & 1 }); + } + } + + let level; + if (drivers.length === 0) { + level = 1; // pull-up + } else if (net === OPEN_DRAIN) { + level = drivers.some(d => d.value === 0) ? 0 : 1; + } else { + level = drivers[0].value; + const levels = new Set(drivers.map(d => d.value)); + if (recordContention && levels.size > 1) { + const who = drivers.map(d => `${d.name}=${d.value}`).join(", "); + this.contentions.push( + `cycle ${cycle}: push-pull contention on pin ${pin} (${who})`); + } + } + + if (level) { + bus |= bit; + } else { + bus &= ~bit; + } + } + return bus; + } + + /** + * One cycle of bus resolution. Returns the level every input samples. + */ + settle(cycle, gpo) { + const provisional = this._resolve(cycle, gpo, this._lastDrives, false); + const drives = this.devices.map(d => { + const [mask, values] = d.tick(cycle, provisional); + return { device: d, mask, values }; + }); + this._lastDrives = drives; + this._bus = this._resolve(cycle, gpo, drives, true); + return this._bus; + } + + /** + * The word the PRU's output register must START from when devices are + * attached. The bus contract is that undriven lines idle high (pull-ups); + * starting R30 at 0 would instead present every clocked device with a + * spurious falling edge on cycle 1 - a frame the firmware never sent, + * which then desynchronises every real frame after it. Only the pins the + * PRU actually drives are its business; the rest belong to the devices. + */ + idlePruWord() { + return this._pruDriveMask; + } + + // -- aggregate views ------------------------------------------------- + + events() { + const out = []; + for (const dev of this.devices) { + for (const ev of (dev.events ? dev.events() : [])) { + out.push(Object.assign({ device: dev.name }, ev)); + } + } + out.sort((a, b) => (a.cycle || 0) - (b.cycle || 0)); + return out; + } + + faults() { + const out = this.contentions.slice(); + for (const dev of this.devices) { + for (const f of (dev.faults ? dev.faults() : [])) { + out.push(`${dev.name}: ${f}`); + } + } + return out; + } + + reset() { + this.contentions = []; + this._lastDrives = []; + this._bus = MASK_20; + for (const dev of this.devices) { + if (dev.reset) dev.reset(); + } + } + + getState() { + return { + bus: this._bus, + contentions: this.contentions.length, + devices: this.devices.map(d => (d.getState ? d.getState() : { name: d.name })) + }; + } +} + +exports = { DeviceBus, OPEN_DRAIN, PUSH_PULL, MASK_20 }; diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js new file mode 100644 index 0000000..599dc18 --- /dev/null +++ b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js @@ -0,0 +1,1628 @@ +/** + * device_models.js - device models attachable to Simulation Settings pins. + * + * JavaScript ports of the DeviceModel implementations in the TI pru-simulator + * checkout (pru_io/quadrature_encoder.py, pru_io/ssi_encoder.py; built under + * and). The ports are line-by-line faithful in behaviour, and + * scripts/sim/test_device_model_crosscheck.js drives the JS and Python models + * with the same stimulus and diffs drives, events and faults, so a drift + * between the two is a red test rather than an assumption. If you change + * behaviour here, change the Python model too, in the same commit series. + * + * WHY THESE TWO FIRST + * The SysConfig dropdown (pru_blocks_static_module.syscfg.js) lists every + * kind the Python DeviceModel contract covers. Only the two ported here are + * selectable in the in-tool (JavaScript) simulator today; the factory refuses + * the rest VISIBLY instead of substituting something unproven. A device mode + * that quietly runs the wrong model is exactly the "polished seam over an + * unproven oracle" failure this item was sequenced to avoid. + * + * FAULTS ARE THE POINT + * A device model that can only produce plausible waveforms is decoration. Its + * value is faults(): what it saw that the protocol forbids. Every model here + * ships a test that drives it with deliberately broken firmware and requires + * a fault (scripts/sim/test_device_models.js). + */ + +const deviceBus = system.getScript("/pru_blocks/common/simulation/device_bus.js"); +const PUSH_PULL = deviceBus.PUSH_PULL; +const OPEN_DRAIN = deviceBus.OPEN_DRAIN; + +// --------------------------------------------------------------------------- +// Quadrature encoder (port of pru_io/quadrature_encoder.py) +// --------------------------------------------------------------------------- +// +// A rotating incremental encoder on three push-pull outputs: A, B and +// (optionally) Z/index. A and B are the standard 2-bit Gray code, so exactly +// one of them changes per count. Z pulses high for one count at the zero +// position, once per revolution. +// +// An encoder is a signal SOURCE: no protocol comes back at it, so it cannot +// observe the firmware doing anything illegal. What it checks is its own +// output - if a corrupt position ever makes A and B change together, that is +// impossible on real hardware and is a fault, not a waveform. +// +// What the self-check CANNOT catch (same caveat as the Python original): +// firmware that samples slower than the shaft turns loses counts. Skipping 2 +// states flips both bits at once and is caught; skipping 3 states is +// indistinguishable from turning one state BACKWARDS and is not. Do not let +// any decoder built against this model claim it detects lost counts from A/B +// alone. + +// Gray-coded (A, B) by position within the 4-state cycle. +const QUAD_PHASES = [[0, 0], [1, 0], [1, 1], [0, 1]]; + +class QuadratureEncoder { + constructor(opts) { + opts = opts || {}; + const aPin = opts.aPin !== undefined ? opts.aPin : 0; + const bPin = opts.bPin !== undefined ? opts.bPin : 1; + const zPin = opts.zPin !== undefined ? opts.zPin : 2; + const countsPerRev = opts.countsPerRev !== undefined ? opts.countsPerRev : 1024; + const cyclesPerCount = opts.cyclesPerCount !== undefined ? opts.cyclesPerCount : 50; + const forward = opts.forward !== undefined ? !!opts.forward : true; + + if (!Number.isInteger(cyclesPerCount) || cyclesPerCount < 1) { + throw new Error("cycles_per_count must be >= 1 - a count cannot take less than one core cycle"); + } + if (!Number.isInteger(countsPerRev) || countsPerRev < 1) { + throw new Error("counts_per_rev must be >= 1"); + } + + this.name = opts.name || "quadrature"; + this.timeDriven = true; + this.aPin = aPin; + this.bPin = bPin; + this.zPin = zPin; + this.countsPerRev = countsPerRev; + this.cyclesPerCount = cyclesPerCount; + this.forward = forward; + + this.nets = {}; + this.nets[aPin] = PUSH_PULL; + this.nets[bPin] = PUSH_PULL; + if (zPin !== null && zPin !== undefined && zPin !== -1) { + this.nets[zPin] = PUSH_PULL; + } else { + this.zPin = null; + } + + this.position = 0; + this._lastAdvance = 0; + this._events = []; + this._faults = []; + this._started = false; + } + + _indexLevel() { + return ((this.position % this.countsPerRev) + this.countsPerRev) % this.countsPerRev === 0 ? 1 : 0; + } + + _stepOne(cycle) { + const phaseIndex = ((p) => ((p % 4) + 4) % 4); + const prev = QUAD_PHASES[phaseIndex(this.position)]; + this.position += this.forward ? 1 : -1; + const now = QUAD_PHASES[phaseIndex(this.position)]; + + const changed = (prev[0] !== now[0] ? 1 : 0) + (prev[1] !== now[1] ? 1 : 0); + if (changed !== 1) { + this._faults.push( + `cycle ${cycle}: A and B changed together (${prev} -> ${now}) - not a valid quadrature transition`); + } + + if (((this.position % this.countsPerRev) + this.countsPerRev) % this.countsPerRev === 0) { + this._events.push({ cycle: cycle, kind: "index", position: this.position }); + } + } + + tick(cycle, bus) { + if (!this._started) { + this._lastAdvance = cycle; + this._started = true; + } + + // Advance by however many counts have elapsed - a loop, not a single + // step: if we are ticked less often than once per count, skipping the + // intermediate states would silently fabricate a legal waveform for an + // illegal rate. Stepping through them makes the A/B jump visible to + // the self-check above. + while (cycle - this._lastAdvance >= this.cyclesPerCount) { + this._lastAdvance += this.cyclesPerCount; + this._stepOne(cycle); + } + + const phase = QUAD_PHASES[((this.position % 4) + 4) % 4]; + let mask = (1 << this.aPin) | (1 << this.bPin); + let values = (phase[0] << this.aPin) | (phase[1] << this.bPin); + if (this.zPin !== null) { + mask |= 1 << this.zPin; + values |= this._indexLevel() << this.zPin; + } + return [mask, values]; + } + + events() { return this._events.slice(); } + faults() { return this._faults.slice(); } + + reset() { + this.position = 0; + this._lastAdvance = 0; + this._started = false; + this._events = []; + this._faults = []; + } + + getState() { + const phase = QUAD_PHASES[((this.position % 4) + 4) % 4]; + return { + name: this.name, + position: this.position, + revolutions: Math.floor(this.position / this.countsPerRev), + a: phase[0], b: phase[1], z: this._indexLevel(), + faults: this._faults.length + }; + } +} + +// --------------------------------------------------------------------------- +// SSI absolute encoder (port of pru_io/ssi_encoder.py) +// --------------------------------------------------------------------------- +// +// Idle is CLK high, DATA high. The master pulls CLK low; on that first +// falling edge the slave latches its current position and puts the MSB on +// DATA. Each subsequent falling edge shifts the next bit out; the master +// samples on the rising edges. After the n data bits, one further falling +// edge drives DATA low, and it stays low for the monoflop time - that low is +// how the master knows the word has ended. +// +// WHAT IT REJECTS (the reason it is an oracle and not a waveform generator): +// * clocks faster than min_clk_period_cycles - a real encoder has an f_max +// and silently returns garbage above it, the worst possible failure; +// * stalls mid-word longer than the monoflop time - on hardware the +// transfer aborts and the next edge reads as a fresh frame, so stalling +// firmware gets a plausible but WRONG position rather than an error; +// * keeps clocking past the end of the word. +// +// WHAT IT CANNOT CHECK: where the master sampled. The slave sees only CLK; +// where the master latched DATA is invisible to it. The cross-check decoder +// samples on rising edges and shares nothing with this model. + +function toGray(value) { + return value ^ (value >> 1); +} + +function fromGray(value, bits) { + let out = 0; + for (let shift = bits - 1; shift >= 0; shift--) { + out |= (((value >> shift) & 1) ^ ((out >> (shift + 1)) & 1)) << shift; + } + return out >>> 0; +} + +class SsiEncoder { + constructor(opts) { + opts = opts || {}; + const clkPin = opts.clkPin !== undefined ? opts.clkPin : 0; + const dataPin = opts.dataPin !== undefined ? opts.dataPin : 1; + const bits = opts.bits !== undefined ? opts.bits : 13; + const position = opts.position !== undefined ? opts.position : 0; + const cyclesPerCount = opts.cyclesPerCount !== undefined ? opts.cyclesPerCount : null; + const minClkPeriodCycles = opts.minClkPeriodCycles !== undefined ? opts.minClkPeriodCycles : 20; + const monoflopCycles = opts.monoflopCycles !== undefined ? opts.monoflopCycles : 400; + const gray = opts.gray !== undefined ? !!opts.gray : true; + + if (!Number.isInteger(bits) || bits < 1) { + throw new Error("bits must be >= 1"); + } + if (!Number.isInteger(minClkPeriodCycles) || minClkPeriodCycles < 1) { + throw new Error("min_clk_period_cycles must be >= 1"); + } + if (!Number.isInteger(monoflopCycles) || monoflopCycles < 1) { + throw new Error("monoflop_cycles must be >= 1"); + } + if (cyclesPerCount !== null && (!Number.isInteger(cyclesPerCount) || cyclesPerCount < 1)) { + throw new Error("cycles_per_count must be >= 1 or null (stationary)"); + } + + this.name = opts.name || "ssi"; + this.timeDriven = true; + this.clkPin = clkPin; + this.dataPin = dataPin; + this.bits = bits; + this.position = position; + this.cyclesPerCount = cyclesPerCount; + this.minClkPeriodCycles = minClkPeriodCycles; + this.monoflopCycles = monoflopCycles; + this.gray = gray; + + // Only DATA is driven. CLK is deliberately absent from nets: the + // device observes it and must never contend with the master on it. + this.nets = {}; + this.nets[dataPin] = PUSH_PULL; + + this._resetState(); + } + + _resetState() { + this._prevClk = 1; // idle high + this._bitIndex = 0; // 0 = idle, 1..bits = data, bits+1 = trailing low + this._latched = 0; + this._data = 1; + this._lastFall = null; + this._advanceBase = null; + this._events = []; + this._faults = []; + } + + _advancePosition(cycle) { + if (this.cyclesPerCount === null) return; + if (this._advanceBase === null) this._advanceBase = cycle; + while (cycle - this._advanceBase >= this.cyclesPerCount) { + this._advanceBase += this.cyclesPerCount; + this.position += 1; + } + } + + _fallingEdge(cycle) { + if (this._lastFall !== null && this._bitIndex) { + const period = cycle - this._lastFall; + if (period < this.minClkPeriodCycles) { + this._faults.push( + `cycle ${cycle}: clock period ${period} cycles is below the encoder's ` + + `minimum of ${this.minClkPeriodCycles} - a real encoder returns garbage ` + + `here rather than an error`); + } + } + this._lastFall = cycle; + + if (this._bitIndex === 0) { + // First edge of a frame: latch the position NOW. Everything after + // this reads the snapshot, so the shaft turning mid-word cannot + // tear the value - the whole point of a latched protocol. + const raw = this.position & ((1 << this.bits) - 1); + this._latched = (this.gray ? toGray(raw) : raw) >>> 0; + this._bitIndex = 1; + this._data = (this._latched >> (this.bits - 1)) & 1; + } else if (this._bitIndex < this.bits) { + this._bitIndex += 1; + this._data = (this._latched >> (this.bits - this._bitIndex)) & 1; + } else if (this._bitIndex === this.bits) { + this._bitIndex = this.bits + 1; + this._data = 0; // trailing low for the monoflop + this._events.push({ cycle: cycle, kind: "frame", + position: this.position, sent: this._latched, bits: this.bits }); + } else { + this._faults.push( + `cycle ${cycle}: clock continued past the end of the ${this.bits}-bit word ` + + `while DATA was still in its monoflop low`); + } + } + + _abort(cycle) { + this._bitIndex = 0; + this._data = 1; + this._lastFall = null; + this._events.push({ cycle: cycle, kind: "abort" }); + } + + tick(cycle, bus) { + this._advancePosition(cycle); + + const clk = (bus >> this.clkPin) & 1; + + if (clk === 0 && this._prevClk === 1) { + this._fallingEdge(cycle); + } else if (this._bitIndex && this._lastFall !== null) { + // Mid-word stall. Checked only while a transfer is in flight; an + // idle bus between frames is not a stall, it is idle. + if (cycle - this._lastFall > this.monoflopCycles) { + this._faults.push( + `cycle ${cycle}: transfer stalled ${cycle - this._lastFall} cycles after ` + + `bit ${this._bitIndex} of ${this.bits} (monoflop is ${this.monoflopCycles}) ` + + `- on hardware this aborts the word and the next edge reads as a new frame`); + this._abort(cycle); + } + } + + this._prevClk = clk; + + if (this._bitIndex === this.bits + 1 && this._lastFall !== null && + cycle - this._lastFall >= this.monoflopCycles) { + // Monoflop expired: release DATA high, ready for the next frame. + this._bitIndex = 0; + this._data = 1; + } + + return [1 << this.dataPin, this._data << this.dataPin]; + } + + events() { return this._events.slice(); } + faults() { return this._faults.slice(); } + + reset() { this._resetState(); } + + getState() { + return { name: this.name, position: this.position, + bit: this._bitIndex, faults: this._faults.length }; + } +} + +// --------------------------------------------------------------------------- +// Hall sensor (port of perif/hall_sensor.py) +// --------------------------------------------------------------------------- +// +// Three push-pull Hall outputs (A,B,C) = 6 60-degree sectors. Code +// (A<<2|B<<1|C) lawful 001,011,010,110,100,101 -> sectors 0..5; 000/111 illegal. +// The model drives the six legal codes while rotating and can inject 000/111. +// An illegal code emits an "illegal" event and a fault; a watchdog fault +// fires when firmware fails to react (assert fault_ack_pin) within the window. + +const HALL_CODES = [1, 3, 2, 6, 4, 5]; +const HALL_LUT = {1:0, 3:1, 2:2, 6:3, 4:4, 5:5}; + +class HallSensor { + constructor(opts) { + opts = opts || {}; + const aPin = opts.aPin !== undefined ? opts.aPin : 0; + const bPin = opts.bPin !== undefined ? opts.bPin : 1; + const cPin = opts.cPin !== undefined ? opts.cPin : 2; + const cyclesPerSector = opts.cyclesPerSector !== undefined ? opts.cyclesPerSector : 60; + const direction = opts.direction !== undefined ? opts.direction : 1; + const faultAckPin = opts.faultAckPin !== undefined ? opts.faultAckPin : null; + const responseWindow = opts.responseWindowCycles !== undefined ? opts.responseWindowCycles : 32; + + if (!Number.isInteger(cyclesPerSector) || cyclesPerSector < 4) { + throw new Error("cycles_per_sector must be >= 4"); + } + if (direction !== 1 && direction !== -1) throw new Error("direction must be +1 or -1"); + + this.name = opts.name || "hall"; + this.timeDriven = true; + this.aPin = aPin; this.bPin = bPin; this.cPin = cPin; + this.cyclesPerSector = cyclesPerSector; + this.direction = direction; + this.faultAckPin = faultAckPin; + this.responseWindow = responseWindow; + + this.nets = {}; + this.nets[aPin] = PUSH_PULL; + this.nets[bPin] = PUSH_PULL; + this.nets[cPin] = PUSH_PULL; + + this._sectorIdx = 0; + this._code = HALL_CODES[0]; + this._cyclesToNext = cyclesPerSector; + this.position = 0; + + this._illegalCode = null; + this._illegalUntil = -1; + this._illegalStart = null; + this._watchdogDeadline = null; + this._ackSeen = false; + + this._events = []; + this._faults = []; + this._started = false; + this._lastAdvance = 0; + } + + scheduleIllegal(startCycle, code, duration) { + if (code !== 0 && code !== 7) throw new Error("illegal code must be 0 or 7"); + this._illegalCode = code; + this._illegalStart = startCycle; + this._illegalUntil = startCycle + duration; + this._watchdogDeadline = startCycle + this.responseWindow; + this._ackSeen = false; + } + + injectIllegal(code, duration) { + if (code !== 0 && code !== 7) throw new Error("illegal code must be 0 or 7"); + // schedule on next tick + this._pendingIllegal = {code, duration}; + } + + tick(cycle, bus) { + if (!this._started) { this._lastAdvance = cycle; this._started = true; } + if (this._pendingIllegal && this._illegalStart === null) { + this._illegalCode = this._pendingIllegal.code; + this._illegalStart = cycle; + this._illegalUntil = cycle + this._pendingIllegal.duration; + this._watchdogDeadline = cycle + this.responseWindow; + this._ackSeen = false; + delete this._pendingIllegal; + } + if (this._watchdogDeadline !== null) { + if (this.faultAckPin !== null && this.faultAckPin !== -1) { + const ack = (bus >> this.faultAckPin) & 1; + if (ack === 1) this._ackSeen = true; + } + if (cycle >= this._watchdogDeadline && !this._ackSeen) { + this._faults.push(`cycle ${cycle}: firmware failed to react to illegal Hall code ${this._illegalCode} within ${this.responseWindow} cycles`); + this._watchdogDeadline = null; + } else if (this._ackSeen) { + this._watchdogDeadline = null; + } + } + let code; + if (this._illegalCode !== null && this._illegalStart !== null && cycle < this._illegalUntil) { + code = this._illegalCode; + if (cycle === this._illegalStart) { + this._events.push({cycle, kind:"illegal", code}); + this._faults.push(`cycle ${cycle}: illegal Hall code ${code.toString(2).padStart(3,"0")} (000/111)`); + } + } else { + if (this._illegalCode !== null && cycle >= this._illegalUntil) { + this._illegalCode = null; this._illegalStart = null; this._illegalUntil = -1; + } + // advance + while (cycle - this._lastAdvance >= this.cyclesPerSector) { + this._lastAdvance += this.cyclesPerSector; + if (this.direction === 1) this._sectorIdx = (this._sectorIdx + 1) % 6; + else this._sectorIdx = (this._sectorIdx - 1 + 6) % 6; + this._code = HALL_CODES[this._sectorIdx]; + this.position += this.direction; + const sector = HALL_LUT[this._code]; + this._events.push({cycle, kind:"edge", code:this._code, sector, dir:this.direction}); + } + code = this._code; + } + const a = (code >> 2) & 1, b = (code >> 1) & 1, c = code & 1; + const mask = (1<= 1"); + } + if (ossdMin > ossdMax) { + throw new Error("ossdMin must be <= ossdMax"); + } + + this.name = opts.name || "ossd"; + this.timeDriven = true; + this.chAPin = chAPin; + this.chBPin = chBPin; + this.ossdMinCycles = ossdMin; + this.ossdMaxCycles = ossdMax; + this.discrepancyCycles = disc; + + this.nets = {}; + this.nets[chAPin] = PUSH_PULL; + this.nets[chBPin] = PUSH_PULL; + + this._schedule = []; // [start, end, kind, ch] + this._events = []; + this._faults = []; + this._prevA = 1; + this._prevB = 1; + this._lowSinceA = null; + this._lowSinceB = null; + } + + scheduleOssdPulse(channel, start, width) { + if (channel !== "A" && channel !== "B") throw new Error("channel must be A or B"); + const ch = channel === "A" ? 0 : 1; + this._schedule.push([start, start + width, "ossd", ch]); + this._schedule.sort((a,b)=>a[0]-b[0]); + } + scheduleTrip(start, duration) { + this._schedule.push([start, start + duration, "trip", null]); + this._schedule.sort((a,b)=>a[0]-b[0]); + } + scheduleSingleLow(channel, start, duration) { + if (channel !== "A" && channel !== "B") throw new Error("channel must be A or B"); + const ch = channel === "A" ? 0 : 1; + this._schedule.push([start, start + duration, "single_low", ch]); + this._schedule.sort((a,b)=>a[0]-b[0]); + } + clearSchedule() { + this._schedule = []; + this._events = []; + this._prevA = 1; this._prevB = 1; + this._lowSinceA = null; this._lowSinceB = null; + } + + _levelAt(cycle, ch) { + for (const [s,e,kind,c] of this._schedule) { + if (s <= cycle && cycle < e) { + if (kind === "trip") return 0; + if (kind === "ossd" && c === ch) return 0; + if (kind === "single_low" && c === ch) return 0; + } + } + return 1; + } + + tick(cycle, bus) { + const a = this._levelAt(cycle, 0); + const b = this._levelAt(cycle, 1); + + if (this._prevA === 0 && a === 1 && this._lowSinceA !== null) { + const w = cycle - this._lowSinceA; + const kind = w <= this.ossdMaxCycles ? "ossd_pulse" : "trip"; + if (w < this.ossdMinCycles) this._faults.push(`cycle ${cycle}: CH_A pulse ${w} < ossd_min`); + this._events.push({ cycle, kind, channel: "A", width: w }); + this._lowSinceA = null; + } + if (this._prevB === 0 && b === 1 && this._lowSinceB !== null) { + const w = cycle - this._lowSinceB; + const kind = w <= this.ossdMaxCycles ? "ossd_pulse" : "trip"; + if (w < this.ossdMinCycles) this._faults.push(`cycle ${cycle}: CH_B pulse ${w} < ossd_min`); + this._events.push({ cycle, kind, channel: "B", width: w }); + this._lowSinceB = null; + } + if (this._prevA === 1 && a === 0) this._lowSinceA = cycle; + if (this._prevB === 1 && b === 0) this._lowSinceB = cycle; + + this._prevA = a; this._prevB = b; + + let mask = (1 << this.chAPin) | (1 << this.chBPin); + let values = (a << this.chAPin) | (b << this.chBPin); + return [mask, values]; + } + + events() { return this._events.slice(); } + faults() { return this._faults.slice(); } + reset() { + this._events = []; this._faults = []; + this._prevA = 1; this._prevB = 1; + this._lowSinceA = null; this._lowSinceB = null; + this._schedule = []; + } + getState() { + return { name: this.name, a: this._prevA, b: this._prevB, faults: this._faults.length }; + } +} + +// --------------------------------------------------------------------------- +// Factory and catalog +// --------------------------------------------------------------------------- + +/** + * Kinds the SysConfig dropdown advertises (must match the options in + * pru_blocks_static_module.syscfg.js). `ported: true` means the in-tool + * JavaScript simulator can actually run it; the rest are refused visibly by + * createDeviceModel below. + */ + +const IDLE = 1; + +class UartTransmitter { + constructor(opts) { + opts = opts || {}; + const pin = opts.pin !== undefined ? opts.pin : 0; + const payload = opts.payload || []; + const bitPeriod = opts.bitPeriod !== undefined ? opts.bitPeriod : 50; + const startCycle = opts.startCycle !== undefined ? opts.startCycle : 0; + const idleGapBits = opts.idleGapBits !== undefined ? opts.idleGapBits : 2; + const frames = opts.frames !== undefined ? opts.frames : 1; + + if (bitPeriod < 1) throw new Error("bit_period must be >= 1 cycle"); + if (frames < 1) throw new Error("frames must be >= 1"); + for (const byte of payload) { + if (byte < 0 || byte > 255) throw new Error(`payload byte ${byte} is not 8-bit`); + } + + this.name = opts.name || "uart_tx"; + this.timeDriven = true; + this.pin = pin; + this.payload = payload.slice(); + this.bitPeriod = bitPeriod; + this.startCycle = startCycle; + this.idleGapBits = idleGapBits; + this.frames = frames; + + this.nets = {}; + this.nets[pin] = PUSH_PULL; + + this._events = []; + this._build(); + } + + _build() { + this._timeline = []; + this._stopIndices = []; + let cycle = this.startCycle; + + for (let frame = 0; frame < this.frames; frame++) { + for (const byte of this.payload) { + this._timeline.push([cycle, 0]); + cycle += this.bitPeriod; + for (let bit = 0; bit < 8; bit++) { + this._timeline.push([cycle, (byte >> bit) & 1]); + cycle += this.bitPeriod; + } + this._timeline.push([cycle, 1]); + this._stopIndices.push(this._timeline.length - 1); + this._events.push({ cycle: cycle, kind: "byte_sent", value: byte }); + cycle += this.bitPeriod; + } + if (frame < this.frames - 1) { + this._timeline.push([cycle, IDLE]); + cycle += this.bitPeriod * this.idleGapBits; + } + } + this._endCycle = cycle; + } + + corruptStopBit(byteIndex) { + if (byteIndex < 0 || byteIndex >= this._stopIndices.length) { + throw new Error(`byte_index ${byteIndex} out of range (0-${this._stopIndices.length - 1})`); + } + const idx = this._stopIndices[byteIndex]; + this._timeline[idx] = [this._timeline[idx][0], 0]; + } + + levelAt(cycle) { + if (cycle < this.startCycle || cycle >= this._endCycle) return IDLE; + let level = IDLE; + for (const [at, value] of this._timeline) { + if (at > cycle) break; + level = value; + } + return level; + } + + tick(cycle, bus) { + return [1 << this.pin, this.levelAt(cycle) << this.pin]; + } + + events() { return this._events.slice(); } + faults() { return []; } + + reset() { + this._events = []; + this._build(); + } + + getState() { + return { name: this.name, bytes: this._stopIndices.length, end_cycle: this._endCycle }; + } +} + +class UartMonitor { + constructor(opts) { + opts = opts || {}; + const pin = opts.pin !== undefined ? opts.pin : 0; + const bitPeriod = opts.bitPeriod !== undefined ? opts.bitPeriod : 50; + + if (bitPeriod < 2) throw new Error("bit_period must be >= 2 cycles - mid-bit sampling needs a midpoint distinct from the edge"); + this.name = opts.name || "uart_monitor"; + this.timeDriven = true; + this.pin = pin; + this.bitPeriod = bitPeriod; + this.nets = {}; + + this._resetState(); + } + + _resetState() { + this._prev = IDLE; + this._frameStart = null; + this._bits = []; + this._nextSample = 0; + this._lowSince = null; + this._events = []; + this._faults = []; + } + + tick(cycle, bus) { + const level = (bus >> this.pin) & 1; + + if (this._frameStart === null) { + if (this._prev === IDLE && level === 0) { + this._frameStart = cycle; + this._bits = []; + this._nextSample = cycle + Math.floor(this.bitPeriod / 2); + } + } else { + if (cycle === this._nextSample) { + if (this._bits.length === 0 && level !== 0) { + this._events.push({ cycle: cycle, kind: "glitch" }); + this._frameStart = null; + } else { + this._bits.push(level); + this._nextSample += this.bitPeriod; + if (this._bits.length === 10) { + this._complete(cycle); + } + } + } + } + + this._trackBreak(cycle, level); + this._prev = level; + return [0, 0]; + } + + _complete(cycle) { + const stop = this._bits[9]; + let value = 0; + for (let i = 0; i < 8; i++) { + value |= (this._bits[i + 1] << i); + } + + if (stop !== 1) { + let hex = value.toString(16).toUpperCase(); + if (hex.length === 1) hex = "0" + hex; + this._faults.push( + `cycle ${cycle}: framing error on 0x${hex} - stop bit ` + + `sampled low. The transmitter's bit period disagrees with the ` + + `receiver's ${this.bitPeriod} cycles, or the line was released late` + ); + } else { + this._events.push({ cycle: cycle, kind: "byte", value: value }); + } + + this._frameStart = null; + this._bits = []; + } + + _trackBreak(cycle, level) { + if (level === 0) { + if (this._lowSince === null) { + this._lowSince = cycle; + } else if (cycle - this._lowSince > 10 * this.bitPeriod) { + this._faults.push( + `cycle ${cycle}: line held low for ` + + `${cycle - this._lowSince} cycles - longer than a whole ` + + `frame, so this is a break or a pin never released` + ); + this._lowSince = cycle; + } + } else { + this._lowSince = null; + } + } + + received() { + return this._events.filter(e => e.kind === "byte").map(e => e.value); + } + + events() { return this._events.slice(); } + faults() { return this._faults.slice(); } + + reset() { + this._resetState(); + } + + getState() { + return { name: this.name, bytes: this.received().length, faults: this._faults.length }; + } +} + + +class SigmaDeltaModulator { + constructor(opts) { + opts = opts || {}; + const clkPin = opts.clkPin !== undefined ? opts.clkPin : 0; + const dataPin = opts.dataPin !== undefined ? opts.dataPin : 1; + const minPeriod = opts.minPeriod !== undefined ? opts.minPeriod : 4; + const saturationThreshold = opts.saturationThreshold !== undefined ? opts.saturationThreshold : 10.0; + const saturationPersist = opts.saturationPersist !== undefined ? opts.saturationPersist : 16; + const initialInput = opts.initialInput !== undefined ? opts.initialInput : 0.0; + + if (clkPin === dataPin) throw new Error("clk_pin and data_pin must differ"); + if (clkPin < 0 || clkPin >= 20 || dataPin < 0 || dataPin >= 20) throw new Error("pin indices must be in [0, 20)"); + if (minPeriod < 1) throw new Error("min_period must be >= 1"); + if (saturationThreshold <= 0) throw new Error("saturation_threshold must be > 0"); + if (saturationPersist < 1) throw new Error("saturation_persist must be >= 1"); + + this.name = opts.name || "sigma_delta_modulator"; + this.timeDriven = false; + this.clkPin = clkPin; + this.dataPin = dataPin; + this.minPeriod = minPeriod; + this.saturationThreshold = saturationThreshold; + this.saturationPersist = saturationPersist; + + this.nets = {}; + this.nets[dataPin] = PUSH_PULL; + + this._input = Number(initialInput); + this.reset(); + } + + setInput(value) { + this._input = Number(value); + } + + onesDensity(window) { + if (window <= 0) throw new Error("window must be > 0"); + if (this._bits.length === 0) return 0.0; + const n = Math.min(window, this._bits.length); + let sum = 0; + for (let i = this._bits.length - n; i < this._bits.length; i++) { + sum += this._bits[i]; + } + return sum / n; + } + + tick(cycle, bus) { + const clk = (bus >> this.clkPin) & 1; + + if (!this._initialised) { + this._prevClk = clk; + this._initialised = true; + return this._drive(); + } + + const rising = clk === 1 && this._prevClk === 0; + this._prevClk = clk; + + if (rising) { + if (this._lastRisingCycle !== null) { + const period = cycle - this._lastRisingCycle; + if (period < this.minPeriod) { + if (!this._periodFaultReported) { + this._faults.push(`clock period violation: ${period} cycles between rising edges at cycle ${this._lastRisingCycle} and ${cycle} < min_period ${this.minPeriod} (reported once; further fast edges suppressed until the clock returns to a legal period)`); + this._periodFaultReported = true; + } + } else { + this._periodFaultReported = false; + } + } + this._lastRisingCycle = cycle; + + let feedback = 0.0; + if (this._hasLastBit) { + feedback = this._lastBit === 1 ? 1.0 : -1.0; + } + + if (!this._hasLastBit) { + this._acc1 += this._input; + this._acc2 += this._acc1; + } else { + this._acc1 += this._input - feedback; + this._acc2 += this._acc1 - feedback; + } + + const bit = this._acc2 >= 0.0 ? 1 : 0; + this._lastBit = bit; + this._hasLastBit = true; + + this._bits.push(bit); + this._bitCycles.push(cycle); + this._events.push({ cycle: cycle, kind: "bit", bit: bit, input: this._input }); + + if (Math.abs(this._acc1) > this.saturationThreshold || Math.abs(this._acc2) > this.saturationThreshold) { + this._saturationCounter += 1; + } else { + this._saturationCounter = 0; + this._saturated = false; + this._saturationFaultReported = false; + } + + if (this._saturationCounter >= this.saturationPersist) { + this._saturated = true; + if (!this._saturationFaultReported) { + this._faults.push(`integrator saturation: |acc1|=${Math.abs(this._acc1).toFixed(2)} |acc2|=${Math.abs(this._acc2).toFixed(2)} exceeded ${this.saturationThreshold} for ${this._saturationCounter} consecutive rising edges (threshold ${this.saturationPersist}) at cycle ${cycle}`); + this._saturationFaultReported = true; + } + } + } + + return this._drive(); + } + + events() { return this._events.slice(); } + faults() { return this._faults.slice(); } + + reset() { + this._acc1 = 0.0; + this._acc2 = 0.0; + this._prevClk = 0; + this._lastBit = 0; + this._hasLastBit = false; + this._lastRisingCycle = null; + this._saturationCounter = 0; + this._saturated = false; + this._bits = []; + this._bitCycles = []; + this._events = []; + this._faults = []; + this._periodFaultReported = false; + this._saturationFaultReported = false; + this._initialised = false; + } + + _drive() { + const mask = 1 << this.dataPin; + if (!this._hasLastBit) { + return [mask, 0]; + } + const values = (this._lastBit << this.dataPin) & mask; + return [mask, values]; + } +} + + +class TCA9538Device { + constructor(address = 0x23) { + this.address = address & 0x7F; + this.outputReg = 0xFF; + this.polarityReg = 0x00; + this.configReg = 0xFF; + + this.state = "IDLE"; + this.sawStart = false; + this.lastTransaction = null; + + this._prevScl = true; + this._prevSda = true; + this._prevSdaMaster = true; + this._shift = 0; + this._bitCount = 0; + this._regPtr = null; + this._match = false; + } + + step(scl, sdaMaster) { + const slaveLow = this._slaveDrivesLow(); + const busSda = sdaMaster && !slaveLow; + + const prevScl = this._prevScl; + const prevSdaMaster = this._prevSdaMaster; + + if (prevScl && scl && prevSdaMaster && !sdaMaster) { + this._onStart(); + } else if (scl && (!prevSdaMaster) && sdaMaster) { + this._onStop(); + } else if ((!prevScl) && scl) { + this._onSclRising(busSda); + } + + this._prevScl = scl; + this._prevSda = busSda; + this._prevSdaMaster = sdaMaster; + return busSda; + } + + _slaveDrivesLow() { + if (this.state === "ACK_ADDR") return this._match; + if (this.state === "ACK_REG" || this.state === "ACK_DATA") return true; + return false; + } + + _onStart() { + this.sawStart = true; + this.state = "ADDR"; + this._shift = 0; + this._bitCount = 0; + this._match = false; + } + + _onStop() { + this.state = "IDLE"; + this._shift = 0; + this._bitCount = 0; + } + + _onSclRising(busSda) { + if (this.state === "ADDR" || this.state === "REGPTR" || this.state === "DATA") { + this._shift = ((this._shift << 1) | (busSda ? 1 : 0)) & 0xFF; + this._bitCount += 1; + if (this._bitCount === 8) { + this._bitCount = 0; + this._completeByte(); + } + } else if (this.state === "ACK_ADDR") { + if (this._match) { + this.state = "REGPTR"; + } else { + this.lastTransaction = { + address: this._shift >> 1, reg: null, + data: null, ack: false + }; + this.state = "IDLE"; + } + this._shift = 0; + } else if (this.state === "ACK_REG") { + this.state = "DATA"; + this._shift = 0; + } else if (this.state === "ACK_DATA") { + this.state = "DONE"; + } else if (this.state === "DONE") { + this.state = "IDLE"; + } + } + + _completeByte() { + if (this.state === "ADDR") { + const addr = this._shift >> 1; + const rwWrite = (this._shift & 1) === 0; + this._match = (addr === this.address) && rwWrite; + this.state = "ACK_ADDR"; + } else if (this.state === "REGPTR") { + this._regPtr = this._shift; + this.state = "ACK_REG"; + } else if (this.state === "DATA") { + if (this._regPtr === 0x01) { + this.outputReg = this._shift; + } else if (this._regPtr === 0x02) { + this.polarityReg = this._shift; + } else if (this._regPtr === 0x03) { + this.configReg = this._shift; + } + this.lastTransaction = { + address: this.address, reg: this._regPtr, + data: this._shift, ack: true + }; + this.state = "ACK_DATA"; + } + } +} + +class TCA9538Model { + constructor(opts) { + opts = opts || {}; + const address = opts.address !== undefined ? opts.address : 0x23; + const sclPin = opts.sclPin !== undefined ? opts.sclPin : 0; + const sdaPin = opts.sdaPin !== undefined ? opts.sdaPin : 1; + + this.name = opts.name || "tca9538"; + this.timeDriven = true; + this.address = address; + this.sclPin = sclPin; + this.sdaPin = sdaPin; + + this.nets = {}; + this.nets[sclPin] = OPEN_DRAIN; + this.nets[sdaPin] = OPEN_DRAIN; + + this.device = new TCA9538Device(address); + this.address = this.device.address; + + this._events = []; + this._faults = []; + this._prevScl = null; + this._prevSda = null; + this._drivingLow = false; + this._cycle = 0; + } + + tick(cycle, bus) { + this._cycle = cycle; + const scl = (bus >> this.sclPin) & 1; + const sda = (bus >> this.sdaPin) & 1; + + this._checkFaults(cycle, scl, sda); + this._recordEvents(cycle, scl, sda); + + const sdaMaster = this._drivingLow ? 1 : sda; + + const busSda = this.device.step(scl === 1, sdaMaster === 1); + this._drivingLow = (!busSda) && (sdaMaster === 1); + this._prevScl = scl; + this._prevSda = sda; + + if (this._drivingLow) { + return [(1 << this.sdaPin), 0]; + } + return [0, 0]; + } + + _recordEvents(cycle, scl, sda) { + if (this._prevScl === null) return; + if (scl === 1 && this._prevSda === 1 && sda === 0) { + this._events.push({ cycle: cycle, kind: "start" }); + } else if (scl === 1 && this._prevSda === 0 && sda === 1) { + this._events.push({ cycle: cycle, kind: "stop" }); + } + } + + _checkFaults(cycle, scl, sda) { + if (this._prevScl === null) return; + if (scl === 1 && this._prevScl === 1 && sda !== this._prevSda) { + if (this.device.state !== "IDLE") { + this._faults.push(`cycle ${cycle}: SDA changed while SCL high mid-transfer (state=${this.device.state}) - START/STOP inside a byte`); + } + } + } + + events() { return this._events.slice(); } + faults() { return this._faults.slice(); } + + reset() { + this.device = new TCA9538Device(this.address); + this._events = []; + this._faults = []; + this._prevScl = null; + this._prevSda = null; + this._drivingLow = false; + this._cycle = 0; + } + + getState() { + return { name: this.name, address: this.address, faults: this._faults.length }; + } +} + + +function crc6(bits, seedOnes = false, invert = true) { + let ff = [seedOnes ? 1 : 0, seedOnes ? 1 : 0, seedOnes ? 1 : 0, seedOnes ? 1 : 0, seedOnes ? 1 : 0, seedOnes ? 1 : 0]; + for (const bit of bits) { + const ex = ff[5] ^ (bit & 1); + ff[5] = ff[4]; + ff[4] = ff[3]; + ff[3] = ff[2]; + ff[2] = ff[1]; + ff[1] = ff[0] ^ ex; + ff[0] = ex; + } + let value = 0; + for (let stage = 5; stage >= 0; stage--) { + value = (value << 1) | ff[stage]; + } + return invert ? ((~value) & 0x3F) : value; +} + +class BissCEncoder { + constructor(opts) { + opts = opts || {}; + this.positionBits = opts.positionBits !== undefined ? opts.positionBits : 24; + this.cdsBit = (opts.cdsBit !== undefined ? opts.cdsBit : 0) & 1; + this.invertCrc = opts.invertCrc !== undefined ? opts.invertCrc : true; + this.ackClocks = Math.max(1, opts.ackClocks !== undefined ? opts.ackClocks : 1); + + if (this.positionBits < 1 || this.positionBits > 32) throw new Error("position_bits must be 1..32"); + + this.position = 0; + this.error = 1; + this.warning = 1; + this.forceBadCrc = false; + + this._bits = []; + this.reset(); + } + + buildFrame() { + const pos = this.position & ((1 << this.positionBits) - 1); + const payload = []; + for (let i = this.positionBits - 1; i >= 0; i--) { + payload.push((pos >> i) & 1); + } + payload.push(this.error & 1); + payload.push(this.warning & 1); + + let crc = crc6(payload, false, this.invertCrc); + if (this.forceBadCrc) { + crc ^= 0x01; + } + const crcBits = []; + for (let i = 5; i >= 0; i--) { + crcBits.push((crc >> i) & 1); + } + + const frame = []; + for (let i = 0; i < this.ackClocks; i++) frame.push(0); // Ack + frame.push(1); // Start + frame.push(this.cdsBit); // CDS + for (const b of payload) frame.push(b); + for (const b of crcBits) frame.push(b); + return frame; + } + + reset() { + this._bits = this.buildFrame(); + } +} + +class BissCModel { + constructor(opts) { + opts = opts || {}; + const maPin = opts.maPin !== undefined ? opts.maPin : 0; + const sloPin = opts.sloPin !== undefined ? opts.sloPin : 1; + const positionBits = opts.positionBits !== undefined ? opts.positionBits : 24; + const tbusyClocks = opts.tbusyClocks !== undefined ? opts.tbusyClocks : 1; + const minMaPeriodCycles = opts.minMaPeriodCycles !== undefined ? opts.minMaPeriodCycles : 2; + const idleRestartCycles = opts.idleRestartCycles !== undefined ? opts.idleRestartCycles : 40; + + this.name = opts.name || "bissc"; + this.timeDriven = true; + this.maPin = maPin; + this.sloPin = sloPin; + this.tbusyClocks = Math.max(0, tbusyClocks); + this.minMaPeriodCycles = minMaPeriodCycles; + this.idleRestartCycles = idleRestartCycles; + + this.nets = {}; + this.nets[sloPin] = PUSH_PULL; + + this.encoder = new BissCEncoder({ + positionBits: positionBits, + cdsBit: opts.cdsBit, + invertCrc: opts.invertCrc, + ackClocks: opts.ackClocks + }); + // A GUI-attached device is configured only through its + // parameters, so the position has to be one (it was always 0). + if (opts.position !== undefined) { + this.encoder.position = opts.position; + this.encoder.reset(); + } + + this._events = []; + this._faults = []; + this._prevMa = null; + this._edges = 0; + this._lastEdgeCycle = null; + this._level = 1; + this._frameDone = false; + } + + get _bits() { + return this.encoder._bits; + } + + tick(cycle, bus) { + const ma = (bus >> this.maPin) & 1; + + if (this._prevMa !== null && ma === 1 && this._prevMa === 0) { + this._onRisingEdge(cycle); + } + this._prevMa = ma; + + return [(1 << this.sloPin), (this._level << this.sloPin)]; + } + + _onRisingEdge(cycle) { + const prev = this._lastEdgeCycle; + if (prev !== null) { + const gap = cycle - prev; + if (gap < this.minMaPeriodCycles) { + this._faults.push(`cycle ${cycle}: MA period ${gap} cycles is below the slave minimum of ${this.minMaPeriodCycles}`); + } + if (gap >= this.idleRestartCycles) { + if (!this._frameDone && this._edges > this.tbusyClocks) { + this._faults.push(`cycle ${cycle}: MA stalled ${gap} cycles after ${this._edges - this.tbusyClocks} of ${this._bits.length} frame bits - transaction abandoned mid-frame`); + } + this._restart(); + } + } + this._lastEdgeCycle = cycle; + + this._edges += 1; + const idx = this._edges - 1 - this.tbusyClocks; + if (idx < 0) { + this._level = 1; + } else if (idx < this._bits.length) { + this._level = this._bits[idx]; + if (idx === this._bits.length - 1) { + this._frameDone = true; + this._emitFrame(cycle); + } + } else { + this._level = 1; + } + } + + _restart() { + this.encoder.reset(); + this._edges = 0; + this._frameDone = false; + this._level = 1; + } + + _emitFrame(cycle) { + this._events.push({ + cycle: cycle, + kind: "frame", + position: this.encoder.position & ((1 << this.encoder.positionBits) - 1), + error: this.encoder.error & 1, + warning: this.encoder.warning & 1, + bits: this._bits.length + }); + } + + events() { return this._events.slice(); } + faults() { return this._faults.slice(); } + + reset() { + this.encoder.reset(); + this._events = []; + this._faults = []; + this._prevMa = null; + this._edges = 0; + this._lastEdgeCycle = null; + this._level = 1; + this._frameDone = false; + } + + getState() { + return { + name: this.name, + position: this.encoder.position, + frame_bits: this._bits.length, + edges: this._edges, + slo: this._level, + faults: this._faults.length + }; + } +} + +const DEVICE_MODEL_KINDS = [ + { kind: "quadrature", displayName: "Quadrature encoder (A/B/Z)", ported: true, + clockLabel: "B channel", multiPin: false }, + { kind: "ssi", displayName: "SSI absolute encoder", ported: true, + clockLabel: "clock", multiPin: true }, + { kind: "hall", displayName: "Hall sensor (3-phase)", ported: true, + clockLabel: "B", multiPin: true }, + { kind: "ossd", displayName: "Dual-channel OSSD safety input", ported: true, + clockLabel: "CH_B", multiPin: true }, + { kind: "bissc", displayName: "BiSS-C absolute encoder", ported: true, + clockLabel: "clock", multiPin: true }, + // audit of `ported: false` for bissline: INTENTIONAL STAGED + // PORT, not a binding gap. Evidence: (1) scripts/sim/test_device_models.js + // ("a kind not yet ported to the in-tool simulator is refused, not + // faked") REQUIRES createDeviceModel("bissline") to throw, so this + // flag is load-bearing; flipping it without a faithful JS port breaks + // that contract and falls through to the internal-error branch below. + // (2) the Python behavioural model in + // perif/bissline_device_model.py is not this JS registry's implementation; + // the external simulator currently resolves a materially different model + // from tools/pru-simulator-upstream. (3) bissc and endat - which also have + // Python models - are staged identically; porting here means a full JS + // class + PARAM_WHITELIST entry + factory branch + crosscheck against + // the Python model, per the file header. Do not flip this flag without + // those. + { kind: "bissline", displayName: "BiSS Line absolute encoder", ported: false, + clockLabel: "clock", multiPin: true }, + { kind: "endat", displayName: "EnDat 2.2 absolute encoder", ported: false, + clockLabel: "clock", multiPin: true }, + { kind: "tca9538", displayName: "TCA9538 I2C IO expander", ported: true, + clockLabel: "clock", multiPin: true }, + { kind: "uart", displayName: "UART transmitter", ported: true, + clockLabel: "clock", multiPin: false }, + { kind: "uart_monitor",displayName: "UART monitor", ported: true, + clockLabel: "clock", multiPin: false }, + { kind: "sigma_delta", displayName: "Sigma-delta modulator", ported: true, + clockLabel: "clock", multiPin: true }, + { kind: "spi_adc", displayName: "SPI ADC (ADS8688-class SPI mode 0)", ported: false, + clockLabel: "clock", multiPin: true }, + { kind: "step_dir", displayName: "STEP/DIR stepper driver", ported: false, + clockLabel: "dir", multiPin: true }, + { kind: "ws2812", displayName: "WS2812 addressable LED string", ported: false, + clockLabel: null, multiPin: false }, + // Perif/mdio_phy_model.py MdioPhyModel decodes the observed MDC/MDIO + // line. offers it in the GUI device-kind dropdown and binds it through + // scripts/sim/device_bindings.py; `ported: false` still holds and is still + // load-bearing - it means the IN-TOOL JS simulator refuses this kind rather + // than faking it, exactly as for bissline and endat above. + { kind: "mdio_phy", displayName: "MDIO Clause 22 PHY", ported: false, + clockLabel: "mdc", multiPin: true } +]; + +function getDeviceModelKind(kind) { + for (const k of DEVICE_MODEL_KINDS) { + if (k.kind === kind) return k; + } + return null; +} + +// Per-kind parameter whitelists. An unknown key is an ERROR, not an ignored +// value - a silently dropped parameter produces a model that is not the one +// the user asked for, which is exactly the failure device mode exists to +// prevent. Pin keys are rejected for the same reason: pins come from the pin +// fields, one source of truth. +const PARAM_WHITELIST = { + quadrature: ["counts_per_rev", "cycles_per_count", "forward"], + uart: ["payload", "bit_period", "start_cycle", "idle_gap_bits", "frames"], + uart_monitor: ["bit_period"], + sigma_delta: ["min_period", "saturation_threshold", "saturation_persist", "initial_input"], + tca9538: ["address"], + bissc: ["position_bits", "position", "tbusy_clocks", "min_ma_period_cycles", "idle_restart_cycles", "cds_bit", "invert_crc", "ack_clocks"], + ssi: ["bits", "position", "cycles_per_count", "min_clk_period_cycles", + "monoflop_cycles", "gray"], + hall: ["cycles_per_sector", "direction", "fault_ack_pin", "response_window_cycles"], + ossd: ["ossd_min_cycles", "ossd_max_cycles", "discrepancy_cycles"] +}; + +const RESERVED_PIN_KEYS = ["clk_pin", "data_pin", "a_pin", "b_pin", "z_pin", "pin"]; + +/** + * Build a device model from a Simulation Settings device-mode binding. + * + * pins: { primary, clock, index } - the selected GPI pin is the PRIMARY + * (data/A); companions come from the Clock/B and Index/Z fields, + * already defaulted from the bound block's pin config by the caller. + * params: the parsed Device Parameters JSON object. + * + * Throws on anything that would otherwise be silently ignored: unknown kind, + * kind not yet ported to the in-tool simulator, unknown or reserved parameter + * keys, or constructor-range violations. Device mode is the one input mode + * where the user does not author the waveform, so every refusal here is a + * would-have-been-silent wrong answer caught early. + */ +function createDeviceModel(kind, pins, params) { + const entry = getDeviceModelKind(kind); + if (!entry) { + throw new Error(`unknown device kind "${kind}" - expected one of: ` + + DEVICE_MODEL_KINDS.map(k => k.kind).join(", ")); + } + if (!entry.ported) { + throw new Error( + `device kind "${kind}" (${entry.displayName}) is not available in the in-tool ` + + `simulator yet. The behavioural model exists only in the external oracle ` + + `checkout today; rather than substitute an unproven stand-in, this binding ` + + `is refused. Ported kinds: ` + + DEVICE_MODEL_KINDS.filter(k => k.ported).map(k => k.kind).join(", ")); + } + + params = params || {}; + const whitelist = PARAM_WHITELIST[kind]; + for (const key of Object.keys(params)) { + if (RESERVED_PIN_KEYS.indexOf(key) !== -1) { + throw new Error(`parameters must not set "${key}" - pins come from the pin fields`); + } + if (whitelist.indexOf(key) === -1) { + throw new Error(`unknown ${kind} parameter "${key}". Known parameters: ` + + whitelist.join(", ")); + } + } + if (kind === "uart") { + return new UartTransmitter({ + pin: pins.primary, + payload: params.payload, + bitPeriod: params.bit_period, + startCycle: params.start_cycle, + idleGapBits: params.idle_gap_bits, + frames: params.frames, + name: kind + }); + } + if (kind === "uart_monitor") { + return new UartMonitor({ + pin: pins.primary, + bitPeriod: params.bit_period, + name: kind + }); + } + if (kind === "sigma_delta") { + return new SigmaDeltaModulator({ + clkPin: pins.clock, + dataPin: pins.primary, + minPeriod: params.min_period, + saturationThreshold: params.saturation_threshold, + saturationPersist: params.saturation_persist, + initialInput: params.initial_input, + name: kind + }); + } + if (kind === "tca9538") { + return new TCA9538Model({ + sclPin: pins.clock, + sdaPin: pins.primary, + address: params.address, + name: kind + }); + } + if (kind === "bissc") { + return new BissCModel({ + maPin: pins.clock, + sloPin: pins.primary, + positionBits: params.position_bits, + tbusyClocks: params.tbusy_clocks, + minMaPeriodCycles: params.min_ma_period_cycles, + idleRestartCycles: params.idle_restart_cycles, + cdsBit: params.cds_bit, + invertCrc: params.invert_crc, + ackClocks: params.ack_clocks, + position: params.position, + name: kind + }); + } + if (kind === "quadrature") { + return new QuadratureEncoder({ + aPin: pins.primary, + bPin: pins.clock, + zPin: (pins.index === undefined || pins.index === -1) ? null : pins.index, + countsPerRev: params.counts_per_rev, + cyclesPerCount: params.cycles_per_count, + forward: params.forward, + name: kind + }); + } + if (kind === "ssi") { + if (pins.clock === undefined || pins.clock === null || pins.clock === -1) { + throw new Error("SSI is a clocked device and needs a Clock pin"); + } + return new SsiEncoder({ + clkPin: pins.clock, + dataPin: pins.primary, + bits: params.bits, + position: params.position, + cyclesPerCount: (params.cycles_per_count === undefined ? null : params.cycles_per_count), + minClkPeriodCycles: params.min_clk_period_cycles, + monoflopCycles: params.monoflop_cycles, + gray: params.gray, + name: kind + }); + } + if (kind === "hall") { + if (pins.clock === undefined || pins.clock === null || pins.clock === -1) { + throw new Error("Hall sensor needs Clock/B pin for second Hall line"); + } + const cPin = (pins.index === undefined || pins.index === -1) ? null : pins.index; + if (cPin === null) throw new Error("Hall sensor needs Index/Z pin for third Hall line"); + return new HallSensor({ + aPin: pins.primary, + bPin: pins.clock, + cPin: cPin, + cyclesPerSector: params.cycles_per_sector, + direction: params.direction, + faultAckPin: params.fault_ack_pin, + responseWindowCycles: params.response_window_cycles, + name: kind + }); + } + if (kind === "ossd") { + if (pins.clock === undefined || pins.clock === null || pins.clock === -1) { + throw new Error("OSSD is a dual-channel device and needs both pins: primary=CH_A, clock=CH_B"); + } + return new DualOssdEmitter({ + chAPin: pins.primary, + chBPin: pins.clock, + ossdMinCycles: params.ossd_min_cycles, + ossdMaxCycles: params.ossd_max_cycles, + discrepancyCycles: params.discrepancy_cycles, + name: kind + }); + } + // Unreachable given the ported check above, but a fallthrough that + // silently returned a model would be the worst line in the file. + throw new Error(`internal error: no constructor for ported kind "${kind}"`); +} + +exports = { + QuadratureEncoder, + UartTransmitter, + UartMonitor, + SigmaDeltaModulator, + TCA9538Model, + TCA9538Device, + BissCModel, + crc6, + SsiEncoder, + HallSensor, + DualOssdEmitter, + DEVICE_MODEL_KINDS, + getDeviceModelKind, + createDeviceModel, + toGray, + fromGray +}; diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js index e9609a0..157ab2f 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js @@ -44,7 +44,7 @@ function parseRegister(regStr) { type: 'word', wordNum: parseInt(match[4]), mask: 0xFFFF, - shift: parseInt(match[4]) * 16 + shift: parseInt(match[4]) * 8 }; } else { // Full register @@ -175,6 +175,15 @@ function executeSbbo(pruState, src, baseAddr, byteOffset, byteCount) { } else { return 0; } + } else if (baseAddr.toLowerCase().startsWith('c')) { + const cNum = parseInt(baseAddr.substring(1), 10); + if (cNum === 26) { + baseValue = 0x0002E000; // IEP base address + } else if (cNum === 28) { + baseValue = 0x00000000; // DMEM0 base address + } else { + baseValue = 0; + } } else { return 0; } @@ -256,6 +265,40 @@ function executeSbbo(pruState, src, baseAddr, byteOffset, byteCount) { if (memAddress >= 0x00010000 && memAddress < 0x00020000) { // SMEM range: 0x00010000 - 0x0001FFFF writeResult = pruSMEM.writeSMEM(memAddress, dataBuffer); + } else if (memAddress >= 0x0002E000 && memAddress < 0x0002E100) { + // IEP registers. Offsets per AM243x TRM SPRUIM2J Table 14-10902 (p6902): + // 0x00 GLOBAL_CFG, 0x10/0x14 COUNT_REG0/1, 0x18/0x1C CAP_CFG/CAP_STATUS, + // 0x20.. CAPRn, 0x70 CMP_CFG, 0x74 CMP_STATUS, 0x78+8j CMPj_REG0/1. + // This file previously used the AM335x-era layout (count at 0x0C, compare + // block at 0x40) - 0x0C is SLOW_COMPEN and 0x40-0x6C are capture registers. + const iepOffset = memAddress - 0x0002E000; + let regVal = 0; + for (let i = 0; i < count; i++) { + regVal |= dataBuffer[i] << (i * 8); + } + if (iepOffset === 0x00) { + pruState.iepGlobalCfg = regVal; // [0] CNT_ENABLE, [7:4] DEFAULT_INC + } else if (iepOffset === 0x10) { + pruState.iepCount = regVal; + } else if (iepOffset === 0x70) { + pruState.iepCmpCfg = regVal; // [0] CMP0_RST_CNT_EN, [16:1] CMP_EN + } else if (iepOffset === 0x74) { + pruState.iepCmpStatus &= ~regVal; // Clear status bits written as 1 (W1C behavior) + } else if (iepOffset === 0x14) { + pruState.iepCountHi = regVal >>> 0; // IEP_COUNT_REG1, upper 32 bits + } else if (iepOffset >= 0x78 && iepOffset < 0x78 + 16 * 8) { + // TRM SPRUIM2J 6.4.13: compares are 64-bit PAIRS, IEP_CMPj_REG0/REG1. + // 0x4C is CMP0's upper half, NOT CMP1 - CMP1_REG0 is at 0x50. Both + // halves are stored: dropping REG1 writes on the floor would make any + // compare above 2^32 unreachable and silently mismatch firmware. + const j = Math.floor((iepOffset - 0x78) / 8); + if (((iepOffset - 0x78) % 8) === 0) { + pruState.iepCmp[j] = regVal >>> 0; + } else { + pruState.iepCmpHi[j] = regVal >>> 0; + } + } + writeResult = true; } else { // DMEM range: 0x00000000 - 0x00001FFF writeResult = pruDMEM0.writeDMEM0(memAddress, dataBuffer); @@ -298,6 +341,15 @@ function executeLBBO(pruState, dest, baseAddr, byteOffset, byteCount) { } else { return 0; } + } else if (baseAddr.toLowerCase().startsWith('c')) { + const cNum = parseInt(baseAddr.substring(1), 10); + if (cNum === 26) { + baseValue = 0x0002E000; // IEP base address + } else if (cNum === 28) { + baseValue = 0x00000000; // DMEM0 base address + } else { + baseValue = 0; + } } else { return 0; } @@ -330,6 +382,51 @@ function executeLBBO(pruState, dest, baseAddr, byteOffset, byteCount) { if (memAddress >= 0x00010000 && memAddress < 0x00020000) { // SMEM range: 0x00010000 - 0x0001FFFF memoryData = pruSMEM.readSMEM(memAddress, count); + } else if (memAddress >= 0x0C000000 && memAddress < 0x0C0FFFFF) { + // MMIO mock (e.g. SDFM 0x0C080000 base) + if (pruState.mmioMap && pruState.mmioMap[memAddress]) { + const history = pruState.mmioMap[memAddress]; + let activeValue = 0; + for (let i = 0; i < history.length; i++) { + if (pruState.cycles >= history[i].cycle) { + activeValue = history[i].value; + } else { + break; + } + } + memoryData = new Uint8Array(count); + for (let i = 0; i < count; i++) { + memoryData[i] = (activeValue >>> (i * 8)) & 0xFF; + } + } else { + memoryData = new Uint8Array(count); // Default 0 + } + } else if (memAddress >= 0x0002E000 && memAddress < 0x0002E100) { + // IEP registers. Offsets per AM243x TRM SPRUIM2J Table 14-10902 (p6902): + // 0x00 GLOBAL_CFG, 0x10/0x14 COUNT_REG0/1, 0x18/0x1C CAP_CFG/CAP_STATUS, + // 0x20.. CAPRn, 0x70 CMP_CFG, 0x74 CMP_STATUS, 0x78+8j CMPj_REG0/1. + // This file previously used the AM335x-era layout (count at 0x0C, compare + // block at 0x40) - 0x0C is SLOW_COMPEN and 0x40-0x6C are capture registers. + const iepOffset = memAddress - 0x0002E000; + let regVal = 0; + if (iepOffset === 0x00) { + regVal = pruState.iepGlobalCfg; + } else if (iepOffset === 0x10) { + regVal = pruState.iepCount; + } else if (iepOffset === 0x70) { + regVal = pruState.iepCmpCfg; + } else if (iepOffset === 0x74) { + regVal = pruState.iepCmpStatus; + } else if (iepOffset === 0x14) { + regVal = pruState.iepCountHi; + } else if (iepOffset >= 0x78 && iepOffset < 0x78 + 16 * 8) { + const j = Math.floor((iepOffset - 0x78) / 8); + regVal = (((iepOffset - 0x78) % 8) === 0) ? pruState.iepCmp[j] : pruState.iepCmpHi[j]; + } + memoryData = new Uint8Array(count); + for (let i = 0; i < count; i++) { + memoryData[i] = (regVal >>> (i * 8)) & 0xFF; + } } else { // DMEM range: 0x00000000 - 0x00001FFF memoryData = pruDMEM0.readDMEM0(memAddress, count); @@ -378,7 +475,7 @@ function executeLBBO(pruState, dest, baseAddr, byteOffset, byteCount) { } // Update the register - pruState.registers[currentReg] = value; + pruState.registers[currentReg] = value >>> 0; // Update counters for next iteration memOffset += bytesInThisReg; @@ -1482,9 +1579,10 @@ function executeInstruction(pruState, pruInstructions, labelMap, r30ValueHistory break; } + case 'LBCO': case 'LBBO': { // Extract operands - // LBBO &dest, baseAddr, byteOffset, byteCount + // LBBO/LBCO &dest, baseAddr, byteOffset, byteCount // Remove the '&' from the destination const lbboDest = operands[0].replace(/&|,/g, ''); const lbboBaseAddr = operands[1].replace(/,/g, ''); @@ -1500,6 +1598,7 @@ function executeInstruction(pruState, pruInstructions, labelMap, r30ValueHistory break; } + case 'SBCO': case 'SBBO': { // Extract operands // SBBO &src, baseAddr, byteOffset, byteCount @@ -1519,6 +1618,7 @@ function executeInstruction(pruState, pruInstructions, labelMap, r30ValueHistory } case 'LOOP': { + console.log(`Executing PC ${pruState.pc}: LOOP ${operands.join(' ')}`); // LOOP label, count const label = operands[0].replace(/,/g, ''); // Parse the count operand which can be a register or immediate @@ -1575,16 +1675,186 @@ function executeInstruction(pruState, pruInstructions, labelMap, r30ValueHistory break; } + case 'ZERO': { + const op1Info = parseRegisterReference(operands[0].replace(/&|,/g, '')); + const byteCountStr = operands[1] ? operands[1].replace(/,/g, '') : "4"; + const byteCount = parseImmediate(byteCountStr); + if (op1Info) { + const startReg = (op1Info.regNum !== undefined) ? op1Info.regNum : op1Info.reg; + const numRegs = Math.ceil(byteCount / 4); + for (let i = 0; i < numRegs && (startReg + i) < 32; i++) { + pruState.registers[startReg + i] = 0; + } + } + pruState.pc++; + break; + } + + case 'FILL': { + const op1Info = parseRegisterReference(operands[0].replace(/&|,/g, '')); + const byteCountStr = operands[1] ? operands[1].replace(/,/g, '') : "4"; + const byteCount = parseImmediate(byteCountStr); + if (op1Info) { + const startReg = (op1Info.regNum !== undefined) ? op1Info.regNum : op1Info.reg; + const numRegs = Math.ceil(byteCount / 4); + for (let i = 0; i < numRegs && (startReg + i) < 32; i++) { + pruState.registers[startReg + i] = 0xFFFFFFFF; + } + } + pruState.pc++; + break; + } + + case 'MULL': { + // MULL rX[.bx/.wx], rY[.bx/.wx], rZ[.bx/.wx] + throw new Error("Simulator Error: PRU instruction 'MULL' does not exist in this PRU core. Use XOUT/XIN with MAC instead."); + break; + } + + case 'XOUT': { + const deviceIdStr = operands[0].replace(/,/g, ''); + const deviceId = parseImmediate(deviceIdStr); + if (handleParsingError(pruState, deviceId)) { + pruState.pc++; + break; + } + + const op2Info = parseRegisterReference(operands[1].replace(/&|,/g, '')); + const byteCountStr = operands[2] ? operands[2].replace(/,/g, '') : "4"; + const byteCount = parseImmediate(byteCountStr); + + if (deviceId === 0) { + // MAC config + if (!pruState.mac) pruState.mac = { enabled: false, mode: 0, result: 0n }; + pruState.mac.enabled = true; + if (op2Info) { + pruState.mac.mode = getRegisterValue(pruState, op2Info); + } + } else if (deviceId >= 10 && deviceId <= 12) { + // SPAD write + const bank = deviceId - 10; + if (byteCount > 120) { + throw new Error(`Simulator Error: SPAD transfer out of bounds (max 120 bytes). Attempted to transfer ${byteCount} bytes.`); + } + if (!pruState.spad) pruState.spad = { banks: [[], [], []] }; + if (op2Info) { + const startReg = op2Info.regNum; + const words = Math.ceil(byteCount / 4); + for(let i=0; i 64 signed multiply + const op1 = pruState.registers[28]; + const op2 = pruState.registers[29]; + // Convert to JS signed 32-bit integer, then to BigInt + const val1 = BigInt(op1 | 0); + const val2 = BigInt(op2 | 0); + pruState.mac.result = val1 * val2; + } + + if (op2Info) { + if (op2Info.regNum === 25) { + pruState.registers[op2Info.regNum] = pruState.mac.mode; + } else if (op2Info.regNum === 27) { + // Return upper 32 bits + pruState.registers[op2Info.regNum] = Number((pruState.mac.result >> 32n) & 0xFFFFFFFFn) >>> 0; + } else { + // Return lower 32 bits + pruState.registers[op2Info.regNum] = Number(pruState.mac.result & 0xFFFFFFFFn) >>> 0; + if (byteCount > 4) { + // Return upper 32 bits in next register + pruState.registers[(op2Info.regNum + 1) % 32] = Number((pruState.mac.result >> 32n) & 0xFFFFFFFFn) >>> 0; + } + } + } + } else if (deviceId >= 10 && deviceId <= 12) { + // SPAD read + const bank = deviceId - 10; + if (byteCount > 120) { + throw new Error(`Simulator Error: SPAD transfer out of bounds (max 120 bytes). Attempted to transfer ${byteCount} bytes.`); + } + if (!pruState.spad) pruState.spad = { banks: [[], [], []] }; + if (op2Info) { + const startReg = op2Info.regNum; + const words = Math.ceil(byteCount / 4); + for(let i=0; i> 4) & 0xF; // [7:4] DEFAULT_INC + // The counter is 64-bit across COUNT_REG0/REG1. Carrying into the + // high word matters: truncating at 32 bits wraps after 2^32 updates + // (~21s at 200MHz) and makes any compare above 2^32 unreachable. + const sum = pruState.iepCount + inc; + pruState.iepCount = sum >>> 0; + if (sum > 0xFFFFFFFF) { + pruState.iepCountHi = (pruState.iepCountHi + 1) >>> 0; + } + let hit0 = false; + for (let j = 0; j < 16; j++) { + // [16:1] CMP_EN, bit 1 maps to CMP0. + // EQUALITY, not >=. The IEP compare is a match on the counter + // value, so with DEFAULT_INC > 1 a compare value that is not on + // an increment boundary is stepped over and never fires - that is + // hardware behaviour, not a modelling shortcut, and firmware must + // program compares consistent with its increment. Using >= would + // instead re-assert the event on every cycle past the threshold. + if ((pruState.iepCmpCfg & (1 << (j + 1))) + && pruState.iepCount === pruState.iepCmp[j] + && pruState.iepCountHi === pruState.iepCmpHi[j]) { + pruState.iepCmpStatus |= (1 << j); + if (j === 0) hit0 = true; + } + } + if (hit0 && (pruState.iepCmpCfg & 0x1)) { // [0] CMP0_RST_CNT_EN + pruState.iepCount = 0; + pruState.iepCountHi = 0; + } + } + r30ValueHistory.push(pruState.registers[30]); r31ValueHistory.push(pruState.registers[31]); } @@ -1629,9 +1946,15 @@ function executeInstruction(pruState, pruInstructions, labelMap, r30ValueHistory * @param {Array} pruInstructionsLabels - Array of labels corresponding to the instructions * @param {number} cycleCount - Number of pruState.cycles to simulate * @param {Object} r31HistoryMap - Optional map of cycle numbers to R31 values for simulation input (default: {}) + * @param {Object} mmioMap - Optional MMIO stub map + * @param {Object} deviceBus - Optional DeviceBus (device_bus.js) with attached + * device models. When given, the bus is settled every cycle and its + * resolved level replaces R31 - this is the closed loop that Timestamp + * and Pattern mode cannot express, because a pre-authored history + * cannot react to what the firmware does. * @return {Object} The pruState and r30ValueHistory after simulation */ -function simulatePruInstructions(pruInstructions, pruInstructionsLabels, cycleCount, r31HistoryMap = {}) { +function simulatePruInstructions(pruInstructions, pruInstructionsLabels, cycleCount, r31HistoryMap = {}, mmioMap = {}, deviceBus = null) { // Initialize pruState.registers (r0-r31) const pruState = { registers : new Array(32).fill(0), @@ -1641,10 +1964,30 @@ function simulatePruInstructions(pruInstructions, pruInstructionsLabels, cycleCo loopStack : [], unknownInstruction : 0, errorParsing : 0, - r31HistoryMap: r31HistoryMap + skippedMacroCount: 0, + skippedMacros: [], + r31HistoryMap: r31HistoryMap, + mmioMap: mmioMap, + deviceBus: deviceBus, + halted: false, + iepCount: 0, + iepGlobalCfg: 0, + iepCmpCfg: 0, + iepCountHi: 0, + iepCmp: new Array(16).fill(0), + iepCmpHi: new Array(16).fill(0), + iepCmpStatus: 0 }; - const r30ValueHistory = [0]; + // With devices attached, R30 starts at the bus idle (driven pins high), + // not at zero: the bus contract is pull-up idle, and a cold 0 would + // present every clocked device with a falling edge the firmware never + // generated. Open-loop runs (no bus) keep the historical zero start. + if (deviceBus) { + pruState.registers[30] = deviceBus.idlePruWord(); + } + + const r30ValueHistory = [deviceBus ? deviceBus.idlePruWord() : 0]; const r31ValueHistory = [0]; // Create label map for jumps @@ -1657,6 +2000,14 @@ function simulatePruInstructions(pruInstructions, pruInstructionsLabels, cycleCo // Execute instructions until cycle count is reached or PC is out of bounds while (pruState.cycles < cycleCount && pruState.pc < pruInstructions.length) { + if (pruState.halted) { + // Firmware halted with devices attached: the devices keep living. + pruState.cycles++; + pruState.registers[31] = pruState.deviceBus.settle(pruState.cycles, pruState.registers[30]); + r30ValueHistory.push(pruState.registers[30]); + r31ValueHistory.push(pruState.registers[31]); + continue; + } executeInstruction(pruState, pruInstructions, labelMap, r30ValueHistory, r31ValueHistory, cycleCount); } diff --git a/.metadata/sysconfig/.meta/pru_blocks/utils/device_model_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/utils/device_model_block.syscfg.js new file mode 100644 index 0000000..891aad6 --- /dev/null +++ b/.metadata/sysconfig/.meta/pru_blocks/utils/device_model_block.syscfg.js @@ -0,0 +1,266 @@ +/** + * Device Model block (part A). + * + * Makes the device the design is simulated against a visible thing in the + * design instead of a row of fields inside Simulation Settings. One instance is + * one attached device: its kind, the pins it drives and listens on, and its + * parameters as typed fields. + * + * It generates NO firmware (declaration only, like Memory Variable). Its one + * output is a binding in pru_device_bindings.json, in the same shape the GPI + * device fields in Simulation Settings produce, so TI pru-simulator attaches it + * through the same code path and the same safety checks. + * + * The GPI device fields keep working: this is an ADDITIONAL source (decision on + *). A pin claimed by both sources is an error that names both. + */ +const table = system.getScript("/pru_blocks/common/device_model_table.js"); + +const STATIC_MODULE = "/pru_blocks/common/pru_blocks_static_module"; +const SELF_MODULE = "/pru_blocks/utils/device_model_block"; +const DEFAULT_KIND = "ssi"; + +// Slot -> default pin, so a fresh instance of any kind starts on distinct pins (table.defaultPin; scripts/check_pin_collisions.js uses it too). +const defaultPin = table.defaultPin; + +function kindOptions() { + return Object.keys(table.KINDS).map(function (k) { + return { name: k, displayName: table.KINDS[k].displayName }; + }); +} + +// The typed fields. Only the selected kind's fields are shown (onChange below); +// all of them exist in the schema so SysConfig can always load a saved design. +function kindConfigs() { + const configs = []; + for (const kind of Object.keys(table.KINDS)) { + const spec = table.KINDS[kind]; + const hidden = kind !== DEFAULT_KIND; + for (const pf of table.pinFields(kind)) { + configs.push({ + name: pf.field, + displayName: pf.spec.label, + description: pf.spec.drives + ? "Pin the DEVICE drives (the PRU reads it)." + (pf.spec.optional ? " -1 if unused." : "") + : "Pin the PRU drives (the device only listens)." + (pf.spec.optional ? " -1 if unused." : ""), + default: defaultPin(pf.spec), + hidden: hidden + }); + } + for (const pf of table.paramFields(kind)) { + const p = pf.spec; + const cfg = { + name: pf.field, + displayName: p.label, + default: p.def, + hidden: hidden + }; + if (p.type === "int" || p.type === "float") { + const lo = p.min !== undefined ? p.min : "-inf"; + const hi = p.max !== undefined ? p.max : "inf"; + cfg.description = "Range " + lo + " .. " + hi + "."; + } + configs.push(cfg); + } + } + return configs; +} + +function currentInstances() { + const mod = (typeof system !== "undefined" && system.modules) ? system.modules[SELF_MODULE] : null; + return (mod && mod.$instances) || []; +} + +function gpiClaims() { + if (typeof system === "undefined" || !system.modules) return []; + const sm = system.modules[STATIC_MODULE]; + if (!sm || !sm.$static || typeof sm.collectGpiDeviceClaims !== "function") return []; + return sm.collectGpiDeviceClaims(sm.$static); +} + +// Read-only text for the GUI: who drives what, and which block reads it. +function pinSummary(inst) { + const spec = table.KINDS[inst.deviceKind]; + if (!spec) return ""; + const drives = [], listens = []; + for (const pf of table.pinFields(inst.deviceKind)) { + const v = inst[pf.field]; + if (v === -1 && pf.spec.optional) continue; + (pf.spec.drives ? drives : listens).push(pf.spec.label + " = pin " + v); + } + return "drives: " + (drives.length ? drives.join(", ") : "nothing (every pin is a PRU output)") + + " | listens on: " + (listens.length ? listens.join(", ") : "nothing"); +} + +function readBySummary(inst) { + if (!table.READER_BLOCKS[inst.deviceKind]) { + return "no block in this tool decodes a '" + inst.deviceKind + "' device (run reports NOT CHECKED)"; + } + const primary = table.toBinding(inst).primaryPin; + const readers = table.findReaderInstances(inst.deviceKind, primary, + (typeof system !== "undefined" && system.modules) ? system.modules : null); + return readers.length + ? readers.map(function (r) { return r.inst.$name; }).join(", ") + : "no block reads pin " + primary + " yet"; +} + +// This block has no Run button of its own (one launcher, one verdict path: the button lives in +// Simulation Settings). Say where it is instead of leaving the user to hunt for it. One text, true whether or not +// the design added the module itself: this block pulls the static module in (moduleStatic.modules below), so +// system.modules always lists it and cannot tell a design that shows the pane from one that does not. +function runHint(inst) { + return "Run lives in Simulation Settings (SIMULATION SETTINGS in the module list; add it from there if you do " + + "not see it) > Real Simulation > Run on TI pru-simulator (save the design first)."; +} + +function validate(inst, report) { + for (const e of table.checkInstance(inst)) { + report.logError(e.message, inst, e.field); + } + if (!table.KINDS[inst.deviceKind]) return; + + const mine = table.claimedPins(inst); + const myClock = table.clockPin(inst); + + // Another Device Model instance on one of my pins. + for (const other of currentInstances()) { + if (other === inst || !table.KINDS[other.deviceKind]) continue; + const otherClock = table.clockPin(other); + for (const a of mine) { + for (const b of table.claimedPins(other)) { + if (a.pin === b.pin) { + // Two PRU-driven clocks on one pin can be legal wiring (one CLK fanned out to two + // encoders), but pru_device_bindings.json attaches one model per pin: refuse, say why. + const sharedClock = a.pin === myClock && b.pin === otherClock; + report.logError( + "Pin " + a.pin + " is claimed by Device Model '" + inst.$name + "' (" + inst.deviceKind + ", " + a.label + + ") and by Device Model '" + other.$name + "' (" + other.deviceKind + ", " + b.label + + "). Two models on one net is a short, not a configuration" + + (sharedClock ? "; shared clock between device models is not supported by the simulator yet." : "."), + inst, "deviceKind"); + } + } + } + } + + // The GPI device fields in Simulation Settings: the other source. + for (const claim of gpiClaims()) { + for (const a of mine) { + for (const b of claim.pins) { + if (a.pin === b.pin) { + report.logError( + "Pin " + a.pin + " is claimed by Device Model '" + inst.$name + "' (" + inst.deviceKind + ", " + a.label + + ") and by the Simulation Settings GPI" + claim.pinNum + " device (" + claim.kind + ", " + b.label + + "). A pin cannot have two device sources: remove one of them.", + inst, "deviceKind"); + } + } + } + } +} + +function getLongDescription() { + return ` +### Device Model + +Attaches a simulated device to the design - an SSI or BiSS-C encoder, a quadrature encoder, a UART transmitter, a SPI ADC and so on - as a block you can see, name and configure with typed fields. TI pru-simulator runs the real firmware against it (\`gmake sim\`, or **Run on TI pru-simulator** in the GUI). + +**It generates no firmware.** It only adds one binding to \`pru_device_bindings.json\`. + +**Two sources, one manifest.** Simulation Settings also attaches devices through its per-pin *GPIn Input Mode = Device* fields. Both work; neither is migrated. A pin claimed by both is an ERROR that names both. + +**Pins.** Each pin says who drives it: *device drives* pins are read by the PRU; *PRU drives* pins are only listened to by the device. The Pins summary and the Read by field are computed for you. + +**One driver per PRU-driven pin.** A GPO that a *PRU drives* pin listens on may be driven only by the blocks that talk to that device; any other block on that GPO, such as a SPI write sharing the clock, is an ERROR because the device counts its output as extra edges. Which blocks, per kind: the SSI, BiSS-C, EnDat and sigma-delta clocks accept only the reader block whose data pin is the device's data pin (SSI, BiSS-C or EnDat initiator, PDM microphone), shown in *Read by*; MDIO's MDC accepts an MDIO master whose MDIO pin is the device's; a SPI ADC's SCLK and MOSI are shared between chips so any SPI block may drive them, but its CS accepts only a SPI block whose CS pin is the device's; STEP/DIR and WS2812 accept the stepper, multi-axis or WS2812 block on that pad. A second block of the reader's own module pointing at a different data pin is foreign. With no matching block in the design a foreign block is a warning instead (it may be a hand-built generator). BiSS Line has no block in this tool, so it only warns when two or more blocks drive its MASTER pad. Two blocks reading one SSI, BiSS-C or EnDat encoder is also a warning: the second frame can start inside the encoder's monoflop time and is read as extra clocks. + +**Parameters.** Every model parameter is a typed field and is range-checked here, not discovered at run time. Parameters this block does not expose keep the model's own default (BiSS-C encoder keywords, SSI/EnDat initial clock level, MDIO register map, BiSS Line sensor data). + +**Simulation scope.** The in-tool JavaScript waveform preview does not attach Device Model blocks; TI pru-simulator does. +`; +} + +function getAIContext() { + return getLongDescription() + ` +## How to Configure (For AI/Scripting) + +\`\`\`javascript +const deviceModel = scripting.addModule("/pru_blocks/utils/device_model_block", {}, false); +const enc = deviceModel.addInstance(); +enc.$name = "Encoder_Model"; +enc.deviceKind = "ssi"; +enc.ssi_dataPin = 1; // device drives, the PRU reads +enc.ssi_clkPin = 0; // PRU drives +enc.ssi_bits = 25; +enc.ssi_position = 4321; +\`\`\` + +Field names are \`_\`; only the selected kind's fields apply. There is no connection to make: the block has no ports. +`; +} + +exports = { + displayName: "Device Model", + defaultInstanceName: "Device_Model_", + description: "A simulated device attached to the design (encoder, UART, SPI ADC, ...).", + longDescription: getAIContext(), + getAIContext: getAIContext, + uiView: "graph", + config: [ + { name: "$shape", hidden: true, default: "square" }, + { name: "$topLabel", hidden: true, default: "" }, + { name: "numOfInputPorts", default: 0, hidden: true }, + { name: "numOfOutputPorts", default: 0, hidden: true }, + { name: "numOfConstants", default: 0, hidden: true }, + { name: "opCode", default: "", hidden: true }, + { name: "output1Size", default: "0", hidden: true, getValue: function (inst) { return "0"; } }, + { name: "noOfCycles", displayName: "Number Of Cycles", default: 0, getValue: function (inst) { return 0; } }, + { + name: "deviceKind", + displayName: "Device Kind", + description: "Which device model TI pru-simulator attaches.", + default: DEFAULT_KIND, + options: kindOptions(), + onChange: function (inst, ui) { + for (const kind of Object.keys(table.KINDS)) { + const shown = kind === inst.deviceKind; + for (const pf of table.pinFields(kind).concat(table.paramFields(kind))) { + if (ui[pf.field]) ui[pf.field].hidden = !shown; + } + } + } + }, + { + name: "pinSummary", + displayName: "Pins", + description: "Which pins the device drives and which it listens on.", + default: "", + getValue: pinSummary + }, + { + name: "runHint", + displayName: "Where to run", + description: "Where the Run on TI pru-simulator button for this design is.", + default: "", + getValue: runHint + }, + { + name: "readBy", + displayName: "Read by", + description: "The block that decodes this device (what resolveDeviceReaders records in the manifest).", + default: "", + getValue: readBySummary + } + ].concat(kindConfigs()), + ports: function (inst) { + return []; + }, + moduleStatic: { + modules: function (inst) { + return [{ + name: "pru_register_allocator_validator", + moduleName: STATIC_MODULE + }]; + } + }, + validate +}; diff --git a/.metadata/sysconfig/.meta/soc/pru_blocks_am243x.syscfg.js b/.metadata/sysconfig/.meta/soc/pru_blocks_am243x.syscfg.js index 0ad114e..c6ee1ca 100644 --- a/.metadata/sysconfig/.meta/soc/pru_blocks_am243x.syscfg.js +++ b/.metadata/sysconfig/.meta/soc/pru_blocks_am243x.syscfg.js @@ -25,6 +25,7 @@ const topModules_pru = [ "/pru_blocks/utils/look_up_table", "/pru_blocks/utils/access_look_up_table", "/pru_blocks/utils/data_splitter", + "/pru_blocks/utils/device_model_block", ], categories: [ { diff --git a/.metadata/sysconfig/.meta/soc/pru_blocks_am261x.syscfg.js b/.metadata/sysconfig/.meta/soc/pru_blocks_am261x.syscfg.js index 0ad114e..c6ee1ca 100644 --- a/.metadata/sysconfig/.meta/soc/pru_blocks_am261x.syscfg.js +++ b/.metadata/sysconfig/.meta/soc/pru_blocks_am261x.syscfg.js @@ -25,6 +25,7 @@ const topModules_pru = [ "/pru_blocks/utils/look_up_table", "/pru_blocks/utils/access_look_up_table", "/pru_blocks/utils/data_splitter", + "/pru_blocks/utils/device_model_block", ], categories: [ { diff --git a/.metadata/sysconfig/.meta/soc/pru_blocks_am64x.syscfg.js b/.metadata/sysconfig/.meta/soc/pru_blocks_am64x.syscfg.js index 0ad114e..c6ee1ca 100644 --- a/.metadata/sysconfig/.meta/soc/pru_blocks_am64x.syscfg.js +++ b/.metadata/sysconfig/.meta/soc/pru_blocks_am64x.syscfg.js @@ -25,6 +25,7 @@ const topModules_pru = [ "/pru_blocks/utils/look_up_table", "/pru_blocks/utils/access_look_up_table", "/pru_blocks/utils/data_splitter", + "/pru_blocks/utils/device_model_block", ], categories: [ { diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/device_model.py b/core/device_model.py new file mode 100644 index 0000000..7d7a6b2 --- /dev/null +++ b/core/device_model.py @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod +from typing import List, Dict, Any + +class DeviceModel(ABC): + @abstractmethod + def tick(self, cycle: int, pins_out: int) -> int: + pass + + @abstractmethod + def events(self) -> List[Dict[str, Any]]: + pass + + @abstractmethod + def faults(self) -> List[str]: + pass diff --git a/perif/__init__.py b/perif/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/perif/bissline_device_model.py b/perif/bissline_device_model.py new file mode 100644 index 0000000..8f3b9fb --- /dev/null +++ b/perif/bissline_device_model.py @@ -0,0 +1,390 @@ +"""BiSS Line absolute encoder slave - DeviceModel. + +BiSS Line extends BiSS C with: + * Single-wire half-duplex: master and slave share one data line + (MA/SLO combined or SLO only) with turnaround timing. + * 8b/10b line coding on the reply payload. + * Hamming(7,4) FEC on position data nibbles. + * CDS (Control Data Slave) bit between Start and position data. + +Frame layout (slave drives on active clock edges, MSB first): + [ACK=0] [Start=1] [CDS] [Position MSB..LSB] [nE] [nW] [CRC-6] + +CRC-6 polynomial 0x43 (x^6 + x + 1), computed over position + nE + nW. +CRC is inverted before transmission per BiSS spec. + +Timing faults (reject wrong firmware): + - bit period below min_bit_period_cycles + - overclocked frame (more clocks than bits in frame) + - CDS not clocked (too few clocks in frame) + +Events: + {kind:frame, cycle, position, cds, error, warning, crc_ok, ...} +""" + +from typing import Any, Dict, List, Optional + +from core.device_model import DeviceModel + + +# ----------------------------------------------------------------- CRC-6 +def crc6_bissc(bits: List[int]) -> int: + """CRC-6 per BiSS spec: poly 0x43 (x^6 + x + 1), result inverted.""" + crc = 0 + for b in bits: + fb = ((crc >> 5) & 1) ^ (b & 1) + crc = ((crc << 1) & 0x3F) + if fb: + crc ^= 0x03 + return crc ^ 0x3F + + +# ------------------------------------------------------------ 8b/10b LUT +# Simplified 8b/10b: RD- column, sufficient for model encode/decode. +_5B6B_RD_NEG = { + 0:0b100111, 1:0b011101, 2:0b101101, 3:0b110001, + 4:0b110101, 5:0b101001, 6:0b011001, 7:0b111000, + 8:0b111001, 9:0b100101, 10:0b010101, 11:0b110100, + 12:0b001101, 13:0b101100, 14:0b011100, 15:0b010111, + 16:0b011011, 17:0b100011, 18:0b010011, 19:0b110010, + 20:0b001011, 21:0b101010, 22:0b011010, 23:0b111010, + 24:0b110011, 25:0b100110, 26:0b010110, 27:0b110110, + 28:0b001110, 29:0b101110, 30:0b011110, 31:0b101011, +} + +_3B4B_RD_NEG = { + 0:0b1011, 1:0b1001, 2:0b0101, 3:0b1100, + 4:0b1101, 5:0b1010, 6:0b0110, 7:0b1110, +} + +_8B10B_ENC = {} +_8B10B_DEC = {} + +def _init_8b10b(): + for bv in range(256): + c6 = _5B6B_RD_NEG[bv & 0x1F] + c4 = _3B4B_RD_NEG[(bv >> 5) & 0x07] + sym = (c6 << 4) | c4 + _8B10B_ENC[bv] = sym + _8B10B_DEC[sym] = bv + +_init_8b10b() + + +def encode_8b10b_byte(bv: int) -> int: + return _8B10B_ENC[bv & 0xFF] + + +def decode_8b10b_symbol(sym: int) -> Optional[int]: + return _8B10B_DEC.get(sym & 0x3FF) + + +# ------------------------------------------------------ Hamming (7,4) FEC +def hamming74_encode(nibble: int) -> int: + """Encode 4-bit nibble to 7-bit Hamming(7,4) codeword. + Bit positions 1..7: [p1 p2 d1 p4 d2 d3 d4]""" + d1 = (nibble >> 3) & 1 + d2 = (nibble >> 2) & 1 + d3 = (nibble >> 1) & 1 + d4 = nibble & 1 + p1 = d1 ^ d2 ^ d4 + p2 = d1 ^ d3 ^ d4 + p4 = d2 ^ d3 ^ d4 + return (p1 << 6) | (p2 << 5) | (d1 << 4) | (p4 << 3) | (d2 << 2) | (d3 << 1) | d4 + + +def hamming74_decode(cw: int) -> tuple: + """Decode 7-bit Hamming(7,4). Returns (nibble, corrected, error_detected).""" + p1 = (cw >> 6) & 1; p2 = (cw >> 5) & 1; d1 = (cw >> 4) & 1 + p4 = (cw >> 3) & 1; d2 = (cw >> 2) & 1; d3 = (cw >> 1) & 1; d4 = cw & 1 + s1 = p1 ^ d1 ^ d2 ^ d4 + s2 = p2 ^ d1 ^ d3 ^ d4 + s4 = p4 ^ d2 ^ d3 ^ d4 + syn = (s4 << 2) | (s2 << 1) | s1 + corrected = False + if syn != 0: + corrected = True + cw ^= (1 << (7 - syn)) + d1 = (cw >> 4) & 1; d2 = (cw >> 2) & 1 + d3 = (cw >> 1) & 1; d4 = cw & 1 + return ((d1 << 3) | (d2 << 2) | (d3 << 1) | d4, corrected, syn != 0) + + +# ============================================================ DeviceModel +class BissLineEncoder(DeviceModel): + """BiSS Line slave (encoder). + + Clock-driven model following the same tick() contract as SsiEndatEncoder. + The master drives MA (clock pin); the slave drives SLO (data pin) back. + In BiSS Line, both directions share one differential pair, so turnaround + timing is critical. + + Parameters: + ma_pin: PRU GPO bit driving clock (MA) + mo_pin: PRU GPO bit where master drives data (MO) + slo_pin: PRU GPI bit where slave drives data (SLO) + data_bits: position word width (16/24/32) + position: encoder position value + error_flag / warning_flag: status bits (True = asserted) + cds_value: Control Data Slave bit value (0 or 1) + min_bit_period_cycles: minimum cycles per full clock period + line_coding: enable 8b/10b on the reply + fec_enable: enable Hamming(7,4) on position nibbles + """ + + def __init__( + self, + ma_pin: int = 0, + mo_pin: int = 2, + slo_pin: int = 1, + data_bits: int = 32, + position: int = 0, + error_flag: bool = False, + warning_flag: bool = False, + cds_value: int = 0, + min_bit_period_cycles: int = 4, + line_coding: bool = True, + fec_enable: bool = True, + ): + self._faults: List[str] = [] + self._events: List[Dict[str, Any]] = [] + + if data_bits not in (16, 24, 32): + self._faults.append(f"data_bits must be 16, 24, or 32, got {data_bits}") + if min_bit_period_cycles < 1: + self._faults.append("min_bit_period_cycles must be >= 1") + + self.ma_pin = ma_pin + self.mo_pin = mo_pin + self.slo_pin = slo_pin + self.data_bits = data_bits + self.position = position & ((1 << data_bits) - 1) + self.error_flag = error_flag + self.warning_flag = warning_flag + self.cds_value = cds_value & 1 + self.min_bit_period_cycles = min_bit_period_cycles + self.line_coding = line_coding + self.fec_enable = fec_enable + + # Build the raw frame + self._raw_frame_bits = self._build_raw_frame() + # Apply FEC to position nibbles, keep header/trailer raw + if self.fec_enable: + self._coded_bits = self._apply_fec(self._raw_frame_bits) + else: + self._coded_bits = list(self._raw_frame_bits) + # Apply 8b/10b + if self.line_coding: + self._wire_bits = self._apply_8b10b(self._coded_bits) + else: + self._wire_bits = list(self._coded_bits) + + # Runtime state + self._last_clk = 0 + self._last_edge_cycle: Optional[int] = None + self._last_rising_edge_cycle: Optional[int] = None + self._clocks_in_burst = 0 + self._shift_index = 0 + self._overclock_flagged = False + self._frame_emitted = False + self._captured_bits: List[int] = [] + + # -------------------------------------------------------- frame build + def _build_raw_frame(self) -> List[int]: + bits: List[int] = [] + bits.append(0) # ACK + bits.append(1) # Start + bits.append(self.cds_value) # CDS + + # Position MSB first + pos_bits: List[int] = [] + for i in range(self.data_bits - 1, -1, -1): + pos_bits.append((self.position >> i) & 1) + bits.extend(pos_bits) + + # nE, nW (inverted flags) + ne = 0 if self.error_flag else 1 + nw = 0 if self.warning_flag else 1 + bits.append(ne) + bits.append(nw) + + # CRC-6 over position + nE + nW + crc = crc6_bissc(pos_bits + [ne, nw]) + for i in range(5, -1, -1): + bits.append((crc >> i) & 1) + + return bits + + def _apply_fec(self, bits: List[int]) -> List[int]: + header = bits[:3] + pos = bits[3:3 + self.data_bits] + trailer = bits[3 + self.data_bits:] + + padded = list(pos) + while len(padded) % 4 != 0: + padded.insert(0, 0) + + fec_bits: List[int] = [] + for i in range(0, len(padded), 4): + nib = 0 + for j in range(4): + nib = (nib << 1) | padded[i + j] + cw = hamming74_encode(nib) + for j in range(6, -1, -1): + fec_bits.append((cw >> j) & 1) + + return header + fec_bits + trailer + + def _apply_8b10b(self, bits: List[int]) -> List[int]: + padded = list(bits) + while len(padded) % 8 != 0: + padded.append(0) + + wire: List[int] = [] + for i in range(0, len(padded), 8): + bv = 0 + for j in range(8): + bv = (bv << 1) | padded[i + j] + sym = encode_8b10b_byte(bv) + for j in range(9, -1, -1): + wire.append((sym >> j) & 1) + return wire + + @property + def total_wire_bits(self) -> int: + return len(self._wire_bits) + + # ---------------------------------------------------------------- tick + def tick(self, cycle: int, pins_out: int) -> int: + clk = (pins_out >> self.ma_pin) & 1 + mo = (pins_out >> self.mo_pin) & 1 + + if clk != self._last_clk: + if clk == 1: # rising edge + if (self._last_rising_edge_cycle is not None + and cycle - self._last_rising_edge_cycle < self.min_bit_period_cycles): + self._faults.append( + f"cycle {cycle}: full bit period " + f"{cycle - self._last_rising_edge_cycle} < min {self.min_bit_period_cycles}") + self._last_rising_edge_cycle = cycle + self._last_edge_cycle = cycle + + self._clocks_in_burst += 1 + + if self._clocks_in_burst > len(self._wire_bits): + if not self._overclock_flagged: + self._overclock_flagged = True + self._faults.append( + f"cycle {cycle}: overclocked " + f"(>{len(self._wire_bits)} clocks for " + f"{len(self._wire_bits)}-bit frame)") + else: + self._shift_index = self._clocks_in_burst + driven_val = self._drive() + slave_bit = (driven_val >> self.slo_pin) & 1 + self._captured_bits.append(slave_bit | mo) + else: + # falling edge + self._last_edge_cycle = cycle + + self._last_clk = clk + return self._drive() + + def _drive(self) -> int: + if self._shift_index == 0 or self._shift_index > len(self._wire_bits): + return 1 << self.slo_pin # idle high + bit = self._wire_bits[self._shift_index - 1] + return bit << self.slo_pin + + def flush(self, cycle: int) -> None: + if self._clocks_in_burst > 0 and not self._frame_emitted: + self._emit_frame(cycle) + + def _emit_frame(self, cycle: int) -> None: + self._frame_emitted = True + + expected_len = len(self._wire_bits) + if self._clocks_in_burst != len(self._captured_bits): + self._faults.append( + f"cycle {cycle}: clocks_in_burst ({self._clocks_in_burst}) != captured_bits ({len(self._captured_bits)})" + ) + return + + if len(self._captured_bits) != expected_len: + self._faults.append( + f"cycle {cycle}: clocked {len(self._captured_bits)} times, expected {expected_len}" + ) + return + + # Decode from wire + clocked_wire = self._captured_bits + + if self.line_coding: + decoded_coded_bits = [] + for i in range(0, len(clocked_wire), 10): + sym = 0 + for j in range(10): + sym = (sym << 1) | clocked_wire[i + j] + bv = decode_8b10b_symbol(sym) + if bv is None: + self._faults.append(f"cycle {cycle}: 8b/10b decode error") + return + for j in range(7, -1, -1): + decoded_coded_bits.append((bv >> j) & 1) + expected_coded_len = len(self._coded_bits) + decoded_coded_bits = decoded_coded_bits[:expected_coded_len] + else: + decoded_coded_bits = list(clocked_wire) + + if self.fec_enable: + header = decoded_coded_bits[:3] + fec_bits = decoded_coded_bits[3:-8] + trailer = decoded_coded_bits[-8:] + pos_bits = [] + for i in range(0, len(fec_bits), 7): + cw = 0 + for j in range(7): + cw = (cw << 1) | fec_bits[i + j] + nibble, corrected, err_det = hamming74_decode(cw) + for j in range(3, -1, -1): + pos_bits.append((nibble >> j) & 1) + pos_bits = pos_bits[-self.data_bits:] + raw = header + pos_bits + trailer + else: + raw = list(decoded_coded_bits) + + pos_bits = raw[3:3 + self.data_bits] + position = 0 + for b in pos_bits: + position = (position << 1) | b + + ne = raw[3 + self.data_bits] + nw = raw[3 + self.data_bits + 1] + crc_data = raw[3 + self.data_bits + 2:3 + self.data_bits + 8] + received_crc = 0 + for b in crc_data: + received_crc = (received_crc << 1) | b + + computed_crc = crc6_bissc(pos_bits + [ne, nw]) + crc_ok = (received_crc == computed_crc) + + self._events.append({ + "kind": "frame", + "cycle": cycle, + "position": position, + "cds": raw[2], + "error": ne == 0, + "warning": nw == 0, + "crc_ok": crc_ok, + "raw_bits": len(raw), + "wire_bits": len(self._wire_bits), + "clocks_received": self._clocks_in_burst, + "line_coded": self.line_coding, + "fec_applied": self.fec_enable, + }) + + def events(self) -> List[Dict[str, Any]]: + return self._events + + def faults(self) -> List[str]: + return self._faults diff --git a/perif/buscosim.py b/perif/buscosim.py new file mode 100644 index 0000000..983d9a5 --- /dev/null +++ b/perif/buscosim.py @@ -0,0 +1,474 @@ +"""Independent bus-cosim decoders -. + +These decode *resolved pin traces* (cycle, pin word) into protocol events. +They share no code or state with the DeviceModel implementations in this +package: they are the second, independent implementation whose event stream +must agree with each model's ``events()`` for a run to be accepted. + +Trace format: iterable of ``(cycle:int, word:int)`` samples, one per +observation point, monotonically increasing in cycle. ``word`` carries all +pins; the decoder is told which bit positions to look at. +""" + +from typing import Any, Dict, Iterable, List, Tuple + +Trace = Iterable[Tuple[int, int]] + + +# ---------------------------------------------------------------- quadrature +_FWD = {0: 1, 1: 3, 3: 2, 2: 0} +_REV = {v: k for k, v in _FWD.items()} + + +def decode_quadrature(trace: Trace, a_bit: int, b_bit: int, z_bit: int) -> List[Dict[str, Any]]: + """Decode A/B/Z into edge counts and index pulses. + + Events: edge {"kind","cycle","state","count","dir"}, index {"kind","cycle"}, + illegal {"kind":"illegal","cycle","from","to"} for non-unit transitions. + """ + events: List[Dict[str, Any]] = [] + prev_state = None + prev_z = 0 + count = 0 + for cycle, word in trace: + a = (word >> a_bit) & 1 + b = (word >> b_bit) & 1 + z = (word >> z_bit) & 1 + state = 2 * a + b + if prev_state is None: + prev_state = state + elif state != prev_state: + if _FWD[prev_state] == state: + count += 1 + events.append({"kind": "edge", "cycle": cycle, "state": state, + "count": count, "dir": 1}) + elif _REV[prev_state] == state: + count -= 1 + events.append({"kind": "edge", "cycle": cycle, "state": state, + "count": count, "dir": -1}) + else: + events.append({"kind": "illegal", "cycle": cycle, + "from": prev_state, "to": state}) + prev_state = state + if z == 1 and prev_z == 0: + events.append({"kind": "index", "cycle": cycle}) + prev_z = z + return events + + +# ----------------------------------------------------------------------- SSI +def decode_ssi(trace: Trace, clk_pin: int, data_pin: int, *, cpol: int, + expected_bits: int, idle_gap_cycles: int = 8) -> List[Dict[str, Any]]: + """Decode SSI frames the way a datasheet receiver does. + + The first active (normally falling) edge latches. DATA is sampled on each + following return-to-idle edge, including R1/MSB; F(n+1) is the terminator + and is not a data sample. Consequently ``expected_bits`` is required (as + is ``cpol``, keyword-only: omitting either raises ``TypeError``): without + it F(n+1) cannot be told from data and a correct 8-bit frame would decode + as 9 bits. It distinguishes a valid n+1-pulse word from an n-pulse word + missing its terminator, reported as ``missing SSI terminator pulse``. + + Frame boundaries are idle gaps longer than ``idle_gap_cycles`` between + active edges. Events: {"kind":"frame","cycle","raw","bits"}; the decoder + reports the bits it saw, or a "fault" event when the pulse count is not + ``expected_bits`` + 1. + """ + active = 1 - cpol + events: List[Dict[str, Any]] = [] + # The first trace value is the PRU reset/setup level, not necessarily the + # protocol idle level. Seed history from it so a reset-low observation + # cannot manufacture an F1 latch for an idle-high SSI transfer. + prev_clk: int | None = None + bits: List[int] = [] + last_active_cycle = None + pulse_count = 0 + + def finish(cycle: int) -> None: + nonlocal bits, pulse_count, last_active_cycle + if not pulse_count: + return + if pulse_count == expected_bits: + events.append({"kind": "fault", "cycle": cycle, + "fault": "missing SSI terminator pulse: expected " + f"{expected_bits} data pulses + 1 terminator pulse, " + f"saw {pulse_count} pulses"}) + elif pulse_count != expected_bits + 1: + events.append({"kind": "fault", "cycle": cycle, + "fault": f"incomplete SSI frame: expected {expected_bits} data " + f"pulses + 1 terminator pulse, saw {pulse_count} pulses"}) + elif bits: + events.append(_ssi_frame_event(bits, last_active_cycle)) + bits = [] + pulse_count = 0 + last_active_cycle = None + + for cycle, word in trace: + clk = (word >> clk_pin) & 1 + data = (word >> data_pin) & 1 + if prev_clk is None: + prev_clk = clk + continue + if clk != prev_clk and clk == active: + if (last_active_cycle is not None + and cycle - last_active_cycle > idle_gap_cycles): + finish(last_active_cycle) + pulse_count += 1 + last_active_cycle = cycle + elif clk != prev_clk and clk == cpol: + # R1..Rn are the datasheet data edges. Do not sample the return + # from F(n+1), whose DATA is the monoflop-low indication. + if 0 < pulse_count <= expected_bits: + bits.append(data) + prev_clk = clk + finish(last_active_cycle) + return events + + +def _ssi_frame_event(bits: List[int], cycle: int) -> Dict[str, Any]: + raw = 0 + for b in bits: + raw = (raw << 1) | b + return {"kind": "frame", "cycle": cycle, "raw": raw, "bits": len(bits)} + + +# --------------------------------------------------------------------- WS2812 +def decode_ws2812(trace: Trace, data_pin: int, t0h: float, t1h: float, + tol: float, reset_cycles: float) -> List[Dict[str, Any]]: + """Decode a WS2812 NRZ stream into pixels (0xRRGGBB from GRB wire order). + + Events: {"kind":"frame","cycle","pixels":[...]}, + {"kind":"timing_error","cycle","width"} for pulses in neither window. + All times in cycles. + """ + events: List[Dict[str, Any]] = [] + bits: List[int] = [] + prev = 0 + since = 0 + for cycle, word in trace: + level = (word >> data_pin) & 1 + if level == prev: + continue + width = cycle - since + if prev == 1: + if abs(width - t0h) <= tol: + bits.append(0) + elif abs(width - t1h) <= tol: + bits.append(1) + else: + events.append({"kind": "timing_error", "cycle": cycle, "width": width}) + else: + if width >= reset_cycles and bits: + events.append({"kind": "frame", "cycle": cycle, + "pixels": _ws2812_pixels(bits)}) + bits = [] + prev = level + since = cycle + if bits: + events.append({"kind": "frame", "cycle": since, "pixels": _ws2812_pixels(bits)}) + return events + + +def _ws2812_pixels(bits: List[int]) -> List[int]: + pixels = [] + for i in range(0, len(bits) - 23, 24): + w = 0 + for b in bits[i:i + 24]: + w = (w << 1) | b + g = (w >> 16) & 0xFF + r = (w >> 8) & 0xFF + bch = w & 0xFF + pixels.append((r << 16) | (g << 8) | bch) + return pixels + + +# ----------------------------------------------------------------------- SPI +def decode_spi_mode0(trace: Trace, sclk_pin: int, sdi_pin: int, sdo_pin: int, + cs_pin: int, msb_first: bool = True) -> List[Dict[str, Any]]: + """Decode SPI mode-0 transactions. MISO is SDI (peripheral->PRU), MOSI SDO. + + Events: {"kind":"transaction","cycle","miso","mosi","bits"} and + {"kind":"framing_error","cycle","detail"}. + """ + events: List[Dict[str, Any]] = [] + prev_sclk, prev_cs = 0, 1 + primed = False + in_frame = False + miso = mosi = nbits = 0 + for cycle, word in trace: + sclk = (word >> sclk_pin) & 1 + cs = (word >> cs_pin) & 1 + if not primed: # the first sample is the reset level, not a CS edge + primed = True + prev_cs = cs + if cs != prev_cs: + if cs == 0: + in_frame = True + miso = mosi = nbits = 0 + else: + if in_frame: + events.append({"kind": "transaction", "cycle": cycle, + "miso": miso, "mosi": mosi, "bits": nbits}) + in_frame = False + prev_cs = cs + if sclk != prev_sclk: + if not in_frame: + events.append({"kind": "framing_error", "cycle": cycle, + "detail": "sclk outside CS"}) + elif sclk == 1: + if msb_first: + miso = ((miso << 1) | ((word >> sdi_pin) & 1)) & 0xFFFFFFFFFF + mosi = ((mosi << 1) | ((word >> sdo_pin) & 1)) & 0xFFFFFFFFFF + else: + miso |= ((word >> sdi_pin) & 1) << nbits + mosi |= ((word >> sdo_pin) & 1) << nbits + nbits += 1 + prev_sclk = sclk + return events + + +# ---------------------------------------------------------------------- Hall +_HALL_LUT = {1: 0, 3: 1, 2: 2, 6: 3, 4: 4, 5: 5} + +def decode_hall(trace: Trace, a_bit: int, b_bit: int, c_bit: int) -> List[Dict[str, Any]]: + """Decode 3-phase Hall into sector edges and illegal codes. + + Code = (A<<2)|(B<<1)|C. Six codes are legal via _HALL_LUT, 0 and 7 are + illegal. Events: {"kind":"edge","cycle","code","sector","dir"}, + {"kind":"illegal","cycle","code"}. + + The decoder does NOT interpolate through illegal states: an illegal + code emits only an illegal event, never an edge. + """ + events: List[Dict[str, Any]] = [] + prev_sector = None + prev_code = None + for cycle, word in trace: + a = (word >> a_bit) & 1 + b = (word >> b_bit) & 1 + c = (word >> c_bit) & 1 + code = (a << 2) | (b << 1) | c + if code in (0, 7): + events.append({"kind": "illegal", "cycle": cycle, "code": code}) + prev_code = code + continue + sector = _HALL_LUT.get(code) + if sector is None: + events.append({"kind": "illegal", "cycle": cycle, "code": code}) + prev_code = code + continue + if prev_code is not None and code == prev_code: + continue + if prev_sector is None: + prev_sector = sector + prev_code = code + continue + # direction from sector delta modulo 6 + diff = (sector - prev_sector) % 6 + if diff == 1: + d = 1 + elif diff == 5: + d = -1 + else: + # non-adjacent jump without illegal in between -> interpolate would be wrong + events.append({"kind": "illegal", "cycle": cycle, + "code": code, "detail": f"non-adjacent {prev_sector}->{sector}"}) + prev_sector = sector + prev_code = code + continue + events.append({"kind": "edge", "cycle": cycle, "code": code, + "sector": sector, "dir": d}) + prev_sector = sector + prev_code = code + return events + + +# ------------------------------------------------------------------ OSSD dual-channel +def decode_ossd( + trace: Trace, + ch_a_pin: int, + ch_b_pin: int, + ossd_max_cycles: int, + discrepancy_cycles: int, +) -> List[Dict[str, Any]]: + """Independent oracle for dual-channel OSSD. + + Decodes a resolved pin trace into pulse and disagreement events. + + Classification: + - LOW pulse width <= ossd_max_cycles -> {"kind":"ossd_pulse"} + - LOW pulse width > ossd_max_cycles -> {"kind":"trip"} + - channels differ for > discrepancy_cycles -> {"kind":"discrepancy_fault"} + + LOW width is measured from falling edge to next rising edge on each + channel independently; at end-of-trace any open LOW is closed at the + last cycle. + + Disagreement detection: contiguous interval where (a != b). One + event per interval that exceeds the window; intervals within the + window still emit ossd_pulse events but no discrepancy_fault. + + Trace format: iterable of (cycle, word) with monotonic cycle. + """ + events: List[Dict[str, Any]] = [] + if not trace: + return events + trace_list = list(trace) + # per-channel: track LOW start + prev_a = (trace_list[0][1] >> ch_a_pin) & 1 + prev_b = (trace_list[0][1] >> ch_b_pin) & 1 + low_since_a = None + low_since_b = None + if prev_a == 0: + low_since_a = trace_list[0][0] + if prev_b == 0: + low_since_b = trace_list[0][0] + # disagreement window tracking + disagree_since: int | None = None + last_cycle = trace_list[0][0] + + def _close_low(channel: str, since: int, end_cycle: int): + width = end_cycle - since + kind = "ossd_pulse" if width <= ossd_max_cycles else "trip" + events.append({"kind": kind, "cycle": end_cycle, "channel": channel, "width": width}) + + for cycle, word in trace_list[1:]: + a = (word >> ch_a_pin) & 1 + b = (word >> ch_b_pin) & 1 + # per-channel pulse closure on rising edge + if prev_a == 0 and a == 1 and low_since_a is not None: + _close_low("A", low_since_a, cycle) + low_since_a = None + if prev_b == 0 and b == 1 and low_since_b is not None: + _close_low("B", low_since_b, cycle) + low_since_b = None + if prev_a == 1 and a == 0: + low_since_a = cycle + if prev_b == 1 and b == 0: + low_since_b = cycle + # disagreement interval + disagree = (a != b) + if disagree and disagree_since is None: + disagree_since = cycle + elif not disagree and disagree_since is not None: + dur = cycle - disagree_since + if dur > discrepancy_cycles: + events.append({"kind": "discrepancy_fault", "cycle": cycle, "duration": dur}) + disagree_since = None + prev_a, prev_b = a, b + last_cycle = cycle + + # close open lows at end + if low_since_a is not None: + _close_low("A", low_since_a, last_cycle) + if low_since_b is not None: + _close_low("B", low_since_b, last_cycle) + if disagree_since is not None: + dur = last_cycle - disagree_since + if dur > discrepancy_cycles: + events.append({"kind": "discrepancy_fault", "cycle": last_cycle, "duration": dur}) + return events + + +# ------------------------------------------------------------------ STEP/DIR +def decode_step_dir(trace: Trace, step_pin: int, dir_pin: int, + min_pulse_cycles: int = 0) -> List[Dict[str, Any]]: + """Decode STEP/DIR into position steps and timing violations. + + Events: {"kind":"step","cycle","position","dir"} and + {"kind":"timing_error","cycle","width"} for narrow high pulses. + """ + events: List[Dict[str, Any]] = [] + prev_step = 0 + since = 0 + position = 0 + for cycle, word in trace: + step = (word >> step_pin) & 1 + d = (word >> dir_pin) & 1 + if step == prev_step: + continue + width = cycle - since + if step == 1: + direction = 1 if d == 1 else -1 + position += direction + events.append({"kind": "step", "cycle": cycle, + "position": position, "dir": direction}) + else: + if min_pulse_cycles and width < min_pulse_cycles: + events.append({"kind": "timing_error", "cycle": cycle, + "width": width}) + prev_step = step + since = cycle + return events + + +# ---------------------------------------------------------------- BiSS Line +def decode_bissline(trace, clk_pin, data_pin, data_bits, + idle_gap_cycles=8): + """Decode BiSS Line frames by sampling DATA on the falling clock edge. + + Returns list of events: {"kind":"frame","cycle","raw","bits"} and + {"kind":"crc_error","cycle"} when CRC-6 does not match. + + CRC-6 is computed independently (poly 0x43, inverted) over the + position + nE + nW bits. Header (ACK, Start, CDS) is excluded. + """ + events = [] + prev_clk = 0 + bits = [] + last_active_cycle = None + for cycle, word in trace: + clk = (word >> clk_pin) & 1 + data = (word >> data_pin) & 1 + if clk != prev_clk: + if clk == 1: + if (bits and last_active_cycle is not None + and cycle - last_active_cycle > idle_gap_cycles): + events.append(_bissline_frame_event(bits, last_active_cycle, data_bits)) + bits = [] + last_active_cycle = cycle + else: + if last_active_cycle is not None: + bits.append(data) + prev_clk = clk + if bits: + events.append(_bissline_frame_event(bits, last_active_cycle, data_bits)) + return events + + +def _bissline_crc6(bits): + """Decoder-local CRC-6 copy for wire-shape checks, not an independent oracle.""" + crc = 0 + for b in bits: + fb = ((crc >> 5) & 1) ^ (b & 1) + crc = ((crc << 1) & 0x3F) + if fb: + crc ^= 0x03 + return crc ^ 0x3F + + +def _bissline_frame_event(bits, cycle, data_bits): + raw = 0 + for b in bits: + raw = (raw << 1) | b + ev = {"kind": "frame", "cycle": cycle, "raw": raw, "bits": len(bits)} + # Try to decode if enough bits: ACK(1) + Start(1) + CDS(1) + pos + nE(1) + nW(1) + CRC(6) + expected = 3 + data_bits + 2 + 6 + if len(bits) >= expected: + pos_bits = bits[3:3 + data_bits] + ne = bits[3 + data_bits] + nw = bits[3 + data_bits + 1] + crc_bits = bits[3 + data_bits + 2:3 + data_bits + 8] + received_crc = 0 + for b in crc_bits: + received_crc = (received_crc << 1) | b + computed = _bissline_crc6(pos_bits + [ne, nw]) + position = 0 + for b in pos_bits: + position = (position << 1) | b + ev["position"] = position + ev["cds"] = bits[2] + ev["crc_ok"] = (received_crc == computed) + ev["error"] = ne == 0 + ev["warning"] = nw == 0 + return ev diff --git a/perif/hall_sensor.py b/perif/hall_sensor.py new file mode 100644 index 0000000..cbcee53 --- /dev/null +++ b/perif/hall_sensor.py @@ -0,0 +1,256 @@ +"""Hall sensor (3-phase BLDC) - DeviceModel. + +Three push-pull Hall outputs (A,B,C) encoding six 60-degree sectors. +Legal Gray-like codes (single-bit change per sector): + code = (A<<2)|(B<<1)|C + 1: 001 -> sector 0 + 3: 011 -> sector 1 + 2: 010 -> sector 2 + 6: 110 -> sector 3 + 4: 100 -> sector 4 + 5: 101 -> sector 5 + 0: 000 illegal + 7: 111 illegal + +Illegal 000/111 handling: + * The model drives the illegal code when injected (inject_illegal). + * An event {"kind":"illegal","cycle":...,"code":...} is emitted. + * A fault is recorded immediately for the illegal code. + * Additionally a watchdog fault is recorded when firmware fails to react + within `response_window_cycles` after the illegal appears. Reaction is + defined as asserting `fault_ack_pin` (if configured) or otherwise the + watchdog always faults - which proves firmware that does nothing is + rejected. This satisfies "fault when firmware fails to react". + +Events(): + {"kind":"edge","cycle":...,"code":...,"sector":...,"dir":...} + {"kind":"illegal","cycle":...,"code":...} +Faults(): + - pin indices out of range / not distinct + - cycles_per_sector too fast + - PRU drives Hall pins (contention) + - illegal Hall code driven (000/111) + - firmware failed to react to illegal within window +""" + +from typing import Any, Dict, List, Optional, Tuple + +from core.device_model import DeviceModel + +# Six legal codes in forward order +_HALL_CODES = [1, 3, 2, 6, 4, 5] +_CODE_TO_SECTOR = {c: i for i, c in enumerate(_HALL_CODES)} +_ILLEGAL_CODES = {0, 7} +_SECTOR_TO_CODE = {v: k for k, v in _CODE_TO_SECTOR.items()} + + +class HallSensorModel(DeviceModel): + """DeviceModel for a 3-phase Hall sensor.""" + + def __init__( + self, + a_bit: int = 0, + b_bit: int = 1, + c_bit: int = 2, + cycles_per_sector: int = 60, + direction: int = 1, + fault_ack_pin: Optional[int] = None, + response_window_cycles: int = 32, + pru_gpo_bits: int = 20, + **kwargs, + ): + # alias handling for alternative pin names used by tests + if "h_a_pin" in kwargs: + a_bit = kwargs["h_a_pin"] + if "h_b_pin" in kwargs: + b_bit = kwargs["h_b_pin"] + if "h_c_pin" in kwargs: + c_bit = kwargs["h_c_pin"] + if "u_pin" in kwargs: + a_bit = kwargs["u_pin"] + if "v_pin" in kwargs: + b_bit = kwargs["v_pin"] + if "w_pin" in kwargs: + c_bit = kwargs["w_pin"] + if "hall_a" in kwargs: + a_bit = kwargs["hall_a"] + if "hall_b" in kwargs: + b_bit = kwargs["hall_b"] + if "hall_c" in kwargs: + c_bit = kwargs["hall_c"] + if "pin_a" in kwargs: + a_bit = kwargs["pin_a"] + if "pin_b" in kwargs: + b_bit = kwargs["pin_b"] + if "pin_c" in kwargs: + c_bit = kwargs["pin_c"] + # cycles alias + if "cycles_per_state" in kwargs: + cycles_per_sector = kwargs["cycles_per_state"] + if "cyclesPerSector" in kwargs: + cycles_per_sector = kwargs["cyclesPerSector"] + + self._faults: List[str] = [] + self._events: List[Dict[str, Any]] = [] + + if not (0 <= a_bit < 32 and 0 <= b_bit < 32 and 0 <= c_bit < 32): + self._faults.append("pin indices out of range") + if len({a_bit, b_bit, c_bit}) != 3: + self._faults.append("a_bit, b_bit and c_bit must be distinct") + if cycles_per_sector < 4: + self._faults.append( + f"cycles_per_sector={cycles_per_sector} too fast for tick granularity") + if direction not in (1, -1): + self._faults.append("direction must be +1 or -1") + if fault_ack_pin is not None and not (0 <= fault_ack_pin < 32): + self._faults.append("fault_ack_pin out of range") + + self.a_bit = a_bit + self.b_bit = b_bit + self.c_bit = c_bit + self.cycles_per_sector = cycles_per_sector + self.direction = direction + self.fault_ack_pin = fault_ack_pin + self.response_window_cycles = response_window_cycles + self._gpo_mask = (1 << pru_gpo_bits) - 1 + + # rotation state + self._sector_idx = 0 # index into _HALL_CODES + self._cycles_to_next = cycles_per_sector + self._code = _HALL_CODES[0] + self._sector = _CODE_TO_SECTOR[self._code] + self.position = 0 # sector steps + + # illegal injection + self._illegal_code: Optional[int] = None + self._illegal_until: int = -1 + self._illegal_start: Optional[int] = None + self._watchdog_deadline: Optional[int] = None + self._ack_seen = False + + # for direction detection in events + self._prev_sector = self._sector + + # ------------------------------------------------------------------ API + def set_motion(self, segments: List[Tuple[int, int, int]]) -> None: + """segments: list of (cycles_per_sector, n_sectors, direction +1/-1).""" + # not used for Hall but keep for compat + for cpc, n, d in segments: + if cpc < 4: + self._faults.append( + f"segment cycles_per_sector={cpc} too fast") + if n <= 0 or d not in (1, -1): + self._faults.append("segment must have n>0 and direction +/-1") + + def set_rotation(self, cycles_per_sector: int, sectors: int, direction: int = 1) -> None: + self.cycles_per_sector = cycles_per_sector + self.direction = direction + self._cycles_to_next = cycles_per_sector + + def inject_illegal(self, code: int = 0, duration_cycles: int = 40) -> None: + if code not in (0, 7): + self._faults.append(f"inject_illegal code must be 0 or 7, got {code}") + return + if duration_cycles <= 0: + self._faults.append("duration_cycles must be positive") + return + self._illegal_code = code + # will be scheduled on next tick; caller sets cycle anchor + self._illegal_pending_duration = duration_cycles + + def schedule_illegal(self, start_cycle: int, code: int = 0, duration_cycles: int = 40) -> None: + """Schedule illegal code [start_cycle, start_cycle+duration).""" + if code not in (0, 7): + self._faults.append(f"schedule_illegal code must be 0 or 7, got {code}") + return + self._illegal_code = code + self._illegal_until = start_cycle + duration_cycles + self._illegal_start = start_cycle + self._watchdog_deadline = start_cycle + self.response_window_cycles + self._ack_seen = False + + # -------------------------------------------------------------- DeviceModel + def tick(self, cycle: int, pins_out: int) -> int: + # contention check + contention = pins_out & self._gpo_mask & ( + (1 << self.a_bit) | (1 << self.b_bit) | (1 << self.c_bit)) + if contention: + self._faults.append( + f"cycle {cycle}: PRU drives Hall pins (contention mask 0x{contention:x})") + + # handle pending inject (first tick after inject_illegal) + if hasattr(self, "_illegal_pending_duration") and self._illegal_start is None: + self._illegal_start = cycle + self._illegal_until = cycle + self._illegal_pending_duration + self._watchdog_deadline = cycle + self.response_window_cycles + self._ack_seen = False + delattr(self, "_illegal_pending_duration") + + # watchdog: check if firmware acknowledged illegal + if self._watchdog_deadline is not None: + if self.fault_ack_pin is not None: + ack_level = (pins_out >> self.fault_ack_pin) & 1 + if ack_level == 1: + self._ack_seen = True + if cycle >= self._watchdog_deadline and not self._ack_seen: + self._faults.append( + f"cycle {cycle}: firmware failed to react to illegal Hall code " + f"{self._illegal_code} within {self.response_window_cycles} cycles") + self._watchdog_deadline = None + elif self._ack_seen: + self._watchdog_deadline = None + + # determine driven code + if self._illegal_code is not None and self._illegal_start is not None and cycle < self._illegal_until: + code = self._illegal_code + # emit illegal event once at start + if cycle == self._illegal_start: + self._events.append({"kind": "illegal", "cycle": cycle, "code": code}) + self._faults.append(f"cycle {cycle}: illegal Hall code {code:03b} (000/111)") + else: + # clear illegal after duration + if self._illegal_code is not None and cycle >= self._illegal_until: + self._illegal_code = None + self._illegal_start = None + self._illegal_until = -1 + # advance sector if not in illegal window + self._cycles_to_next -= 1 + if self._cycles_to_next <= 0: + prev_code = self._code + if self.direction == 1: + self._sector_idx = (self._sector_idx + 1) % len(_HALL_CODES) + else: + self._sector_idx = (self._sector_idx - 1) % len(_HALL_CODES) + self._code = _HALL_CODES[self._sector_idx] + self._sector = _CODE_TO_SECTOR[self._code] + self.position += self.direction + # direction for event + d = self.direction + self._events.append({ + "kind": "edge", "cycle": cycle, "code": self._code, + "sector": self._sector, "dir": d, + }) + self._cycles_to_next += self.cycles_per_sector + # clear watchdog if legal resumed and ack was seen? watchdog already cleared + code = self._code + + # drive pins + a = (code >> 2) & 1 + b = (code >> 1) & 1 + c = code & 1 + return (a << self.a_bit) | (b << self.b_bit) | (c << self.c_bit) + + def events(self) -> List[Dict[str, Any]]: + return self._events + + def faults(self) -> List[str]: + return self._faults + + # expose for buscosim / tests + @property + def legal_codes(self): + return list(_HALL_CODES) + + @property + def code_to_sector(self): + return dict(_CODE_TO_SECTOR) diff --git a/perif/mdio_phy_model.py b/perif/mdio_phy_model.py new file mode 100644 index 0000000..c3c825d --- /dev/null +++ b/perif/mdio_phy_model.py @@ -0,0 +1,278 @@ +"""MDIO Clause 22 Ethernet PHY - DeviceModel. + +A compact observed-line PHY model for the IEEE 802.3 Clause 22 (MII) +management interface. The STA/master drives MDC (clock, <= 2.5 MHz) and +MDIO during preamble/header/write-data; the PHY drives MDIO during the +read turn-around and read data. + +The model decodes ONLY what is on the wires. Each tick it observes +(mdc, mdio_master_drive) on the PRU GPO word, reconstructs the shared +line as a wired-AND (dominant 0, pull-up 1), and samples that observed +line on every rising MDC edge - exactly what a real PHY sees. There is +no transmit-buffer shortcut: the frame recorded in events() and the +value written into the register file are re-derived from the sampled +line bits, so a corrupted wire bit produces a corrupted result (which +the tests exploit as a positive proof of observed-line decoding). + +Clause 22 wire contract (the ONLY contract this model enforces): + preamble >= preamble_bits clocked 1 bits (default 16, a lenient floor; idle-high MDIO counts, as on wire). + IEEE 802.3 22.2.4.5.1 specifies 32; mdio_master_block emits 32. The default stays 16 so + existing 16-bit drivers keep passing; pass preamble_bits=32 for a strict check. + ST 01, MSB first + OP 01 = write, 10 = read (00/11 illegal) + PHYAD 5 bits, MSB first + REGAD 5 bits, MSB first + TA read: master releases (1) then PHY drives 0; + write: master drives 10 + DATA 16 bits, MSB first + +Vendor behaviour (TI ICSS MDIO doorbell/pre-trigger register layout, +software API sequencing) is deliberately NOT modelled here: the model covers +the IEEE 802.3 Clause 22 frame on the wire only. + +Faults (reject wrong firmware): + - MDC rising edges closer than 2*min_half_period_cycles (> 2.5 MHz + at the reference 200 MHz PRU clock with the default 40) + - preamble shorter than preamble_bits when ST is attempted + - ST != 01 + - OP == 00 or 11 + - write turn-around != 10 + - master drives MDIO low during the read turn-around/data window + (contention with the PHY) + - frame abandoned mid-field (edge count short / clocking stops) + +Events: + {"kind":"read"/"write", "cycle", "phy_addr", "reg_addr", "data"} +Only frames addressed to this PHY's phy_addr produce events or drive +the line; frames to other addresses are clocked through silently, and +an unanswered read leaves MDIO released (1) on the wire. +""" + +from typing import Any, Dict, List, Optional + +from core.device_model import DeviceModel + +_ST = 0b01 +_OP_WRITE = 0b01 +_OP_READ = 0b10 +_HDR_BITS = 14 # ST(2) + OP(2) + PHYAD(5) + REGAD(5) +_DATA_BITS = 16 + + +class MdioPhyModel(DeviceModel): + """Clause 22 PHY management slave, decoding the observed line.""" + + def __init__( + self, + phy_addr: int = 0, + mdc_bit: int = 0, + mdio_bit: int = 1, + min_half_period_cycles: int = 40, # 200 MHz / 2.5 MHz / 2 + preamble_bits: int = 16, # lenient floor; the standard (and mdio_master_block) use 32 + registers: Optional[Dict[int, int]] = None, + abort_gap_factor: int = 8, + **kwargs, + ): + self._faults: List[str] = [] + self._events: List[Dict[str, Any]] = [] + + if not (0 <= phy_addr < 32): + self._faults.append(f"phy_addr {phy_addr} out of 5-bit range") + if mdc_bit == mdio_bit: + self._faults.append("mdc_bit and mdio_bit must differ") + if min_half_period_cycles < 1: + self._faults.append("min_half_period_cycles must be >= 1") + if not (1 <= preamble_bits <= 32): + self._faults.append("preamble_bits must be 1..32") + + self.phy_addr = phy_addr & 0x1F + self.mdc_bit = mdc_bit + self.mdio_bit = mdio_bit + self.min_half_period_cycles = min_half_period_cycles + self.preamble_bits = preamble_bits + self.abort_gap_cycles = abort_gap_factor * 2 * min_half_period_cycles + self.registers: Dict[int, int] = dict(registers or {}) + + # observed-line decode state + self._prev_mdc = 1 # line idle level before first tick + self._phase = "PRE" # PRE -> HDR -> TA -> DATA -> PRE + self._pre_ones = 0 + self._hdr_bits: List[int] = [] + self._op = 0 + self._frame_phy = 0 + self._frame_reg = 0 + self._mine = False + self._ta_bits: List[int] = [] + self._data_bits: List[int] = [] + self._last_rise: Optional[int] = None + + # PHY MDIO drive (1 = released / pull-up) + self._phy_cur = 1 + self._phy_queue: List[int] = [] + + # every (cycle, mdc, observed mdio line) sampled - exposed for + # tests that must assert on the wire, never on driver intent + self.line_trace: List[Dict[str, int]] = [] + + # ---------------------------------------------------------------- helpers + def _fault(self, cycle: int, msg: str) -> None: + self._faults.append(f"cycle {cycle}: {msg}") + + def _reset_frame(self) -> None: + self._phase = "PRE" + self._pre_ones = 0 + self._hdr_bits = [] + self._ta_bits = [] + self._data_bits = [] + self._mine = False + self._phy_queue = [] + self._phy_cur = 1 + self._last_rise = None + + # ------------------------------------------------------------ DeviceModel + def tick(self, cycle: int, pins_out: int) -> int: + mdc = (pins_out >> self.mdc_bit) & 1 + master = (pins_out >> self.mdio_bit) & 1 + + # shared MDIO line: dominant-0 wired-AND of master and PHY drives + line = master & self._phy_cur + self.line_trace.append( + {"cycle": cycle, "mdc": mdc, "mdio": line, "master": master, + "phy": self._phy_cur}) + + # falling edge: PHY prepares the bit the master will sample next + if self._prev_mdc == 1 and mdc == 0: + if self._phy_queue: + self._phy_cur = self._phy_queue.pop(0) + + # rising edge: master-side sample of the observed line + if self._prev_mdc == 0 and mdc == 1: + self._prev_mdc = mdc + self._on_rising_edge(cycle, line) + else: + self._prev_mdc = mdc + + # frame abandoned: clocking stopped mid-field (short edge count) + if (self._phase != "PRE" and self._last_rise is not None + and cycle - self._last_rise > self.abort_gap_cycles): + got = _HDR_BITS if self._phase == "HDR" else ( + len(self._ta_bits) if self._phase == "TA" else len(self._data_bits)) + self._fault(cycle, f"frame abandoned in {self._phase} after " + f"{got} bits (MDC edge count short)") + self._reset_frame() + + return self._phy_cur << self.mdio_bit + + # ------------------------------------------------------------- decode FSM + def _on_rising_edge(self, cycle: int, bit: int) -> None: + if (self._last_rise is not None + and cycle - self._last_rise < 2 * self.min_half_period_cycles): + self._fault(cycle, "MDC above 2.5 MHz limit " + f"(rising edges {cycle - self._last_rise} cycles apart, " + f"minimum {2 * self.min_half_period_cycles})") + self._last_rise = cycle + + if self._phase == "PRE": + if bit == 1: + self._pre_ones += 1 + else: + if self._pre_ones >= self.preamble_bits: + self._phase = "HDR" + self._hdr_bits = [0] # this 0 is ST bit 1 + else: + self._fault(cycle, f"preamble shorter than " + f"{self.preamble_bits} bits " + f"(saw {self._pre_ones})") + self._reset_frame() + return + + if self._phase == "HDR": + self._hdr_bits.append(bit) + if len(self._hdr_bits) < _HDR_BITS: + return + st = (self._hdr_bits[0] << 1) | self._hdr_bits[1] + op = (self._hdr_bits[2] << 1) | self._hdr_bits[3] + self._op = op + self._frame_phy = 0 + for b in self._hdr_bits[4:9]: + self._frame_phy = (self._frame_phy << 1) | b + self._frame_reg = 0 + for b in self._hdr_bits[9:14]: + self._frame_reg = (self._frame_reg << 1) | b + self._mine = self._frame_phy == self.phy_addr + + if st != _ST: + self._fault(cycle, f"bad Clause 22 start ST={st:02b} " + "(expected 01)") + self._reset_frame() + return + if op not in (_OP_READ, _OP_WRITE): + self._fault(cycle, f"bad opcode OP={op:02b} " + "(00 and 11 are illegal)") + self._reset_frame() + return + + # arm the PHY drive for a read addressed to us: + # TA bit 2 = 0, then register data MSB first + if self._mine and op == _OP_READ: + value = self.registers.get(self._frame_reg, 0) & 0xFFFF + # first entry is TA bit 1 (PHY released); the drive begins + # with TA bit 2 = 0, popped on the falling edge before it + self._phy_queue = [1, 0] + [ + (value >> (15 - i)) & 1 for i in range(_DATA_BITS)] + + self._phase = "TA" + return + + if self._phase == "TA": + self._ta_bits.append(bit) + # during a read addressed to us the master must be off the wire + if (self._mine and self._op == _OP_READ + and self.line_trace[-1]["master"] == 0): + self._fault(cycle, "master drives MDIO during read " + "turn-around/data (contention with PHY)") + if len(self._ta_bits) < 2: + return + ta = (self._ta_bits[0] << 1) | self._ta_bits[1] + if self._mine and self._op == _OP_WRITE and ta != 0b10: + self._fault(cycle, f"write turn-around TA={ta:02b} " + "(expected 10)") + self._reset_frame() + return + self._phase = "DATA" + return + + if self._phase == "DATA": + self._data_bits.append(bit) + if (self._mine and self._op == _OP_READ + and self.line_trace[-1]["master"] == 0): + self._fault(cycle, "master drives MDIO during read data " + "(contention with PHY)") + if len(self._data_bits) < _DATA_BITS: + return + data = 0 + for b in self._data_bits: + data = (data << 1) | b + if self._mine: + kind = "write" if self._op == _OP_WRITE else "read" + self._events.append({ + "kind": kind, "cycle": cycle, + "phy_addr": self._frame_phy, "reg_addr": self._frame_reg, + "data": data, + }) + if self._op == _OP_WRITE: + self.registers[self._frame_reg] = data + self._reset_frame() + return + + def events(self) -> List[Dict[str, Any]]: + return self._events + + def faults(self) -> List[str]: + return self._faults + + def flush(self, cycle: int) -> None: + """End of stimulus: any frame still open was left incomplete.""" + if self._phase != "PRE": + self._fault(cycle, f"stimulus ended mid-frame in {self._phase}") + self._reset_frame() diff --git a/perif/ossd_emitter.py b/perif/ossd_emitter.py new file mode 100644 index 0000000..2cb7aff --- /dev/null +++ b/perif/ossd_emitter.py @@ -0,0 +1,233 @@ +"""Dual-channel OSSD emitter - DeviceModel. + +Drives two normally-high safety channels (CH_A, CH_B) into PRU GPI pins. +Models three behaviours the monitor must distinguish: + +* OSSD test pulses - short LOW pulses (typically 100 us to 1 ms, i.e. + ~20k-200k PRU cycles at 200 MHz). A correct monitor tolerates them + and keeps the safety output asserted. +* Genuine trips - one or both channels LOW for longer than the OSSD + maximum. A correct monitor must de-assert / signal fault. +* Channel disagreement - channels at different levels for longer than + the configured discrepancy window. Within the window (and within the + OSSD max) is tolerable OSSD staggering; beyond it is a fault. + +The model is push-pull: it drives the two pins, the PRU only reads. +``pins_out`` is checked for contention (firmware must not drive these +pins). + +Schedule API +------------ +Schedule events before or during a run; ``tick`` resolves them +cycle-by-cycle. All times are PRU core cycles (1 cycle = 5 ns at +200 MHz). + +Events / faults / oracle +------------------------ +``events()`` records injected pulses/trips; ``faults()`` records +construction or electrical errors. ``perif/buscosim.decode_ossd`` is the +independent oracle that re-derives pulse classes from the resolved pin +trace and must agree with this model's classification. + +Timing notes +------------ +Typical OSSD test-pulse width range quoted by light-curtain and +safety-PLC vendors is 100 us - 1 ms. At 200 MHz that is 20 000 - +200 000 cycles. The model's defaults are scaled for fast simulation +(80 - 400 cycles) so tests do not need 200k-cycle runs, but any +width can be configured; the oracle uses the caller's thresholds, so +the distinction is always explicit. Keep claims tied to the configured +thresholds, not to the nominal 100 us figure. +""" + +from typing import Any, Dict, List, Optional, Tuple + +from core.device_model import DeviceModel + + +class DualOssdEmitter(DeviceModel): + """Dual-channel OSSD source driving two GPI pins. + + Channels idle HIGH. Injected activity drives them LOW. + + Args: + ch_a_pin, ch_b_pin: GPI bit positions (0..19, distinct). + ossd_min_cycles: minimum test-pulse width considered OSSD + (pulses narrower are timing faults). Default 20. + ossd_max_cycles: maximum test-pulse width still classified as + OSSD (wider is a genuine trip). Default 400. + discrepancy_cycles: reference window for the monitor's channel- + disagreement timer (informational; the model's + ``decode`` oracle uses the caller's value). Default 500. + """ + + def __init__( + self, + ch_a_pin: int = 0, + ch_b_pin: int = 1, + ossd_min_cycles: int = 20, + ossd_max_cycles: int = 400, + discrepancy_cycles: int = 500, + pru_gpo_bits: int = 20, + ): + self._faults: List[str] = [] + self._events: List[Dict[str, Any]] = [] + if not (0 <= ch_a_pin < 32 and 0 <= ch_b_pin < 32): + self._faults.append("pin indices out of range") + if ch_a_pin == ch_b_pin: + self._faults.append("ch_a_pin and ch_b_pin must differ") + if ossd_min_cycles < 1 or ossd_max_cycles < 1: + self._faults.append("ossd pulse bounds must be >= 1") + if ossd_min_cycles > ossd_max_cycles: + self._faults.append("ossd_min_cycles must be <= ossd_max_cycles") + if discrepancy_cycles < 1: + self._faults.append("discrepancy_cycles must be >= 1") + self.ch_a_pin = ch_a_pin + self.ch_b_pin = ch_b_pin + self.ossd_min_cycles = ossd_min_cycles + self.ossd_max_cycles = ossd_max_cycles + self.discrepancy_cycles = discrepancy_cycles + self._gpo_mask = (1 << pru_gpo_bits) - 1 + + # scheduled intervals: list of (start, end_exclusive, kind, channel) + # kind: "ossd" (single channel low), "trip" (both low), + # "single_low" (one channel low, for discrepancy tests) + self._schedule: List[Tuple[int, int, str, Optional[int]]] = [] + # runtime: last driven levels for edge detection + self._prev_a = 1 + self._prev_b = 1 + self._low_since_a: Optional[int] = None + self._low_since_b: Optional[int] = None + + # ------------------------------------------------------------------ schedule + def schedule_ossd_pulse(self, channel: str, start: int, width: int) -> None: + """Schedule a short LOW test pulse on 'A' or 'B'.""" + if channel not in ("A", "B"): + self._faults.append(f"unknown channel '{channel}'") + return + if width < 1: + self._faults.append("pulse width must be >= 1") + return + ch = 0 if channel == "A" else 1 + self._schedule.append((start, start + width, "ossd", ch)) + self._schedule.sort() + + def schedule_trip(self, start: int, duration: int) -> None: + """Both channels LOW for duration (genuine trip).""" + if duration < 1: + self._faults.append("trip duration must be >= 1") + return + self._schedule.append((start, start + duration, "trip", None)) + self._schedule.sort() + + def schedule_single_low(self, channel: str, start: int, duration: int) -> None: + """One channel LOW while the other stays HIGH (disagreement).""" + if channel not in ("A", "B"): + self._faults.append(f"unknown channel '{channel}'") + return + ch = 0 if channel == "A" else 1 + self._schedule.append((start, start + duration, "single_low", ch)) + self._schedule.sort() + + def clear_schedule(self) -> None: + self._schedule = [] + self._events = [] + self._prev_a = 1 + self._prev_b = 1 + self._low_since_a = None + self._low_since_b = None + + # ---------------------------------------------------------------- internal + def _level_at(self, cycle: int, ch: int) -> int: + """1 = high, 0 = low.""" + level = 1 + for s, e, kind, c in self._schedule: + if s <= cycle < e: + if kind == "trip": + return 0 + if kind == "ossd" and c == ch: + return 0 + if kind == "single_low" and c == ch: + return 0 + return level + + # -------------------------------------------------------------- DeviceModel + def tick(self, cycle: int, pins_out: int) -> int: + contention = pins_out & self._gpo_mask & ( + (1 << self.ch_a_pin) | (1 << self.ch_b_pin) + ) + if contention: + self._faults.append( + f"cycle {cycle}: PRU drives OSSD input pins (contention mask 0x{contention:x})" + ) + a = self._level_at(cycle, 0) + b = self._level_at(cycle, 1) + + # edge -> event on rising edge (end of low) + if self._prev_a == 0 and a == 1 and self._low_since_a is not None: + width = cycle - self._low_since_a + kind = "ossd_pulse" if width <= self.ossd_max_cycles else "trip" + if width < self.ossd_min_cycles: + self._faults.append( + f"cycle {cycle}: CH_A pulse {width} < ossd_min {self.ossd_min_cycles}" + ) + self._events.append( + {"kind": kind, "cycle": cycle, "channel": "A", "width": width} + ) + self._low_since_a = None + if self._prev_b == 0 and b == 1 and self._low_since_b is not None: + width = cycle - self._low_since_b + kind = "ossd_pulse" if width <= self.ossd_max_cycles else "trip" + if width < self.ossd_min_cycles: + self._faults.append( + f"cycle {cycle}: CH_B pulse {width} < ossd_min {self.ossd_min_cycles}" + ) + self._events.append( + {"kind": kind, "cycle": cycle, "channel": "B", "width": width} + ) + self._low_since_b = None + + if self._prev_a == 1 and a == 0: + self._low_since_a = cycle + if self._prev_b == 1 and b == 0: + self._low_since_b = cycle + + # also record trip events where both low together - detect on falling edge of either + # use simple check: when both just went low together + if a == 0 and b == 0 and (self._prev_a == 1 or self._prev_b == 1): + # start of a both-low interval; actual classification on rise + pass + + self._prev_a = a + self._prev_b = b + + # drive GPI: high=1, low=0 + word = 0 + if a: + word |= 1 << self.ch_a_pin + if b: + word |= 1 << self.ch_b_pin + return word + + def flush(self, cycle: int) -> None: + """Close any open low at end of run.""" + if self._low_since_a is not None: + width = cycle - self._low_since_a + kind = "ossd_pulse" if width <= self.ossd_max_cycles else "trip" + self._events.append( + {"kind": kind, "cycle": cycle, "channel": "A", "width": width} + ) + self._low_since_a = None + if self._low_since_b is not None: + width = cycle - self._low_since_b + kind = "ossd_pulse" if width <= self.ossd_max_cycles else "trip" + self._events.append( + {"kind": kind, "cycle": cycle, "channel": "B", "width": width} + ) + self._low_since_b = None + + def events(self) -> List[Dict[str, Any]]: + return self._events + + def faults(self) -> List[str]: + return self._faults diff --git a/perif/quadrature_index_encoder.py b/perif/quadrature_index_encoder.py new file mode 100644 index 0000000..969eeaa --- /dev/null +++ b/perif/quadrature_index_encoder.py @@ -0,0 +1,120 @@ +"""Quadrature encoder with index (Z) - DeviceModel. + +Simulates an incremental rotary/linear encoder driving A, B and Z lines into +PRU GPI pins. The PRU only reads; ``pins_out`` is still checked so firmware +that wrongly drives the encoder lines is flagged as a bus-contention fault. + +Motion is scripted as a list of ``(cycles_per_count, n_counts, direction)`` +segments; a count is one x4 quadrature count (one state transition). The Z +(index) output pulses once per ``cpr`` counts of absolute shaft position. + +Events (``events()``): + {"kind": "edge", "cycle", "state", "count", "dir"} + {"kind": "index", "cycle"} +Faults (``faults()``): + - invalid construction parameters + - PRU driving the encoder pins (contention) + - segment requesting faster edges than the tick granularity allows +""" + +from typing import Any, Dict, List, Tuple + +from core.device_model import DeviceModel + +# x4 state table: state = 2*A + B. Forward (CW) order 0,1,3,2,0... +_FWD = {0: 1, 1: 3, 3: 2, 2: 0} +_REV = {v: k for k, v in _FWD.items()} + + +class QuadratureIndexEncoder(DeviceModel): + def __init__( + self, + a_bit: int = 0, + b_bit: int = 1, + z_bit: int = 2, + cpr: int = 1024, + index_width_cycles: int = 40, + pru_gpo_bits: int = 20, + ): + self._faults: List[str] = [] + self._events: List[Dict[str, Any]] = [] + if not (0 <= a_bit < 32 and 0 <= b_bit < 32 and 0 <= z_bit < 32): + self._faults.append("pin indices out of range") + if len({a_bit, b_bit, z_bit}) != 3: + self._faults.append("a_bit, b_bit and z_bit must be distinct") + if cpr <= 0: + self._faults.append("cpr must be positive") + if index_width_cycles <= 0: + self._faults.append("index_width_cycles must be positive") + self.a_bit, self.b_bit, self.z_bit = a_bit, b_bit, z_bit + self.cpr = cpr + self.index_width_cycles = index_width_cycles + self._gpo_mask = (1 << pru_gpo_bits) - 1 + + # motion state + self._segments: List[Tuple[int, int, int]] = [] + self._seg_idx = 0 + self._seg_counts_done = 0 + self._cycles_to_next_edge = 0 + self._state = 0 # 2A+B + self.count = 0 # signed x4 count, absolute + self._z_high_until = -1 # cycle at which Z deasserts + self._done = False + + # ------------------------------------------------------------------ API + def set_motion(self, segments: List[Tuple[int, int, int]]) -> None: + """segments: list of (cycles_per_count, n_counts, direction +1/-1).""" + for cpc, n, d in segments: + if cpc < 4: + self._faults.append( + f"segment cycles_per_count={cpc} too fast for tick granularity") + if n <= 0 or d not in (+1, -1): + self._faults.append("segment must have n_counts>0 and direction +/-1") + self._segments = list(segments) + self._seg_idx = 0 + self._seg_counts_done = 0 + if segments: + self._cycles_to_next_edge = segments[0][0] + + # -------------------------------------------------------------- DeviceModel + def tick(self, cycle: int, pins_out: int) -> int: + contention = pins_out & self._gpo_mask & ( + (1 << self.a_bit) | (1 << self.b_bit) | (1 << self.z_bit)) + if contention: + self._faults.append( + f"cycle {cycle}: PRU drives encoder pins (contention mask 0x{contention:x})") + + if not self._done and self._segments: + self._cycles_to_next_edge -= 1 + if self._cycles_to_next_edge <= 0: + cpc, n, d = self._segments[self._seg_idx] + prev = self._state + self._state = _FWD[prev] if d > 0 else _REV[prev] + self.count += d + self._events.append({ + "kind": "edge", "cycle": cycle, "state": self._state, + "count": self.count, "dir": d, + }) + if self.count % self.cpr == 0 and self.count != 0: + self._z_high_until = cycle + self.index_width_cycles + self._events.append({"kind": "index", "cycle": cycle}) + self._seg_counts_done += 1 + if self._seg_counts_done >= n: + self._seg_idx += 1 + self._seg_counts_done = 0 + if self._seg_idx >= len(self._segments): + self._done = True + else: + cpc = self._segments[self._seg_idx][0] + self._cycles_to_next_edge += cpc + + a = (self._state >> 1) & 1 + b = self._state & 1 + z = 1 if cycle <= self._z_high_until else 0 + return (a << self.a_bit) | (b << self.b_bit) | (z << self.z_bit) + + def events(self) -> List[Dict[str, Any]]: + return self._events + + def faults(self) -> List[str]: + return self._faults diff --git a/perif/spi_adc.py b/perif/spi_adc.py new file mode 100644 index 0000000..10b88eb --- /dev/null +++ b/perif/spi_adc.py @@ -0,0 +1,140 @@ +"""SPI ADC (ADS8688-class, SPI mode 0) - DeviceModel. + +The PRU is the controller (drives SCLK / SDO=MOSI / CS); the ADC is the +peripheral and drives SDI=MISO. Mode 0: clock idles low; the controller +sets MOSI while the clock is low and samples MISO on the rising edge; the +peripheral samples MOSI on the rising edge and advances MISO on the falling +edge. The ADC presents its MSB from the moment CS asserts. + +``conversion`` is the value the ADC shifts out, MSB first, ``bits`` wide. +The ADC also records the MOSI command word it received. + +Faults (what rejects wrong firmware): + - SCLK edge while CS is deasserted (CS high, never asserted) + - SCLK edge with no CS assertion edge since reset (CS never raised + past its reset level, so no frame ever opened) + - frame with other than ``bits`` clocks when CS releases + - more than ``bits`` clocks in a frame + - CS re-asserted mid-frame + (CS low at reset is the reset level, not an assertion edge: a controller + that clocks straight out of reset-low faults with "no CS assertion edge + since reset", not "CS deasserted" - CS is physically LOW at that point.) + +Events: + {"kind": "transaction", "cycle", "miso", "mosi", "bits"} +""" + +from typing import Any, Dict, List + +from core.device_model import DeviceModel + + +class SpiAdc(DeviceModel): + def __init__( + self, + sclk_pin: int = 0, + sdi_pin: int = 1, # PRU reads this (ADC MISO) + sdo_pin: int = 2, # PRU drives this (ADC MOSI) + cs_pin: int = 3, + bits: int = 16, + conversion: int = 0, + miso_idle: int = 0, + msb_first: bool = True, + ): + self._faults: List[str] = [] + self._events: List[Dict[str, Any]] = [] + if len({sclk_pin, sdi_pin, sdo_pin, cs_pin}) != 4: + self._faults.append("sclk/sdi/sdo/cs pins must be distinct") + if not (1 <= bits <= 32): + self._faults.append("bits must be 1..32") + self.sclk_pin = sclk_pin + self.sdi_pin = sdi_pin + self.sdo_pin = sdo_pin + self.cs_pin = cs_pin + self.bits = bits + self.conversion = conversion & ((1 << bits) - 1) + self.miso_idle = miso_idle + self.msb_first = msb_first + + self._last_sclk = 0 + self._last_cs = 1 + self._primed = False # first tick = reset level, not an edge + self._in_frame = False + self._clk_count = 0 # rising edges seen in this frame + self._bit_ptr = 0 # MISO bit index currently driven + self._mosi = 0 + + def _miso(self) -> int: + if not self._in_frame or self._bit_ptr >= self.bits: + return self.miso_idle + if self.msb_first: + return (self.conversion >> (self.bits - 1 - self._bit_ptr)) & 1 + return (self.conversion >> self._bit_ptr) & 1 + + def tick(self, cycle: int, pins_out: int) -> int: + sclk = (pins_out >> self.sclk_pin) & 1 + cs = (pins_out >> self.cs_pin) & 1 + mosi = (pins_out >> self.sdo_pin) & 1 + + if not self._primed: + # The level seen on the first tick is the reset state (PRU R30 + # resets to 0), not a CS edge: a CS low from reset is not a frame + # assertion. Only a later genuine fall opens a frame. + self._primed = True + self._last_cs = cs + + if cs != self._last_cs: + self._last_cs = cs + if cs == 0: + if self._in_frame: + self._faults.append(f"cycle {cycle}: CS re-asserted mid-frame") + self._in_frame = True + self._clk_count = 0 + self._bit_ptr = 0 + self._mosi = 0 + else: + if self._in_frame: + if self._clk_count != self.bits: + self._faults.append( + f"cycle {cycle}: frame released after {self._clk_count} " + f"clocks, expected {self.bits}") + else: + self._events.append({ + "kind": "transaction", "cycle": cycle, + "miso": self.conversion, "mosi": self._mosi, + "bits": self.bits, + }) + self._in_frame = False + + if sclk != self._last_sclk: + self._last_sclk = sclk + if not self._in_frame: + if cs == 0: + # CS never raised past its reset level: no assertion edge + # was ever seen, and CS is physically LOW here. + self._faults.append(f"cycle {cycle}: SCLK edge with no CS assertion edge since reset") + else: + self._faults.append(f"cycle {cycle}: SCLK edge while CS deasserted") + elif sclk == 1: + # rising edge: sample MOSI (controller samples MISO here too, + # so MISO must NOT move on this edge) + if self.msb_first: + self._mosi = ((self._mosi << 1) | mosi) & 0xFFFFFFFF + else: + self._mosi |= mosi << self._clk_count + self._clk_count += 1 + if self._clk_count > self.bits: + self._faults.append( + f"cycle {cycle}: more than {self.bits} clocks in frame") + else: + # falling edge: advance MISO to the next bit + if self._clk_count > 0 and self._bit_ptr < self.bits: + self._bit_ptr += 1 + + return self._miso() << self.sdi_pin + + def events(self) -> List[Dict[str, Any]]: + return self._events + + def faults(self) -> List[str]: + return self._faults diff --git a/perif/ssi_endat_encoder.py b/perif/ssi_endat_encoder.py new file mode 100644 index 0000000..f36804e --- /dev/null +++ b/perif/ssi_endat_encoder.py @@ -0,0 +1,282 @@ +"""SSI / EnDat absolute encoder - DeviceModel. + +Two protocols behind one pin pair (CLK driven by the PRU initiator, DATA +returned by the encoder): + +* ``protocol="ssi"`` - classic SSI frame, MSB first, total_width data clocks + plus one end-of-frame clock (n+1 pulses, per the vendor datasheets). The + first active (falling for CPOL=1) edge latches; each return-to-idle edge + presents a data bit. The active edge after the LSB starts the monoflop and + drives DATA low; DATA is released high again once ``mono_cycles`` have + elapsed after that edge (checked every step, as in the vendor model). + Like real encoders and TI pru-simulator's SsiEncoder, the model + latches on the first active edge and shifts on each return-to-idle edge, so + a master that samples during the active pulse reads the previous bit and a + frame missing its F(n+1) terminator is rejected. It still does not model + analogue setup/hold at the sampling instant; edge-placement evidence is the + pru-simulator SsiEncoder (gmake sim / scripts/sim/run_bound_devices.py). + +* ``protocol="endat"`` - RETIRED by. Its framing (start bit 0, F1/F2 + always, CRC x^5+x^3+1 preset 0) matched no cited source. EnDat has ONE model: + TI pru-simulator's pru_io/endat_encoder.py, framed against HEIDENHAIN ID + 383942 and TI's EnDat driver (docs/endat-crc-provenance.md). Asking for + protocol="endat" here raises ValueError naming it. + +Timing faults (this is what rejects wrong firmware): + - clock half period below ``min_half_period_cycles`` + - more clock pulses in a frame than the frame can carry (n data + 1 end) + - new frame started before the monoflop / recovery time elapsed + +Events: + {"kind": "frame", "cycle", "raw", "st", "mt", "parity_ok", "error_flag"} +""" + +from typing import Any, Dict, List, Optional + + +MISSING_SSI_TERMINATOR = "missing SSI terminator pulse" + +from core.device_model import DeviceModel + + +def _gray(n: int) -> int: + return n ^ (n >> 1) + + +def _degray(g: int) -> int: + n = 0 + while g: + n ^= g + g >>= 1 + return n + + +class SsiEndatEncoder(DeviceModel): + def __init__( + self, + protocol: str = "ssi", + clk_pin: int = 0, + data_pin: int = 1, + cpol: int = 0, + total_width: int = 25, + st_bits: int = 13, + mt_bits: int = 12, + parity_enable: bool = True, + parity_type: int = 0, # 0 = even, 1 = odd + error_bit_enable: bool = True, + gray_code: bool = False, + st_value: int = 0, + mt_value: int = 0, + error_flag: bool = False, + force_bad_parity: bool = False, + min_half_period_cycles: int = 4, + mono_cycles: int = 30, + ): + self._faults: List[str] = [] + self._events: List[Dict[str, Any]] = [] + if protocol == "endat": + raise ValueError( + "EnDat is retired from this model: use pru-simulator's " + "pru_io.endat_encoder.EndatEncoder, the one cited EnDat model") + if protocol not in ("ssi",): + self._faults.append(f"unknown protocol '{protocol}'") + if cpol not in (0, 1): + self._faults.append("cpol must be 0 or 1") + if protocol == "ssi" and st_bits + mt_bits > total_width: + self._faults.append( + f"payload ({st_bits}+{mt_bits}) wider than total_width ({total_width})") + if min_half_period_cycles < 1: + self._faults.append("min_half_period_cycles must be >= 1") + self.protocol = protocol + self.clk_pin = clk_pin + self.data_pin = data_pin + self.cpol = cpol + self.total_width = total_width + self.st_bits = st_bits + self.mt_bits = mt_bits + self.parity_enable = parity_enable + self.parity_type = parity_type + self.error_bit_enable = error_bit_enable + self.gray_code = gray_code + self.st_value = st_value + self.mt_value = mt_value + self.error_flag = error_flag + self.force_bad_parity = force_bad_parity + self.min_half_period_cycles = min_half_period_cycles + self.mono_cycles = mono_cycles + + # runtime state + # The first observed level is pre-run history, not a clock edge. In + # particular R30 resets low before firmware establishes the SSI idle + # level; treating that as F1 would shift the whole word by one pulse. + self._last_clk: Optional[int] = None + self._last_edge_cycle: Optional[int] = None + self._last_active_cycle: Optional[int] = None + self._last_frame_end: Optional[int] = None + self._clocks_in_burst = 0 + self._frame_bits: List[int] = [] + self._overclock_flagged = False + self._ssi_data = 1 + self._ssi_release_cycle: Optional[int] = None # F(n+1) cycle + self._frame = self._build_ssi_frame() if protocol == "ssi" else 0 + + # ------------------------------------------------------------- SSI frame + def _build_ssi_frame(self) -> int: + """Frame layout (MSB first): [MT | ST | error? | parity?], masked to + total_width. Follows the landed oracle (scripts/ssi_oracle.js): + gray applies to the whole ST+MT payload word; parity is even parity of + all frame bits above the parity bit (parity_type 1 inverts).""" + st = self.st_value & ((1 << self.st_bits) - 1) + mt = self.mt_value & ((1 << self.mt_bits) - 1) + payload = (mt << self.st_bits) | st + if self.gray_code: + payload = _gray(payload) & ((1 << (self.st_bits + self.mt_bits)) - 1) + extras = (1 if self.error_bit_enable else 0) + (1 if self.parity_enable else 0) + frame = payload << extras + if self.error_bit_enable: + frame |= (0 if self.error_flag else 1) << (1 if self.parity_enable else 0) + if self.parity_enable: + p = 0 + upper = frame >> 1 + for _ in range(self.total_width - 1): + p ^= upper & 1 + upper >>= 1 + if self.parity_type == 1: + p ^= 1 + if self.force_bad_parity: + p ^= 1 + frame |= p + if self.total_width >= 32: + frame &= 0xFFFFFFFF + else: + frame &= (1 << self.total_width) - 1 + return frame + + # ------------------------------------------------------------------ tick + def tick(self, cycle: int, pins_out: int) -> int: + clk = (pins_out >> self.clk_pin) & 1 + active = 1 - self.cpol + + # Monoflop expiry is time-based, not edge-based (vendor + # pru_io/ssi_encoder.py:190-194): release DATA high on any step. + if (self.protocol == "ssi" and self._ssi_release_cycle is not None + and cycle - self._ssi_release_cycle >= self.mono_cycles): + self._ssi_data = 1 + self._ssi_release_cycle = None + + if self._last_clk is None: + self._last_clk = clk + return self._drive() + + if clk != self._last_clk: + if (clk == active and self._last_edge_cycle is not None + and cycle - self._last_edge_cycle < self.min_half_period_cycles): + self._faults.append( + f"cycle {cycle}: clock half period " + f"{cycle - self._last_edge_cycle} < min {self.min_half_period_cycles}") + self._last_edge_cycle = cycle + if clk == active: + self._ssi_clock(cycle) + else: + self._ssi_shift() + self._last_clk = clk + return self._drive() + + def _drive(self) -> int: + return self._ssi_data << self.data_pin + + # ------------------------------------------------------------------- SSI + def _ssi_clock(self, cycle: int) -> None: + # A burst is a run of clocks whose gaps never exceed mono_cycles + # (physically: the monoflop time is several clock periods, so any + # gap longer than mono_cycles ends the frame). + if (self._last_active_cycle is None + or cycle - self._last_active_cycle > self.mono_cycles): + self._finalize_ssi_burst() + if (self._last_frame_end is not None + and cycle - self._last_frame_end < self.mono_cycles): + self._faults.append( + f"cycle {cycle}: new frame before monoflop time elapsed") + self._clocks_in_burst = 0 + self._frame_bits = [] + self._overclock_flagged = False + self._ssi_data = 1 + self._ssi_release_cycle = None + self._last_active_cycle = cycle + self._clocks_in_burst += 1 + if self._clocks_in_burst == self.total_width + 1: + # Vendor model parity: F1 latches, R1..Rn shift the n data bits, + # and F(n+1) drives DATA low for the monoflop. See + # pru_io/ssi_encoder.py:217-231 in PRU_SIMULATOR_PATH. + self._ssi_data = 0 + self._ssi_release_cycle = cycle + self._emit_ssi_frame(cycle) + return + if self._clocks_in_burst > self.total_width + 1: + if not self._overclock_flagged: + self._overclock_flagged = True + self._faults.append( + f"cycle {cycle}: frame overclocked (> {self.total_width} data " + f"clocks + 1 end-of-frame clock)") + return + + def _ssi_shift(self) -> None: + """Present the next bit on the return-to-idle edge (R1..Rn).""" + if 1 <= self._clocks_in_burst <= self.total_width: + bit = (self._frame >> (self.total_width - self._clocks_in_burst)) & 1 + self._frame_bits.append(bit) + self._ssi_data = bit + + def _finalize_ssi_burst(self) -> None: + """Reject a data word that did not receive its F(n+1) terminator.""" + if self._clocks_in_burst == self.total_width: + self._faults.append( + f"{MISSING_SSI_TERMINATOR} after {self.total_width} data pulses") + elif 0 < self._clocks_in_burst < self.total_width: + self._faults.append( + f"incomplete SSI frame: saw {self._clocks_in_burst - 1} of " + f"{self.total_width} data pulses") + + def _emit_ssi_frame(self, cycle: int) -> None: + raw = 0 + for b in self._frame_bits: + raw = (raw << 1) | b + pos_bits = raw + parity_ok = None + error_flag = None + if self.parity_enable: + parity_bit = pos_bits & 1 + p = 0 + upper = pos_bits >> 1 + for _ in range(self.total_width - 1): + p ^= upper & 1 + upper >>= 1 + if self.parity_type == 1: + p ^= 1 + parity_ok = (parity_bit == p) + pos_bits >>= 1 + if self.error_bit_enable: + error_flag = not (pos_bits & 1) + pos_bits >>= 1 + st = pos_bits & ((1 << self.st_bits) - 1) + mt = (pos_bits >> self.st_bits) & ((1 << self.mt_bits) - 1) + if self.gray_code: + payload = _degray(pos_bits & ((1 << (self.st_bits + self.mt_bits)) - 1)) + st = payload & ((1 << self.st_bits) - 1) + mt = (payload >> self.st_bits) & ((1 << self.mt_bits) - 1) + self._events.append({ + "kind": "frame", "cycle": cycle, "raw": raw, "st": st, "mt": mt, + "parity_ok": parity_ok, "error_flag": error_flag, + }) + self._last_frame_end = cycle + + def flush(self, cycle: int) -> None: + """Finalize an unterminated SSI burst at end of run.""" + if self.protocol == "ssi": + self._finalize_ssi_burst() + + def events(self) -> List[Dict[str, Any]]: + return self._events + + def faults(self) -> List[str]: + return self._faults diff --git a/perif/step_dir_driver.py b/perif/step_dir_driver.py new file mode 100644 index 0000000..ce79734 --- /dev/null +++ b/perif/step_dir_driver.py @@ -0,0 +1,99 @@ +"""STEP/DIR stepper driver input stage - DeviceModel. + +Receives STEP/DIR pulses from the PRU (e.g. NoCodePRU ``stepper_block``) and +tracks shaft position in whole steps. This is the driver-input model: it +checks the timing contract a real driver (DRV8825-class) imposes, which is +what rejects wrong firmware. + +Faults: + - STEP high or low pulse narrower than ``min_pulse_cycles`` + - DIR changed within ``dir_setup_cycles`` before a STEP rising edge + - STEP activity while the driver is disabled (EN low), if en_pin is set + +Events: + {"kind": "step", "cycle", "position", "dir"} +""" + +from typing import Any, Dict, List, Optional + +from core.device_model import DeviceModel + + +class StepDirDriver(DeviceModel): + def __init__( + self, + step_pin: int = 0, + dir_pin: int = 1, + en_pin: Optional[int] = None, + min_pulse_cycles: int = 20, + dir_setup_cycles: int = 10, + dir_positive: int = 1, # DIR level meaning +1 step + ): + self._faults: List[str] = [] + self._events: List[Dict[str, Any]] = [] + if step_pin == dir_pin: + self._faults.append("step_pin and dir_pin must differ") + if min_pulse_cycles < 1: + self._faults.append("min_pulse_cycles must be >= 1") + if dir_positive not in (0, 1): + self._faults.append("dir_positive must be 0 or 1") + self.step_pin = step_pin + self.dir_pin = dir_pin + self.en_pin = en_pin + self.min_pulse_cycles = min_pulse_cycles + self.dir_setup_cycles = dir_setup_cycles + self.dir_positive = dir_positive + + self.position = 0 + self._last_step = 0 + self._step_since = 0 + self._seen_first_low = False + self._last_dir = -1 + self._dir_since = 0 + + def tick(self, cycle: int, pins_out: int) -> int: + step = (pins_out >> self.step_pin) & 1 + d = (pins_out >> self.dir_pin) & 1 + en = (pins_out >> self.en_pin) & 1 if self.en_pin is not None else 1 + + if d != self._last_dir: + self._last_dir = d + self._dir_since = cycle + + if step == self._last_step: + return 0 + + width = cycle - self._step_since + if step == 1: + # rising edge of STEP: one micro-step is taken here + if self._seen_first_low and width < self.min_pulse_cycles: + self._faults.append( + f"cycle {cycle}: STEP low pulse {width} < min {self.min_pulse_cycles}") + if self.en_pin is not None and en == 0: + self._faults.append(f"cycle {cycle}: STEP while driver disabled") + if cycle - self._dir_since < self.dir_setup_cycles: + self._faults.append( + f"cycle {cycle}: DIR setup {cycle - self._dir_since} " + f"< min {self.dir_setup_cycles}") + direction = 1 if d == self.dir_positive else -1 + self.position += direction + self._events.append({ + "kind": "step", "cycle": cycle, + "position": self.position, "dir": direction, + }) + else: + # falling edge: completed high pulse + if width < self.min_pulse_cycles: + self._faults.append( + f"cycle {cycle}: STEP high pulse {width} < min {self.min_pulse_cycles}") + self._seen_first_low = True + + self._last_step = step + self._step_since = cycle + return 0 # open-loop driver: nothing to feed back + + def events(self) -> List[Dict[str, Any]]: + return self._events + + def faults(self) -> List[str]: + return self._faults diff --git a/perif/ws2812_string.py b/perif/ws2812_string.py new file mode 100644 index 0000000..3185352 --- /dev/null +++ b/perif/ws2812_string.py @@ -0,0 +1,138 @@ +"""WS2812 addressable LED string - DeviceModel. + +Receives the NRZ data stream the PRU drives on its GPO pin and decodes it +into GRB pixels. Timing is checked against the WS2812 datasheet windows +(scaled to PRU cycles); anything outside the windows is a fault, which is +what rejects wrong firmware (wrong bit timing, truncated reset, overlong +frame). + +Default timings use the datasheet nominal values at ``ns_per_cycle`` ns per +PRU cycle (5 ns at 200 MHz): + T0H 400 ns, T1H 800 ns, T0L 850 ns, T1L 450 ns, RES > 50 us low. +Tolerance defaults to +/-150 ns per pulse, plus the requirement that a +'1' high time is clearly longer than a '0' high time. + +Events: + {"kind": "bit", "cycle", "value"} + {"kind": "frame", "cycle", "pixels": [0xRRGGBB ...]} +""" + +from typing import Any, Dict, List + +from core.device_model import DeviceModel + + +class Ws2812String(DeviceModel): + def __init__( + self, + data_pin: int = 0, + num_leds: int = 1, + ns_per_cycle: float = 5.0, + t0h_ns: float = 400.0, + t0l_ns: float = 850.0, + t1h_ns: float = 800.0, + t1l_ns: float = 450.0, + tol_ns: float = 220.0, + reset_ns: float = 50000.0, + max_bits: int = 24 * 64, + ): + self._faults: List[str] = [] + self._events: List[Dict[str, Any]] = [] + if num_leds <= 0: + self._faults.append("num_leds must be positive") + if ns_per_cycle <= 0: + self._faults.append("ns_per_cycle must be positive") + self.data_pin = data_pin + self.num_leds = num_leds + self.max_bits = max_bits + c = ns_per_cycle + self._t0h = t0h_ns / c + self._t0l = t0l_ns / c + self._t1h = t1h_ns / c + self._t1l = t1l_ns / c + self._tol = tol_ns / c + self._reset_cycles = reset_ns / c + + self._last_level = 0 + self._level_since = 0 # cycle of last edge + self._high_cycles = 0 + self._bits: List[int] = [] + self._latched = True # start in reset state + + def _classify_high(self, cycles: int) -> int: + """0/1 if within a window, else -1.""" + if abs(cycles - self._t0h) <= self._tol: + return 0 + if abs(cycles - self._t1h) <= self._tol: + return 1 + return -1 + + def _classify_low(self, cycles: int) -> bool: + return (abs(cycles - self._t0l) <= self._tol + or abs(cycles - self._t1l) <= self._tol) + + def tick(self, cycle: int, pins_out: int) -> int: + level = (pins_out >> self.data_pin) & 1 + if level == self._last_level: + return 0 + width = cycle - self._level_since + + if self._last_level == 1: + # falling edge: a complete high pulse + bit = self._classify_high(width) + self._high_cycles = width + if bit < 0: + self._faults.append( + f"cycle {cycle}: high pulse {width} cycles matches neither " + f"T0H ({self._t0h:.0f}) nor T1H ({self._t1h:.0f})") + else: + self._bits.append(bit) + self._events.append({"kind": "bit", "cycle": cycle, "value": bit}) + if len(self._bits) > self.max_bits: + self._faults.append( + f"cycle {cycle}: overlong frame (> {self.max_bits} bits without reset)") + self._latched = False + else: + # rising edge: low gap ended + if width >= self._reset_cycles: + if self._bits: + self._latch(cycle) + self._latched = True + elif not self._latched: + if not self._classify_low(width): + self._faults.append( + f"cycle {cycle}: low gap {width} cycles matches neither " + f"T0L ({self._t0l:.0f}) nor T1L ({self._t1l:.0f}) " + f"and is shorter than reset ({self._reset_cycles:.0f})") + + self._last_level = level + self._level_since = cycle + return 0 # WS2812 drives nothing back + + def _latch(self, cycle: int) -> None: + if len(self._bits) % 24 != 0: + self._faults.append( + f"cycle {cycle}: frame of {len(self._bits)} bits is not a multiple of 24") + pixels = [] + for i in range(0, len(self._bits) - 23, 24): + w = 0 + for b in self._bits[i:i + 24]: + w = (w << 1) | b + g = (w >> 16) & 0xFF + r = (w >> 8) & 0xFF + bch = w & 0xFF + pixels.append((r << 16) | (g << 8) | bch) + self._events.append({"kind": "frame", "cycle": cycle, "pixels": pixels}) + self._bits = [] + + def flush(self, cycle: int) -> None: + """End-of-run: a trailing low level longer than reset latches the frame.""" + if self._last_level == 0 and self._bits \ + and cycle - self._level_since >= self._reset_cycles: + self._latch(cycle) + + def events(self) -> List[Dict[str, Any]]: + return self._events + + def faults(self) -> List[str]: + return self._faults diff --git a/pru_io/__init__.py b/pru_io/__init__.py new file mode 100644 index 0000000..a6f954c --- /dev/null +++ b/pru_io/__init__.py @@ -0,0 +1,7 @@ +"""pru_io package. + +the EnDat model that lived here was retired by EnDat has one +model, TI pru-simulator's pru_io/endat_encoder.py. +""" + +from .waveform_player import WaveformPlayer # noqa: F401 diff --git a/pru_io/waveform_player.py b/pru_io/waveform_player.py new file mode 100644 index 0000000..ade1a08 --- /dev/null +++ b/pru_io/waveform_player.py @@ -0,0 +1,185 @@ +"""WaveformPlayer — cycle-keyed R31 stimulus as a DeviceModel.. + +WHY +--- +Injecting GPI stimulus by writing ``io_port.gpi`` (or ``set_gpi_word``) +bypasses the device bus entirely: nothing resolves the injected word against +what the PRU or another device drives on those pins, so a stimulus that +shorts against a device model looks identical to one that does not, and the +applied points never appear in ``events()`` to be diffed against the +bus-cosim decoder. This module is that missing stimulus device. + +CYCLES, NOT STEPS +----------------- +The map is keyed by PRU CYCLE — the value ``counters.cycles`` has when the +bus settles — not by instruction index. Under any multi-cycle instruction +(SBBO with stalls, loops, delayed branches) ``cycles`` advances more than +one per step, so a step-indexed and a cycle-indexed waveform are different +experiments. Keying by step silently turns a multi-cycle run into a +time-shifted one; keying by cycle is what the hardware would see. The core +settles the bus at ``counters.cycles`` (pru_core.py, post-instruction +settle), so a player attached through ``DeviceBus`` is cycle-keyed by +construction. + +HOLD SEMANTICS +-------------- +A value applies from its cycle until the next entry replaces it. If a tick +crosses several entries in one call (a multi-cycle instruction advanced the +clock past them), every crossed entry is applied in order and each is +recorded as an event. + +EXHAUSTION IS A FAULT +--------------------- +Running past the last entry — the waveform exhausted before the program +halted — raises a real fault, not a payload flag. Stimulus beyond the end of +the waveform is unspecified; a run that keeps executing on held inputs looks +green while proving nothing, so it must not be ignorable. The last value is +still HELD on the pins (that is what pull-ups and latches do), but the fault +stands. + +WIRE CONTRACT +------------- +Implements the TI pru-simulator ``DeviceBus`` device contract: ``tick`` +returns ``(drive_mask, drive_values)``, ``nets`` declares the driven pins +(push-pull: a stimulus source colliding with another driver is contention, +which the bus records as a fault rather than silently resolving), and +``time_driven = True`` because the player advances with the clock, not with +pin activity. + +Accepts both emitter shapes: the wrapped ``{"gpi_word_by_cycle": {...}}`` +form and the bare ``{cycle: value}`` map. Keys may be ints or numeric +strings (JSON objects arrive string-keyed). +""" + +from typing import Any, Dict, List, Optional, Tuple + +MASK_20 = (1 << 20) - 1 +PUSH_PULL = "push_pull" + +try: # this repo's ABC + from core.device_model import DeviceModel # type: ignore +except ImportError: # pragma: no cover - loaded inside the TI pru-simulator + from pru_io.device_model import DeviceModel # type: ignore + + +class WaveformPlayer(DeviceModel): + #: advances with the clock, not with pin activity + time_driven = True + + def __init__(self, stimulus: Optional[Dict[Any, int]] = None, *, + pins: Optional[List[int]] = None, + gpi_word_by_cycle: Optional[Dict[Any, int]] = None, + name: str = "waveform_player"): + if stimulus is not None and gpi_word_by_cycle is not None: + raise ValueError("pass either stimulus= or gpi_word_by_cycle=, not both") + data = stimulus if stimulus is not None else gpi_word_by_cycle + if data is None: + data = {} + if isinstance(data, dict) and set(data.keys()) == {"gpi_word_by_cycle"}: + self.shape = "wrapped" + data = data["gpi_word_by_cycle"] + else: + self.shape = "bare" + if not isinstance(data, dict) or not data: + raise ValueError( + "waveform must be a non-empty {cycle: word} map or " + '{"gpi_word_by_cycle": {...}}') + + points: List[Tuple[int, int]] = [] + for k, v in data.items(): + try: + cyc = int(k) + except (TypeError, ValueError): + raise ValueError(f"waveform key {k!r} is not a cycle number") from None + if cyc < 0: + raise ValueError(f"waveform cycle {cyc} is negative") + try: + word = int(v) + except (TypeError, ValueError): + raise ValueError(f"waveform value at cycle {cyc} is not an integer") from None + points.append((cyc, word & MASK_20)) + points.sort() + self._points = points + + if pins is not None: + for p in pins: + if p < 0 or p > 19: + raise ValueError(f"pin {p} out of range 0..19") + driven = sorted(set(pins)) + else: + # every bit any word asserts; a bit the waveform never sets is + # never driven (it stays with whatever else owns it) + driven = sorted({b for _, w in points for b in range(20) if w & (1 << b)}) + if not driven: + raise ValueError( + "waveform asserts no bits and no pins= given; " + "such a player drives nothing") + self.pins: List[int] = driven + self.nets: Dict[int, str] = {p: PUSH_PULL for p in driven} + self.drive_mask = sum(1 << p for p in driven) + self.name = name + + self._events: List[Dict[str, Any]] = [] + self._faults: List[str] = [] + self._idx = 0 + self._current: Optional[int] = None + self._exhausted = False + + # -- DeviceBus wire contract ------------------------------------------- + + def tick(self, cycle: int, bus: int) -> Tuple[int, int]: + """Advance to *cycle*. Returns (drive_mask, drive_values). + + HOLD: the newest entry at or before *cycle* applies. Entries the + clock crossed since the last tick are applied (and recorded) in + order, so a multi-cycle instruction cannot swallow a point. + """ + while self._idx < len(self._points) and self._points[self._idx][0] <= cycle: + cyc, word = self._points[self._idx] + self._current = word + self._idx += 1 + self._events.append({"kind": "gpi_word", "cycle": cyc, "value": word}) + if self._points and cycle > self._points[-1][0] and not self._exhausted: + self._exhausted = True + self._faults.append( + f"cycle {cycle}: waveform exhausted before halt " + f"(last entry at cycle {self._points[-1][0]}; " + f"holding its value — inputs past this point are unspecified)") + if self._current is None: + return 0, 0 + return self.drive_mask, self._current + + def events(self) -> List[Dict[str, Any]]: + return list(self._events) + + def faults(self) -> List[str]: + return list(self._faults) + + @property + def exhausted(self) -> bool: + return self._exhausted + + # -- codebase convention: reset / snapshot / restore / get_state -------- + + def reset(self) -> None: + self._events.clear() + self._faults.clear() + self._idx = 0 + self._current = None + self._exhausted = False + + def snapshot(self) -> dict: + return {"idx": self._idx, "current": self._current, + "exhausted": self._exhausted} + + def restore(self, snap: dict) -> None: + self._idx = snap["idx"] + self._current = snap["current"] + self._exhausted = snap["exhausted"] + + def get_state(self) -> dict: + return {"name": self.name, "pins": list(self.pins), + "points": len(self._points), + "applied": self._idx, + "exhausted": self._exhausted, + "faults": len(self._faults)} diff --git a/scripts/asm_label.js b/scripts/asm_label.js new file mode 100644 index 0000000..b5f6d53 --- /dev/null +++ b/scripts/asm_label.js @@ -0,0 +1,31 @@ +'use strict'; +/** + * asm_label.js - one shared splitter for `label: instr` lines. + * + * The TI PRU assembler (clpru) accepts a label followed by an instruction on + * the same line (e.g. `pb_ws?: sub r1, r1, 1`). Several JS macro expanders + * used to treat a line as a label only when the whole trimmed line ended with + * ':', so such a line was misread as an instruction, the label was never + * defined, and a later branch to it had an unresolved target. + * + * splitLabel(line) -> null when the line carries no code (blank or + * comment-only), otherwise { label, rest } where `label` is the raw label + * text or null and `rest` is the instruction text after the label ('' when the + * line is a label alone). Callers apply their own `?` normalisation to both + * halves. A label is an identifier ([A-Za-z0-9_?$]+) immediately followed by + * ':' at the start of the trimmed line; a ':' inside an operand or after a + * ';' comment is never a label. + */ +function splitLabel(line) { + const t = String(line).trim(); + if (!t || t.startsWith(';')) return null; + let code = t; + const semi = code.indexOf(';'); + if (semi !== -1) code = code.slice(0, semi).trim(); + if (!code) return null; + const m = code.match(/^([A-Za-z0-9_?$]+):(.*)$/); + if (!m) return { label: null, rest: code }; + return { label: m[1], rest: m[2].trim() }; +} + +module.exports = { splitLabel }; diff --git a/scripts/dual_oracle_harness.js b/scripts/dual_oracle_harness.js new file mode 100644 index 0000000..8d5a162 --- /dev/null +++ b/scripts/dual_oracle_harness.js @@ -0,0 +1,683 @@ +/** + * dual_oracle_harness.js - Dual-Oracle Verification Harness + * + * Uses TI pru-simulator as an independent second simulator: runs block clpru ELFs under TI pru-simulator and + * compares the result against the in-tool JS simulator. Also provides the block loader and macro expander that the + * scripts/sim tools use. + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync, spawnSync } = require('child_process'); +const vm = require('vm'); +const { splitLabel } = require('./asm_label'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const SIM_DIR = path.join(REPO_ROOT, '.metadata/sysconfig/.meta/pru_blocks/common/simulation'); +const WORKSPACE_TOOLS_DIR = path.resolve(REPO_ROOT, '../../tools'); +const DEFAULT_SIMULATOR_DIR = path.join(WORKSPACE_TOOLS_DIR, 'pru-simulator-upstream'); +const BUILD_TMP_DIR = path.join(REPO_ROOT, 'build_out/dual_oracle_tmp'); + +// Find clpru compiler binary +function findClpru() { + if (process.env.CGT_TI_PRU_PATH) { + const p = path.join(process.env.CGT_TI_PRU_PATH, 'bin', process.platform === 'win32' ? 'clpru.exe' : 'clpru'); + if (fs.existsSync(p)) return p; + } + const defaultPaths = [ + 'C:/ti/ti-cgt-pru_2.3.3/bin/clpru.exe', + 'C:/ti/ti-cgt-pru_2.3.3/bin/clpru', + '/opt/ti/ti-cgt-pru_2.3.3/bin/clpru' + ]; + for (const p of defaultPaths) { + if (fs.existsSync(p)) return p; + } + // Check PATH + try { + const cmd = process.platform === 'win32' ? 'where.exe clpru' : 'which clpru'; + const res = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim().split('\n')[0].trim(); + if (res && fs.existsSync(res)) return res; + } catch (_) {} + throw new Error("clpru compiler not found. Set CGT_TI_PRU_PATH or install ti-cgt-pru_2.3.3."); +} + +// Find Python binary +function findPython() { + if (process.env.PYTHON && fs.existsSync(process.env.PYTHON)) return process.env.PYTHON; + const candidates = ['python', 'python3', 'py']; + for (const cmd of candidates) { + try { + const res = spawnSync(cmd, ['--version'], { encoding: 'utf8' }); + if (res.status === 0) return cmd; + } catch (_) {} + } + return 'python'; +} + +// Find TI pru-simulator repository directory: PRU_SIMULATOR_PATH, else the first +// /tools/pru-simulator-upstream of the repository, at any depth. A directory +// only counts when it holds simulator.py; a directory with the right name but no +// simulator in it is skipped. +function isSimulatorCheckout(dir) { + return fs.existsSync(path.join(dir, 'simulator.py')); +} + +function findSimulatorDir() { + const tried = []; + const env = process.env.PRU_SIMULATOR_PATH; + if (env) { + tried.push(env); + if (isSimulatorCheckout(env)) return env; + } + tried.push(DEFAULT_SIMULATOR_DIR); + if (isSimulatorCheckout(DEFAULT_SIMULATOR_DIR)) return DEFAULT_SIMULATOR_DIR; + let dir = REPO_ROOT; + while (true) { + const candidate = path.join(dir, 'tools', 'pru-simulator-upstream'); + tried.push(candidate); + if (isSimulatorCheckout(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error( + 'TI pru-simulator not found (looked for simulator.py in each). ' + + 'Set PRU_SIMULATOR_PATH. Tried: ' + tried.join(' | ') + ); +} + +// Load JS simulation core in isolated context +const scriptCache = {}; +function loadSimScript(scriptPath) { + const code = fs.readFileSync(scriptPath, 'utf8'); + const sandbox = { + console: console, + Math: Math, + parseInt: parseInt, + isNaN: isNaN, + eval: eval, + Uint8Array: Uint8Array, + Array: Array, + JSON: JSON, + system: { + getScript: (p) => { + const basename = path.basename(p); + if (!scriptCache[basename]) { + scriptCache[basename] = loadSimScript(path.join(SIM_DIR, basename)); + } + return scriptCache[basename]; + } + }, + exports: {} + }; + vm.createContext(sandbox); + vm.runInContext(code, sandbox); + return sandbox.exports; +} + +const pruCore = loadSimScript(path.join(SIM_DIR, 'pru_core.js')); +const pruDMEM0 = scriptCache['pru_dmem0.js']; + +/** + * Universal non-throwing proxy for SysConfig modules + */ +function makeStub() { + const fn = function () { return makeStub(); }; + return new Proxy(fn, { + get(_t, prop) { + if (prop === Symbol.toPrimitive || prop === 'toString' || prop === 'valueOf') return () => ''; + if (prop === Symbol.iterator) return undefined; + if (prop === 'then') return undefined; + return makeStub(); + }, + apply() { return makeStub(); } + }); +} + +/** + * Loads a block module in a Node.js sandbox environment + */ +function loadBlockModule(blockName, blockSubdir) { + const file = path.join(REPO_ROOT, `.metadata/sysconfig/.meta/pru_blocks/${blockSubdir}/${blockName}.syscfg.js`); + const src = fs.readFileSync(file, 'utf8'); + const sandbox = { + system: makeStub(), + exports: {}, + module: { exports: {} }, + require: () => makeStub(), + console: { log() {}, error() {}, warn() {} } + }; + sandbox.global = sandbox; + vm.createContext(sandbox); + vm.runInContext(src, sandbox, { filename: file, timeout: 5000 }); + const mod = (sandbox.exports && Object.keys(sandbox.exports).length) ? sandbox.exports + : (sandbox.module.exports && Object.keys(sandbox.module.exports).length) ? sandbox.module.exports + : sandbox.exports; + return mod; +} + +/** + * Drops a trailing `; comment` (a `;` outside double quotes) from an assembler line. + */ +function stripAsmComment(text) { + let inQuote = false; + for (let i = 0; i < text.length; i++) { + if (text[i] === '"') inQuote = !inQuote; + else if (text[i] === ';' && !inQuote) return text.slice(0, i).trimEnd(); + } + return text; +} + +/** + * Evaluates a numeric-only expression (decimal/hex literals, + - * / % & | << >> ( ) and the + * comparison operators; a single `=` is equality, as in the TI assembler). Returns null when + * any token is not a number, so a leftover register/symbol name is never guessed at. + */ +function evalNumericExpression(text) { + const expr = String(text).trim(); + if (!expr) return null; + const tokens = expr.match(/0x[0-9a-f]+|\d+|<<|>>|<=|>=|==|!=|&&|\|\||[-+*\/%&|^~!<>()=]|\S/gi); + if (!tokens || tokens.join('') !== expr.replace(/\s+/g, '')) return null; + for (const t of tokens) { + if (!/^(0x[0-9a-f]+|\d+|<<|>>|<=|>=|==|!=|&&|\|\||[-+*\/%&|^~!<>()=])$/i.test(t)) return null; + } + const js = tokens.map(t => t === '=' ? '==' : t).join(' '); + try { + const v = Function('"use strict"; return (' + js + ');')(); + return typeof v === 'boolean' ? (v ? 1 : 0) : (Number.isFinite(v) ? v : null); + } catch (e) { return null; } +} + +/** + * Like evalCondition but also reports whether the condition could be decided. + * Returns { value, decided } (plus `reason` when undecided). An undecidable condition has value false, but the + * caller can now see that, instead of the branch silently vanishing. + * + * `definedSymbols` makes the symbol-table guards decidable. Either a Set (used for both + * guard kinds) or { symbols: Set, substitutions: Set }: `$isdefed("X")` tests `.set/.equ` + * symbols only, `$defined(X)` also sees `.asg` substitution symbols (verified against clpru + * 2.3.3: an `.asg` name is `$defined` but not `$isdefed`; unquoted `$isdefed(X)` is the + * assembler error E0301, so only the quoted form is accepted). + */ +function evalConditionDetailed(rawCond, mapping, definedSymbols) { + // `.if (a == b) ; rising`: the comment must go before the paren strip, + // else the trailing text keeps the outer parens from balancing and the whole + // condition is mis-split. + // Decode first: `<` itself contains a semicolon, which must not be + // mistaken for the beginning of an assembler comment. + let cond = stripAsmComment(String(rawCond).replace(/</gi, '<').replace(/>/gi, '>')).trim(); + // `.if (a == b)`: strip one balanced outer pair, otherwise the split below + // compares "(a" with "b)" and every == is false, every != true. + while (cond.startsWith('(') && cond.endsWith(')')) { + let depth = 0, wraps = true; + for (let k = 0; k < cond.length - 1; k++) { + if (cond[k] === '(') depth++; + else if (cond[k] === ')') depth--; + if (depth === 0) { wraps = false; break; } + } + if (!wraps) break; + cond = cond.slice(1, -1).trim(); + } + // Before the substitution below, which would rewrite the quoted name. + // `$isdefed("SYM")` / `$defined(SYM)` are decidable only for a caller that tracks assembler + // symbol definitions (emit_all_blocks.js). Without a symbol set they stay undecidable. + const dm = cond.match(/^(!?)\s*\$(isdefed|defined)\(\s*("([^"]+)"|[A-Za-z_]\w*)\s*\)$/); + if (dm) { + const quoted = dm[3].startsWith('"'); + if (!definedSymbols || (dm[2] === 'isdefed' && !quoted)) return { value: false, decided: false }; + const sets = definedSymbols instanceof Set + ? { symbols: definedSymbols, substitutions: definedSymbols } : definedSymbols; + const name = quoted ? dm[4] : dm[3]; + const has = sets.symbols.has(name) || (dm[2] === 'defined' && sets.substitutions.has(name)); + return { value: has !== (dm[1] === '!'), decided: true }; + } + for (const [pName, val] of Object.entries(mapping)) { + cond = cond.replace(new RegExp('\\b' + pName + '\\b', 'g'), val); + } + const num = evalNumericExpression(cond); + if (num !== null) return { value: num !== 0, decided: true }; + // After substitution anything still alphabetic is an unbound register or symbol + // (`R12`, `(R12 + 1)`, `R12 & 1`, `SAMPLE_HIGH`): the guard cannot be decided, whatever + // shape it has. Quoted strings and the words true/false are not unbound operands. + const residual = cond.replace(/"[^"]*"/g, '').match(/\b[A-Za-z_]\w*/g) || []; + const unbound = residual.filter(t => !/^(true|false)$/i.test(t)); + for (const op of ['==', '!=']) { + if (!cond.includes(op)) continue; + const parts = cond.split(op).map(p => p.trim()); + // An unbound operand keeps the historical string-compare guess (`R12 != 1` -> true) so + // block coverage is unchanged, but is flagged undecided for the caller to report. + const value = op === '==' ? parts[0] === parts[1] : parts[0] !== parts[1]; + return unbound.length === 0 ? { value, decided: true } + : { value, decided: false, reason: 'unbound operand(s): ' + [...new Set(unbound)].join(', ') }; + } + if (cond.toLowerCase() === 'true') return { value: true, decided: true }; + if (cond.toLowerCase() === 'false') return { value: false, decided: true }; + return { value: false, decided: false, reason: unbound.length ? 'unbound operand(s): ' + [...new Set(unbound)].join(', ') : 'unsupported expression' }; +} + +/** + * Evaluates the condition of an `.if` / `.elseif` line (text after the directive) as the + * assembler would, with macro parameters substituted from `mapping`. An undecidable + * condition reads false; use evalConditionDetailed to tell the difference. + */ +function evalCondition(rawCond, mapping, definedSymbols) { + return evalConditionDetailed(rawCond, mapping, definedSymbols).value; +} + +/** + * Preprocesses and expands a PRU assembly macro by replacing its parameters. + */ +function expandMacro(macroStr, argsMap) { + const lines = macroStr.split('\n'); + const expandedLines = []; + + let macroHeaderIndex = -1; + let params = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line.includes('.macro')) { + macroHeaderIndex = i; + const parts = line.split('.macro'); + params = parts[1].split(',').map(p => p.trim()); + break; + } + } + + if (macroHeaderIndex === -1) { + throw new Error("Could not find .macro definition in macro string"); + } + + const paramNames = params; + const mapping = {}; + const registersMap = { + 'TEMP_REG1': 'R28', + 'TEMP_REG2': 'R29', + 'TEMP_REG3': 'R26', + 'TEMP_REG4': 'R27' + }; + + for (let i = 0; i < paramNames.length; i++) { + const pName = paramNames[i]; + if (argsMap[pName] !== undefined) { + mapping[pName] = argsMap[pName]; + } else if (argsMap[i] !== undefined) { + mapping[pName] = argsMap[i]; + } + } + + const bodyLines = lines.slice(macroHeaderIndex + 1); + const condStack = [{ active: true, taken: true, parent: true }]; + + for (let line of bodyLines) { + const trimmed = line.trim(); + + if (trimmed.startsWith('.endm')) { + break; + } + + if (trimmed.startsWith('.if ')) { + const parentActive = condStack[condStack.length - 1].active; + const isTrue = evalCondition(trimmed.substring(4), mapping); + condStack.push({ active: parentActive && isTrue, taken: isTrue, parent: parentActive }); + continue; + } + + if (trimmed.startsWith('.elseif')) { + const top = condStack[condStack.length - 1]; + const isTrue = !top.taken && evalCondition(trimmed.substring(7), mapping); + top.active = top.parent && isTrue; + top.taken = top.taken || isTrue; + continue; + } + + if (trimmed.startsWith('.else')) { + const top = condStack[condStack.length - 1]; + top.active = top.parent && !top.taken; + top.taken = true; + continue; + } + + if (trimmed.startsWith('.endif')) { + condStack.pop(); + continue; + } + + if (condStack[condStack.length - 1].active) { + let processedLine = line; + for (const [pName, val] of Object.entries(mapping)) { + processedLine = processedLine.replace(new RegExp('\\b' + pName + '\\b', 'g'), val); + } + for (const [tempName, reg] of Object.entries(registersMap)) { + if (mapping[tempName] === undefined) { + processedLine = processedLine.replace(new RegExp('\\b' + tempName + '\\b', 'g'), reg); + } + } + + processedLine = processedLine.replace(/\bsine_lut\b/g, '0'); + expandedLines.push(processedLine); + } + } + + const uniqueId = Math.floor(Math.random() * 1000000); + const finalLines = expandedLines.map(line => { + return line.replace(/\?/g, `_${uniqueId}`); + }); + + return finalLines; +} + +/** + * Compiles PRU assembly text to a real ELF using clpru and linker. + */ +function compileAssemblyToElf(assemblyText, testId = 'test') { + if (!fs.existsSync(BUILD_TMP_DIR)) { + fs.mkdirSync(BUILD_TMP_DIR, { recursive: true }); + } + + const asmFile = path.join(BUILD_TMP_DIR, `${testId}.asm`); + const cmdFile = path.join(BUILD_TMP_DIR, `${testId}.cmd`); + const outFile = path.join(BUILD_TMP_DIR, `${testId}.out`); + + const linkerCmd = ` +MEMORY { + PAGE 0: PRU_IMEM : org = 0x00000000 len = 0x00004000 + PAGE 1: PRU0_DMEM : org = 0x00000000 len = 0x00002000 +} +SECTIONS { + .text > PRU_IMEM, PAGE 0 + .data > PRU0_DMEM, PAGE 1 + .bss > PRU0_DMEM, PAGE 1 +} +`; + fs.writeFileSync(asmFile, assemblyText, 'utf8'); + fs.writeFileSync(cmdFile, linkerCmd, 'utf8'); + + const clpruBin = findClpru(); + const args = ['-v3', '-O2', asmFile, '-z', cmdFile, '-o', outFile, '--entry_point=main']; + + const compileRes = spawnSync(clpruBin, args, { encoding: 'utf8' }); + if (compileRes.status !== 0) { + const errorMsg = (compileRes.stdout || '') + '\n' + (compileRes.stderr || ''); + return { + success: false, + error: errorMsg.trim(), + asmFile, + outFile: null + }; + } + + return { + success: true, + error: null, + asmFile, + outFile + }; +} + +/** + * Runs an ELF file under TI pru-simulator headless runner. + */ +function runTiSimulatorElf(elfPath, options = {}) { + const pythonBin = findPython(); + const simDir = findSimulatorDir(); + const core = options.core || 'pru0'; + const maxSteps = options.maxSteps || 100000; + const until = options.until || 'halt'; + + const args = [ + '-m', 'tools.headless_runner', + '--elf', elfPath, + '--core', core, + '--until', until, + '--max-steps', String(maxSteps), + '--json' + ]; + + const runRes = spawnSync(pythonBin, args, { + cwd: simDir, + encoding: 'utf8' + }); + + try { + const payload = JSON.parse(runRes.stdout); + return { + exitCode: runRes.status, + payload + }; + } catch (e) { + return { + exitCode: runRes.status, + error: `Failed to parse TI simulator JSON: ${e.message}\nRaw stdout: ${runRes.stdout}\nStderr: ${runRes.stderr}`, + payload: null + }; + } +} + +/** + * One shared .asm formatter. A line carrying a label (bare `lbl:` + * or same-line `label: instr`) is emitted with the label in column 1 and the + * instruction (if any) after it on the same line; anything else is indented. + * clpru rejects ANY indented label (E0003 'Invalid instruction'), so the old + * `endsWith(':')` test -- which indented `label: instr` -- broke same-line + * labels. A trailing `;` comment is preserved verbatim. + */ +function formatAsmLine(l) { + const t = String(l).trim(); + if (!t) return ''; + const parts = splitLabel(l); + if (!parts || !parts.label) return ` ${t}`; + const semi = t.indexOf(';'); + const comment = semi !== -1 ? ` ${t.slice(semi).trim()}` : ''; + return parts.rest ? `${parts.label}: ${parts.rest}${comment}` : `${parts.label}:${comment}`; +} + +/** + * The exact asm text executeDualOracle feeds to clpru, factored out + * so a test can drive the real formatting call sites (formatAsmLine over + * initAsm and bodyAsm) without needing clpru or the TI pru-simulator. + * executeDualOracle calls THIS function - there is no second formatter. + */ +function buildElfAssemblyText(initAsm, bodyAsm) { + return [ + '; Auto-generated Dual-Oracle PRU Assembly Test', + ' .global main', + 'main:', + ...initAsm.map(formatAsmLine), + ...bodyAsm.map(formatAsmLine), + ' halt', + '' + ].join('\n'); +} + +/** + * Runs JS simulation in pru_core.js. + */ +function runJsSimulation(rawInstructions, maxCycles = 100, r31Map = {}, mmioMap = {}) { + const processedInstructions = []; + const labels = []; + for (let line of rawInstructions) { + // Accept `label: instr` on one line (label defined at that + // instruction); `label:` alone keeps the historical nop padding behaviour. + const parts = splitLabel(line); + if (!parts) continue; + if (parts.label && !parts.rest) { + processedInstructions.push("nop"); + labels.push(parts.label); + } else if (parts.label) { + processedInstructions.push(parts.rest); + labels.push(parts.label); + } else { + processedInstructions.push(parts.rest); + labels.push(0); + } + } + return pruCore.simulatePruInstructions(processedInstructions, labels, maxCycles, r31Map, mmioMap); +} + +/** + * Core Dual-Oracle Execution and Differential Checker + * + * Executes the given instruction sequence / macro under BOTH: + * Oracle 1: In-tool JavaScript Simulator (Fast Pre-check) + * Oracle 2: TI pru-simulator executing the clpru-compiled real ELF + * + * Asserts full differential agreement. Disagreement is loud and fails. + */ +function executeDualOracle(testConfig) { + const { + testId = `test_${Date.now()}_${Math.floor(Math.random() * 1000)}`, + name = 'unnamed_test', + initialRegisters = {}, + instructions = [], + macro = null, + macroArgs = {}, + maxCycles = 5000, + maxSteps = 10000, + r31Map = {}, + mmioMap = {}, + expectedRegisters = null, + // injectable so a test can drive the REAL + // executeDualOracle path (including the buildElfAssemblyText call + // site below) and capture the exact asm handed to the compiler, + // without needing clpru or the TI pru-simulator. + compileElf = compileAssemblyToElf, + runTiElf = runTiSimulatorElf + } = testConfig; + + // 1. Build complete assembly instruction list + const initAsm = []; + for (let [reg, val] of Object.entries(initialRegisters)) { + const regName = reg.toUpperCase(); + initAsm.push(`ldi32 ${regName}, 0x${(val >>> 0).toString(16)}`); + } + + let bodyAsm = []; + if (macro) { + bodyAsm = expandMacro(macro, macroArgs); + } else { + bodyAsm = instructions; + } + + const fullJsAsm = [ + ...initAsm, + ...bodyAsm, + 'halt' + ]; + + // --- ORACLE 1: Fast JS Pre-Check (instant GUI) --- + // JS core is kept for instant feedback but is NOT the CI authority. + // A JS crash or disagreement is LOGGED, not thrown; CI authority is + // the clpru-built ELF on TI pru-simulator (Oracle 2) plus expectedRegisters. + let jsResult = null; + let jsError = null; + try { + jsResult = runJsSimulation(fullJsAsm, maxCycles, r31Map, mmioMap); + } catch (err) { + jsError = err; + } + + const jsRegs = jsResult ? jsResult.pruState.registers.map(v => (v >>> 0)) : null; + + // --- COMPILE REAL ELF via clpru --- + const elfAssembly = buildElfAssemblyText(initAsm, bodyAsm); + + const compileOutcome = compileElf(elfAssembly, testId); + if (!compileOutcome.success) { + const err = new Error(`[Dual-Oracle clpru COMPILATION REJECTED] Test '${name}':\n${compileOutcome.error}`); + err.isCompilationFailure = true; + err.compileOutcome = compileOutcome; + throw err; + } + + // --- ORACLE 2: TI pru-simulator on Real ELF --- + const tiResult = runTiElf(compileOutcome.outFile, { maxSteps }); + if (!tiResult.payload || !tiResult.payload.success) { + const reason = tiResult.payload ? tiResult.payload.reason : 'unknown_error'; + const errors = tiResult.payload ? (tiResult.payload.errors || []).join('; ') : tiResult.error; + throw new Error(`[Dual-Oracle TI SIMULATOR FAILED] Test '${name}': Reason=${reason}, Errors=${errors}`); + } + + const tiRegs = tiResult.payload.state.registers.map(hexStr => parseInt(hexStr, 16) >>> 0); + + // --- DIFFERENTIAL ORACLE COMPARISON (JS vs TI) --- + // Disagreements are LOGGED to results/simulator_disagreements.log for + // investigation; they do NOT fail CI. CI fails only if the TI oracle + // itself fails (compile error, simulator error, expectedRegisters mismatch). + const mismatches = []; + let disagreementMsg = null; + if (jsError) { + disagreementMsg = `[Dual-Oracle DISAGREEMENT] Test '${name}': JS Simulator crashed while TI pru-simulator succeeded!\nJS Error: ${jsError.message}`; + } else { + for (let r = 0; r < 32; r++) { + if (jsRegs[r] !== tiRegs[r]) { + mismatches.push({ + register: `R${r}`, + jsValue: `0x${jsRegs[r].toString(16).padStart(8, '0').toUpperCase()}`, + tiValue: `0x${tiRegs[r].toString(16).padStart(8, '0').toUpperCase()}`, + diff: (tiRegs[r] - jsRegs[r]) + }); + } + } + if (mismatches.length > 0) { + disagreementMsg = `[Dual-Oracle DISAGREEMENT] Test '${name}': JS Simulator and TI pru-simulator DISAGREE on real ELF!\n`; + disagreementMsg += `+----------+---------------+-------------------+-----------------+\n`; + disagreementMsg += `| Register | JS Simulator | TI pru-simulator | Match? |\n`; + disagreementMsg += `+----------+---------------+-------------------+-----------------+\n`; + for (const m of mismatches) { + disagreementMsg += `| ${m.register.padEnd(8)} | ${m.jsValue.padEnd(13)} | ${m.tiValue.padEnd(17)} | MISMATCH |\n`; + } + disagreementMsg += `+----------+---------------+-------------------+-----------------+`; + } + } + if (disagreementMsg) { + const logPath = path.join(REPO_ROOT, 'results', 'simulator_disagreements.log'); + if (!fs.existsSync(path.dirname(logPath))) fs.mkdirSync(path.dirname(logPath), { recursive: true }); + const entry = `[${new Date().toISOString()}] ${disagreementMsg}\n`; + fs.appendFileSync(logPath, entry); + // Also emit to stdout so CI logs show the disagreement + console.warn(entry.trim()); + } + + // Optional expected register check + if (expectedRegisters) { + for (let [reg, expVal] of Object.entries(expectedRegisters)) { + const regIdx = parseInt(reg.replace(/r/i, '')); + const expected = expVal >>> 0; + const actual = tiRegs[regIdx]; + if (actual !== expected) { + throw new Error(`[Dual-Oracle EXPECTATION MISMATCH] Test '${name}': R${regIdx} expected 0x${expected.toString(16).toUpperCase()} but both oracles produced 0x${actual.toString(16).toUpperCase()}`); + } + } + } + + return { + passed: true, + name, + testId, + cycles: { + jsCycles: jsResult ? jsResult.pruState.cycles : -1, + tiCycles: tiResult.payload.state.cycles, + tiSteps: tiResult.payload.state.steps + }, + registers: tiRegs, + elfPath: compileOutcome.outFile + }; +} + +module.exports = { + formatAsmLine, + buildElfAssemblyText, + findClpru, + findPython, + findSimulatorDir, + loadBlockModule, + expandMacro, + evalCondition, + evalConditionDetailed, + evalNumericExpression, + stripAsmComment, + compileAssemblyToElf, + runTiSimulatorElf, + runJsSimulation, + executeDualOracle +}; diff --git a/scripts/sim/asm_probe.py b/scripts/sim/asm_probe.py new file mode 100644 index 0000000..b17d4e9 --- /dev/null +++ b/scripts/sim/asm_probe.py @@ -0,0 +1,87 @@ +"""sample the register file where a generated-asm macro has just produced its value. + +The allocator releases a block's registers after its last consumer, so a test that reads the register file at HALT sees +whatever later blocks left there. A value is only meaningful at the instruction that produced it. This module turns +"the line of pru_syscfg.asm that invokes macro M" into an ELF instruction address (clpru assembler listing + linker map) +and records the register file the first time the core is about to execute that address, which is the state right after +the macro finished. + +Build the firmware with ``DFLAGS=--asm_listing --list_directory=`` (see ``LISTING_DFLAGS``) so +``pru_syscfg.lst`` sits next to the map. Evidence stays the real clpru ELF running on TI pru-simulator. +""" + +import os +import re + +LISTING_NAME = "pru_syscfg.lst" +ENTRY_LABEL = "sysconfig_generated_start" +# A top-level listing row: " <8 hex byte offset> [] ". Macro-expansion rows start +# with a nesting digit in column 0 and wide padding, so the single space after the line number excludes them. +_ROW = re.compile(r"^\s*(\d+) ([0-9a-f]{8})(?: +[0-9A-F]{14})?\s*(.*)$") + + +def listing_dflags(generated_dir): + """gmake DFLAGS that make clpru write pru_syscfg.lst into the firmware's generated directory.""" + return "DFLAGS=--asm_listing --list_directory=" + str(generated_dir).replace("\\", "/") + + +def parse_listing(listing_text): + """Top-level rows of the assembler listing as [(source_line, byte_offset, text)] in file order.""" + rows = [] + for raw in listing_text.splitlines(): + m = _ROW.match(raw) + if m: + rows.append((int(m.group(1)), int(m.group(2), 16), m.group(3).strip())) + return rows + + +def entry_pc(map_text): + """Instruction index (word address) of sysconfig_generated_start in the linked image, from the linker map.""" + m = re.search(r"^\s*\d+\s+([0-9A-Fa-f]{8})\s+" + ENTRY_LABEL + r"\s*$", map_text, re.M) + assert m, f"{ENTRY_LABEL} missing from the linker map" + return int(m.group(1), 16) // 4 + + +def macro_end_pcs(listing_text, map_text, macro_names): + """{macro_name: pc of the instruction that runs right after that macro's expansion}. + + The macro is looked up by its mnemonic at the start of a top-level listing row and must be invoked exactly once. + The core is about to execute the returned pc exactly when the macro (and so the value it produces) is complete. + """ + rows = parse_listing(listing_text) + label = [off for _, off, text in rows if text.startswith(ENTRY_LABEL + ":")] + assert label, f"{ENTRY_LABEL}: missing from the assembler listing" + base = entry_pc(map_text) - label[0] // 4 + out = {} + for name in macro_names: + hits = [i for i, (_, _, text) in enumerate(rows) if re.match(re.escape(name) + r"\b", text)] + assert len(hits) == 1, f"{name} must appear exactly once in the listing, found {len(hits)}" + off = rows[hits[0]][1] + nxt = [o for _, o, _ in rows[hits[0] + 1:] if o > off] + assert nxt, f"no instruction follows {name} in the listing" + out[name] = base + nxt[0] // 4 + return out + + +def listing_and_map(generated_dir, map_name): + with open(os.path.join(generated_dir, LISTING_NAME), encoding="utf-8", errors="replace") as f: + listing = f.read() + with open(os.path.join(generated_dir, map_name), encoding="utf-8", errors="replace") as f: + mapped = f.read() + return listing, mapped + + +class RegisterProbe: + """Hook for the simulator step loop: copies the register file the first time each watched pc is about to execute.""" + + def __init__(self, core, pcs): + self.core = core + self.watch = {pc: name for name, pc in pcs.items()} + assert len(self.watch) == len(pcs), "two probes share one pc" + self.at = {} + + def __call__(self, sim): + pc = sim.cores[self.core].pc + name = self.watch.get(pc) + if name is not None and name not in self.at: + self.at[name] = [r & 0xFFFFFFFF for r in sim.registers(self.core)] diff --git a/scripts/sim/crc_dut.js b/scripts/sim/crc_dut.js new file mode 100644 index 0000000..c49e327 --- /dev/null +++ b/scripts/sim/crc_dut.js @@ -0,0 +1,189 @@ +/** + * crc_dut.js - device-under-test driver for the CRC block macro + *. It is NOT an oracle: it only runs what the block emits. + * + * Modes (JSON spec on argv[2], JSON result on stdout): + * {"mode":"table", "opCode":"m_calculate_crc16", "crcPolynomial":33024} + * -> prints the block's emitted lookup table as {"lut":[...]} + * {"mode":"run", "opCode":"m_calculate_crc16", "crcPolynomial":33024, + * "init":0, "bytes":[255,255]} + * -> loads the emitted LUT into DMEM0, then chains the emitted macro + * (getMacro("", opCode)) over the bytes on the headless + * pru_core.js instruction simulator and prints {"result":} + * + * Used by tests/test_crc_lut_index_scaling.py, whose oracle is an + * independent bit-serial CRC written in the test itself. + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const ROOT = path.join(__dirname, '..', '..'); +const SIM_DIR = path.join(ROOT, '.metadata/sysconfig/.meta/pru_blocks/common/simulation'); + +const scriptCache = {}; + +function loadScript(scriptPath) { + const code = fs.readFileSync(scriptPath, 'utf-8'); + const sandbox = { + console: console, Math: Math, parseInt: parseInt, isNaN: isNaN, + eval: eval, Uint8Array: Uint8Array, Array: Array, JSON: JSON, + system: { + getScript: (p) => { + const basename = path.basename(p); + if (!scriptCache[basename]) { + scriptCache[basename] = loadScript(path.join(SIM_DIR, basename)); + } + return scriptCache[basename]; + } + }, + exports: {} + }; + vm.createContext(sandbox); + vm.runInContext(code, sandbox); + return sandbox.exports; +} + +// Universal non-throwing proxy so module top-level system.* calls never crash. +function makeStub() { + const fn = function () { return makeStub(); }; + return new Proxy(fn, { + get(_t, prop) { + if (prop === Symbol.toPrimitive || prop === 'toString' || prop === 'valueOf') return () => ''; + if (prop === Symbol.iterator) return undefined; + if (prop === 'then') return undefined; + return makeStub(); + }, + apply() { return makeStub(); }, + }); +} + +function loadBlockModule(blockName, blockSubdir) { + const file = path.join(ROOT, `.metadata/sysconfig/.meta/pru_blocks/${blockSubdir}/${blockName}.syscfg.js`); + const src = fs.readFileSync(file, 'utf-8'); + const sandbox = { + system: makeStub(), + exports: {}, + module: { exports: {} }, + require: () => makeStub(), + console: { log() {}, error() {}, warn() {} }, + }; + sandbox.global = sandbox; + vm.createContext(sandbox); + vm.runInContext(src, sandbox, { filename: file, timeout: 5000 }); + return sandbox.exports; +} + +function expandMacro(macroStr, argsMap) { + const lines = macroStr.split('\n'); + let header = -1, params = []; + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes('.macro')) { + header = i; + params = lines[i].split('.macro')[1].split(',').map(p => p.trim()); + break; + } + } + if (header === -1) throw new Error('no .macro definition'); + const mapping = {}; + params.forEach((p, i) => { + if (argsMap[p] !== undefined) mapping[p] = argsMap[p]; + else if (argsMap[i] !== undefined) mapping[p] = argsMap[i]; + }); + const regs = { TEMP_REG1: 'R28', TEMP_REG2: 'R29', TEMP_REG3: 'R26', TEMP_REG4: 'R27' }; + const out = []; + for (let i = header + 1; ; i++) { + const t = lines[i] === undefined ? '' : lines[i].trim(); + if (t === '' || t.startsWith(';')) { if (lines[i] === undefined) break; continue; } + if (t.startsWith('.endm')) break; + let pl = lines[i]; + for (const [p, v] of Object.entries(mapping)) { + pl = pl.replace(new RegExp('\\b' + p + '\\b', 'g'), v); + } + for (const [t2, r] of Object.entries(regs)) { + if (mapping[t2] === undefined) { + pl = pl.replace(new RegExp('\\b' + t2 + '\\b', 'g'), r); + } + } + out.push(pl.replace(/\?/g, '_1')); + } + return out; +} + +function lutValues(mod, spec) { + const inst = { crcPolynomial: spec.crcPolynomial, opCode: spec.opCode, crcType: spec.opCode }; + return mod.getLookUptable(inst).split(',').map(s => parseInt(s.trim(), 10)); +} + +const LUT_BASE = 0x200; // DMEM0 address used by the "run" mode +const RESULT_REG = 10; // macro crcResult = R10 +const INIT_REG = 4; // macro crcInit = R4 +const DATA_REG = 5; // macro dataByte = R5 + +function main() { + const spec = JSON.parse(process.argv[2]); + const mod = loadBlockModule('crc_block', 'application_specific'); + const values = lutValues(mod, spec); + const entryBytes = spec.opCode === 'm_calculate_crc32' ? 4 : spec.opCode === 'm_calculate_crc16' ? 2 : 1; + + if (spec.mode === 'table') { + process.stdout.write(JSON.stringify({ lut: values })); + return; + } + if (spec.mode !== 'run') throw new Error('unknown mode ' + spec.mode); + + const pruCore = loadScript(path.join(SIM_DIR, 'pru_core.js')); + const pruDMEM0 = scriptCache['pru_dmem0.js']; + + // Load the block's emitted table little-endian at entry-byte stride: + // exactly the memory layout the generated firmware places in .initdmem. + const bytes = new Uint8Array(values.length * entryBytes); + values.forEach((v, i) => { + for (let b = 0; b < entryBytes; b++) { + bytes[i * entryBytes + b] = (v >>> (8 * b)) & 0xFF; + } + }); + pruDMEM0.writeDMEM0(LUT_BASE, bytes); + + const macro = mod.getMacro('', spec.opCode); + const instructions = []; + const labels = []; + let crc = spec.init >>> 0; + instructions.push(`ldi32 R${INIT_REG}, ${crc >>> 0}`); + for (const byte of spec.bytes) { + instructions.push(`ldi32 R${DATA_REG}, ${byte}`); + for (const line of expandMacro(macro, { + crcResult: `R${RESULT_REG}`, crcInit: `R${INIT_REG}`, + dataByte: `R${DATA_REG}`, lutAddress: String(LUT_BASE), + })) { + instructions.push(line); + } + instructions.push(`mov R${INIT_REG}, R${RESULT_REG}`); + } + instructions.push('halt'); + instructions.forEach(() => labels.push(0)); + + const res = pruCore.simulatePruInstructions(instructions, labels, 64 * instructions.length, {}, {}); + // Open-loop (no device bus) runs end with pc past the last instruction + // rather than pruState.halted set. + if (!(res.pruState.halted || res.pruState.pc >= instructions.length - 1)) { + throw new Error('did not halt: ' + JSON.stringify({ + pc: res.pruState.pc, unknown: res.pruState.unknownInstruction, + parseErrors: res.pruState.errorParsing, n: instructions.length, + regs: res.pruState.registers.slice(0, 12), + })); + } + const width = entryBytes * 8; + const mask = width === 32 ? 0xFFFFFFFF : (1 << width) - 1; + if (spec.debug) { + console.error(JSON.stringify({ instructions, regs: res.pruState.registers.map(r => r >>> 0) })); + } + process.stdout.write(JSON.stringify({ + result: (res.pruState.registers[RESULT_REG] & mask) >>> 0, + unknownInstruction: res.pruState.unknownInstruction, + errorParsing: res.pruState.errorParsing, + })); +} + +main(); diff --git a/scripts/sim/device_bindings.py b/scripts/sim/device_bindings.py new file mode 100644 index 0000000..0b5360c --- /dev/null +++ b/scripts/sim/device_bindings.py @@ -0,0 +1,548 @@ +#!/usr/bin/env python3 +"""Turn the emitted device-binding manifest into attached device models. + + python scripts/sim/device_bindings.py describe build_out/pru_device_bindings.json + python scripts/sim/device_bindings.py check build_out/pru_device_bindings.json + +WHAT THIS IS +------------, the consumer half. SysConfig embeds the binding record in the +generated `pru_syscfg.asm` (see `buildDeviceBindings` in the static module) and +make extracts it to `pru_device_bindings.json`. This module +reads it and instantiates the real Python device models from TI pru-simulator, +so the build runs the oracle the user selected in the GUI without the user +naming a path or writing Python. + +Decision C on the Python models are the authority. The GUI is a +selector, not a simulator. + +WHY THE PIN TRANSLATION LIVES HERE AND NOT IN THE GUI +----------------------------------------------------- +The GUI has one generic set of pin fields - primary, clock/B, index/Z - because +a single selector must cover eight devices. Every model names its pins +differently: `a_pin/b_pin/z_pin`, `clk_pin/data_pin`, `ma_pin/slo_pin`, +`master_pin/slave_pin`, `clock_pin/data_pin`, `scl_pin/sda_pin`, `pin`. + +The mapping is kept next to the models it must agree with, so renaming a pin in +a model breaks a test HERE rather than silently producing an idle trace in +someone's simulation. + +The direction is the part worth getting right, and it is not guessable from the +names. Each model's `nets` dict declares which pins the DEVICE drives; every +other pin it only observes. So for a clocked device the device-driven pin is +the data line and the PRU-driven pin is the clock: + + ssi nets = {data_pin} -> primary is data, clock is clk_pin + bissc nets = {slo_pin} -> primary is SLO, clock is MA + bissline nets = {slave_pin} -> primary is slave, clock is master + endat nets = {data_pin} -> primary is data, clock is clock_pin + tca9538 nets = {scl, sda} -> primary is SDA, clock is SCL + +Wire BiSS-C the other way round - primary to MA - and the encoder waits forever +on a clock that never comes, producing a clean, silent, entirely wrong PASS. +That is the failure this table exists to prevent, and it is why the table is +derived from `nets` rather than from which name sounds like "the first pin". + +TWO MODEL CONTRACTS, ONE INVARIANT +---------------------------------- +the models in this repository's `perif/` package subclass +`core.device_model`, not the upstream `pru_io` base. They declare no `nets`; +their `tick(cycle, pins_out)` RETURNS the GPI word the device asserts. That is +the same fact, observed rather than declared, so `driven_pins()` reads whichever +of the two a model offers and the direction invariant above applies to every +kind in the registry. Exempting the models with no `nets` - or checking them by +reading back the keyword the binding just set, which cannot fail - would leave +exactly the silent reversal this module exists to catch. + +Two of them drive nothing at all: a STEP/DIR driver and a WS2812 string are +pure PRU outputs. For those the primary slot cannot mean "the pin the device +drives", so they are declared `sink=True` and a separate test proves the claim +by observing that they really do assert nothing. + +FAIL CLOSED +----------- +A manifest that cannot be read, a version this code does not understand, an +unknown device kind, or a parameter the model would silently drop are all +refusals. Attaching nothing and reporting success is the outcome the whole +device-in-the-loop effort exists to prevent: it looks green because it never +ran. +""" +from __future__ import annotations + +import argparse +import contextlib +import inspect +import json +import os +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import elf_cosim # noqa: E402 + + +SimulatorNotFound = elf_cosim.SimulatorNotFound + + +def locate_simulator(explicit=None): + """Find TI's pru-simulator checkout: an explicit path holding simulator.py, else + elf_cosim.find_pru_simulator (PRU_SIMULATOR_PATH, then tools/pru-simulator-upstream).""" + if explicit and os.path.isfile(os.path.join(explicit, "simulator.py")): + return os.path.abspath(explicit) + return elf_cosim.find_pru_simulator() + +#: Manifest shapes this code understands. Bumped by the emitter when the shape +#: changes, so an old consumer refuses a new manifest instead of half-reading it. +SUPPORTED_VERSIONS = (1,) + + +class BindingError(Exception): + """A binding that must not be attached. Carries user-facing text.""" + + +class _Spec: + """How one GUI device kind maps onto one model class. + + `primary`/`clock`/`index` are the model's own keyword names for the pin the + GUI calls primary, clock/B and index/Z. `None` means the model has no such + pin, and supplying one in the GUI is an error rather than something to drop. + """ + + def __init__(self, module, cls, primary, clock=None, index=None, + device_drives_second=False, sink=False, extra_pins=()): + self.module = module + self.cls = cls + self.primary = primary + self.clock = clock + self.index = index + # A sink drives NOTHING back at the PRU: every one of its pins is an + # output of the PRU and an input of the device. The primary slot then + # cannot mean "the pin the device drives", because there is no such + # pin. Saying so explicitly is the point - the alternative is a spec + # that looks identical to a reversed clocked device, which is the one + # error this module exists to catch. Declared here, proved by + # `test_a_sink_really_drives_nothing`. + self.sink = sink + # Model pin keywords with no GUI slot left to carry them. They arrive + # through `params`, but they are PINS: they get the same range check, + # the same reuse check and the same cross-device short check as the + # pin fields, and a missing one is a refusal rather than a default. + self.extra_pins = tuple(extra_pins) + # Is the second pin a PRU-driven clock, or something the device also + # drives? Not cosmetic - it is what makes "the device must not drive + # its clock" a checkable invariant for the devices where it holds, and + # an explicit, justified exception for the two where it does not. + self.device_drives_second = device_drives_second + + @property + def needs_clock(self): + return self.clock is not None + + +# Kind strings are the `name` values of gpi{N}_deviceKind in the static module. +# Keep this table and that option list in step: a kind offered in the GUI with +# no entry here is a dead selector, which the test suite asserts against. +REGISTRY = { + # B and Z are not a clock: a quadrature encoder DRIVES all three channels, + # and the PRU only ever reads them. The "clock" slot is the GUI's generic + # name for the second pin, not a claim about direction. + "quadrature": _Spec("pru_io.quadrature_encoder", "QuadratureEncoder", + primary="a_pin", clock="b_pin", index="z_pin", + device_drives_second=True), + "ssi": _Spec("pru_io.ssi_encoder", "SsiEncoder", + primary="data_pin", clock="clk_pin"), + "bissc": _Spec("pru_io.bissc_device_model", "BissCModel", + primary="slo_pin", clock="ma_pin"), + "bissline": _Spec("pru_io.bissline_device_model", "BissLineModel", + primary="slave_pin", clock="master_pin"), + "endat": _Spec("pru_io.endat_encoder", "EndatEncoder", + primary="data_pin", clock="clock_pin"), + # I2C SCL is open-drain and a slave may stretch the clock, so the device + # legitimately drives SCL low as well. This is the one clocked device where + # both pins appear in nets, and it is correct. + "tca9538": _Spec("pru_io.tca9538_device_model", "TCA9538Model", + primary="sda_pin", clock="scl_pin", + device_drives_second=True), + "uart": _Spec("pru_io.uart_device", "UartTransmitter", + primary="pin"), + "sigma_delta": _Spec("pru_io.sd_modulator_device", "SigmaDeltaModulator", + primary="data_pin", clock="clk_pin"), + # ---------------------------------------------------------------- + # The four models below live in this repository's perif/ package and + # subclass `core.device_model`, NOT the upstream `pru_io` base. They + # declare no `nets`; instead `tick(cycle, pins_out)` RETURNS the GPI word + # the device asserts, so the set bits of that return are the same fact + # `nets` states declaratively. `driven_pins()` reads whichever of the two + # a model offers, which is what keeps the direction invariant checkable + # across both contracts instead of exempting half the registry from it. + "spi_adc": _Spec("perif.spi_adc", "SpiAdc", + # The ADC drives MISO, which this model calls sdi_pin + # ("the pin the PRU reads"). SCLK and MOSI are the + # controller's. CS is a fourth pin and the manifest has + # only three slots, so it comes through params under + # full pin validation - see extra_pins. + primary="sdi_pin", clock="sclk_pin", index="sdo_pin", + extra_pins=("cs_pin",)), + "step_dir": _Spec("perif.step_dir_driver", "StepDirDriver", + # "open-loop driver: nothing to feed back" - the model + # returns 0 forever. STEP/DIR/EN are all PRU outputs, + # and DIR occupies the clock slot because it is the + # second pin, not because anything clocks it. + primary="step_pin", clock="dir_pin", index="en_pin", + sink=True), + "ws2812": _Spec("perif.ws2812_string", "Ws2812String", + # Single-wire NRZ input. "WS2812 drives nothing back". + primary="data_pin", sink=True), + "mdio_phy": _Spec("perif.mdio_phy_model", "MdioPhyModel", + # MDIO is shared: the PHY drives it during read + # turn-around and read data, so it is genuinely the + # device-driven pin. MDC is the STA's. + primary="mdio_bit", clock="mdc_bit"), +} + +#: Pin assignments come from the manifest's pin fields. A model keyword that is +#: also a pin name must never be settable through the params blob, or there are +#: two sources of truth for one fact and they drift. The GUI validator refuses +#: these too; duplicated deliberately, because this module is also reachable +#: from a hand-written manifest that never passed through the GUI. +_PIN_KEYWORDS = frozenset( + {s.primary for s in REGISTRY.values()} + | {s.clock for s in REGISTRY.values() if s.clock} + | {s.index for s in REGISTRY.values() if s.index} +) + + +# -------------------------------------------------------------------------- +# manifest +# -------------------------------------------------------------------------- +def load_manifest(path): + """Read and structurally validate a manifest. Raises BindingError.""" + if not os.path.isfile(path): + raise BindingError( + "no device-binding manifest at %s. make extracts it from the " + "generated syscfg/pru_syscfg.asm (the core makefile's syscfg rule " + "runs scripts/sim/extract_device_bindings.py); an absent file means " + "generation or extraction did not run, which is not the same as " + "'no devices attached'." % path) + try: + with open(path, encoding="utf-8") as fh: + manifest = json.load(fh) + except (OSError, ValueError) as exc: + raise BindingError("could not read manifest %s: %s" % (path, exc)) + + if not isinstance(manifest, dict): + raise BindingError("manifest must be a JSON object, got %s" + % type(manifest).__name__) + + version = manifest.get("version") + if version not in SUPPORTED_VERSIONS: + raise BindingError( + "manifest version %r is not supported (this consumer understands " + "%s). Refusing rather than reading a shape it may misinterpret." + % (version, ", ".join(map(str, SUPPORTED_VERSIONS)))) + + bindings = manifest.get("bindings") + if not isinstance(bindings, list): + raise BindingError("manifest 'bindings' must be a list, got %s" + % type(bindings).__name__) + + seen = {} + for entry in bindings: + if not isinstance(entry, dict): + raise BindingError("each binding must be an object, got %s" + % type(entry).__name__) + _validate_entry(entry, seen) + return manifest + + +def _pin(entry, key, required): + value = entry.get(key) + if value is None: + if required: + raise BindingError("binding is missing required pin %r" % key) + return None + # bool is an int subclass; True would otherwise pass as pin 1. + if isinstance(value, bool) or not isinstance(value, int): + raise BindingError("%s must be an integer pin number, got %r" + % (key, value)) + if not 0 <= value <= 19: + raise BindingError("%s must be 0-19, got %d" % (key, value)) + return value + + +def _validate_entry(entry, seen): + kind = entry.get("kind") + if kind not in REGISTRY: + raise BindingError( + "unknown device kind %r (known: %s). A kind offered by the GUI " + "with no model behind it would attach nothing and pass." + % (kind, ", ".join(sorted(REGISTRY)))) + spec = REGISTRY[kind] + + primary = _pin(entry, "primaryPin", required=True) + clock = _pin(entry, "clockPin", required=False) + index = _pin(entry, "indexPin", required=False) + + if spec.needs_clock and clock is None: + raise BindingError( + "device %r on pin %d is clocked and needs a clock pin. Without one " + "it waits forever and produces an idle trace that looks exactly " + "like a firmware that never ran." % (kind, primary)) + if not spec.needs_clock and clock is not None: + raise BindingError("device %r has no clock pin, but clockPin=%d was set" + % (kind, clock)) + if spec.index is None and index is not None: + raise BindingError("device %r has no index pin, but indexPin=%d was set" + % (kind, index)) + + params = entry.get("params", {}) + if not isinstance(params, dict): + raise BindingError("params for %r must be an object, got %s" + % (kind, type(params).__name__)) + + # Pins with no GUI slot. They travel in params because there is nowhere + # else, but they are validated as pins - a cs_pin that skipped the range, + # reuse and short checks is the two-sources-of-truth drift this module + # refuses everywhere else, and an absent one would silently take the + # model's own default and put CS on a pin nobody selected. + extra = [] + for key in spec.extra_pins: + if key not in params: + raise BindingError( + "device %r needs %r, which has no GUI pin field and must be " + "given in params. Without it the model takes its own default " + "pin, and a device wired to a pin nobody chose produces an " + "idle trace that looks exactly like a firmware that never ran." + % (kind, key)) + extra.append(_pin(params, key, required=True)) + + pins = [p for p in (primary, clock, index) if p is not None] + extra + if len(set(pins)) != len(pins): + raise BindingError("device %r on pin %d reuses a pin: %s" + % (kind, primary, pins)) + + for pin in pins: + if pin in seen and seen[pin] != primary: + raise BindingError( + "pin %d is claimed by two devices (%r and this one on pin %d). " + "Two models driving one net is a short, not a configuration." + % (pin, seen[pin], primary)) + seen[pin] = primary + + for reserved in sorted(_PIN_KEYWORDS & set(params)): + raise BindingError( + "params for %r must not set %r - pins come from the pin fields, " + "and two sources of truth for one pin will drift." + % (kind, reserved)) + return entry + + +# -------------------------------------------------------------------------- +# instantiation +# -------------------------------------------------------------------------- +#: Top-level package names this repository and the pru-simulator checkout both +#: define. Whichever root is first on sys.path wins for ALL of them, so an +#: import has to be scoped to one root rather than leaving both in place. +_COLLIDING_TOPS = frozenset({"core", "perif", "pru_io", "simulator", "tools"}) + + +@contextlib.contextmanager +def _import_from(root): + """Import with `root` winning, then put sys.path and sys.modules back. + + The two checkouts both define `core` and `perif`. Mutating sys.path in + place and leaving it mutated makes the result depend on which device was + bound FIRST: bind a perif model before an upstream one in the same process + and the upstream import fails, because this repo's `pru_io` now shadows the + simulator's. That is order-dependent and a test suite can be green on it by + luck of alphabetical ordering, so the scope is restored rather than trusted. + """ + saved_modules = {k: sys.modules.pop(k) for k in list(sys.modules) + if k.split(".")[0] in _COLLIDING_TOPS} + saved_path = list(sys.path) + sys.path.insert(0, root) + try: + yield + finally: + for k in [k for k in list(sys.modules) + if k.split(".")[0] in _COLLIDING_TOPS]: + del sys.modules[k] + sys.modules.update(saved_modules) + sys.path[:] = saved_path + + +def resolve_class(spec, simulator_path=None): + """Import a model class from the checkout that owns it.""" + path = locate_simulator(simulator_path) + # models live in this repository's perif/ package and import this + # repository's core/; every other binding resolves from the upstream + # pru-simulator checkout, which has its own core/ and perif/. + root = ROOT if spec.module.split(".")[0] == "perif" else path + with _import_from(root): + try: + module = __import__(spec.module, fromlist=[spec.cls]) + except ImportError as exc: + raise BindingError("could not import %s from %s: %s" + % (spec.module, root, exc)) + try: + return getattr(module, spec.cls) + except AttributeError: + raise BindingError( + "%s has no class %s - the model was renamed and this " + "registry was not updated" % (spec.module, spec.cls)) + + +def build_device(entry, simulator_path=None): + """Instantiate one model from one validated binding entry.""" + spec = REGISTRY[entry["kind"]] + cls = resolve_class(spec, simulator_path) + + kwargs = dict(entry.get("params") or {}) + kwargs[spec.primary] = entry["primaryPin"] + if spec.clock and entry.get("clockPin") is not None: + kwargs[spec.clock] = entry["clockPin"] + if spec.index and entry.get("indexPin") is not None: + kwargs[spec.index] = entry["indexPin"] + + # Reject a parameter the model would drop. The GUI promises "unknown keys + # are reported rather than ignored"; this is where that promise is kept. + # Models taking **kwargs forward to an inner encoder, so their unknown keys + # surface as the TypeError caught below instead. + sig = inspect.signature(cls.__init__) + takes_var_kw = any(p.kind is inspect.Parameter.VAR_KEYWORD + for p in sig.parameters.values()) + if not takes_var_kw: + unknown = sorted(set(kwargs) - set(sig.parameters)) + if unknown: + raise BindingError( + "device %r does not accept %s. A parameter the model drops " + "silently gives you a model that is not the one you asked for. " + "Accepted: %s" + % (entry["kind"], ", ".join(map(repr, unknown)), + ", ".join(p for p in sig.parameters if p != "self"))) + + try: + return cls(**kwargs) + except TypeError as exc: + raise BindingError("could not construct %r with %s: %s" + % (entry["kind"], kwargs, exc)) + + +def driven_pins(model, stimulus=None): + """Which pin numbers does this DEVICE drive? Works for both contracts. + + Upstream `pru_io` models state it declaratively in `nets`. the `core.device_model` models state it by behaviour: `tick()` returns the GPI + word the device asserts, so the union of set bits over a few cycles is the + same fact, observed instead of asserted. + + `stimulus`, when supplied, is an iterable of ``(cycle, pins_out)`` samples. + It lets callers prove a sink remains quiet while it processes a real input + edge, rather than mistaking an unexercised early-return path for behaviour. + The default is the all-pins-low quiet case, then all-pins-high, then low + again: the high-to-low step is a genuine active-low chip-select assertion + EDGE, so a device that only drives inside a frame is observed driving. (The + all-low level alone no longer suffices: a first-tick level is the reset + state, not an edge - r3.) A model that returns a tuple is an upstream model that should have + had `nets`; that is a registry error, not something to guess around, so it + raises. + """ + nets = getattr(model, "nets", None) + if nets is not None: + return set(nets) + if stimulus is None: + stimulus = ((cycle, 0 if cycle < 4 or cycle >= 6 else 0xFFFFFFFF) + for cycle in range(10)) + driven = 0 + for cycle, pins_out in stimulus: + word = model.tick(cycle, pins_out) + if not isinstance(word, int) or isinstance(word, bool): + raise BindingError( + "%s.tick returned %r; a model with no `nets` must return the " + "GPI word it drives, or this module cannot tell which pin it " + "drives and the direction check is unenforceable" + % (type(model).__name__, word)) + driven |= word + return {b for b in range(20) if driven >> b & 1} + + +def build_all(manifest, simulator_path=None): + """Instantiate every binding. Returns [(entry, model), ...].""" + return [(e, build_device(e, simulator_path)) + for e in manifest.get("bindings", [])] + + +def attach_all(io_port, manifest, simulator_path=None): + """Instantiate and attach every binding to a simulator IOPort.""" + attached = [] + for entry, model in build_all(manifest, simulator_path): + io_port.attach_device(model) + attached.append((entry, model)) + return attached + + +# -------------------------------------------------------------------------- +# CLI +# -------------------------------------------------------------------------- +def _describe(manifest): + out = [] + for e in manifest.get("bindings", []): + spec = REGISTRY[e["kind"]] + pins = ["%s=%d" % (spec.primary, e["primaryPin"])] + if spec.clock and e.get("clockPin") is not None: + pins.append("%s=%d" % (spec.clock, e["clockPin"])) + if spec.index and e.get("indexPin") is not None: + pins.append("%s=%d" % (spec.index, e["indexPin"])) + out.append({"kind": e["kind"], "class": spec.cls, + "pins": pins, "params": e.get("params", {})}) + return out + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("command", choices=("describe", "check")) + ap.add_argument("manifest") + ap.add_argument("--simulator", help="path to a pru-simulator checkout") + ap.add_argument("--allow-empty", action="store_true", + help="treat a manifest with no devices as success") + args = ap.parse_args(argv) + + try: + manifest = load_manifest(args.manifest) + except BindingError as exc: + print("REFUSED: %s" % exc) + return 2 + + bindings = manifest.get("bindings", []) + if not bindings and not args.allow_empty: + # Comparing zero cases is a failure, not a pass - the same rule as + # second_oracle.py. Pass --allow-empty when a project genuinely has no + # attached devices and you want the step to be a no-op. + print("REFUSED: manifest has no device bindings. A run that attaches " + "nothing proves nothing; pass --allow-empty if that is intended.") + return 2 + + if args.command == "describe": + print(json.dumps({"version": manifest["version"], + "devices": _describe(manifest)}, indent=1)) + return 0 + + try: + built = build_all(manifest, args.simulator) + except SimulatorNotFound as exc: + print("REFUSED: %s" % exc) + return 2 + except BindingError as exc: + print("REFUSED: %s" % exc) + return 2 + + for entry, model in built: + print(" ok %-12s -> %s(%s) drives=%s" + % (entry["kind"], type(model).__name__, + getattr(model, "name", entry["primaryPin"]), + sorted(driven_pins(model)))) + print("\n%d device(s) constructed and pin-mapped." % len(built)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sim/device_model_replay.js b/scripts/sim/device_model_replay.js new file mode 100644 index 0000000..96f9bb2 --- /dev/null +++ b/scripts/sim/device_model_replay.js @@ -0,0 +1,143 @@ +/** + * device_model_replay.js - replay a fixed pin stimulus through the JS device + * models and print the resulting R31 trace, events and faults as JSON. + * + * Exists for scripts/sim/test_device_model_crosscheck.py, which replays the + * SAME stimulus through the Python oracle models and diffs the two. Keeping + * this runner tiny and deterministic is what makes that diff meaningful. + * + * Usage: + * node scripts/sim/device_model_replay.js stimulus.json + * + * Stimulus JSON: + * { + * "model": { "kind": "ssi"|"quadrature", "options": { ...constructor opts } }, + * "gpo": [[cycle, word], ...], // PRU output changes; holds between + * "cycles": N, // total cycles to settle + * "drive_exclude": [pin, ...] // pins the PRU does NOT drive + * } + * + * Output JSON: { "r31": {cycle: word}, "events": [...], "faults": [...] } + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const SIM_DIR = path.join(__dirname, '..', '..', '.metadata', 'sysconfig', '.meta', + 'pru_blocks', 'common', 'simulation'); + +const scriptCache = {}; +function loadSimScript(scriptPath) { + const code = fs.readFileSync(scriptPath, 'utf8'); + const sandbox = { + console, Math, JSON, Number, Array, Object, Set, Error, + system: { + getScript: (p) => { + const basename = path.basename(p); + if (!scriptCache[basename]) { + scriptCache[basename] = loadSimScript(path.join(SIM_DIR, basename)); + } + return scriptCache[basename]; + } + }, + exports: {} + }; + vm.createContext(sandbox); + vm.runInContext(code, sandbox); + return sandbox.exports; +} + +const busScript = loadSimScript(path.join(SIM_DIR, 'device_bus.js')); +const models = loadSimScript(path.join(SIM_DIR, 'device_models.js')); + +function main() { + const stimulus = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); + const opts = stimulus.model.options; + + let device; + if (stimulus.model.kind === 'ssi') { + device = new models.SsiEncoder({ + clkPin: opts.clk_pin, dataPin: opts.data_pin, bits: opts.bits, + position: opts.position, cyclesPerCount: opts.cycles_per_count ?? null, + minClkPeriodCycles: opts.min_clk_period_cycles, + monoflopCycles: opts.monoflop_cycles, gray: opts.gray + }); + } else if (stimulus.model.kind === 'quadrature') { + device = new models.QuadratureEncoder({ + aPin: opts.a_pin, bPin: opts.b_pin, + zPin: (opts.z_pin === undefined || opts.z_pin === null) ? null : opts.z_pin, + countsPerRev: opts.counts_per_rev, + cyclesPerCount: opts.cycles_per_count, forward: opts.forward + }); + + } else if (stimulus.model.kind === 'uart') { + device = new models.UartTransmitter({ + pin: opts.pin, payload: opts.payload, bitPeriod: opts.bit_period, + startCycle: opts.start_cycle, idleGapBits: opts.idle_gap_bits, frames: opts.frames + }); + } else if (stimulus.model.kind === 'uart_monitor') { + device = new models.UartMonitor({ + pin: opts.pin, bitPeriod: opts.bit_period + }); + + } else if (stimulus.model.kind === 'sigma_delta') { + device = new models.SigmaDeltaModulator({ + clkPin: opts.clk_pin, dataPin: opts.data_pin, + minPeriod: opts.min_period, saturationThreshold: opts.saturation_threshold, + saturationPersist: opts.saturation_persist, initialInput: opts.initial_input + }); + if (opts.inputs) { + // we will need to feed input over time? + // Wait, Python's DeviceModel doesn't have a standardized way to pass inputs via the runner. + // Oh, but SigmaDeltaModulator needs set_input(). Let's see how crosscheck does it. + // Wait, we can't change set_input dynamically unless we add it to gpo. + // Let's just set the initialInput and leave it constant. + } + + } else if (stimulus.model.kind === 'tca9538') { + device = new models.TCA9538Model({ + sclPin: opts.scl_pin, sdaPin: opts.sda_pin, address: opts.address + }); + + } else if (stimulus.model.kind === 'bissc') { + device = new models.BissCModel({ + maPin: opts.ma_pin, sloPin: opts.slo_pin, + positionBits: opts.position_bits, tbusyClocks: opts.tbusy_clocks, + minMaPeriodCycles: opts.min_ma_period_cycles, idleRestartCycles: opts.idle_restart_cycles, + cdsBit: opts.cds_bit, invertCrc: opts.invert_crc, ackClocks: opts.ack_clocks + }); + } else { + throw new Error('replay runner does not know kind ' + stimulus.model.kind); + } + + const bus = new busScript.DeviceBus(); + bus.attach(device); + let mask = busScript.MASK_20; + for (const pin of (stimulus.drive_exclude || [])) { + mask &= ~(1 << pin); + } + bus.setPruDriveMask(mask); + + const script = stimulus.gpo || []; + const r31 = {}; + let gpo = busScript.MASK_20; + let idx = 0; + for (let cycle = 1; cycle <= stimulus.cycles; cycle++) { + while (idx < script.length && script[idx][0] <= cycle) { + gpo = script[idx][1]; + idx++; + } + r31[cycle] = bus.settle(cycle, gpo); + } + + console.log(JSON.stringify({ + r31, + events: device.events(), + faults: device.faults(), + busFaults: bus.faults() + })); +} + +main(); diff --git a/scripts/sim/elf_cosim.py b/scripts/sim/elf_cosim.py new file mode 100644 index 0000000..08938d3 --- /dev/null +++ b/scripts/sim/elf_cosim.py @@ -0,0 +1,232 @@ +"""elf_cosim.py - closed-loop co-simulation: real clpru ELF <-> DeviceModel. + +Pipeline: + 1. expand a NoCodePRU block macro via scripts/sim/emit_block_asm.js (Node) + 2. assemble+link with clpru into a real COFF .out ELF + 3. load the ELF into the TI pru-simulator (Python, tools/pru-simulator-upstream) + 4. step instruction-by-instruction; each step the DeviceModel sees the PRU + GPO word (pins_out) and drives the GPI word the PRU samples next + 5. return the per-step resolved trace so an independent bus-cosim decoder + (perif/buscosim.py) can re-derive the protocol events + +Nothing here judges protocol correctness - that is the tests' job. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import subprocess +import sys +import tempfile +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple +from pathlib import Path + +# Default install location of TI tools, as in imports.mak (TOOLS_PATH). +TI_TOOLS = Path(os.environ.get("TOOLS_PATH", (os.environ.get("SystemDrive", "C:") + "/ti") if os.name == "nt" else Path.home() / "ti")) + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + +CLPRU_CANDIDATES = [ + str(TI_TOOLS / "ti-cgt-pru_2.3.3" / "bin" / "clpru.exe"), + str(TI_TOOLS / "ti-cgt-pru_2.3.3" / "bin" / "clpru"), + "/opt/ti/ti-cgt-pru_2.3.3/bin/clpru", +] + +LINKER_CMD = """\ +MEMORY { + PAGE 0: PRU_IMEM : org = 0x00000000 len = 0x00004000 + PAGE 1: PRU0_DMEM : org = 0x00000000 len = 0x00002000 +} +SECTIONS { + .text > PRU_IMEM, PAGE 0 + .data > PRU0_DMEM, PAGE 1 + .bss > PRU0_DMEM, PAGE 1 +} +""" + + +def find_clpru() -> str: + env = os.environ.get("CGT_TI_PRU_PATH") + if env: + cand = os.path.join(env, "bin", "clpru.exe") + if os.path.exists(cand): + return cand + cand = os.path.join(env, "bin", "clpru") + if os.path.exists(cand): + return cand + for cand in CLPRU_CANDIDATES: + if os.path.exists(cand): + return cand + raise RuntimeError("clpru not found; set CGT_TI_PRU_PATH") + + +def _is_simulator_checkout(path: str) -> bool: + """A directory counts as a TI pru-simulator checkout only when it holds simulator.py; + a directory that merely has the expected name is skipped.""" + return os.path.isfile(os.path.join(path, "simulator.py")) + + +class SimulatorNotFound(RuntimeError): + """TI pru-simulator checkout not found. Tests treat it as a skip (see conftest.py).""" + + +def find_pru_simulator() -> str: + """Locate the TI pru-simulator checkout (tools/pru-simulator-upstream), + walking ancestors so worktrees at any depth resolve. Only a directory + that contains simulator.py counts.""" + tried = [] + env = os.environ.get("PRU_SIMULATOR_PATH") + if env: + tried.append(env) + if _is_simulator_checkout(env): + return env + d = REPO_ROOT + while True: + cand = os.path.join(d, "tools", "pru-simulator-upstream") + tried.append(cand) + if _is_simulator_checkout(cand): + return cand + parent = os.path.dirname(d) + if parent == d: + break + d = parent + raise SimulatorNotFound( + "pru-simulator-upstream not found (looked for simulator.py in each); " + "set PRU_SIMULATOR_PATH. Tried: " + " | ".join(tried)) + + +def expand_block_asm(spec: Dict[str, Any], workdir: str) -> str: + """Run the Node emitter and return the expanded assembly text.""" + spec_path = os.path.join(workdir, "spec.json") + with open(spec_path, "w", encoding="utf-8") as f: + json.dump(spec, f) + emitter = os.path.join(REPO_ROOT, "scripts", "sim", "emit_block_asm.js") + res = subprocess.run(["node", emitter, spec_path], capture_output=True, text=True) + if res.returncode != 0: + raise RuntimeError(f"emit_block_asm failed: {res.stderr}\n{res.stdout}") + return res.stdout + + +def compile_asm(asm_text: str, workdir: str, name: str) -> str: + asm_path = os.path.join(workdir, f"{name}.asm") + cmd_path = os.path.join(workdir, f"{name}.cmd") + out_path = os.path.join(workdir, f"{name}.out") + with open(asm_path, "w", encoding="utf-8") as f: + f.write(asm_text) + with open(cmd_path, "w", encoding="utf-8") as f: + f.write(LINKER_CMD) + clpru = find_clpru() + res = subprocess.run( + # --obj_directory: without it clpru drops .obj in the CALLER's + # cwd, which for pytest is the repo root. + [clpru, "-v3", "-O2", f"--obj_directory={workdir}", asm_path, + "-z", cmd_path, "-o", out_path, "--entry_point=main"], + capture_output=True, text=True) + if res.returncode != 0 or not os.path.exists(out_path): + raise RuntimeError( + f"clpru failed (rc={res.returncode}):\n{res.stdout}\n{res.stderr}") + return out_path + + +@dataclass +class CosimResult: + trace: List[Tuple[int, int, int]] = field(default_factory=list) + # (cycle, gpo_word, gpi_word) per step, gpo as driven by the PRU *before* + # the step, gpi as applied by the model for that step. + registers: List[int] = field(default_factory=list) + cycles: int = 0 + halted: bool = False + steps: int = 0 + + +def run_elf_closed_loop( + elf_path: str, + model, + max_steps: int = 20000, + core: str = "pru0", + on_step=None, +) -> CosimResult: + """Run a real ELF against a DeviceModel in a closed pin loop. + + ``model`` implements the DeviceModel interface: + tick(cycle, pins_out) -> pins_in (GPI word). + ``on_step`` (optional) is called each step with (cycle, gpo, gpi, sim) so + tests can inject extra stimuli (e.g. an EnDat mode command). + """ + sim_dir = find_pru_simulator() + # The pru-simulator and this repo BOTH ship a top-level "core" package. + # Run the whole loop with the simulator's namespace swapped in, so its + # absolute imports (incl. lazy ones inside methods) resolve correctly. + with _sim_namespace(sim_dir): + from simulator import Simulator # type: ignore + + sim = Simulator(os.path.join(sim_dir, "memory.cfg")) + errors = sim.load_elf(core, open(elf_path, "rb").read()) + if errors: + raise RuntimeError(f"ELF load errors: {errors}") + pru = sim.cores[core] + + result = CosimResult() + result = _loop(sim, pru, core, model, max_steps, on_step) + return result + + +@contextlib.contextmanager +def _sim_namespace(sim_dir: str): + # The pru-simulator and this repo share several top-level package names + # (core/, perif/, ...). Swap the colliding namespace for the duration. + colliding = [ + d for d in os.listdir(sim_dir) + if os.path.isfile(os.path.join(sim_dir, d, "__init__.py")) + or os.path.isfile(os.path.join(sim_dir, d + ".py")) + ] + tops = set(colliding) | {"core", "simulator", "tools"} + saved = {k: sys.modules.pop(k) for k in list(sys.modules) + if k.split(".")[0] in tops} + saved_path = list(sys.path) + sys.path.insert(0, sim_dir) + try: + yield + finally: + for k in [k for k in list(sys.modules) if k.split(".")[0] in tops]: + del sys.modules[k] + sys.modules.update(saved) + sys.path[:] = saved_path + + +def _loop(sim, pru, core, model, max_steps, on_step) -> CosimResult: + result = CosimResult() + for step in range(max_steps): + gpo_pins = pru.io_port.get_gpo_pins() + gpo_word = 0 + for n, b in enumerate(gpo_pins): + if b: + gpo_word |= (1 << n) + cycle = pru.counters.cycles + gpi_word = model.tick(cycle, gpo_word) & 0xFFFFF + if on_step is not None: + on_step(cycle, gpo_word, gpi_word, sim) + pru.io_port.set_gpi_word(gpi_word) + result.trace.append((cycle, gpo_word, gpi_word)) + sim.step(core, 1) + if pru.halted: + result.halted = True + break + result.steps = step + 1 if max_steps else 0 + result.cycles = pru.counters.cycles + result.registers = list(sim.registers(core)) + return result + + +def build_block_firmware(spec: Dict[str, Any], name: str, + workdir: Optional[str] = None) -> Tuple[str, str]: + """Expand + compile a block macro; returns (elf_path, asm_path).""" + if workdir is None: + workdir = tempfile.mkdtemp(prefix=f"{name}_") + os.makedirs(workdir, exist_ok=True) + asm_text = expand_block_asm(spec, workdir) + elf_path = compile_asm(asm_text, workdir, name) + return elf_path, os.path.join(workdir, f"{name}.asm") diff --git a/scripts/sim/emit_block_asm.js b/scripts/sim/emit_block_asm.js new file mode 100644 index 0000000..797b66a --- /dev/null +++ b/scripts/sim/emit_block_asm.js @@ -0,0 +1,68 @@ +/** + * emit_block_asm.js - expand a NoCodePRU block macro into a clpru-ready + * assembly program on stdout. Used by scripts/sim/elf_cosim.py. + * + * Usage: + * node scripts/sim/emit_block_asm.js + * + * spec = { + * "block": "ssi_initiator_block", // block syscfg module name + * "subdir": "application_specific", // pru_blocks subdirectory + * "macroCall": "m_ssi_read", // getMacro() first argument + * "macroArgs": {"RES": "R4", ...}, // expansion arguments + * "prelude": ["ldi32 R4, 0x1234"], // optional asm before the macro + * "postlude": ["halt"], // optional asm after the macro + * "labelPrefix": "main", // entry label (default main) + * "macroRepeats": 1, // expand the macro this many times + * "programCtppr0": true // default true: program c28 if used + * } + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const { loadBlockModule, expandMacro, formatAsmLine } = require('../dual_oracle_harness.js'); + +function main() { + const specPath = process.argv[2]; + if (!specPath) { + console.error('usage: node emit_block_asm.js '); + process.exit(2); + } + const spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); + const mod = loadBlockModule(spec.block, spec.subdir); + const opCode = spec.opCode || spec.macroCall; + const macro = mod.getMacro(spec.macroCall, opCode); + // expandMacro returns an array of already-parameter-substituted lines + const repeats = spec.macroRepeats || 1; + const bodyLines = Array.from({ length: repeats }, () => + expandMacro(macro, spec.macroArgs || {})).flat(); + const entry = spec.labelPrefix || 'main'; + // Shared formatAsmLine keeps labels (bare or same-line) in + // column 1; the old endsWith(':') check indented `label: instr`, which + // clpru rejects (E0003). + const fmt = formatAsmLine; + // C28 = CTPPR0.C28_POINTER << 8 and CTPPR0 resets to 0, so a block + // that uses c28 needs the same CTPPR0 write the generated firmware performs + // (pru_blocks_static_module getConstantTableSetup, core icss_g0_pru0 -- the + // core this harness runs). spec.programCtppr0 === false leaves it out. + const ctSetup = (spec.programCtppr0 !== false && /\bc28\b/i.test(macro)) ? [ + ' ldi R28, 0x100', + ' ldi32 R29, 0x22028', + ' sbbo &R28, R29, 0, 4', + ] : []; + const lines = [ + '; Auto-generated by emit_block_asm.js', + ' .text', + ` .global ${entry}`, + `${entry}:`, + ...ctSetup, + ...(spec.prelude || []).map(fmt), + ...bodyLines.map(fmt).filter((l) => l !== ''), + ...(spec.postlude || []).map(fmt), + ' .end', + '', + ]; + process.stdout.write(lines.join('\n')); +} + +main(); diff --git a/scripts/sim/extract_device_bindings.py b/scripts/sim/extract_device_bindings.py new file mode 100644 index 0000000..35abbd9 --- /dev/null +++ b/scripts/sim/extract_device_bindings.py @@ -0,0 +1,23 @@ +"""Extract the binding record emitted in the CLI's always-run assembly template.""" +import argparse +import json +from pathlib import Path + + +def extract(asm, destination): + lines = Path(asm).read_text(encoding="utf-8").splitlines() + start = lines.index("; PRU_DEVICE_BINDINGS_BEGIN") + end = lines.index("; PRU_DEVICE_BINDINGS_END", start + 1) + payload = "\n".join(line.removeprefix("; ") for line in lines[start + 1:end]) + manifest = json.loads(payload) + if manifest.get("version") != 1 or not isinstance(manifest.get("bindings"), list): + raise ValueError("invalid generated device-binding record") + Path(destination).write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("asm") + parser.add_argument("destination") + args = parser.parse_args() + extract(args.asm, args.destination) diff --git a/scripts/sim/gui_run.py b/scripts/sim/gui_run.py new file mode 100644 index 0000000..17dc215 --- /dev/null +++ b/scripts/sim/gui_run.py @@ -0,0 +1,668 @@ +"""The "Run on TI pru-simulator" button's wrapper. + + python scripts/sim/gui_run.py --project + --digest --com + +Runs the same chain as `gmake sim`, on the SAVED .syscfg: + 1. generate SysConfig CLI via the project makefile's `syscfg` target + 2. stale gate the GUI's design digest must equal the one embedded in the + generated pru_syscfg.asm, else REFUSED (the design on screen is + not the design that would be run) + 3. build clpru, via the makefile's `all` target + 4. run scripts/sim/run_bound_devices.py + +The verdict is ONLY the exit code of run_bound_devices.py (0 PASS, 1 FAIL, +2 REFUSED, 3 NOT CHECKED). This wrapper never judges: it does not read the +decoded value to decide anything, it only carries it to the panel. Anything that +stops the chain before the runner (no tool, stale design, failed build) is +REFUSED with the reason; an unknown exit code is REFUSED, never PASS. + +With --live the live web view (scripts/sim/live_view.py) is started on +the ELF just built, detached so it outlives this process, once a PASS/FAIL/NOT +CHECKED verdict exists. It is never started for a REFUSED run, and it can never +change the verdict or the exit code: its outcome is the result's "liveView". + +The result JSON is written to --com on every path, including internal errors. +""" +import argparse +import glob +import json +import os +import re +import shlex +import socket +import subprocess +import sys +import tempfile +import time +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from scripts.sim import extract_device_bindings # noqa: E402 + +VERDICT_BY_EXIT = {0: "PASS", 1: "FAIL", 2: "REFUSED", 3: "NOT CHECKED"} +MAKE_TIMEOUT = 600 +RUN_TIMEOUT = 900 +DEFAULT_LIVE_PORT = 8765 +LIVE_READY_SECONDS = 15 +#: Verdicts for which a live view is wanted: a failing design is exactly what +#: you want to watch. REFUSED has no ELF for the design on screen. +LIVE_VERDICTS = ("PASS", "FAIL", "NOT CHECKED") + + +def verdict_for(exit_code): + """(verdict, not_a_verdict) from run_bound_devices.py's exit code - the only input. + A code outside 0-3 is a crashed or killed runner, not a judgement: REFUSED.""" + if exit_code in VERDICT_BY_EXIT: + return VERDICT_BY_EXIT[exit_code], False + return "REFUSED", True + + +class Refused(Exception): + """The chain cannot run. Carries the user-facing reason.""" + + +def read_imports(root): + """Variables imports.mak sets with = / := / ?= (literal values only).""" + values = {} + path = Path(root) / "imports.mak" + if not path.exists(): + return values + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + m = re.match(r"\s*(?:export\s+)?([A-Z_]+)\s*(\?=|:=|=)\s*(.*?)\s*$", line) + if m and "$(" not in m.group(3): + values.setdefault(m.group(1), m.group(3)) + return values + + +def tools_path(root, env): + return (env.get("TOOLS_PATH") or read_imports(root).get("TOOLS_PATH") + or ((os.environ.get("SystemDrive", "C:") + "/ti") if os.name == "nt" else str(Path.home() / "ti"))) + + +def find_gmake(root, env): + """gmake is not on a GUI process's PATH. Env override, PATH, then CCS under + imports.mak's TOOLS_PATH (any ccs* version: imports.mak names ccs1280, the + install may be another).""" + exe = "gmake.exe" if os.name == "nt" else "gmake" + override = env.get("GMAKE") + if override and Path(override).exists(): + return override + for directory in env.get("PATH", "").split(os.pathsep): + if directory and (Path(directory) / exe).exists(): + return str(Path(directory) / exe) + imports = read_imports(root) + ccs = env.get("CCS_PATH") or imports.get("CCS_PATH") + candidates = [Path(ccs) / "utils" / "bin" / exe] if ccs else [] + candidates += [Path(p) for p in sorted(glob.glob( + str(Path(tools_path(root, env)) / "ccs*" / "ccs" / "utils" / "bin" / exe)), reverse=True)] + for candidate in candidates: + if candidate.exists(): + return str(candidate) + raise Refused("gmake not found: it is not on the GUI process PATH and no CCS install " + f"was found under {tools_path(root, env)}. Set GMAKE to its full path") + + +def find_clpru(root, env): + exe = "clpru.exe" if os.name == "nt" else "clpru" + path = Path(tools_path(root, env)) / "ti-cgt-pru_2.3.3" / "bin" / exe + if not path.exists(): + raise Refused(f"clpru not found at {path} (TOOLS_PATH from the environment or imports.mak)") + return str(path) + + +def resolve_project(project): + """(syscfg, make_dir) from an example.syscfg path or a firmware/make dir.""" + if not project: + raise Refused("no project: the GUI did not report the open .syscfg path; set " + "'TI Run Project' in Simulation Settings") + path = Path(project).resolve() + if path.is_file(): + if path.name != "example.syscfg": + raise Refused(f"{path.name}: the project makefile builds example.syscfg, so " + f"only that file can be run") + fw = path.parent + elif path.is_dir(): + fw = path.parent if (path / "makefile").exists() and (path.parent / "example.syscfg").exists() else path + else: + raise Refused(f"project path does not exist: {project}") + syscfg = fw / "example.syscfg" + make_dir = fw / "ti-pru-cgt" + if not syscfg.exists() or not (make_dir / "makefile").exists(): + raise Refused(f"{fw} has no example.syscfg with a ti-pru-cgt/makefile beside it") + return syscfg, make_dir + + +def elf_path(make_dir): + text = (make_dir / "makefile").read_text(encoding="utf-8", errors="replace") + m = re.search(r"^OUTPUT_NAME\s*:?=\s*(\S+)", text, re.M) + if not m: + raise Refused(f"{make_dir / 'makefile'} sets no OUTPUT_NAME") + return make_dir / "generated" / f"{m.group(1)}.out" + + +def embedded_digest(asm): + m = re.search(r"^; PRU_DESIGN_DIGEST (\S+)\s*$", Path(asm).read_text(encoding="utf-8"), re.M) + return m.group(1) if m else None + + +def run_logged(argv, cwd, env, log, timeout): + with open(log, "a", encoding="utf-8", errors="replace") as f: + f.write(f"\n$ {' '.join(argv)}\n") + f.flush() + try: + flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0 + proc = subprocess.Popen(argv, cwd=cwd, env=env, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, creationflags=flags) + try: + stdout, stderr = proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + kill_process_tree(proc) + f.write(f"TIMEOUT after {timeout}s (terminated process tree rooted at {proc.pid})\n") + raise Refused(f"{Path(argv[0]).name} timed out after {timeout}s; terminated its process tree") + f.write((stdout or "") + (stderr or "")) + return subprocess.CompletedProcess(argv, proc.returncode, stdout, stderr) + except subprocess.TimeoutExpired: + f.write(f"TIMEOUT after {timeout}s\n") + raise Refused(f"{Path(argv[0]).name} timed out after {timeout}s") + + +def kill_process_tree(proc): + """Stop only this command and its children; never kill by image name.""" + if os.name == "nt": + subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], + capture_output=True, text=True, timeout=30) + else: + proc.kill() + + +class ProjectLock: + def __init__(self, make_dir): + self.path = Path(make_dir) / ".ti_run.lock" + + def _holder_pid(self): + """The pid recorded in the lock file, or None when it holds no parsable pid.""" + try: + m = re.match(r"pid=(\d+)", self.path.read_text(encoding="ascii")) + except (OSError, UnicodeDecodeError): + return None + return int(m.group(1)) if m else None + + def _holder_alive(self): + """False only when the recorded owner is provably gone (a hard-killed run). + + A lock with no parsable pid (empty, garbage, unreadable) is treated as held: it is + also what a live run's lock looks like between O_EXCL create and the pid write.""" + pid = self._holder_pid() + return True if pid is None else pid_alive(pid) + + def __enter__(self): + for attempt in (0, 1): + try: + self.fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + break + except FileExistsError: + if attempt == 0 and not self._holder_alive(): + try: + self.path.unlink() + except FileNotFoundError: + pass + continue + pid = self._holder_pid() + holder = f"pid {pid}" if pid is not None else "an unreadable pid" + raise Refused(f"another TI pru-simulator run is already using {self.path.parent}: " + f"lock file {self.path} is held by {holder}; wait for it to finish, " + "or delete that file if that process is gone") + try: + os.write(self.fd, f"pid={os.getpid()}\n".encode()) + except BaseException: + self.__exit__() + raise + return self + + def __exit__(self, *_): + os.close(self.fd) + try: + self.path.unlink() + except FileNotFoundError: + pass + + +def pid_alive(pid): + """Is a process with this PID running? (os.kill(pid, 0) would TERMINATE it on Windows.)""" + if os.name == "nt": + import ctypes + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION + if not handle: + return kernel32.GetLastError() == 5 # access denied: it exists + code = ctypes.c_ulong() + ok = kernel32.GetExitCodeProcess(handle, ctypes.byref(code)) + kernel32.CloseHandle(handle) + return bool(ok) and code.value == 259 # STILL_ACTIVE + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def live_view_command(env): + """The argv prefix that starts the live view. NCP_LIVE_VIEW_CMD replaces it + (test-only: lets tests stand in a stub without TI pru-simulator; + POSIX quoting, so use forward slashes in paths).""" + override = env.get("NCP_LIVE_VIEW_CMD") + if override: + return shlex.split(override) + return [sys.executable, str(ROOT / "scripts" / "sim" / "live_view.py")] + + +def powershell_exe(): + """PowerShell by absolute path: SysConfig's GUI process may have a PATH without it.""" + root = os.environ.get("SystemRoot") or os.environ.get("windir") or (os.environ.get("SystemDrive", "C:") + "\\Windows") + exe = Path(root) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe" + return str(exe) if exe.exists() else "powershell" + + +def process_command_line(pid): + """The command line of a running process, or None when it cannot be read. + psutil is not a dependency: Windows asks PowerShell (Win32_Process), POSIX + reads /proc or `ps`.""" + if os.name == "nt": + try: + out = subprocess.run( + [powershell_exe(), "-NoProfile", "-NonInteractive", "-Command", + f"(Get-CimInstance Win32_Process -Filter 'ProcessId={int(pid)}').CommandLine"], + capture_output=True, text=True, timeout=30) + except (OSError, subprocess.TimeoutExpired): + return None + return out.stdout.strip() or None + try: + raw = Path(f"/proc/{int(pid)}/cmdline").read_bytes() + return raw.replace(b"\0", b" ").decode("utf-8", "replace").strip() or None + except OSError: + pass + try: + out = subprocess.run(["ps", "-p", str(int(pid)), "-o", "args="], + capture_output=True, text=True, timeout=30) + except (OSError, subprocess.TimeoutExpired): + return None + return out.stdout.strip() or None + + +def _norm_cmd(text): + """Slashes and case folded, so a Windows command line compares equal to the + path/argument strings this tool recorded.""" + return str(text).replace("\\", "/").lower() + + +def is_live_view_process(pid, port=None, elf=None): + """True only when a live pid is really THE live_view.py this tool started: its + command line must name live_view.py and, when given, the recorded `--port` and + ELF path. A recycled pid - or another project's or the user's own live view - + never matches. Asked without port/elf it only checks for live_view.py.""" + if not pid_alive(pid): + return False + cmd = _norm_cmd(process_command_line(pid) or "") + if "live_view.py" not in cmd: + return False + if port is not None and not re.search(rf"--port[ =]+{int(port)}(?!\d)", cmd): + return False + if elf is not None and _norm_cmd(elf) not in cmd: + return False + return True + + +def kill_pid_tree(pid): + """Stop exactly this pid and its children; never by image name.""" + if os.name == "nt": + subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], + capture_output=True, text=True, timeout=30) + else: + import signal + try: # started with start_new_session, so the pid is its group id + os.killpg(pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + + +def wait_until(predicate, seconds): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.1) + return predicate() + + +def port_is_free(port): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + try: + sock.bind(("127.0.0.1", port)) + except OSError: + return False + return True + + +def live_info_answers(port): + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/live/info", timeout=2) as resp: + return resp.status == 200 + except (OSError, ValueError): + return False + + +def port_accepts(port, timeout=0.2): + """True when something accepts a TCP connection on 127.0.0.1:port. A short + connect_ex: on Windows urlopen against a closed port burns ~2 s in refused-connect + retries however small its timeout is.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(timeout) + try: + return sock.connect_ex(("127.0.0.1", int(port))) == 0 + except OSError: + return False + + +def spawn_detached(argv, env, log): + """Start argv so it outlives this process (and the launcher, and SysConfig's + wait): no console, own process group/session, stdio on the log file.""" + with open(log, "ab") as out: + kwargs = {"env": env, "stdin": subprocess.DEVNULL, "stdout": out, + "stderr": subprocess.STDOUT, "close_fds": True} + if os.name == "nt": + base = (subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP + | subprocess.CREATE_NO_WINDOW) + try: + # Leave a parent job object if the host put us in one that allows + # it. SysConfig 1.28 uses a plain child_process.spawn (no job), so + # this is belt and braces; a job without breakaway raises: retry. + return subprocess.Popen(argv, creationflags=base | 0x01000000, **kwargs) + except OSError: + return subprocess.Popen(argv, creationflags=base, **kwargs) + return subprocess.Popen(argv, start_new_session=True, **kwargs) + + +def live_record_path(make_dir): + return Path(make_dir) / ".ti_live_view.json" + + +def stop_recorded_live_view(make_dir): + """Stop the live view an earlier --live run recorded - and ONLY it: the pid + must be alive and its command line must be live_view.py with the recorded + --port and ELF. Returns a note + about a recorded pid that was left alone, else None.""" + try: + record = json.loads(live_record_path(make_dir).read_text(encoding="utf-8")) + pid = int(record.get("pid")) + port, elf = int(record.get("port")), str(record.get("elf") or "") + except (OSError, ValueError, TypeError, AttributeError): + return None + if not pid_alive(pid): + return None + if not elf or not is_live_view_process(pid, port, elf): + return (f"pid {pid} recorded by an earlier live view is not that live_view.py " + f"process (port {port}); left alone") + kill_pid_tree(pid) + wait_until(lambda: not pid_alive(pid), 10) + return None + + +def start_live_view(make_dir, elf, manifest, port, env): + """Start the live view on exactly this ELF and manifest. Returns the + result's "liveView" dict; never raises and never touches the verdict.""" + log = Path(make_dir) / "syscfg" / "ti_live_view.log" + view = {"status": "failed", "url": f"http://127.0.0.1:{port}", "pid": None, + "reason": None, "log": str(log)} + try: + if not 1 <= port <= 65535: + view.update(status="not started", reason=f"port {port} is not a valid TCP port") + return view + note = stop_recorded_live_view(make_dir) + # The old view's port may need a moment to be released. + if not wait_until(lambda: port_is_free(port), 5): + view.update(status="not started", + reason=f"port {port} is in use by something that is not this tool's " + f"previous live view; pick another port" + + (f" ({note})" if note else "")) + return view + argv = live_view_command(env) + [str(elf), str(manifest), "--port", str(port)] + if env.get("NCP_LIVE_NO_BROWSER") == "1": + argv.append("--no-browser") + log.parent.mkdir(exist_ok=True) + log.write_text(f"$ {' '.join(argv)}\n", encoding="utf-8") + proc = spawn_detached(argv, env, log) + view["pid"] = proc.pid + timeout = float(env.get("NCP_LIVE_READY_TIMEOUT") or LIVE_READY_SECONDS) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + code = proc.poll() + if code is not None: + view.update(reason=f"the live view exited with code {code} before it answered; see {log}") + return view + if live_info_answers(port): + view.update(status="started") + live_record_path(make_dir).write_text(json.dumps( + {"pid": proc.pid, "port": port, "elf": str(elf), + "started": time.strftime("%Y-%m-%dT%H:%M:%S")}), encoding="utf-8") + return view + time.sleep(0.2) + kill_pid_tree(proc.pid) + view.update(reason=f"the live view did not answer on port {port} within {timeout:g}s; " + f"stopped it; see {log}") + return view + except Exception as exc: # the view is a convenience: report, never raise + view.update(status="failed", reason=f"{type(exc).__name__}: {exc}") + return view + + +def not_started(port, reason): + return {"status": "not started", "url": f"http://127.0.0.1:{port}", "pid": None, + "reason": reason, "log": None} + + +def port_owner_pid(port): + """The pid listening on 127.0.0.1:port, or None when it cannot be read. + Windows asks PowerShell (Get-NetTCPConnection), POSIX asks lsof.""" + cmd = ([powershell_exe(), "-NoProfile", "-NonInteractive", "-Command", + f"(Get-NetTCPConnection -LocalPort {int(port)} -State Listen -ErrorAction SilentlyContinue " + f"| Select-Object -First 1).OwningProcess"] + if os.name == "nt" else ["lsof", "-t", f"-iTCP:{int(port)}", "-sTCP:LISTEN"]) + try: + out = subprocess.run(cmd, capture_output=True, text=True, timeout=30).stdout.split() + except (OSError, subprocess.TimeoutExpired): + return None + return int(out[0]) if out and out[0].isdigit() else None + + +def design_of_view_command(cmd): + """A short design name from a live_view.py command line: the folder under + examples/ that holds its ELF, else the ELF's file name, else None.""" + found = re.search(r'([^\s"]+\.(?:out|elf))', _norm_cmd(cmd)) + if not found: + return None + elf = found.group(1) + under = re.search(r"/examples/([^/]+)/", elf) + return under.group(1) if under else elf.rsplit("/", 1)[-1] + + +def other_design_view(port, elf): + """a run that did not ask for a live view still says so when a live + view of a DIFFERENT design is serving on the configured port. It only looks: + that view is never stopped by a run that did not ask for one. None when + nothing answers there, the view is this run's own ELF, or the ELF this run + would build is unknown (then it cannot be told apart from the run's own view, + and saying nothing beats calling it foreign), or the listener is readable and + is not a live_view.py at all.""" + if not elf or not port_accepts(port) or not live_info_answers(port): + return None + pid = port_owner_pid(port) + cmd = process_command_line(pid) if pid else None + if cmd and _norm_cmd(elf) in _norm_cmd(cmd): + return None + if cmd and "live_view.py" not in _norm_cmd(cmd): + return None # something else is answering there; it is not a live view to report + design = design_of_view_command(cmd) if cmd else None + who = f"design '{design}'" if design else "a design this run cannot identify" + return {"status": "other design", "url": f"http://127.0.0.1:{port}", "pid": pid, "design": design, + "reason": f"a live view of {who} is still serving on port {port}; this run did not ask for " + f"a live view, so it was left running (tick 'Open live web view' to replace it)", + "log": None} + + +def expected_elf(project): + """The ELF this run builds (or would have built), known before any build so the + own-design check works on a REFUSED run too. None when it cannot be resolved.""" + try: + return str(elf_path(resolve_project(project)[1])) + except Exception: + return None + + +def parse_run_output(out): + """Display-only facts from run_bound_devices.py's output. Not used to judge.""" + devices = [] + for block in re.split(r"\n(?=Device \w+ on GPI\d+:)", out): + head = re.match(r"Device (\w+) on GPI(\d+):", block) + if not head: + continue + device = {"kind": head.group(1), "pin": int(head.group(2))} + m = re.search(r"Firmware decoded R[\d.\w]+: (\d+); model position: (\d+)", block) + if m: + device["decoded"], device["expected"] = int(m.group(1)), int(m.group(2)) + notes = [ln.strip() for ln in block.splitlines() + if re.match(r"\s*(FAIL|NOT CHECKED):", ln)] + if notes: + device["note"] = "; ".join(notes) + devices.append(device) + cycles = re.search(r"^Cycles: (\d+)", out, re.M) + return devices, int(cycles.group(1)) if cycles else None + + +def run_chain(args, env, result): + root = ROOT + syscfg, make_dir = resolve_project(args.project) + gmake = find_gmake(root, env) + find_clpru(root, env) + from scripts.sim.elf_cosim import find_pru_simulator + try: + env["PRU_SIMULATOR_PATH"] = find_pru_simulator() + except RuntimeError as exc: + raise Refused(str(exc)) + if not args.digest: + raise Refused("the GUI sent no design digest, so the design on screen cannot be " + "matched to the saved file") + env["PATH"] = os.path.dirname(gmake) + os.pathsep + env.get("PATH", "") + env.setdefault("PRU_NO_CODE_TOOL_PATH", str(root)) + result["project"] = str(syscfg) + with ProjectLock(make_dir): + log = make_dir / "syscfg" / "ti_run.log" + log.parent.mkdir(exist_ok=True) + log.write_text("", encoding="utf-8") + result["log"] = str(log) + (make_dir / "generated").mkdir(exist_ok=True) + make = [gmake, "-C", str(make_dir)] + + gen = run_logged(make + ["syscfg"], make_dir, env, log, MAKE_TIMEOUT) + asm = make_dir / "syscfg" / "pru_syscfg.asm" + if gen.returncode != 0 or not asm.exists(): + raise Refused(f"SysConfig generation failed (exit {gen.returncode}); see {log}") + on_disk = embedded_digest(asm) + result["designDigest"] = args.digest + if on_disk is None: + raise Refused("the generated assembly carries no design digest; cannot match it to the GUI") + if on_disk != args.digest: + raise Refused("the design on screen differs from the saved .syscfg (digest " + f"{args.digest} vs {on_disk}); save the design, then run again") + + build = run_logged(make + ["all", "BUILD_MCUPLUS=n"], make_dir, env, log, MAKE_TIMEOUT) + if build.returncode != 0: + raise Refused(f"clpru build failed (exit {build.returncode}); see {log}") + if embedded_digest(asm) != on_disk: + raise Refused("the saved .syscfg changed while building; run again") + elf = elf_path(make_dir) + if not elf.exists(): + raise Refused(f"build reported success but {elf} does not exist") + manifest = make_dir / "syscfg" / "pru_device_bindings.json" + extract_device_bindings.extract(asm, manifest) + vcd = make_dir / "syscfg" / "ti_run.vcd" + if vcd.exists(): + vcd.unlink() + + cmd = [sys.executable, str(root / "scripts" / "sim" / "run_bound_devices.py"), + str(elf), str(manifest), "--vcd", str(vcd)] + if args.cycles: + cmd += ["--cycles", str(args.cycles)] + run = run_logged(cmd, make_dir, env, log, RUN_TIMEOUT) + result["exitCode"] = run.returncode + result["verdict"], not_a_verdict = verdict_for(run.returncode) + result["elf"], result["manifest"] = str(elf), str(manifest) + result["devices"], result["cycles"] = parse_run_output(run.stdout) + result["vcd"] = str(vcd) if vcd.exists() else None + if not_a_verdict: + result["reason"] = (f"run_bound_devices.py exited {run.returncode}, which is not a " + f"verdict; see {log}") + elif result["verdict"] != "PASS": + tail = [ln for ln in (run.stdout + run.stderr).splitlines() + if re.match(r"\s*(FAIL|NOT CHECKED|REFUSED)", ln)] + result["reason"] = "; ".join(ln.strip() for ln in tail[:3]) or None + if args.live: + if result["verdict"] in LIVE_VERDICTS: + result["liveView"] = start_live_view(make_dir, elf, manifest, args.live_port, env) + else: + result["liveView"] = not_started(args.live_port, f"the run was {result['verdict']}") + + +def write_result(path, result): + """Atomic: the GUI may read $comFile the instant this process exits.""" + data = json.dumps(result, indent=2) + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(path)), suffix=".tmp") + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(data) + os.replace(tmp, path) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--project", default="") + ap.add_argument("--digest", default="") + ap.add_argument("--com", required=True) + ap.add_argument("--cycles", type=int, default=None) + ap.add_argument("--live", action="store_true", + help="after the run, open the live web view on the ELF just built") + ap.add_argument("--live-port", type=int, default=DEFAULT_LIVE_PORT) + args = ap.parse_args(argv) + result = {"schema": 1, "verdict": None, "exitCode": None, "reason": None, "devices": [], + "cycles": None, "vcd": None, "designDigest": args.digest or None} + try: + run_chain(args, dict(os.environ), result) + except Refused as exc: + result["verdict"], result["reason"] = "REFUSED", str(exc) + except Exception as exc: # never leave $comFile holding a stale or empty result + result["verdict"], result["reason"] = "REFUSED", f"wrapper error: {type(exc).__name__}: {exc}" + if not args.live: + try: + note = other_design_view(args.live_port, result.get("elf") or expected_elf(args.project)) + except Exception: # a convenience line: never let it touch the verdict + note = None + if note: + result["liveView"] = note + if args.live and "liveView" not in result: + result["liveView"] = not_started( + args.live_port, f"no live view: the run was {result['verdict']}, so there is no " + f"ELF for the design on screen ({result['reason']})") + write_result(args.com, result) + print(f"{result['verdict']}: {result['reason'] or ''}".rstrip(": ")) + return 0 if result["verdict"] == "PASS" else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sim/gui_run_launcher.js b/scripts/sim/gui_run_launcher.js new file mode 100644 index 0000000..309bfa0 --- /dev/null +++ b/scripts/sim/gui_run_launcher.js @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * What SysConfig's launcher actually starts for the "Run on TI + * pru-simulator" button. SysConfig runs `node --com $comFile ...` + * with its own node.exe (system.getNodePath()), because that is the one + * interpreter the GUI process is certain to have. The GUI's PATH is not a + * shell's, so `python` may not resolve; this finds one and hands over to + * scripts/sim/gui_run.py. If none is found it still writes a REFUSED result to + * $comFile - a launched process that dies silently would leave only the + * "wrapper did not write a result" placeholder. + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const childProcess = require('child_process'); + +function candidates(env, platform) { + const list = []; + if (env.NCP_PYTHON) list.push([env.NCP_PYTHON]); + if (platform === 'win32') { + // per-user python.org installs are found by the py launcher + list.push(['python'], ['py', '-3']); + const drive = env.SystemDrive || 'C:'; + for (const base of [drive + '\\Python313', drive + '\\Python312', drive + '\\Python311', drive + '\\Python310']) { + list.push([path.win32.join(base, 'python.exe')]); + } + } else { + list.push(['python3'], ['python']); + } + return list; +} + +/** First candidate that runs `--version` as Python 3, as [cmd, ...prefixArgs]. */ +function findPython(env, platform, spawn) { + spawn = spawn || childProcess.spawnSync; + for (const candidate of candidates(env || process.env, platform || process.platform)) { + const probe = spawn(candidate[0], candidate.slice(1).concat(['--version']), + { encoding: 'utf8', env: env || process.env }); + const text = String((probe && (probe.stdout || '')) + (probe && (probe.stderr || ''))); + if (probe && !probe.error && probe.status === 0 && /Python 3\./.test(text)) { + return candidate; + } + } + return null; +} + +function argValue(argv, name) { + const at = argv.indexOf(name); + return at === -1 ? '' : argv[at + 1] || ''; +} + +function writeRefused(com, reason, digest) { + if (!com) return; + fs.writeFileSync(com, JSON.stringify({ + schema: 1, verdict: 'REFUSED', exitCode: null, reason: reason, devices: [], + cycles: null, vcd: null, designDigest: digest || null + }, null, 2)); +} + +function main(argv) { + const com = argValue(argv, '--com'); + const digest = argValue(argv, '--digest'); + const repo = argValue(argv, '--repo') || path.resolve(__dirname, '..', '..'); + const python = findPython(); + if (!python) { + writeRefused(com, 'python 3 not found from the GUI process; set NCP_PYTHON to its full path', digest); + return 1; + } + const wrapper = path.join(repo, 'scripts', 'sim', 'gui_run.py'); + const forward = ['--com', com, '--digest', digest, '--project', argValue(argv, '--project')]; + // The port is always forwarded (an unticked run probes it for another design's view); + // only --live is conditional. + const livePort = argValue(argv, '--live-port'); + if (argv.indexOf('--live') !== -1) forward.push('--live'); + if (livePort || argv.indexOf('--live') !== -1) forward.push('--live-port', livePort || '8765'); + const result = childProcess.spawnSync(python[0], python.slice(1).concat([wrapper], forward), + { stdio: 'inherit', env: process.env }); + if (result.error) { + writeRefused(com, 'could not start the wrapper: ' + result.error.message, digest); + return 1; + } + return result.status === null ? 1 : result.status; +} + +module.exports = { findPython, candidates, main }; +if (require.main === module) { + process.exit(main(process.argv.slice(2))); +} diff --git a/scripts/sim/live_view.py b/scripts/sim/live_view.py new file mode 100644 index 0000000..6f55290 --- /dev/null +++ b/scripts/sim/live_view.py @@ -0,0 +1,467 @@ +"""Watch a bound-device design run live in TI pru-simulator's browser dashboard. + + python scripts/sim/live_view.py + [--port N] [--host H] [--core pruN] [--cycles N] [--no-browser] + +`gmake live` is `gmake sim` with a window on it. It imports the +dashboard TI ships (ui/server.py from PRU_SIMULATOR_PATH - not forked, not +copied), loads the ELF, and attaches every binding in the manifest through +run_bound_devices.attach_bound_devices, the SAME function `gmake sim` uses. +Press Run or Step in the dashboard and the PRU is driving the real device +models; the pin grid and Signal Graph show the real exchange. + +On top of the stock dashboard this adds, without editing it: + GET /live/verdict run_bound_devices.report_run on the current state - + PASS / FAIL / NOT CHECKED, the very judge `gmake sim` + runs, plus its full text. `final` is false until HALT. + GET /live/vcd the CLK/DATA waveform (same writer as `gmake sim --vcd`); + withheld (404) whenever the verdict is, for the same reason + GET /live/info what is attached and on which pins + GET / the stock page plus a verdict banner + +Facts settled by reading and probing TI pru-simulator (see tests/test_live_view.py): + * ui.server builds its Simulator at import, as a module global. Everything it + does reaches that object through the module, so it is attached BEFORE + uvicorn starts; nothing needs a running server. + * hard_reset()/reset() keep attached devices but clear the bus, the devices' + history and the PC (back to 0, not the ELF entry). Both are wrapped here to + restore the entry PC and re-prime the bus, so a Reset button press gives a + run identical to the first. + * Run's graph capture is decimated 1 sample in 100 (ui.server._capture_due) + unless the peripheral interface is on - a CLK with a period of 8 cycles + vanishes at that stride. It is replaced here to sample every instruction. + * Bindings and the grid share numbering: clockPin n is R30 bit n = GPO n, + primaryPin n is R31 bit n = GPI n, 0..19. The graph records gpi[20], so + the pins the device drives are visible. + +Exit codes follow run_bound_devices: 2 = REFUSED (loudly, before any server +starts). The server itself exits 0 on Ctrl+C. +""" +import argparse +import contextlib +import io +import json +import os +import sys +import threading +import webbrowser + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, ROOT) + +from scripts.sim import device_bindings, run_bound_devices as rbd +from scripts.sim.elf_cosim import find_pru_simulator, _sim_namespace + +DEFAULT_PORT = 8765 +LOOPBACK = ("127.0.0.1", "localhost", "::1") +#: Per-cycle (gpo, gpi) samples kept for the VCD. A runaway firmware must not +#: eat the machine; past this the VCD is withheld rather than silently clipped. +MAX_SAMPLES = 2_000_000 + + +FOREIGN = ("the loaded program is not this design's ELF - the bound devices " + "belong to the design, so nothing is judged. Reload the design's ELF, " + "reset and run to get a verdict") +HAND_EDIT = "state edited by hand ({}) - reset and run to get a verdict" +STEP_BACK = HAND_EDIT.format("step back; the device models cannot be rewound") +REPLACED = ("the simulator was replaced (PUT /config or /config/clock_speed) - " + "the bound devices are on the old one, so nothing is judged. Only " + "restarting the live view recovers (press Run on TI pru-simulator " + "again, or start live_view.py again); reloading the design does not") +#: Edits a Reset does NOT undo: upstream reset() clears registers, counters, PC +#: and the IO port, but keeps data memory and the peripheral/loopback/SD/XFR +#: configuration. Clearing "everything" on prime would mean enumerating all of +#: that upstream state, and a miss would read PASS; so these stay withheld until +#: the live view is restarted. +PERSISTENT_EDIT = ("state edited by hand ({}) and a Reset does not undo it - " + "memory and peripheral configuration survive a reset. Restart " + "the live view to get a verdict") +#: The only hand edits a Reset really undoes (registers, IO port pins, step back). Anything +#: else outside SAFE_ACTIONS - known or not - is treated as persistent. +RESET_UNDOES = ("set_register", "set_input", "step_back") +#: ALLOWLIST of dashboard actions that leave the run intact: the wrappers below +#: own step/run/reset/load handling (step_back is caught in restore), the rest +#: only read or set breakpoints. EVERY other action - known or not, whatever its +#: `core` field says (write_memory/fill_memory ignore it, run_multicore steps a +#: partner) - marks the run edited and withholds the verdict. +SAFE_ACTIONS = ("step", "run", "reset", "hard_reset", "load", "load_elf", + "get_state", "read_memory", "toggle_breakpoint", "clear_breakpoints") + + +class LiveViewRefused(Exception): + """The inputs cannot be shown live. Carries the user-facing reason.""" + + +class LiveState: + """Everything the live view tracks about the one loaded run.""" + + def __init__(self, sim, core, entry_pc, attached, budget, budget_source, + decoded_register, elf_bytes=b"", max_samples=MAX_SAMPLES): + self.sim, self.core, self.entry_pc = sim, core, entry_pc + self.elf_bytes, self.max_samples = elf_bytes, max_samples + #: Why no PASS/FAIL may be shown. `foreign`: the loaded program is not the + #: design's ELF. `edited`: state was changed by hand (step back, register + #: or memory write, pin toggle) and the device models cannot follow it. + self.foreign = None + self.edited = None + #: Like `edited`, but prime() does not clear it: see PERSISTENT_EDIT. + self.persistent = None + #: ui.server module; set by _wrap_sim. PUT /config swaps its global `sim`. + self.server = None + self.attached = attached + self.budget, self.budget_source = budget, budget_source + self.decoded_register = decoded_register + self.samples = [] + self.truncated = False + self.resets = 0 + + @property + def pru(self): + return self.sim.cores[self.core] + + def prime(self): + """Put the core and the bus back at the state `gmake sim` starts from.""" + pru = self.pru + pru.pc = self.entry_pc + # Upstream reset leaves GPI alone ("external stimulus", io_port.reset), so + # a pin set by hand (set_input, set_register 31) would survive a Reset and + # `gmake sim` never has it. Clear it on EVERY prime (reset, hard_reset, + # load, load_elf), whatever reset path got here; the devices re-drive theirs. + pru.io_port.gpi = 0 + rbd.prime_device_bus(pru, self.attached) + self.samples = [(pru.io_port.gpo, pru.io_port.gpi)] + self.truncated = False + self.edited = None + + def withheld(self): + """The reason no verdict may be given, or None.""" + if self.server is not None and self.server.sim is not self.sim: + return REPLACED + return self.foreign or self.persistent or self.edited + + def record(self): + if len(self.samples) >= self.max_samples: + self.truncated = True + return + pru = self.pru + self.samples.append((pru.io_port.gpo, pru.io_port.gpi)) + + def verdict(self): + """Judge the current state with run_bound_devices.report_run.""" + why = self.withheld() + if why: + return {"verdict": "NOT CHECKED", "exit_code": rbd.EXIT_NOT_CHECKED, + "final": bool(self.pru.halted), "halted": bool(self.pru.halted), + "cycles": self.pru.counters.cycles, "resets": self.resets, + "reason": why, "report": f"NOT CHECKED: {why}\n"} + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = rbd.report_run(self.sim, self.core, self.attached, self.budget, + self.budget_source, self.decoded_register) + names = {rbd.EXIT_PASS: "PASS", rbd.EXIT_FAIL: "FAIL", + rbd.EXIT_NOT_CHECKED: "NOT CHECKED"} + return {"verdict": names[code], "exit_code": code, + "final": bool(self.pru.halted), "halted": bool(self.pru.halted), + "cycles": self.pru.counters.cycles, "resets": self.resets, + "reason": None, "report": out.getvalue()} + + def vcd_text(self): + first = next((e for e, _ in self.attached if e["kind"] == "ssi"), None) + if first is None: + raise LookupError("no ssi binding attached: the VCD writer draws an " + "SSI CLK/DATA pair") + if self.truncated: + raise LookupError(f"more than {self.max_samples} cycles recorded; " + f"VCD withheld rather than clipped") + import tempfile + fd, path = tempfile.mkstemp(suffix=".vcd") + os.close(fd) + try: + rbd.write_pin_vcd(path, self.samples, first["clockPin"], first["primaryPin"]) + with open(path, encoding="ascii") as f: + return f.read() + finally: + os.unlink(path) + + def info(self): + return {"core": self.core, "entry_pc": self.entry_pc, + "budget": self.budget, "budget_source": self.budget_source, + "attached": [{"kind": e["kind"], "model": type(m).__name__, + "gpi_pin": e["primaryPin"], "clk_r30_pin": e.get("clockPin"), + "driven_gpi_pins": sorted(device_bindings.driven_pins(m))} + for e, m in self.attached]} + + +BANNER = """ +
+ NoCode live view - bound devices attached
+ verdict: (run to HALT)
+ download VCD +  full verdict +
+ +""" + + +def _wrap_sim(state, server): + """Hook the module-global Simulator and dashboard without editing either.""" + sim = state.sim + pru = state.pru + state.server = server + original_step = pru.step + + def step(): + original_step() + state.record() + pru.step = step + + original_reset, original_hard = sim.reset, sim.hard_reset + original_load_elf, original_load = sim.load_elf, sim.load + + def reset(core): + original_reset(core) + if core == state.core: + state.resets += 1 + state.prime() + + def hard_reset(): + original_hard() + state.resets += 1 + state.prime() + + def load_elf(core, elf_data): + errors = original_load_elf(core, elf_data) + if core == state.core and not errors: + from tools.headless_runner import _validate_linked_elf + try: + state.entry_pc = _validate_linked_elf(elf_data) + except ValueError as exc: + return [f"invalid linked ELF: {exc}"] + # Upstream load leaves halted and the counters as they were; a + # loaded program starts from a fresh core, like Reset. + original_reset(core) + state.prime() + state.foreign = None if bytes(elf_data) == state.elf_bytes else FOREIGN + return errors + + def load(core, source, *args, **kwargs): + errors = original_load(core, source, *args, **kwargs) + if core == state.core: + original_reset(core) + state.prime() + state.foreign = FOREIGN + return errors + + original_restore = server._restore + + def restore(core, snap): + original_restore(core, snap) + if core == state.core: + state.edited = STEP_BACK + state.samples = [] + + sim.reset, sim.hard_reset, sim.load_elf, sim.load = reset, hard_reset, load_elf, load + server._restore = restore + # Dashboard Run captures 1 sample in 100 by default; the SSI clock would + # alias away. Sample every instruction while a device is attached. + server._capture_due = lambda c, steps: True + + +def make_app(elf_path, manifest_path, core=None, cycles=None, decoded_register=None, + max_samples=MAX_SAMPLES): + """Build the live app. Call inside run_bound_devices' `_sim_namespace`. + + Raises LiveViewRefused for any input `gmake sim` would REFUSE, or fail to + load, with the same reasons. + """ + try: + manifest = device_bindings.load_manifest(manifest_path) + ns = argparse.Namespace(cycles=cycles) + budget, budget_source = rbd.cycle_budget(ns, manifest) + except device_bindings.BindingError as exc: + raise LiveViewRefused(str(exc)) from exc + if not manifest.get("bindings"): + raise LiveViewRefused("manifest has no device bindings. An empty manifest " + "is REFUSED.") + sim_dir = find_pru_simulator() + from tools.headless_runner import _validate_linked_elf + from ui import server + + sim = server.sim + core = rbd.normalize_core(core or manifest.get("core")) + if not core or core not in sim.cores: + raise LiveViewRefused(f"unknown or absent PRU core {core!r}") + with open(elf_path, "rb") as f: + elf_data = f.read() + try: + entry_pc = _validate_linked_elf(elf_data) + except ValueError as exc: + raise LiveViewRefused(f"invalid linked ELF: {exc}") from exc + errors = sim.load_elf(core, elf_data) + if errors: + raise LiveViewRefused(f"ELF load errors: {errors}") + try: + attached = rbd.attach_bound_devices(sim.cores[core], manifest, sim_dir) + except rbd.DeviceRefused as exc: + raise LiveViewRefused(str(exc)) from exc + + state = LiveState(sim, core, entry_pc, attached, budget, budget_source, + decoded_register, elf_bytes=bytes(elf_data), + max_samples=max_samples) + state.prime() + _wrap_sim(state, server) + + from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse + app = server.app + app.router.routes[:] = [r for r in app.router.routes + if getattr(r, "path", None) != "/"] + + @app.get("/") + async def index(): + page = open(os.path.join(server.STATIC_DIR, "index.html"), + encoding="utf-8").read() + return HTMLResponse(page.replace("", BANNER + "", 1) + if "" in page else page + BANNER) + + @app.get("/live/verdict") + async def live_verdict(): + return JSONResponse(state.verdict()) + + @app.get("/live/info") + async def live_info(): + return JSONResponse(state.info()) + + @app.get("/live/vcd") + async def live_vcd(): + why = state.withheld() + if why: + return PlainTextResponse(f"VCD withheld: {why}", status_code=404) + try: + return PlainTextResponse(state.vcd_text(), media_type="text/plain") + except LookupError as exc: + return PlainTextResponse(str(exc), status_code=404) + + app.add_middleware(_HandEditWatch, state=state) + app.state.live = state + return app + + +class _HandEditWatch: + """ASGI middleware: note dashboard actions that edit state behind the devices. + + Anything outside SAFE_ACTIONS may change the core, memory or pins directly; + the device models never see it, so a verdict afterwards would judge a run + that did not happen. Flagged until the next reset/load. Malformed frames are + not actions (upstream drops them) and are left alone. + """ + + def __init__(self, app, state): + self.app, self.state = app, state + + async def __call__(self, scope, receive, send): + if scope["type"] != "websocket": + return await self.app(scope, receive, send) + + async def watched(): + event = await receive() + if event.get("type") == "websocket.receive" and event.get("text"): + try: + msg = json.loads(event["text"]) + except ValueError: + msg = None + action = msg.get("action") if isinstance(msg, dict) else None + if action not in SAFE_ACTIONS: + if action in RESET_UNDOES: + self.state.edited = HAND_EDIT.format(action) + else: + self.state.persistent = PERSISTENT_EDIT.format(action) + return event + return await self.app(scope, watched, send) + + +@contextlib.contextmanager +def live_session(elf_path, manifest_path, **kwargs): + """Yield the live FastAPI app with TI pru-simulator's namespace swapped in. + + The dashboard imports simulator packages lazily, per request, so the swap + must last as long as the app is used. Leaving the block restores this + repo's own `core`/`perif` modules. + """ + with _sim_namespace(find_pru_simulator()): + yield make_app(elf_path, manifest_path, **kwargs) + + +def main(argv=None): + ap = argparse.ArgumentParser(description="Live browser view of a bound-device run") + ap.add_argument("elf", help="Path to compiled ELF (.out)") + ap.add_argument("manifest", help="Path to pru_device_bindings.json") + ap.add_argument("--port", type=int, default=DEFAULT_PORT) + ap.add_argument("--host", default="127.0.0.1", + help="Bind address (default loopback). The dashboard can load " + "ELFs and write config with no authentication; anything " + "but loopback needs --allow-remote") + ap.add_argument("--allow-remote", action="store_true", + help="Allow a non-loopback --host (exposes the dashboard to " + "the network, unauthenticated)") + ap.add_argument("--max-samples", type=int, default=MAX_SAMPLES, + help="Per-instruction pin samples kept for /live/vcd and the " + "graph (default %(default)s, about 120 bytes each, so ~240 MB " + "at the default). Past it recording stops and the VCD is " + "withheld; Run in the dashboard samples EVERY instruction " + "(stock samples 1 in 100, which aliases the SSI clock)") + ap.add_argument("--cycles", type=int, default=None, + help="Cycle budget the verdict is judged against (as gmake sim)") + ap.add_argument("--core", help="Override the SysConfig core in the manifest") + ap.add_argument("--decoded-register", type=int, + help="Explicit decoded-value register, hand-built ELFs only") + ap.add_argument("--no-browser", action="store_true", help="Do not open a browser (also NCP_LIVE_NO_BROWSER=1)") + args = ap.parse_args(argv) + + if args.host not in LOOPBACK and not args.allow_remote: + print(f"REFUSED: --host {args.host} is not loopback. The dashboard accepts " + f"ELF loads and config writes with no authentication; pass " + f"--allow-remote to expose it anyway", file=sys.stderr) + return rbd.EXIT_REFUSED + if args.host not in LOOPBACK: + print(f"WARNING: serving an unauthenticated dashboard on {args.host}", + file=sys.stderr) + try: + with live_session(args.elf, args.manifest, core=args.core, cycles=args.cycles, + decoded_register=args.decoded_register, + max_samples=args.max_samples) as app: + from ui import server + import uvicorn + if not server._port_is_free(args.host, args.port): + print(f"REFUSED: port {args.port} on {args.host} is already in use; " + f"pass --port", file=sys.stderr) + return rbd.EXIT_REFUSED + url = f"http://{args.host}:{args.port}/" + state = app.state.live + print(f"Attached {len(state.attached)} devices: " + + ", ".join(f"{e['kind']} (GPI{e['primaryPin']})" for e, _ in state.attached)) + print(f"Live view: {url} (Ctrl+C to stop)") + if not (args.no_browser or os.environ.get("NCP_LIVE_NO_BROWSER") == "1"): + threading.Timer(1.0, webbrowser.open, args=(url,)).start() + uvicorn.run(app, host=args.host, port=args.port, log_level="warning") + except LiveViewRefused as exc: + print(f"REFUSED: {exc}", file=sys.stderr) + return rbd.EXIT_REFUSED + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sim/lut_cycles.js b/scripts/sim/lut_cycles.js new file mode 100644 index 0000000..b104b6d --- /dev/null +++ b/scripts/sim/lut_cycles.js @@ -0,0 +1,21 @@ +/** + * lut_cycles.js - print the access_look_up_table block's reported + * noOfCycles for each referenced-table dataType. + * + * node scripts/sim/lut_cycles.js + * -> {"byte":5,"ushort":7,"uint":7} + * + * Reads the block's own cycle model (the config getValue); it is a DUT + * driver, not an oracle. tests/test_access_lut_index_scaling.py + * compares it with the cycles the TI pru-simulator actually spends. + */ +'use strict'; +const { loadBlockModule } = require('../dual_oracle_harness.js'); + +const mod = loadBlockModule('access_look_up_table', 'utils'); +const cfg = mod.config.find((c) => c.name === 'noOfCycles'); +const out = {}; +for (const dataType of ['byte', 'ushort', 'uint']) { + out[dataType] = cfg.getValue({ lutTable: { $name: 'T', dataType } }); +} +process.stdout.write(JSON.stringify(out)); diff --git a/scripts/sim/resolve_block_call.js b/scripts/sim/resolve_block_call.js new file mode 100644 index 0000000..1132e2e --- /dev/null +++ b/scripts/sim/resolve_block_call.js @@ -0,0 +1,62 @@ +/** + * resolve_block_call.js - resolve ONE block instance the way SysConfig would + * and print the macro call the generator would emit for it. + * + * Usage: + * node scripts/sim/resolve_block_call.js / '' + * + * Prints JSON: + * { opCode, noOfCycles, output1Size, macro, constants: [c1, c2, ...], + * errors: [{message, field}], warnings: [...], infos: [...] } + * + * The instance is built from every config default, then the overrides, then + * each computed (getValue) field in config order - the same order SysConfig + * evaluates them. `constants` is constant1..constantN, i.e. the macro call's + * arguments after RES (pru_register_allocator.js appends them in that order). + * Nothing here knows any block's timing: it only evaluates the block's own JS. + */ +'use strict'; +const { loadBlockModule } = require('../dual_oracle_harness.js'); + +function main() { + const [blockPath, overridesJson] = process.argv.slice(2); + if (!blockPath) { + console.error("usage: node resolve_block_call.js / ''"); + process.exit(2); + } + const slash = blockPath.lastIndexOf('/'); + const mod = loadBlockModule(blockPath.slice(slash + 1), blockPath.slice(0, slash)); + const overrides = overridesJson ? JSON.parse(overridesJson) : {}; + + const inst = {}; + for (const c of mod.config) { + if (c.default !== undefined) inst[c.name] = c.default; + } + Object.assign(inst, overrides); + for (const c of mod.config) { + if (typeof c.getValue === 'function' && !(c.name in overrides)) { + inst[c.name] = c.getValue(inst); + } + } + + const out = { errors: [], warnings: [], infos: [] }; + const report = { + logError: (m, _i, f) => out.errors.push({ message: String(m), field: f || null }), + logWarning: (m, _i, f) => out.warnings.push({ message: String(m), field: f || null }), + logInfo: (m, _i, f) => out.infos.push({ message: String(m), field: f || null }), + }; + if (typeof mod.validate === 'function') mod.validate(inst, report); + + out.opCode = inst.opCode; + out.noOfCycles = inst.noOfCycles; + out.output1Size = inst.output1Size; + out.description = mod.longDescription || ''; + out.macro = mod.getMacro(inst.opCode, inst.opCode); + out.constants = []; + for (let k = 1; k <= (inst.numOfConstants || 0); k++) { + out.constants.push(inst['constant' + k]); + } + process.stdout.write(JSON.stringify(out)); +} + +main(); diff --git a/scripts/sim/run_bound_devices.py b/scripts/sim/run_bound_devices.py new file mode 100644 index 0000000..520b554 --- /dev/null +++ b/scripts/sim/run_bound_devices.py @@ -0,0 +1,415 @@ +"""Run a built PRU ELF on TI pru-simulator against the GUI-attached device models. + + python scripts/sim/run_bound_devices.py + [--cycles N] [--core pruN] [--decoded-register N] + +This is what `gmake sim` runs. The manifest is the one make extracts +from the generated pru_syscfg.asm; every binding in it is instantiated as the +real Python device model and attached to the simulated core's pins. + +Exit codes - the verdict is the exit code, and only PASS is 0: + 0 PASS every attached device was judged and agreed with the firmware + 1 FAIL something was judged and was wrong + 2 REFUSED the inputs cannot be run (no/empty manifest, bad ELF, bad core) + 3 NOT CHECKED the run happened, nothing failed, but at least one device + could not be judged (no decoder for its kind, no reader + block, result register overwritten). Never reported as a pass. + +Which register holds the firmware's decoded value is NOT a property of the +block: SysConfig's register allocator picks it per design. The manifest carries +the allocator's choice for the block that reads each device (`readers`, from +resolveDeviceReaders in the static module). --decoded-register is an explicit +override for hand-built ELFs only. +""" +import os +import argparse +import sys +import re +from pathlib import Path + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, ROOT) + +from scripts.sim import device_bindings +from scripts.sim.elf_cosim import find_pru_simulator, _sim_namespace + +#: Device kinds whose decoded value this runner can compare with the model. +DECODERS = ("ssi", "bissc") +DEFAULT_CYCLES = 20000 + +EXIT_PASS, EXIT_FAIL, EXIT_REFUSED, EXIT_NOT_CHECKED = 0, 1, 2, 3 + + +def write_pin_vcd(path, samples, clk_pin, data_pin): + """Save the real simulator's clock/data pin levels after each PRU cycle.""" + lines = ["$date generated by TI pru-simulator run $end", + "$version run_bound_devices.py $end", "$timescale 1ns $end", + "$scope module SSI $end", + f"$var wire 1 ! CLK_R30_{clk_pin} $end", + f"$var wire 1 \" DATA_R31_{data_pin} $end", + "$upscope $end", "$enddefinitions $end", "$dumpvars"] + prev = None + for cycle, (gpo, gpi) in enumerate(samples): + levels = ((gpo >> clk_pin) & 1, (gpi >> data_pin) & 1) + if cycle == 0: + lines.extend((f"{levels[0]}!", f"{levels[1]}\"", "$end")) + elif levels != prev: + lines.append(f"#{cycle}") + if levels[0] != prev[0]: + lines.append(f"{levels[0]}!") + if levels[1] != prev[1]: + lines.append(f"{levels[1]}\"") + prev = levels + Path(path).write_text("\n".join(lines) + "\n", encoding="ascii") + + +class NotChecked(Exception): + """The result register cannot be located. Carries the user-facing reason.""" + + +def parse_register(notation): + """'R1' -> (1, 0, 4); 'R0.b2' -> (0, 2, 1); 'R3.w1' -> (3, 1, 2).""" + m = re.fullmatch(r"R(\d+)(?:\.(b|w)(\d))?", notation.strip()) + if not m or int(m.group(1)) > 31: + raise NotChecked(f"cannot parse result register {notation!r}") + if m.group(2) == "b": + byte = int(m.group(3)) + if byte > 3: + raise NotChecked(f"cannot parse result register {notation!r}") + return int(m.group(1)), byte, 1 + if m.group(2) == "w": + word = int(m.group(3)) + if word > 2: + raise NotChecked(f"cannot parse result register {notation!r}") + return int(m.group(1)), word, 2 + return int(m.group(1)), 0, 4 + + +def result_register(entry, override): + """(notation, (reg, byte, width), provenance) for one decoder binding.""" + if override is not None: + return f"R{override}", (override, 0, 4), "--decoded-register override" + kind, pin = entry["kind"], entry["primaryPin"] + readers = entry.get("readers") + if readers is None: + raise NotChecked( + f"the {kind} binding on GPI{pin} carries no reader record, so the " + f"register holding the decoded value is unknown. Regenerate the " + f"manifest with SysConfig (gmake syscfg), or pass --decoded-register " + f"for a hand-built ELF") + if not readers: + raise LookupError( + f"no block in the design reads the {kind} device on GPI{pin} (no " + f"{kind} reader block has its DATA pin on GPI{pin}), so there is no " + f"decoded value to check") + if len(readers) > 1: + names = ", ".join(r.get("block", "?") for r in readers) + raise NotChecked( + f"{len(readers)} blocks read the {kind} device on GPI{pin} ({names}); " + f"the register left at HALT belongs to at most one of them") + reader = readers[0] + notation = reader.get("resultRegister") + block = reader.get("block", "?") + if not notation: + raise NotChecked(f"block {block} reads GPI{pin} but the allocator gave " + f"it no output register") + later = reader.get("laterWriters") or [] + if later: + raise NotChecked( + f"{notation} (result of block {block}) is reallocated to later " + f"block(s) {', '.join(later)} before HALT, so its value at HALT is " + f"not the read") + return notation, parse_register(notation), f"block {block}, SysConfig register allocator" + + +def register_value(registers, where): + reg, byte, width = where + return (registers[reg] >> (8 * byte)) & ((1 << (8 * width)) - 1) + + +def judge_decoder(entry, model, evs, registers, override): + """Print and judge one ssi/bissc device. Returns 'pass' | 'fail' | 'not checked'.""" + kind = entry["kind"] + failed = False + if not evs: + print(" FAIL: device never produced a frame event") + if hasattr(model, "get_state"): + print(f" Device state: {model.get_state()}") + failed = True + try: + notation, where, provenance = result_register(entry, override) + except LookupError as exc: + print(f" FAIL: {exc}") + return "fail" + except NotChecked as exc: + print(f" NOT CHECKED: {exc}") + return "fail" if failed else "not checked" + print(f" Result register: {notation} ({provenance})") + raw = register_value(registers, where) + expected = getattr(model, "position", None) + if expected is None: + expected = model.encoder.position + if kind == "ssi": + decoded = raw + else: + decoded = (raw >> 8) & ((1 << model.encoder.position_bits) - 1) + print(f" Firmware decoded {notation}: {decoded}; model position: {expected}") + if kind == "bissc" and evs: + frame = 0 + for bit in model.encoder._bits: + frame = (frame << 1) | bit + if raw != frame: + print(f" FAIL: firmware frame {raw} differs from model frame {frame}") + failed = True + if decoded != expected: + print(" FAIL: firmware decoded position differs from model") + failed = True + return "fail" if failed else "pass" + + +class DeviceRefused(Exception): + """A binding cannot be attached to TI pru-simulator. Carries the reason.""" + + +def attach_bound_devices(pru, manifest, sim_dir): + """Build every binding and attach it to `pru`'s pins. Returns [(entry, model), ...]. + + The one attach path: `gmake sim` (main below) and the live browser view + (live_view.py) both call this, so what the dashboard drives is + exactly what the headless verdict judged. Raises DeviceRefused, never + attaches a subset. + """ + try: + attached = device_bindings.build_all(manifest, sim_dir) + except Exception as exc: + raise DeviceRefused(str(exc)) from exc + for entry, model in attached: + # the perif/ models (core.device_model contract: tick() + # returns a GPI word, no `nets`) cannot sit on TI pru-simulator's + # DeviceBus, which resolves nets per pin. Refuse by name rather + # than crash mid-run with an AttributeError. + if getattr(model, "nets", None) is None: + raise DeviceRefused( + f"device kind {entry['kind']!r} " + f"({type(model).__module__}.{type(model).__name__}) uses the " + f"core.device_model contract with no `nets`; TI pru-simulator's " + f"device bus cannot attach it, so gmake sim does not support " + f"this kind yet") + for _, model in attached: + pru.io_port.attach_device(model) + prime_device_bus(pru, attached) + return attached + + +def prime_device_bus(pru, attached): + """Mask PRU drive off the device-driven pins and settle the idle level. + + A live view repeats this after a core reset: reset clears the bus state and + the devices' history but keeps them attached. + """ + driven = set().union(*(device_bindings.driven_pins(model) for _, model in attached)) + device_bus = pru.io_port.device_bus + device_bus.set_pru_drive_mask(((1 << 20) - 1) & ~sum(1 << pin for pin in driven)) + # Establish the idle clock level before the first PRU edge. The model + # counts transitions; otherwise its first sample only seeds _prev_ma. + device_bus.settle(-1, pru.io_port.gpo) + + +def normalize_core(core): + """SysConfig core name ('icssg0_pru0', 'rtu_pru1') -> simulator core name.""" + if core and "_" in core: + match = re.search(r"(?:_|^)(rtu_pru|tx_pru|pru)([01])$", core) + core = ("rtu" if match.group(1) == "rtu_pru" else + "tx_pru" if match.group(1) == "tx_pru" else "pru") + match.group(2) if match else core + return core + + +def cycle_budget(args, manifest): + """(budget, provenance). --cycles overrides the manifest's cyclesToSimulate.""" + if args.cycles is not None: + return args.cycles, "--cycles" + value = manifest.get("cyclesToSimulate") + if value is None: + return DEFAULT_CYCLES, "default; manifest has no cyclesToSimulate" + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise device_bindings.BindingError( + f"manifest cyclesToSimulate must be a positive integer, got {value!r}") + return value, "manifest cyclesToSimulate" + + +def report_run(sim, core, attached, budget, budget_source, decoded_register=None): + """Print the run's findings and return the verdict exit code. + + The judge shared by `gmake sim` and the live view: everything from the + clock levels to RESULT. Reads the simulator's current state only. + """ + pru = sim.cores[core] + device_bus = pru.io_port.device_bus + # Where the firmware left each PRU-driven clock. A frame that + # ends with the clock off its idle level is invisible to the decode + # (the next transaction simply starts with a phantom edge), so say it. + for entry, _ in attached: + if entry.get("clockPin") is not None: + level = (pru.io_port.gpo >> entry["clockPin"]) & 1 + print(f"Clock R30.{entry['clockPin']} level at end of run: {level}") + + cycles = pru.counters.cycles + stalls = pru.counters.stall_cycles + print(f"Cycles: {cycles} (budget: {budget} from {budget_source})") + print(f"Stalls: {stalls}") + print(f"Halted: {pru.halted}") + + has_faults = not pru.halted + not_checked = [] + if not pru.halted: + print("FAIL: cycle budget exhausted before HALT") + contentions = list(device_bus.contentions) + print(f"Bus contentions: {len(contentions)}") + for contention in contentions: + print(f" CONTENTION: {contention}") + if contentions: + print(f"FAIL: {len(contentions)} bus contention(s) - the PRU and a " + f"device drove one push-pull net to opposite levels") + has_faults = True + registers = sim.registers(core) + for entry, model in attached: + kind = entry["kind"] + print(f"\nDevice {kind} on GPI{entry['primaryPin']}:") + faults = getattr(model, "faults", lambda: [])() + if faults: + has_faults = True + print(" FAULTS:") + for f in faults: + print(f" - {f}") + else: + print(" FAULTS: None") + + if hasattr(model, "count"): + print(f" Count: {model.count}") + if hasattr(model, "pos"): + print(f" Position: {model.pos}") + if hasattr(model, "position"): + print(f" Model position: {model.position}") + if hasattr(model, "frames"): + frames = model.frames + if callable(frames): + frames = frames() + if isinstance(frames, int): + # e.g. UartTransmitter: a configured repeat count, not a log. + print(f" Frames configured: {frames}") + else: + print(f" Frames: {len(frames)}") + + evs = model.events() if hasattr(model, "events") else [] + print(f" Events: {len(evs)}") + + if kind in DECODERS: + verdict = judge_decoder(entry, model, evs, registers, decoded_register) + if verdict == "fail": + has_faults = True + elif verdict == "not checked": + not_checked.append(kind) + else: + if not evs: + print(" Device recorded no frame/activity event") + print(f" NOT CHECKED: no decoder for kind {kind}; this run is " + f"not a verdict on the firmware") + not_checked.append(kind) + + # Also print registers so tests can assert decoded values! + print("\nRegisters:") + for i, reg in enumerate(sim.registers(core)): + print(f" R{i}: {reg}") + + if has_faults: + print("\nRESULT: FAIL") + return EXIT_FAIL + if not_checked: + print(f"\nRESULT: NOT CHECKED ({', '.join(not_checked)}) - not a pass") + return EXIT_NOT_CHECKED + print("\nRESULT: PASS") + return EXIT_PASS + + +def main(argv=None): + ap = argparse.ArgumentParser(description="Run built ELF against device models") + ap.add_argument("elf", help="Path to compiled ELF (.out)") + ap.add_argument("manifest", help="Path to pru_device_bindings.json") + ap.add_argument("--cycles", type=int, default=None, + help="Cycle budget; overrides the manifest's cyclesToSimulate " + f"(default when neither is given: {DEFAULT_CYCLES})") + ap.add_argument("--core", help="Override the SysConfig core in the manifest") + ap.add_argument("--decoded-register", type=int, + help="Explicit override of the register holding the decoded " + "value, for hand-built ELFs. A SysConfig build carries " + "the allocator's choice in the manifest.") + ap.add_argument("--vcd", help="Write CLK/DATA pin waveform from the TI simulator") + args = ap.parse_args(argv) + + try: + manifest = device_bindings.load_manifest(args.manifest) + budget, budget_source = cycle_budget(args, manifest) + except device_bindings.BindingError as exc: + print(f"REFUSED: {exc}", file=sys.stderr) + return EXIT_REFUSED + + bindings = manifest.get("bindings", []) + if not bindings: + print("REFUSED: manifest has no device bindings. An empty manifest is REFUSED.", file=sys.stderr) + return EXIT_REFUSED + + sim_dir = find_pru_simulator() + + with _sim_namespace(sim_dir): + from simulator import Simulator + + sim = Simulator(os.path.join(sim_dir, "memory.cfg")) + from tools.headless_runner import _validate_linked_elf + core = normalize_core(args.core or manifest.get("core")) + if not core or core not in sim.cores: + print(f"REFUSED: unknown or absent PRU core {core!r}", file=sys.stderr) + return EXIT_REFUSED + with open(args.elf, "rb") as f: + elf_data = f.read() + try: + entry_pc = _validate_linked_elf(elf_data) + except ValueError as exc: + print(f"REFUSED: invalid linked ELF: {exc}", file=sys.stderr) + return EXIT_REFUSED + errors = sim.load_elf(core, elf_data) + if errors: + print(f"ELF load errors: {errors}", file=sys.stderr) + return EXIT_FAIL + + pru = sim.cores[core] + pru.pc = entry_pc + + try: + attached = attach_bound_devices(pru, manifest, sim_dir) + except DeviceRefused as exc: + print(f"REFUSED: {exc}", file=sys.stderr) + return EXIT_REFUSED + + print(f"Attached {len(attached)} devices") + samples = [] + if args.vcd: + samples.append((pru.io_port.gpo, pru.io_port.gpi)) + for step in range(budget): + sim.step(core, 1) + if args.vcd: + samples.append((pru.io_port.gpo, pru.io_port.gpi)) + if pru.halted: + break + + if args.vcd: + first = next((entry for entry, _ in attached if entry["kind"] == "ssi"), None) + if first is not None: + write_pin_vcd(args.vcd, samples, first["clockPin"], first["primaryPin"]) + print(f"VCD: {args.vcd} (CLK R30.{first['clockPin']}, DATA R31.{first['primaryPin']})") + + return report_run(sim, core, attached, budget, budget_source, + args.decoded_register) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/simulator.js b/scripts/simulator.js new file mode 100644 index 0000000..7f6c242 --- /dev/null +++ b/scripts/simulator.js @@ -0,0 +1,2488 @@ +/** + * simulator.js - Enhanced PRU Simulator with agent-driven validation support + * + * Provides a modular Simulator class wrapping SysConfig's pru_core.js / pru_dmem0.js / + * pru_smem.js simulation engines. Adds: + * + * G1 - load_elf(buffer): parse TI COFF PRU firmware binaries, load sections into DMEM/SMEM + * G2 - step() / stepPaced(): single-cycle through multi-cycle stepping for multicore orchestration + * G3 - runUntil({maxCycles, watchpoints}): halt on cycle budget, halt, PC, register, or memory conditions + * G4 - exportVcd(filePath): Value Change Dump waveform export + */ + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +// the same-line label grammar must be the repo's canonical +// one (asm_label.js splitLabel, [A-Za-z0-9_?$]+). The previous local regex +// [A-Za-z_$][\w$]* rejected `?` anywhere and leading digits, so e.g. +// `pb_ws?: add r1,r1,1` never registered pb_ws? as a label and a +// `qbne pb_ws?, r1, 3` branch silently fell through with errorParsing set. +const { splitLabel } = require('./asm_label'); + +/** + * Minimal TI COFF (Common Object File Format) parser for PRU firmware .out files. + * + * The TI PRU toolchain (clpru) produces COFF REL32 files. This parser extracts: + * - File header + * - Section headers (.text, .data, .bss, etc.) + * - Section raw data + * - Symbol table entries + * + * TI COFF reference: + * https://www.ti.com/lit/an/spraao8/spraao8.pdf + */ +class CoffParser { + /** + * @param {Buffer} buffer - Raw binary buffer of a COFF .out file + */ + constructor(buffer) { + this.buffer = buffer; + this.header = null; + this.sections = []; + this.symbols = []; + this.stringTable = null; + } + + /** + * Parse the COFF file structure. + * @returns {this} + */ + parse() { + const buf = this.buffer; + if (buf.length < 22) { + throw new Error(`COFF file too small: ${buf.length} bytes`); + } + + // ---------- File header (20 bytes for COFF REL32) ---------- + const magic = buf.readUInt16LE(0); + // TI PRU magic: 0x00C2 for COFF, 0x00C3 for COFF with clpru v2.3+ + if (magic !== 0x00C2 && magic !== 0x00C3) { + throw new Error(`Not a TI COFF file. Magic: 0x${magic.toString(16)} (expected 0x00C2 or 0x00C3)`); + } + const nscns = buf.readUInt16LE(2); // Number of sections + const timdat = buf.readInt32LE(4); // Date/time stamp + const symptr = buf.readUInt32LE(8); // Symbol table offset + const nsyms = buf.readUInt32LE(12); // Number of symbol table entries + const opthdr = buf.readUInt16LE(16); // Size of optional header (0 for PRU) + const flags = buf.readUInt16LE(18); // Flags + + this.header = { magic, nscns, timdat, symptr, nsyms, opthdr, flags }; + let offset = 20 + opthdr; // start of section headers + + // ---------- Section headers (40 bytes each) ---------- + for (let i = 0; i < nscns; i++) { + if (offset + 40 > buf.length) break; + const s_name = buf.toString('ascii', offset, offset + 8).replace(/\0/g, '').trim(); + const s_paddr = buf.readUInt32LE(offset + 8); + const s_vaddr = buf.readUInt32LE(offset + 12); + const s_size = buf.readUInt32LE(offset + 16); + const s_scnptr = buf.readUInt32LE(offset + 20); + const s_relptr = buf.readUInt32LE(offset + 24); + const s_lnnoptr = buf.readUInt32LE(offset + 28); + const s_nreloc = buf.readUInt16LE(offset + 32); + const s_nlnno = buf.readUInt16LE(offset + 34); + const s_flags = buf.readUInt32LE(offset + 36); + + // Load raw data if the section has content and is not BSS (0x80) + let data = null; + const IS_BSS = (s_flags & 0x80) !== 0; + if (!IS_BSS && s_size > 0 && s_scnptr > 0 && s_scnptr + s_size <= buf.length) { + data = buf.slice(s_scnptr, s_scnptr + s_size); + } + + this.sections.push({ + name: s_name, + paddr: s_paddr, + vaddr: s_vaddr, + size: s_size, + scnptr: s_scnptr, + relptr: s_relptr, + nreloc: s_nreloc, + flags: s_flags, + data + }); + + offset += 40; + } + + // ---------- Symbol table ---------- + if (symptr > 0 && nsyms > 0) { + let symOff = symptr; + for (let i = 0; i < nsyms; i++) { + if (symOff + 18 > buf.length) break; + // Symbol name (8 bytes: either inline string or offset into string table) + const n_name = buf.toString('ascii', symOff, symOff + 8); + const n_value = buf.readInt32LE(symOff + 8); + const n_scnum = buf.readInt16LE(symOff + 12); + const n_type = buf.readUInt16LE(symOff + 14); + const n_sclass = buf.readUInt8(symOff + 16); + const n_numaux = buf.readUInt8(symOff + 17); + + // Check if name is a string table offset (first 4 bytes form an offset when starts with \0\0\0\0) + let name = n_name.replace(/\0/g, '').trim(); + if (name.length === 0 || (name.length > 0 && n_name.charCodeAt(0) === 0)) { + // Name is in string table: offset is n_name as uint32 + const strOff = buf.readUInt32LE(symOff); + if (strOff > 0 && this.stringTable && strOff < this.stringTable.length) { + name = this.stringTable.toString('ascii', strOff).split('\0')[0]; + } else { + name = `unnamed_${i}`; + } + } + + this.symbols.push({ name, value: n_value, scnum: n_scnum, type: n_type, sclass: n_sclass, numaux: n_numaux }); + symOff += 18; + } + + // String table immediately follows symbol table entries + const strTabOff = symptr + nsyms * 18; + if (strTabOff < buf.length) { + const strTabSize = buf.readUInt32LE(strTabOff); + if (strTabSize > 0 && strTabOff + strTabSize <= buf.length) { + this.stringTable = buf.slice(strTabOff, strTabOff + strTabSize); + } + } + } + + return this; + } + + /** + * Get a specific section by name. + * @param {string} name + * @returns {Object|null} + */ + section(name) { + return this.sections.find(s => s.name === name) || null; + } + + /** + * Get the entry point (symbol "_c_int00" or first defined symbol value) + * @returns {number} + */ + entryPoint() { + const main = this.symbols.find(s => s.name === '_c_int00' || s.name === '_start' || s.name === 'main'); + if (main) return main.value >>> 0; + // Fall back to .text section vaddr + const text = this.section('.text'); + return text ? text.vaddr : 0; + } +} + +/** + * COFF symbol storage class constants used by TI tools + */ +const C_FILE = 0x66; +const C_EXT = 0x02; +const C_STAT = 0x03; +const C_LABEL = 0x05; +const C_MOS = 0x1A; +const C_MOE = 0x1B; + +/** + * Multi-format ELF/COFF loader for PRU firmware binaries. + * Handles both TI COFF (.out) and ELF (.out/.elf) with .pru encoding. + * + * @param {Buffer} buffer - Raw firmware binary + * @returns {Object} { text: Buffer, data: Buffer|null, symbols: Array, entryPoint: number } + */ +function loadPruFirmware(buffer) { + if (buffer.length < 4) { + throw new Error(`Firmware buffer too small: ${buffer.length} bytes`); + } + + // Detect format by magic bytes + const magic16 = buffer.readUInt16LE(0); + const magic32 = buffer.readUInt32LE(0); + + // ELF magic: 0x7F 'E' 'L' 'F' + if (buffer[0] === 0x7F && buffer[1] === 0x45 && buffer[2] === 0x4C && buffer[3] === 0x46) { + return loadElfFirmware(buffer); + } + + // TI COFF magic + if (magic16 === 0x00C2 || magic16 === 0x00C3) { + return loadCoffFirmware(buffer); + } + + // Raw binary (just instructions at offset 0) + // Assume each instruction is 4 bytes + // This is a fallback for simple test binaries + return { + text: buffer, + data: null, + symbols: [], + entryPoint: 0, + format: 'raw' + }; +} + +/** + * Load ELF PRU firmware. + * Parses minimal ELF header to find .text and .data program headers. + */ +function loadElfFirmware(buffer) { + if (buffer.length < 52) { + throw new Error(`ELF file too small: ${buffer.length} bytes`); + } + + const is64Bit = buffer[4] === 2; + const isLittleEndian = buffer[5] === 1; + const readU16 = isLittleEndian ? (off) => buffer.readUInt16LE(off) : (off) => buffer.readUInt16BE(off); + const readU32 = isLittleEndian ? (off) => buffer.readUInt32LE(off) : (off) => buffer.readUInt32BE(off); + const readU64 = isLittleEndian ? (off) => Number(buffer.readBigUInt64LE(off)) : (off) => Number(buffer.readBigUInt64BE(off)); + + if (is64Bit) { + throw new Error('64-bit ELF not supported (PRU is 32-bit)'); + } + + // ELF header fields + const e_type = readU16(16); + const e_machine = readU16(18); + // PRU ELF machine IDs: EM_TI_PRU = 203 (0xCB), or EM_PRU = 0xCB + if (e_machine !== 0xCB) { + throw new Error(`Not a PRU ELF file. Machine: 0x${e_machine.toString(16)} (expected 0xCB)`); + } + const e_entry = readU32(24); + const e_phoff = readU32(28); + const e_shoff = readU32(32); + const e_flags = readU32(36); + const e_ehsize = readU16(40); + const e_phentsize = readU16(42); + const e_phnum = readU16(44); + const e_shentsize = readU16(46); + const e_shnum = readU16(48); + const e_shstrndx = readU16(50); + + // Parse section headers to find .text and .data + let textData = null; + let dataSection = null; + const symbols = []; + + if (e_shoff > 0 && e_shnum > 0) { + // First read section name string table + let shstrtabData = null; + if (e_shstrndx < e_shnum && e_shstrndx >= 0) { + const shOff = e_shoff + e_shstrndx * e_shentsize; + const sh_nameOff = readU32(shOff + 16); // sh_offset + const sh_size = readU32(shOff + 20); // sh_size + if (sh_nameOff + sh_size <= buffer.length) { + shstrtabData = buffer.slice(sh_nameOff, sh_nameOff + sh_size); + } + } + + for (let i = 0; i < e_shnum; i++) { + const shOff = e_shoff + i * e_shentsize; + if (shOff + 40 > buffer.length) break; + const sh_name = readU32(shOff); + const sh_type = readU32(shOff + 4); + const sh_flags = readU32(shOff + 8); + const sh_addr = readU32(shOff + 12); + const sh_offset = readU32(shOff + 16); + const sh_size = readU32(shOff + 20); + const sh_link = readU32(shOff + 24); + const sh_info = readU32(shOff + 28); + + // Get section name from string table + let sectionName = ''; + if (shstrtabData && sh_name < shstrtabData.length) { + const end = shstrtabData.indexOf(0, sh_name); + sectionName = shstrtabData.toString('ascii', sh_name, end >= 0 ? end : undefined); + } + + // Load section data + let sectionData = null; + if (sh_type !== 8 /* SHT_NOBITS */ && sh_offset + sh_size <= buffer.length && sh_size > 0) { + sectionData = buffer.slice(sh_offset, sh_offset + sh_size); + } + + if (sectionName === '.text' && sectionData) { + textData = sectionData; + } + if ((sectionName === '.data' || sectionName === '.rodata') && sectionData) { + dataSection = { addr: sh_addr, data: sectionData, name: sectionName }; + } + + // Parse symbol table + if (sh_type === 2 /* SHT_SYMTAB */ && sectionData) { + const symSize = 16; // 32-bit ELF symbol: 16 bytes + const numSyms = Math.floor(sectionData.length / symSize); + // Get the associated string table from sh_link + let strTabData = null; + if (sh_link < e_shnum && sh_link > 0) { + const strShOff = e_shoff + sh_link * e_shentsize; + const strOff = readU32(strShOff + 16); + const strSize = readU32(strShOff + 20); + if (strOff + strSize <= buffer.length) { + strTabData = buffer.slice(strOff, strOff + strSize); + } + } + for (let s = 0; s < numSyms; s++) { + const symOff = s * 16; + const st_name = readU32(symOff); + const st_value = readU32(symOff + 4); + const st_size = readU32(symOff + 8); + const st_info = buffer.readUInt8(symOff + 12); + const st_other = buffer.readUInt8(symOff + 13); + const st_shndx = readU16(symOff + 14); + + let symName = ''; + if (strTabData && st_name < strTabData.length) { + const end = strTabData.indexOf(0, st_name); + symName = strTabData.toString('ascii', st_name, end >= 0 ? end : undefined); + } + symbols.push({ + name: symName, + value: st_value, + size: st_size, + bind: st_info >> 4, + type: st_info & 0xF, + shndx: st_shndx + }); + } + } + } + } + + // Fall back to program headers if section headers didn't yield data + if (!textData && e_phoff > 0 && e_phnum > 0) { + for (let i = 0; i < e_phnum; i++) { + const phOff = e_phoff + i * e_phentsize; + if (phOff + 32 > buffer.length) break; + const p_type = readU32(phOff); + const p_offset = readU32(phOff + 4); + const p_vaddr = readU32(phOff + 8); + const p_paddr = readU32(phOff + 12); + const p_filesz = readU32(phOff + 16); + const p_memsz = readU32(phOff + 20); + const p_flags = readU32(phOff + 24); + const p_align = readU32(phOff + 28); + + if (p_type === 1 /* PT_LOAD */ && p_offset + p_filesz <= buffer.length && p_filesz > 0) { + const segData = buffer.slice(p_offset, p_offset + p_filesz); + // Executable segments (PF_X = 1) are .text + if (p_flags & 1) { + textData = segData; + } else { + dataSection = { addr: p_vaddr, data: segData, name: '.data' }; + } + } + } + } + + return { + text: textData || Buffer.alloc(0), + data: dataSection, + symbols, + entryPoint: e_entry, + format: 'elf' + }; +} + +/** + * Load TI COFF PRU firmware. + */ +function loadCoffFirmware(buffer) { + const parser = new CoffParser(buffer).parse(); + + const textSect = parser.section('.text'); + const dataSect = parser.section('.data'); + + let dataSection = null; + if (dataSect && dataSect.data) { + dataSection = { addr: dataSect.vaddr, data: dataSect.data, name: '.data' }; + } + // Also check for .const, .rodata, .bss if no .data + if (!dataSection) { + for (const s of ['.const', '.rodata', '.bss']) { + const sect = parser.section(s); + if (sect && sect.data && sect.size > 0) { + dataSection = { addr: sect.vaddr, data: sect.data, name: s }; + break; + } + } + } + + return { + text: textSect && textSect.data ? textSect.data : Buffer.alloc(0), + data: dataSection, + symbols: parser.symbols, + entryPoint: parser.entryPoint(), + format: 'coff' + }; +} + +// ---------- VCD (Value Change Dump) Export ---------- + +/** + * Format a VCD timestamp line. + * @param {number} cycle + * @returns {string} + */ +function vcdTimestamp(cycle) { + return `#${cycle}\n`; +} + +/** + * VCD-encode a single-bit signal value: '0' or '1'. + * @param {number} bitValue + * @returns {string} + */ +function vcdBit(bitValue) { + return bitValue ? '1' : '0'; +} + +/** + * VCD-encode a multi-bit signal value. + * @param {number} value + * @param {number} width + * @returns {string} + */ +function vcdVector(value, width) { + return `b${value.toString(2).padStart(width, '0')} `; +} + +/** + * Export R30/R31/pin-level traces as VCD format. + * + * @param {Array} r30History - Per-cycle R30 values + * @param {Array} [r31History] - Per-cycle R31 values + * @param {Object} [options] + * @param {string} [options.moduleName='PRU'] - Module name for VCD header + * @param {string} [options.date] - Date string + * @param {string} [options.version='PRU Simulator'] - VCD version + * @param {number} [options.timeScale=1] - Timescale unit (ns per cycle) + * @param {Array} [options.signals] - Specific bit indices to export (default: all 32 bits) + * @returns {string} VCD content + */ +function exportVcd(r30History, r31History, options = {}) { + const modName = options.moduleName || 'PRU'; + const dateStr = options.date || new Date().toISOString(); + const version = options.version || 'PRU Simulator'; + const timeScale = options.timeScale || 1; + const sigFilter = options.signals; // undefined means all + const cycles = r30History.length; + + const lines = []; + lines.push('$version ' + version + ' $end'); + lines.push('$date ' + dateStr + ' $end'); + lines.push('$timescale ' + timeScale + 'ns $end'); + lines.push('$scope module ' + modName + ' $end'); + + // Declare R30 bits + const r30Bits = sigFilter || Array.from({ length: 32 }, (_, i) => i); + for (const bit of r30Bits) { + const code = String.fromCharCode(33 + (bit % 26)) + (bit < 26 ? '' : Math.floor(bit / 26).toString(36)); + lines.push(`$var wire 1 ${code} R30_${bit} $end`); + } + + // Declare R31 bits + const r31 = r31History || []; + const r31Bits = sigFilter || Array.from({ length: 32 }, (_, i) => i); + for (const bit of r31Bits) { + const code = String.fromCharCode(97 + (bit % 26)) + (bit < 26 ? '' : Math.floor(bit / 26).toString(36)); + lines.push(`$var wire 1 ${code} R31_${bit} $end`); + } + + lines.push('$upscope $end'); + lines.push('$enddefinitions $end'); + lines.push('$dumpvars'); + + // Initial values at cycle 0 + for (const bit of r30Bits) { + const code = String.fromCharCode(33 + (bit % 26)) + (bit < 26 ? '' : Math.floor(bit / 26).toString(36)); + lines.push(`${vcdBit((r30History[0] >> bit) & 1)}${code}`); + } + for (const bit of r31Bits) { + const code = String.fromCharCode(97 + (bit % 26)) + (bit < 26 ? '' : Math.floor(bit / 26).toString(36)); + const val = r31.length > 0 ? (r31[0] >> bit) & 1 : 0; + lines.push(`${vcdBit(val)}${code}`); + } + lines.push('$end'); + + // Per-cycle changes + for (let c = 1; c < cycles; c++) { + lines.push(vcdTimestamp(c).trim()); + for (const bit of r30Bits) { + const prev = (r30History[c - 1] >> bit) & 1; + const curr = (r30History[c] >> bit) & 1; + if (curr !== prev) { + const code = String.fromCharCode(33 + (bit % 26)) + (bit < 26 ? '' : Math.floor(bit / 26).toString(36)); + lines.push(`${vcdBit(curr)}${code}`); + } + } + if (r31.length > c) { + for (const bit of r31Bits) { + const prev = (r31[c - 1] >> bit) & 1; + const curr = (r31[c] >> bit) & 1; + if (curr !== prev) { + const code = String.fromCharCode(97 + (bit % 26)) + (bit < 26 ? '' : Math.floor(bit / 26).toString(36)); + lines.push(`${vcdBit(curr)}${code}`); + } + } + } + } + + return lines.join('\n') + '\n'; +} + +// ======================================================================== +// Simulator class +// ======================================================================== + +const SIM_DIR = path.join(__dirname, '../.metadata/sysconfig/.meta/pru_blocks/common/simulation'); + +/** + * Comprehensive PRU Simulator with programmatic agent-driven validation support. + * + * Usage: + * const sim = new Simulator(); + * sim.loadInstructions([...], [...labels]); + * const state = sim.runUntil({ maxCycles: 100, watchpoints: [{ type: 'halt' }] }); + * console.log(state.registers); + * + * Multiple core simulation: + * const core0 = new Simulator({ id: 'PRU0' }); + * const core1 = new Simulator({ id: 'PRU1' }); + * for (let i = 0; i < 100; i++) { + * core0.stepPaced(1); + * core1.stepPaced(1); + * } + */ +/** + * ICSSG spinlock (XFR2SPIN) ownership flags. + * + * SPRUIM2J Table 6-60 gives XFR2SPIN as "1 copy per PRU_ICSSG system", and + * 6.4.6.2.9 as "64 ownership flags (shared by SLICE0 and SLICE1 within an + * PRU_ICSSG subsystem)". The defining property is that the SAME 64 flags are + * seen by all six cores of the subsystem. + * + * That is why this state is module-scoped and keyed by subsystem rather than + * hung off pruState like every other device model in this file. Each Simulator + * compiles its own copy of the memory models into its own vm context, so + * per-instance state is invisible to a second core BY CONSTRUCTION - a spinlock + * modelled that way would let both cores "win" the same flag, which is exactly + * the defect that sank the first attempt at this item. A lock whose state is + * not shared is not a lock. + * + * Keys are subsystem ids ('icssg0' by default); values map flag id -> the core + * id currently holding it. + */ +const SPINLOCK_FLAGS = new Map(); +const SPINLOCK_NUM_FLAGS = 64; +const SPINLOCK_DEVICE_ID = 0x90; // 144 + +// ICSSG BS RAM — per-core private 2KB (SPRUIM2J 6.4.6.2.5 Fig 6-49). +// TRM's "16KB" line on this row is copy-pasted from the FDB table; device is +// 2KB = 64 rows x 32B, row 0..63. Broadside IDs: 48 (0x30) address+data +// XOUT/XIN, 49 (0x31) snoop XOUT. Fig 6-49 assigns one device ID per core; +// 30 (0x1E) is RTU_PRU's ID and is not BS RAM on a PRU/TX_PRU core. All data windows are +// architecturally R2..R9 (< =32B); address is R10, exactly 2 bytes. Per-core +// private means per-Simulator instance, not per-subsystem like XFR2SPIN. +const BS_RAM_DEVICE_WRITE = 48; +const BS_RAM_DEVICE_SNOOP = 49; +const BS_RAM_DEVICE_READ = 48; +const BS_RAM_NUM_ROWS = 64; +const BS_RAM_ROW_BYTES = 32; + +const FDB_DEVICE_DATA_ADDR = 30; +const FDB_DEVICE_PATTERN = 31; +const FDB_DEVICE_COMPARE = 35; +const FDB_NUM_ROWS = 512; +const FDB_ROW_BYTES = 32; +const FDB_PATTERN_BYTES = 16; + +function spinlockSubsystem(name) { + if (!SPINLOCK_FLAGS.has(name)) SPINLOCK_FLAGS.set(name, new Map()); + return SPINLOCK_FLAGS.get(name); +} + +class Simulator { + // See _xfr2trDrain. Not from the TRM -- a simulator pacing choice. + static XFR2TR_DRAIN_PERIOD_CYCLES = 20; + + // Tests must be able to start from a known state without leaking + // ownership between scenarios; nothing in the hardware corresponds to this. + static resetSpinlocks() { SPINLOCK_FLAGS.clear(); } + + /** Inspect who holds a flag. Test/debug helper, not a hardware path. */ + static spinlockOwner(flagId, subsystem = 'icssg0') { + const owner = spinlockSubsystem(subsystem).get(flagId & (SPINLOCK_NUM_FLAGS - 1)); + return owner === undefined ? null : owner; + } + + /** + * @param {Object} [options] + * @param {string} [options.id='PRU0'] - Instance identifier for multicore + * @param {boolean} [options.autoLoadSimulation=true] - Auto-load pru_core.js/dmem0/smem + * @param {Object} [options.simulationDir] - Override simulation scripts directory + */ + constructor(options = {}) { + this.id = options.id || 'PRU0'; + // Which PRU_ICSSG subsystem this core belongs to. Two Simulators + // with the same subsystem contend for one set of 64 spinlock flags; two + // with different subsystems do not (Table 6-60: one copy per subsystem). + this.subsystem = options.subsystem || 'icssg0'; + this._autoLoad = options.autoLoadSimulation !== false; + this._simDir = options.simulationDir || SIM_DIR; + + // Simulation state + this._pruState = null; + this._instructions = []; + this._labels = []; + this._labelMap = {}; + this._r30History = []; + this._r31History = []; + this._r31HistoryMap = {}; + this._mmioMap = {}; + + // Firmware loading state + this._firmware = null; // { text, data, symbols, entryPoint, format } + this._dmemSymbols = {}; // Symbol name → { address, size } + this._rawInstructions = null; // 32-bit instruction words from .text + + // Watchpoint state + this._watchpoints = []; + this._watchpointHit = null; + + // pru_core.js exports (loaded on demand) + this._pruCore = null; + this._pruDMEM0 = null; + this._pruSMEM = null; + + // Script loading cache + this._scriptCache = {}; + } + + /** + * Ensure simulation scripts are loaded (idempotent). + */ + _ensureLoaded() { + if (this._pruCore) return; + + if (!fs.existsSync(this._simDir)) { + throw new Error(`Simulation directory not found: ${this._simDir}`); + } + + // Load pru_core.js, pru_dmem0.js, pru_smem.js via Node.js vm sandbox + this._pruCore = this._loadSimScript('pru_core.js'); + this._pruDMEM0 = this._scriptCache['pru_dmem0.js']; + this._pruSMEM = this._scriptCache['pru_smem.js']; + } + + /** + * Load a SysConfig simulation script in a Node.js vm sandbox. + * @param {string} filename + * @returns {Object} + */ + _loadSimScript(filename) { + const filePath = path.join(this._simDir, filename); + const code = fs.readFileSync(filePath, 'utf8'); + + const sandbox = { + console: { log() {}, error() {}, warn() {} }, + Math, + parseInt, + isNaN, + eval, + Uint8Array, + Array, + JSON, + system: { + getScript: (p) => { + const basename = path.basename(p); + if (!this._scriptCache[basename]) { + this._scriptCache[basename] = this._loadSimScript(basename); + } + return this._scriptCache[basename]; + } + }, + exports: {} + }; + vm.createContext(sandbox); + vm.runInContext(code, sandbox, { filename: filePath, timeout: 10000 }); + return sandbox.exports; + } + + // ================================================================== + // API: Instruction loading + // ================================================================== + + /** + * Load assembly instructions for simulation. + * @param {Array} instructions - Assembly instruction strings + * @param {Array} labels - Labels per instruction index (0 for no label) + */ + loadInstructions(instructions, labels = []) { + this._ensureLoaded(); + this._instructions = [...instructions]; + this._labels = [...labels]; + this._labelMap = {}; + for (let i = 0; i < this._labels.length; i++) { + if (this._labels[i] !== 0 && this._labels[i] !== undefined) { + this._labelMap[this._labels[i]] = i; + } + } + // A macro body pasted straight out of getMacro() (the same call + // pru_syscfg.inc.xdt makes) carries its backward-branch target as an + // INLINE "name:" line, e.g. "xs_spin$1$:", not as an entry in the + // parallel `labels` array above. Register those too, pointing at their + // own index: the line itself decodes as an unknown opcode and is a + // harmless no-op (_executeSingleInstruction's default case), so landing + // on it and falling through to the next real instruction is correct. + // Without this, qbbc/qbbs/qbeq/... targeting such a label found no entry + // in labelMap, the branch was silently not taken, and a contended spin + // loop looked like a single arbitration attempt instead of a spin. + // + // Contrary to what this comment used to claim, NOT every + // producer splits same-line labels before calling loadInstructions + // (scripts/sim/test_bswap.js, test_xfr2spin.js, + // test_xfr2tr_ring.js and run_headless_json.js pass raw macro lines + // through). loadInstructions therefore handles `lbl: instr` itself now: + // the label is registered at the line's own index and the stored line is + // replaced with the bare instruction, so a branch to the label EXECUTES + // that instruction instead of no-oping past it (and sequential flow no + // longer silently drops it). No block emits a same-line label today, so + // this only closes the latent gap; it changes nothing for bare labels. + for (let i = 0; i < this._instructions.length; i++) { + const line = this._instructions[i]; + if (typeof line !== 'string') continue; + // same label grammar as asm_label.js (reused, not + // duplicated) - accepts `?` and leading digits in label names. + const parts = splitLabel(line); + if (!parts || !parts.label) continue; + if (this._labelMap[parts.label] === undefined) this._labelMap[parts.label] = i; + if (parts.rest) this._instructions[i] = parts.rest; + } + this._resetState(); + } + + /** + * G1: Load PRU firmware binary (.out / .elf) via COFF/ELF parsing. + * + * Parses the binary to extract: + * - .text section (instructions) - stored as raw 32-bit words + * - .data section - loaded into DMEM0 + * - Symbol table - available for watchpoint/breakpoint lookup + * + * Note: The simulator currently executes string-based assembly instructions. + * This method stores the firmware's memory image and symbols. A future + * enhancement will add raw opcode execution. For now, use loadInstructions() + * with assembly strings, then load_elf() for data/symbol context. + * + * @param {Buffer|string} source - Binary buffer or path to .out/.elf file + * @returns {Object} Parsed firmware info + */ + load_elf(source) { + this._ensureLoaded(); + + const buffer = Buffer.isBuffer(source) ? source : fs.readFileSync(source); + const firmware = loadPruFirmware(buffer); + this._firmware = firmware; + + // Load data section into DMEM0 + if (firmware.data && firmware.data.data) { + const dmemAddr = firmware.data.addr; + this._pruDMEM0.writeDMEM0(dmemAddr, firmware.data.data); + } + + // Register symbols from the binary + this._dmemSymbols = {}; + for (const sym of firmware.symbols) { + if (sym.name && typeof sym.value === 'number') { + // For COFF symbol table entries with scnum > 0 (defined in a section) + const addr = sym.value >>> 0; + const size = sym.size || 4; + this._dmemSymbols[sym.name] = { address: addr, size }; + this._pruDMEM0.addSymbol(sym.name, addr, size); + } + } + + // Store raw text section + if (firmware.text && firmware.text.length > 0) { + this._rawInstructions = []; + for (let i = 0; i < firmware.text.length; i += 4) { + if (i + 4 <= firmware.text.length) { + this._rawInstructions.push(firmware.text.readUInt32LE(i)); + } + } + } + + return firmware; + } + + // ================================================================== + // API: Simulation control + // ================================================================== + + /** + * Reset simulation state (registers, history, PC, cycles). + */ + _resetState() { + this._pruState = { + registers: new Array(32).fill(0), + pc: 0, + cycles: 0, + carry: 0, + loopStack: [], + unknownInstruction: 0, + errorParsing: 0, + r31HistoryMap: this._r31HistoryMap, + mmioMap: this._mmioMap, + iepCount: 0, + iepCmp0: 0, + iepCmp1: 0, + iepCmpStatus: 0, + // XFR2TR ring state (SPRUIM2J Table 6-112). queue holds the + // submitted-but-not-yet-drained TR IDs (capacity 8, the FIFO depth). + xfr2tr: null, + // BS RAM 2KB per-core private 64x32B. Per-core means per-Simulator + // instance (this._pruState), not per-subsystem like XFR2SPIN. Lazily + // allocated; each row is 8 words (32 bytes). Also the flat SPAD fallback + // that previously made broadside accesses appear to work is now gated: 48/49 are + // no longer handled by _spad, they are BS RAM or unsupported. + bsRam: null, + bsRamAddr: 0, + fdb: null, + unsupported_xfr: [], + queue_empty: this._queueEmptyStatus !== undefined ? this._queueEmptyStatus : 0xFFFFFFFF + }; + this._r30History = [0]; + this._r31History = [0]; + this._watchpointHit = null; + } + + /** + * Get current R30 value history (per-cycle snapshot). + * @returns {Array} + */ + get r30History() { + return [...this._r30History]; + } + + /** + * Get current R31 value history (per-cycle snapshot). + * @returns {Array} + */ + get r31History() { + return [...this._r31History]; + } + + /** + * Get current simulation state (registers, PC, cycles). + * @returns {Object} + */ + get state() { + if (!this._pruState) return null; + return { + registers: [...this._pruState.registers], + pc: this._pruState.pc, + cycles: this._pruState.cycles, + carry: this._pruState.carry, + iepCount: this._pruState.iepCount, + iepCmp0: this._pruState.iepCmp0, + iepCmp1: this._pruState.iepCmp1, + iepCmpStatus: this._pruState.iepCmpStatus, + xfr2tr: this._pruState.xfr2tr ? JSON.parse(JSON.stringify(this._pruState.xfr2tr)) : null, + bsRam: this._pruState.bsRam ? JSON.parse(JSON.stringify(this._pruState.bsRam)) : null, + bsRamAddr: this._pruState.bsRamAddr, + fdb: this._pruState.fdb ? { + address: this._pruState.fdb.address, + addressSet: this._pruState.fdb.addressSet, + patternBytes: this._pruState.fdb.patternBytes, + writes: this._pruState.fdb.writes, + reads: this._pruState.fdb.reads, + patternSets: this._pruState.fdb.patternSets, + compares: this._pruState.fdb.compares + } : null, + // Per-core spinlock arbitration tally. The flag OWNERSHIP table + // is deliberately not exposed here - it is subsystem state shared by all + // six cores, not this core's state; read it via Simulator.spinlockOwner. + spinlock: this._pruState.spinlock ? { ...this._pruState.spinlock } : null, + spinlockForeignReleases: this._pruState.spinlockForeignReleases + ? this._pruState.spinlockForeignReleases.map((r) => ({ ...r })) : [], + unsupported_xfr: this._pruState.unsupported_xfr ? [...this._pruState.unsupported_xfr] : [], + unknownInstruction: this._pruState.unknownInstruction, + errorParsing: this._pruState.errorParsing + }; + } + + /** + * Set ICSSG QUEUE_EMPTY status for broadside device 0xF0. + * @param {number} status - 32-bit queue empty bitmask + */ + setQueueEmptyStatus(status) { + this._queueEmptyStatus = status >>> 0; + if (this._pruState) this._pruState.queue_empty = this._queueEmptyStatus; + } + + /** + * Set R31 input injection map. + * @param {Object} map - Cycle → value + */ + setR31Map(map) { + this._r31HistoryMap = { ...map }; + if (this._pruState) { + this._pruState.r31HistoryMap = this._r31HistoryMap; + } + } + + /** + * Set MMIO register map. + * @param {Object} map - Address → [{ cycle, value }, ...] + */ + setMmioMap(map) { + this._mmioMap = { ...map }; + if (this._pruState) { + this._pruState.mmioMap = this._mmioMap; + } + } + + /** + * G2: Step the simulation by exactly N cycles. + * + * Unlike the original simulatePruInstructions() which runs to completion, + * step() executes up to `cycleCount` cycles and returns. Multiple + * Simulator instances can be stepped in lockstep for multicore + * co-simulation. + * + * @param {number} [cycleCount=1] - Number of cycles to execute + * @returns {Object} Simulation state after stepping + */ + stepPaced(cycleCount = 1) { + this._ensureLoaded(); + if (!this._instructions.length) { + throw new Error('No instructions loaded. Call loadInstructions() first.'); + } + if (this._pruState && this._pruState.pc >= this._instructions.length) { + // PC is out of bounds — simulation has ended + return this.state; + } + + // Reset if fresh + if (!this._pruState) { + this._resetState(); + } + + const targetCycle = this._pruState.cycles + cycleCount; + const pruCore = this._pruCore; + const instructions = this._instructions; + const labelMap = this._labelMap; + const r30History = this._r30History; + const r31History = this._r31History; + const pruState = this._pruState; + + while (pruState.cycles < targetCycle && pruState.pc < instructions.length) { + // HALT detection: HALT instruction sets cycles = ARBITRARILY HIGH to end simulation + // We need to check if the last instruction was HALT + const instr = instructions[pruState.pc]; + if (instr && instr.toUpperCase().trim() === 'HALT') { + // Record the halted state + pruState.cycles++; + r30History.push(pruState.registers[30]); + r31History.push(pruState.registers[31]); + pruState.pc++; + break; + } + + // Execute one instruction via pru_core's function + // We can't call pruCore.simulatePruInstructions() directly since it + // starts fresh. Instead we replicate the inner loop manually. + pruCore._executeSingleInstruction + ? pruCore._executeSingleInstruction(pruState, instructions, labelMap, r30History, r31History, targetCycle) + : this._executeSingleInstruction(pruState, instructions, labelMap, r30History, r31History, targetCycle); + } + + return this.state; + } + + /** + * The flag this core is currently pointing at. + * + * Table 6-98: "R1.b0[5-0] ... own_req_vector[5:0] input ... Defines which + * flag to arbitrate", and 6.4.6.2.9 notes R1.b0 is "snooped or continually + * monitored by the Spinlock" - so the selector is simply whatever is in + * R1.b0 at the moment of the transaction, not something latched by an + * earlier XOUT. + */ + _spinlockSelectedFlag(pruState) { + return (pruState.registers[1] >>> 0) & (SPINLOCK_NUM_FLAGS - 1); + } + + /** + * XIN 0x90 - arbitrate for the selected flag and return ownership in + * R1.b3[0] (own_req_status; 0h lost, 1h won). + * + * Mutual exclusion lives here: the flag table is shared across Simulator + * instances of the same subsystem, so once one core holds a flag every other + * core's XIN returns 0 and its generated spin loop cannot fall through. The + * result is written as a single BIT; the other seven bits of R1.b3 are left + * alone, which is what makes the block's `and RES, R1.b3, 1` mask load + * bearing rather than cosmetic. + */ + _spinlockArbitrate(pruState, destRegN) { + // Table 6-98 names R1 (b3) as the arbitration register. An XIN aimed + // anywhere else is recorded, not silently answered. + if (destRegN !== 1) { + this._recordUnsupportedXfr(pruState, SPINLOCK_DEVICE_ID); + return; + } + const flags = spinlockSubsystem(this.subsystem); + const flagId = this._spinlockSelectedFlag(pruState); + const holder = flags.get(flagId); + + let won; + if (holder === undefined) { + // Free: this core takes it. Fixed-priority arbitration (6.4.6.2.9) is + // not modelled as a priority order because only one core can be inside + // this call at a time in a lockstep-stepped co-simulation; what IS + // modelled is that the loser gets 0. + flags.set(flagId, this.id); + won = 1; + } else if (holder === this.id) { + // Already ours. The TRM gives no re-entrancy semantics; re-arbitrating + // for a flag you already hold returns "owned" because own_req_status + // reports ownership, not a transition. + won = 1; + } else { + won = 0; + } + + const bit = 1 << 24; // R1.b3[0] + pruState.registers[1] = ((pruState.registers[1] >>> 0) & ~bit) | (won ? bit : 0); + pruState.registers[1] = pruState.registers[1] >>> 0; + + if (!pruState.spinlock) pruState.spinlock = { acquires: 0, wins: 0, losses: 0, releases: 0 }; + pruState.spinlock.acquires++; + if (won) pruState.spinlock.wins++; else pruState.spinlock.losses++; + } + + /** + * XOUT 0x90 - "Flag clear" (Table 6-98). + * + * Modelled faithfully as an UNCONDITIONAL clear of the flag selected by + * R1.b0: the TRM describes own_req_clr as clearing that flag, with no owner + * check. That is precisely why releasing a lock this core never acquired is + * dangerous, and why validateXfr2spinGlobal rejects an unmatched release at + * build time. Softening it here to "only the owner may clear" would hide the + * very hazard the validator exists to prevent. + */ + _spinlockRelease(pruState, srcRegN) { + if (srcRegN !== 1) { + this._recordUnsupportedXfr(pruState, SPINLOCK_DEVICE_ID); + return; + } + const flags = spinlockSubsystem(this.subsystem); + const flagId = this._spinlockSelectedFlag(pruState); + const holder = flags.get(flagId); + + if (!pruState.spinlock) pruState.spinlock = { acquires: 0, wins: 0, losses: 0, releases: 0 }; + pruState.spinlock.releases++; + if (holder !== undefined && holder !== this.id) { + if (!pruState.spinlockForeignReleases) pruState.spinlockForeignReleases = []; + pruState.spinlockForeignReleases.push({ flagId, heldBy: holder, clearedBy: this.id }); + } + flags.delete(flagId); + } + + /** + * BS RAM 64x32B per-core private (SPRUIM2J 6.4.6.2.5). + */ + _bsRamState(pruState) { + if (!pruState.bsRam) { + pruState.bsRam = Array.from({ length: BS_RAM_NUM_ROWS }, () => new Array(8).fill(0)); + pruState.bsRamAddr = 0; + } + return pruState.bsRam; + } + + _bsRamXout(pruState, bank, srcRegN, count) { + // Address programming: R10, exactly 2 bytes, selects row 0..63. + if (srcRegN === 10 && bank === BS_RAM_DEVICE_WRITE) { + if (count !== 2) { this._recordUnsupportedXfr(pruState, bank); return; } + const row = pruState.registers[10] & 0xFFFF; + // The TRM is silent on 64..511; validator should have refused. Simulator + // does not invent a wrap — treat >63 as unsupported and leave address unchanged. + if (row > 63) { this._recordUnsupportedXfr(pruState, bank); return; } + this._bsRamState(pruState); + pruState.bsRamAddr = row & 0x3F; + return; + } + // Data write: R2..R9 window, up to 32B. Both direct (48) and snoop (49) share this window. + if ((bank === BS_RAM_DEVICE_WRITE || bank === BS_RAM_DEVICE_SNOOP) && srcRegN === 2) { + if (count < 1 || count > 32) { this._recordUnsupportedXfr(pruState, bank); return; } + const bs = this._bsRamState(pruState); + const row = pruState.bsRamAddr & 0x3F; + if (row >= BS_RAM_NUM_ROWS) { this._recordUnsupportedXfr(pruState, bank); return; } + const words = Math.ceil(count / 4); + for (let i = 0; i < words && i < 8; i++) { + bs[row][i] = pruState.registers[2 + i] >>> 0; + } + // AutoIndex would increment here; TRM silent past row 63, so simulator + // does NOT auto-wrap — validator is the guard. + return; + } + // Anything else on BS RAM IDs is unsupported (wrong register/window/size). + this._recordUnsupportedXfr(pruState, bank); + } + + _bsRamXin(pruState, bank, destRegN, count) { + // Snoop (49) is XOUT-only; XIN on snoop is unsupported. + if (bank === BS_RAM_DEVICE_SNOOP) { this._recordUnsupportedXfr(pruState, bank); for (let i=0;i 32) { this._recordUnsupportedXfr(pruState, bank); for (let i=0;i= BS_RAM_NUM_ROWS) { this._recordUnsupportedXfr(pruState, bank); return; } + const words = Math.ceil(count / 4); + for (let i = 0; i < words && i < 8; i++) { + if (destRegN + i <= 31) pruState.registers[destRegN + i] = bs[row][i] >>> 0; + } + return; + } + this._recordUnsupportedXfr(pruState, bank); + } + + _fdbState(pruState) { + if (!pruState.fdb) { + pruState.fdb = { + table: Array.from({ length: FDB_NUM_ROWS }, () => new Uint8Array(FDB_ROW_BYTES)), + address: 0, + addressSet: false, + pattern: new Uint8Array(FDB_PATTERN_BYTES), + patternBytes: 0, + writes: 0, + reads: 0, + patternSets: 0, + addressSets: 0, + compares: 0 + }; + } + return pruState.fdb; + } + + _copyRegsToBytes(pruState, startReg, count, maxBytes) { + const n = Math.min(count, maxBytes); + const out = new Uint8Array(n); + for (let i = 0; i < n; i++) { + const regIdx = startReg + Math.floor(i / 4); + const byteInReg = i % 4; + out[i] = ((pruState.registers[regIdx] >>> 0) >>> (byteInReg * 8)) & 0xFF; + } + return out; + } + + _copyBytesToRegs(pruState, bytes, startReg, count) { + const regs = Math.ceil(count / 4); + for (let r = 0; r < regs && startReg + r <= 31; r++) pruState.registers[startReg + r] = 0; + for (let i = 0; i < count && i < bytes.length; i++) { + const regIdx = startReg + Math.floor(i / 4); + const byteInReg = i % 4; + if (regIdx <= 31) pruState.registers[regIdx] = (pruState.registers[regIdx] | (bytes[i] << (byteInReg * 8))) >>> 0; + } + } + + _fdbXout(pruState, bank, srcRegN, count) { + const fdb = this._fdbState(pruState); + if (bank === FDB_DEVICE_DATA_ADDR && srcRegN === 10) { + if (count !== 2) { this._recordUnsupportedXfr(pruState, bank); return; } + fdb.address = (pruState.registers[10] >>> 0) & 0x1FF; + fdb.addressSet = true; + fdb.addressSets++; + return; + } + if (bank === FDB_DEVICE_DATA_ADDR && srcRegN === 2) { + if (!fdb.addressSet || count < 1 || count > FDB_ROW_BYTES) { this._recordUnsupportedXfr(pruState, bank); return; } + const bytes = this._copyRegsToBytes(pruState, 2, count, FDB_ROW_BYTES); + fdb.table[fdb.address].set(bytes, 0); + fdb.writes++; + return; + } + if (bank === FDB_DEVICE_PATTERN && srcRegN === 2) { + if (count < 1 || count > FDB_PATTERN_BYTES) { this._recordUnsupportedXfr(pruState, bank); return; } + const bytes = this._copyRegsToBytes(pruState, 2, count, FDB_PATTERN_BYTES); + fdb.pattern.fill(0); + fdb.pattern.set(bytes, 0); + fdb.patternBytes = count; + fdb.patternSets++; + return; + } + this._recordUnsupportedXfr(pruState, bank); + } + + _fdbXin(pruState, bank, destRegN, count) { + const fdb = this._fdbState(pruState); + if (bank === FDB_DEVICE_DATA_ADDR) { + if (!fdb.addressSet || destRegN !== 2 || count < 1 || count > FDB_ROW_BYTES) { + this._recordUnsupportedXfr(pruState, bank); + return; + } + this._copyBytesToRegs(pruState, fdb.table[fdb.address], 2, count); + fdb.reads++; + return; + } + if (bank === FDB_DEVICE_COMPARE) { + if (destRegN !== 10 || count < 1 || count > 2) { this._recordUnsupportedXfr(pruState, bank); return; } + let matched = fdb.addressSet && fdb.patternBytes > 0 ? 1 : 0; + if (matched) { + const row = fdb.table[fdb.address]; + for (let i = 0; i < fdb.patternBytes; i++) { + if (row[i] !== fdb.pattern[i]) { matched = 0; break; } + } + } + pruState.registers[10] = (pruState.registers[10] & ~0xFF) | matched; + fdb.compares++; + return; + } + this._recordUnsupportedXfr(pruState, bank); + } + + /** + * XFR2TR ring state (SPRUIM2J Table 6-112), lazily created on the + * first XOUT that touches BS ID 0x70/0x71/0x72. + */ + _xfr2trState(pruState) { + if (!pruState.xfr2tr) { + pruState.xfr2tr = { sourceBase: 0, trSize: 8, ringBase: 0, ringNums: 0, writePtr: 0, queue: [], nextDrainCycle: null }; + } + return pruState.xfr2tr; + } + + /** + * A broadside ID this block did not model in the direction it was used + * (e.g. XIN on the XOUT-only 0x70/0x72, or an XOUT/XIN with the wrong + * register/byte-count). Table 6-112 and PR #30's policy: record it and + * continue, do not trap and do not invent generic FIFO/staged-data state. + */ + _recordUnsupportedXfr(pruState, id) { + if (!pruState.unsupported_xfr) pruState.unsupported_xfr = []; + if (pruState.unsupported_xfr.indexOf(id) === -1) pruState.unsupported_xfr.push(id); + } + + /** + * Table 6-112's submit FIFO drains into the ring in the background as + * hardware processes it, but the TRM does not document a drain rate, + * cycles-per-ID or otherwise. Draining once per EXECUTED INSTRUCTION would + * undo a tight burst of back-to-back submits before a design could ever + * reach the 8-entry capacity, so this paces draining on elapsed CYCLES + * instead (XFR2TR_DRAIN_PERIOD_CYCLES) -- a documented simulator modeling + * choice, not a hardware timing claim. It is slow enough that a short + * submit burst fills the FIFO, and frequent enough that a genuine + * wait-for-busy poll loop (which spans many cycles) makes real progress + * rather than livelocking against a HOLD. + */ + _xfr2trDrain(pruState) { + const tr = pruState.xfr2tr; + if (!tr || !tr.queue.length) return; + if (tr.nextDrainCycle === null) { + tr.nextDrainCycle = pruState.cycles + Simulator.XFR2TR_DRAIN_PERIOD_CYCLES; + return; + } + if (pruState.cycles < tr.nextDrainCycle) return; + tr.queue.shift(); + const ringSize = tr.ringNums + 1; + tr.writePtr = ringSize > 0 ? (tr.writePtr + 1) % ringSize : 0; + tr.nextDrainCycle = pruState.cycles + Simulator.XFR2TR_DRAIN_PERIOD_CYCLES; + } + + /** + * XOUT to BS ID 0x70/0x71/0x72. Returns true if PC should advance, false to + * HOLD (Table 6-112's 8-level FIFO is full): no side effect is applied and + * the same instruction is retried next cycle, using the existing PC-hold + * pattern rather than rewinding PC or replaying a partial XOUT. + */ + _xfr2trXout(pruState, bank, srcRegN, count) { + const tr = this._xfr2trState(pruState); + if (bank === 112) { + // BS ID 0x70 XOUT: R6[17:0]=tr_msrc_base, R7[0]=tr_size. Write-only. + if (srcRegN !== 6 || count !== 8) { this._recordUnsupportedXfr(pruState, bank); return true; } + tr.sourceBase = pruState.registers[6] >>> 0 & 0x3FFFF; + tr.trSize = (pruState.registers[7] & 1) ? 64 : 8; + return true; + } + if (bank === 113) { + // BS ID 0x71 XOUT: R6[17:0]=tr_rsrc_base, R7[19:8]=tr_rsrc_nums, R7[1]=tr_rscr_reset. + if (srcRegN !== 6 || count !== 8) { this._recordUnsupportedXfr(pruState, bank); return true; } + tr.ringBase = pruState.registers[6] >>> 0 & 0x3FFFF; + tr.ringNums = (pruState.registers[7] >>> 8) & 0xFFF; + if (pruState.registers[7] & 2) { tr.queue = []; tr.writePtr = 0; } + return true; + } + // BS ID 0x72 XOUT: R5:R2 packed TR IDs, 2 bytes/ID, left packed, no holes. + // ID 0 is a valid TR ID (Table 6-112: R2[11:0]=ID0), never a hole/sentinel. + if (srcRegN !== 2 || count < 2 || count > 16 || (count % 2) !== 0) { + this._recordUnsupportedXfr(pruState, bank); + return true; + } + const numIds = count / 2; + const ids = []; + for (let i = 0; i < numIds; i++) { + const word = pruState.registers[2 + Math.floor(i / 2)] >>> 0; + ids.push((i % 2 === 0) ? (word & 0xFFF) : ((word >>> 16) & 0xFFF)); + } + if (tr.queue.length + ids.length > 8) return false; // HOLD: no side effect, PC does not advance. + tr.queue.push.apply(tr.queue, ids); + return true; + } + + /** + * XIN from BS ID 0x71 (the only XFR2TR port with a documented XIN + * direction). 0x70/0x72 are XOUT-only in Table 6-112; an XIN aimed at them + * is recorded as unsupported and zero-filled rather than answered. + */ + _xfr2trXin(pruState, bank, destRegN, count) { + if (bank !== 113 || destRegN !== 7 || count !== 8) { + this._recordUnsupportedXfr(pruState, bank); + for (let i = 0; i < Math.ceil(count / 4) && destRegN + i <= 31; i++) pruState.registers[destRegN + i] = 0; + return; + } + const tr = this._xfr2trState(pruState); + const occ = Math.min(tr.queue.length, 0xF); + pruState.registers[7] = ((tr.queue.length ? 1 : 0) | (occ << 8)) >>> 0; + pruState.registers[8] = tr.writePtr >>> 0; + } + + /** + * Internal single-instruction execution (mirrors pru_core.js executeInstruction). + */ + /** + * Conditional-branch decode, aligned case-for-case with the + * production core in + * .metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js. + * + * Every block in this repo emits TI syntax, where the LABEL is operand 0: + * + * qbbc xs_spin?, R1.b3, 0 + * + * That is what clpru assembles and what pru_core.js decodes. This decoder + * used to read operand 0 as the register and operand 2 as the label - the + * reverse - so _parseRegister("xs_spin?") failed, _handleParsingError bumped + * the pc, and the branch was SILENTLY not taken. Nothing was reported: the + * spin loop in m_xfr2spin_acquire fell straight through its own backward + * branch and a contended lock therefore looked like a single lost + * arbitration instead of a core parked in a loop. Every wait/spin loop run + * through Simulator.stepPaced had the same hole; no call site in the repo + * ever used the reversed order, so nothing depended on it. + * + * An unresolved target is counted as a parse error rather than silently + * stepping to the next instruction, again matching pru_core.js: a branch to + * a label that is not in the map is a broken program, and staying quiet + * about it is what made the original defect invisible. + */ + _executeConditionalBranch(pruState, opcode, operands, labelMap) { + const label = (operands[0] || '').replace(/[,:]/g, '').trim(); + + const reg1Info = this._parseRegister((operands[1] || '').replace(/,/g, '')); + if (this._handleParsingError(pruState, reg1Info)) { pruState.pc++; return; } + const reg1Value = this._getRegisterValue(pruState, reg1Info); + + const op2 = (operands[2] || '').replace(/,/g, '').trim(); + let src2Value; + if (op2.toLowerCase().startsWith('r')) { + const reg2Info = this._parseRegister(op2); + if (this._handleParsingError(pruState, reg2Info)) { pruState.pc++; return; } + src2Value = this._getRegisterValue(pruState, reg2Info); + } else { + src2Value = this._parseImmediate(op2); + if (this._handleParsingError(pruState, src2Value)) { pruState.pc++; return; } + } + + // Comparison direction follows pru_core.js: for QBGT/QBGE/QBLT/QBLE the + // form is `QB LABEL, REG1, OP` and the test is OP REG1. + let take; + switch (opcode) { + case 'QBEQ': take = reg1Value === src2Value; break; + case 'QBNE': take = reg1Value !== src2Value; break; + case 'QBGT': take = (src2Value >>> 0) > (reg1Value >>> 0); break; + case 'QBGE': take = (src2Value >>> 0) >= (reg1Value >>> 0); break; + case 'QBLT': take = (src2Value >>> 0) < (reg1Value >>> 0); break; + case 'QBLE': take = (src2Value >>> 0) <= (reg1Value >>> 0); break; + case 'QBBS': take = (reg1Value & (1 << (src2Value & 0x1F))) !== 0; break; + case 'QBBC': take = (reg1Value & (1 << (src2Value & 0x1F))) === 0; break; + default: pruState.pc++; return; + } + + if (!take) { pruState.pc++; return; } + if (labelMap[label] !== undefined) { + pruState.pc = labelMap[label]; + } else { + pruState.errorParsing++; + pruState.pc++; + } + } + + _executeSingleInstruction(pruState, instructions, labelMap, r30History, r31History, maxCycles) { + const instr = instructions[pruState.pc]; + + // Skip placeholder instructions + if (instr === 0 || !instr) { + pruState.pc++; + return; + } + + const instructionMatch = instr.trim().match(/^(\S+)(?:\s+(.*))?$/); + if (!instructionMatch) { + pruState.pc++; + pruState.cycles++; + return; + } + + const opcode = instructionMatch[1].toUpperCase(); + const operandsString = instructionMatch[2] || ''; + const operands = operandsString.split(/,\s*/).map(op => op.trim()); + + let cyclesToAdd = 1; + + switch (opcode) { + case 'LDI': { + const regInfo = this._parseRegister(operands[0].replace(/,/g, '')); + const src2Value = this._parseImmediate(operands[1]); + if (this._handleParsingError(pruState, regInfo, src2Value)) { pruState.pc++; break; } + let finalValue; + if (regInfo.type === 'byte') finalValue = (src2Value >>> 0) & 0xFF; + else if (regInfo.type === 'word') finalValue = (src2Value >>> 0) & 0xFFFF; + else finalValue = (src2Value >>> 0) & 0xFFFF; + this._setRegisterValue(pruState, regInfo, finalValue); + pruState.pc++; + break; + } + case 'LDI32': { + const destRegInfo = this._parseRegister(operands[0].replace(/,/g, '')); + const src2Value = this._parseImmediate(operands[1]); + if (this._handleParsingError(pruState, destRegInfo, src2Value)) { pruState.pc++; break; } + this._setRegisterValue(pruState, destRegInfo, (src2Value >>> 0)); + pruState.pc++; + break; + } + case 'MOV': { + const destRegInfo = this._parseRegister(operands[0].replace(/,/g, '')); + if (this._handleParsingError(pruState, destRegInfo)) { pruState.pc++; break; } + let src2Value; + if (operands[1].toLowerCase().startsWith('r')) { + const srcRegInfo = this._parseRegister(operands[1].replace(/,/g, '')); + if (this._handleParsingError(pruState, srcRegInfo)) { pruState.pc++; break; } + src2Value = this._getRegisterValue(pruState, srcRegInfo); + } else { + src2Value = this._parseImmediate(operands[1]); + if (this._handleParsingError(pruState, src2Value)) { pruState.pc++; break; } + } + this._setRegisterValue(pruState, destRegInfo, src2Value); + pruState.pc++; + break; + } + case 'ADD': { + const destRegInfo = this._parseRegister(operands[0].replace(/,/g, '')); + let src1Value, src2Value; + if (this._handleParsingError(pruState, destRegInfo)) { pruState.pc++; break; } + const src1Reg = this._parseRegister(operands[1].replace(/,/g, '')); + if (this._handleParsingError(pruState, src1Reg)) { pruState.pc++; break; } + src1Value = this._getRegisterValue(pruState, src1Reg); + if (operands[2].toLowerCase().startsWith('r')) { + const src2Reg = this._parseRegister(operands[2].replace(/,/g, '')); + if (this._handleParsingError(pruState, src2Reg)) { pruState.pc++; break; } + src2Value = this._getRegisterValue(pruState, src2Reg); + } else { + try { + src2Value = this._parseImmediate(operands[2]); + if (this._handleParsingError(pruState, src2Value)) { pruState.pc++; break; } + } catch (e) { + src2Value = 0; + } + } + const result = (src1Value >>> 0) + (src2Value >>> 0); + pruState.carry = (result > 0xFFFFFFFF) ? 1 : 0; + this._setRegisterValue(pruState, destRegInfo, result >>> 0); + pruState.pc++; + break; + } + case 'SUB': { + const destRegInfo = this._parseRegister(operands[0].replace(/,/g, '')); + if (this._handleParsingError(pruState, destRegInfo)) { pruState.pc++; break; } + let src1Value, src2Value; + const src1Reg = this._parseRegister(operands[1].replace(/,/g, '')); + if (this._handleParsingError(pruState, src1Reg)) { pruState.pc++; break; } + src1Value = this._getRegisterValue(pruState, src1Reg); + if (operands[2].toLowerCase().startsWith('r')) { + const src2Reg = this._parseRegister(operands[2].replace(/,/g, '')); + if (this._handleParsingError(pruState, src2Reg)) { pruState.pc++; break; } + src2Value = this._getRegisterValue(pruState, src2Reg); + } else { + src2Value = this._parseImmediate(operands[2]); + if (this._handleParsingError(pruState, src2Value)) { pruState.pc++; break; } + } + const result = (src1Value >>> 0) - (src2Value >>> 0); + pruState.carry = (src1Value >= src2Value) ? 1 : 0; + this._setRegisterValue(pruState, destRegInfo, result >>> 0); + pruState.pc++; + break; + } + case 'AND': { + const destRegInfo = this._parseRegister(operands[0].replace(/,/g, '')); + if (this._handleParsingError(pruState, destRegInfo)) { pruState.pc++; break; } + let src1Value, src2Value; + const src1Reg = this._parseRegister(operands[1].replace(/,/g, '')); + if (this._handleParsingError(pruState, src1Reg)) { pruState.pc++; break; } + src1Value = this._getRegisterValue(pruState, src1Reg); + if (operands[2].toLowerCase().startsWith('r')) { + const src2Reg = this._parseRegister(operands[2].replace(/,/g, '')); + if (this._handleParsingError(pruState, src2Reg)) { pruState.pc++; break; } + src2Value = this._getRegisterValue(pruState, src2Reg); + } else { + src2Value = this._parseImmediate(operands[2]); + if (this._handleParsingError(pruState, src2Value)) { pruState.pc++; break; } + } + this._setRegisterValue(pruState, destRegInfo, (src1Value & src2Value) >>> 0); + pruState.pc++; + break; + } + case 'OR': { + const destRegInfo = this._parseRegister(operands[0].replace(/,/g, '')); + if (this._handleParsingError(pruState, destRegInfo)) { pruState.pc++; break; } + let src1Value, src2Value; + const src1Reg = this._parseRegister(operands[1].replace(/,/g, '')); + if (this._handleParsingError(pruState, src1Reg)) { pruState.pc++; break; } + src1Value = this._getRegisterValue(pruState, src1Reg); + if (operands[2].toLowerCase().startsWith('r')) { + const src2Reg = this._parseRegister(operands[2].replace(/,/g, '')); + if (this._handleParsingError(pruState, src2Reg)) { pruState.pc++; break; } + src2Value = this._getRegisterValue(pruState, src2Reg); + } else { + src2Value = this._parseImmediate(operands[2]); + if (this._handleParsingError(pruState, src2Value)) { pruState.pc++; break; } + } + this._setRegisterValue(pruState, destRegInfo, (src1Value | src2Value) >>> 0); + pruState.pc++; + break; + } + case 'XOR': { + const destRegInfo = this._parseRegister(operands[0].replace(/,/g, '')); + if (this._handleParsingError(pruState, destRegInfo)) { pruState.pc++; break; } + let src1Value, src2Value; + const src1Reg = this._parseRegister(operands[1].replace(/,/g, '')); + if (this._handleParsingError(pruState, src1Reg)) { pruState.pc++; break; } + src1Value = this._getRegisterValue(pruState, src1Reg); + if (operands[2].toLowerCase().startsWith('r')) { + const src2Reg = this._parseRegister(operands[2].replace(/,/g, '')); + if (this._handleParsingError(pruState, src2Reg)) { pruState.pc++; break; } + src2Value = this._getRegisterValue(pruState, src2Reg); + } else { + src2Value = this._parseImmediate(operands[2]); + if (this._handleParsingError(pruState, src2Value)) { pruState.pc++; break; } + } + this._setRegisterValue(pruState, destRegInfo, (src1Value ^ src2Value) >>> 0); + pruState.pc++; + break; + } + case 'NOT': { + const destReg = this._parseRegister(operands[0].replace(/,/g, '')); + if (this._handleParsingError(pruState, destReg)) { pruState.pc++; break; } + let srcVal; + if (operands[1].toLowerCase().startsWith('r')) { + const srcReg = this._parseRegister(operands[1].replace(/,/g, '')); + if (this._handleParsingError(pruState, srcReg)) { pruState.pc++; break; } + srcVal = this._getRegisterValue(pruState, srcReg); + } else { + srcVal = this._parseImmediate(operands[1]); + if (this._handleParsingError(pruState, srcVal)) { pruState.pc++; break; } + } + this._setRegisterValue(pruState, destReg, (~srcVal) >>> 0); + pruState.pc++; + break; + } + case 'LSL': { + const dest = this._parseRegister(operands[0].replace(/,/g, '')); + if (this._handleParsingError(pruState, dest)) { pruState.pc++; break; } + let src1V, src2V; + const src1R = this._parseRegister(operands[1].replace(/,/g, '')); + if (this._handleParsingError(pruState, src1R)) { pruState.pc++; break; } + src1V = this._getRegisterValue(pruState, src1R); + if (operands[2].toLowerCase().startsWith('r')) { + const src2R = this._parseRegister(operands[2].replace(/,/g, '')); + if (this._handleParsingError(pruState, src2R)) { pruState.pc++; break; } + src2V = this._getRegisterValue(pruState, src2R); + } else { + src2V = this._parseImmediate(operands[2]); + if (this._handleParsingError(pruState, src2V)) { pruState.pc++; break; } + } + this._setRegisterValue(pruState, dest, (src1V << (src2V & 0x1F)) >>> 0); + pruState.pc++; + break; + } + case 'LSR': { + const dest = this._parseRegister(operands[0].replace(/,/g, '')); + if (this._handleParsingError(pruState, dest)) { pruState.pc++; break; } + let src1V, src2V; + const src1R = this._parseRegister(operands[1].replace(/,/g, '')); + if (this._handleParsingError(pruState, src1R)) { pruState.pc++; break; } + src1V = this._getRegisterValue(pruState, src1R); + if (operands[2].toLowerCase().startsWith('r')) { + const src2R = this._parseRegister(operands[2].replace(/,/g, '')); + if (this._handleParsingError(pruState, src2R)) { pruState.pc++; break; } + src2V = this._getRegisterValue(pruState, src2R); + } else { + src2V = this._parseImmediate(operands[2]); + if (this._handleParsingError(pruState, src2V)) { pruState.pc++; break; } + } + this._setRegisterValue(pruState, dest, (src1V >>> (src2V & 0x1F)) >>> 0); + pruState.pc++; + break; + } + case 'SET': { + const destR = this._parseRegister(operands[0].replace(/,/g, '')); + if (this._handleParsingError(pruState, destR)) { pruState.pc++; break; } + let src2v; + if (operands[2].toLowerCase().startsWith('r')) { + const src2R = this._parseRegister(operands[2].replace(/,/g, '')); + if (this._handleParsingError(pruState, src2R)) { pruState.pc++; break; } + src2v = this._getRegisterValue(pruState, src2R); + } else { + src2v = this._parseImmediate(operands[2]); + if (this._handleParsingError(pruState, src2v)) { pruState.pc++; break; } + } + const currVal = this._getRegisterValue(pruState, destR); + this._setRegisterValue(pruState, destR, (currVal | (1 << src2v)) >>> 0); + pruState.pc++; + break; + } + case 'CLR': { + const destR = this._parseRegister(operands[0].replace(/,/g, '')); + if (this._handleParsingError(pruState, destR)) { pruState.pc++; break; } + let src2v; + if (operands[2].toLowerCase().startsWith('r')) { + const src2R = this._parseRegister(operands[2].replace(/,/g, '')); + if (this._handleParsingError(pruState, src2R)) { pruState.pc++; break; } + src2v = this._getRegisterValue(pruState, src2R); + } else { + src2v = this._parseImmediate(operands[2]); + if (this._handleParsingError(pruState, src2v)) { pruState.pc++; break; } + } + const currVal = this._getRegisterValue(pruState, destR); + this._setRegisterValue(pruState, destR, (currVal & ~(1 << src2v)) >>> 0); + pruState.pc++; + break; + } + case 'QBA': { + // Unconditional branch + if (operands[0]) { + const label = operands[0].replace(':', '').trim(); + if (labelMap[label] !== undefined) { + pruState.pc = labelMap[label]; + } else { + pruState.pc++; + } + } else { + pruState.pc++; + } + cyclesToAdd = 0; // QBA is 0 wait cycles on PRU + break; + } + // All eight conditional branches decode LABEL-first, the TI + // syntax every block in this repo emits and the only form clpru and + // pru_core.js accept. See _executeConditionalBranch. + case 'QBEQ': + case 'QBNE': + case 'QBGT': + case 'QBGE': + case 'QBLT': + case 'QBLE': + case 'QBBS': + case 'QBBC': + this._executeConditionalBranch(pruState, opcode, operands, labelMap); + break; + case 'JMP': { + if (operands[0].toLowerCase().startsWith('r')) { + const reg = this._parseRegister(operands[0].replace(/,/g, '')); + if (this._handleParsingError(pruState, reg)) { pruState.pc++; break; } + pruState.pc = this._getRegisterValue(pruState, reg); + } else { + const label = operands[0].replace(':', '').trim(); + if (labelMap[label] !== undefined) pruState.pc = labelMap[label]; + else pruState.pc++; + } + cyclesToAdd = 0; + break; + } + case 'NOP': + pruState.pc++; + break; + case 'ZERO': { + const op1Info = this._parseRegisterReference(operands[0].replace(/&|,/g, '')); + const byteCountStr = operands[1] ? operands[1].replace(/,/g, '') : "4"; + const byteCount = this._parseImmediate(byteCountStr); + if (op1Info) { + const startReg = (op1Info.regNum !== undefined) ? op1Info.regNum : op1Info.reg; + const numRegs = Math.ceil(byteCount / 4); + for (let i = 0; i < numRegs && (startReg + i) < 32; i++) { + pruState.registers[startReg + i] = 0; + } + } + pruState.pc++; + break; + } + case 'FILL': { + const op1Info = this._parseRegisterReference(operands[0].replace(/&|,/g, '')); + const byteCountStr = operands[1] ? operands[1].replace(/,/g, '') : "4"; + const byteCount = this._parseImmediate(byteCountStr); + if (op1Info) { + const startReg = (op1Info.regNum !== undefined) ? op1Info.regNum : op1Info.reg; + const numRegs = Math.ceil(byteCount / 4); + for (let i = 0; i < numRegs && (startReg + i) < 32; i++) { + pruState.registers[startReg + i] = 0xFFFFFFFF; + } + } + pruState.pc++; + break; + } + case 'HALT': { + // End simulation: set cycles to max to exit the loop in stepPaced + pruState.pc++; + pruState.cycles = maxCycles || Number.MAX_SAFE_INTEGER; + cyclesToAdd = 0; + break; + } + case 'LOOP': { + // LOOP label, reg → iterate reg times starting at label + const loopLabel = operands[0].replace(':', '').trim(); + let iterCount; + if (operands[1].toLowerCase().startsWith('r')) { + const reg = this._parseRegister(operands[1]); + if (this._handleParsingError(pruState, reg)) { pruState.pc++; break; } + iterCount = this._getRegisterValue(pruState, reg); + } else { + iterCount = this._parseImmediate(operands[1]); + if (this._handleParsingError(pruState, iterCount)) { pruState.pc++; break; } + } + if (labelMap[loopLabel] !== undefined) { + pruState.loopStack.push({ + targetLabel: labelMap[loopLabel], + returnPC: pruState.pc + 1, + count: iterCount, + currentIteration: 1 + }); + } + pruState.pc++; + cyclesToAdd = 0; + break; + } + case 'LBBO': + case 'LBCO': { + // LBBO &dest, base, offset, count + const destStr = operands[0].replace(/^&/, '').trim(); + const destRegInfo = this._parseRegister(destStr); + if (this._handleParsingError(pruState, destRegInfo)) { pruState.pc++; break; } + // base register + let baseValue = 0; + const baseRef = operands[1].trim(); + if (baseRef.toLowerCase().startsWith('r')) { + const br = this._parseRegister(baseRef); + if (this._handleParsingError(pruState, br)) { pruState.pc++; break; } + baseValue = this._getRegisterValue(pruState, br); + } else if (baseRef.toLowerCase().startsWith('c')) { + const cnum = parseInt(baseRef.substring(1), 10); + baseValue = cnum === 28 ? 0x00000000 : cnum === 26 ? 0x0002E000 : 0; + } + // offset + let offsetValue = 0; + const offsetStr = operands[2].trim(); + if (offsetStr.toLowerCase().startsWith('r')) { + const ori = this._parseRegister(offsetStr); + if (this._handleParsingError(pruState, ori)) { pruState.pc++; break; } + offsetValue = this._getRegisterValue(pruState, ori); + } else { + offsetValue = parseInt(offsetStr, 0); + } + // byte count + const byteCount = parseInt(operands[3], 0); + if (isNaN(byteCount) || byteCount < 1) { pruState.pc++; break; } + + const memAddress = baseValue + offsetValue; + let data; + if (memAddress >= 0x00010000 && memAddress < 0x00020000) { + data = this._pruSMEM.readSMEM(memAddress, byteCount); + } else if (memAddress >= 0x0002E000 && memAddress < 0x0002E100) { + // IEP register reads — mirrors the SBCO write path (below) and + // pru_core.js LBCO handler. Without this, reads to c26 fell + // through to DMEM0 and returned wrong data. + const iepOffset = memAddress - 0x0002E000; + let regVal = 0; + if (iepOffset === 0x0C) regVal = pruState.iepCount; + else if (iepOffset === 0x44) regVal = pruState.iepCmpStatus; + else if (iepOffset === 0x48) regVal = pruState.iepCmp0; + else if (iepOffset === 0x4C) regVal = pruState.iepCmp1; + data = new Uint8Array(byteCount); + for (let i = 0; i < byteCount; i++) { + data[i] = (regVal >>> (i * 8)) & 0xFF; + } + } else { + data = this._pruDMEM0.readDMEM0(memAddress, byteCount); + } + + // Write into destination register(s) + if (data) { + let currentReg = destRegInfo.regNum; + let currentByte = destRegInfo.bytePos || destRegInfo.wordPos * 2 || 0; + let bytesRemaining = byteCount; + let bufOffset = 0; + while (bytesRemaining > 0) { + const bytesFromThisReg = Math.min(bytesRemaining, 4 - currentByte); + let regVal = this._getRegisterValue(pruState, { reg: currentReg, type: 'full' }); + for (let i = 0; i < bytesFromThisReg; i++) { + if (bufOffset + i < data.length) { + const byteShift = (currentByte + i) * 8; + regVal = (regVal & ~(0xFF << byteShift)) | (data[bufOffset + i] << byteShift); + } + } + pruState.registers[currentReg] = regVal >>> 0; + bufOffset += bytesFromThisReg; + bytesRemaining -= bytesFromThisReg; + currentReg = (currentReg + 1) % 31; + currentByte = 0; + } + } + cyclesToAdd = 1 + Math.ceil(byteCount / 4); + pruState.pc++; + break; + } + case 'SBBO': + case 'SBCO': { + const srcStr = operands[0].replace(/^&/, '').trim(); + const srcRegInfo = this._parseRegister(srcStr); + if (this._handleParsingError(pruState, srcRegInfo)) { pruState.pc++; break; } + let baseValue = 0; + const baseRef = operands[1].trim(); + if (baseRef.toLowerCase().startsWith('r')) { + const br = this._parseRegister(baseRef); + if (this._handleParsingError(pruState, br)) { pruState.pc++; break; } + baseValue = this._getRegisterValue(pruState, br); + } else if (baseRef.toLowerCase().startsWith('c')) { + const cnum = parseInt(baseRef.substring(1), 10); + baseValue = cnum === 28 ? 0x00000000 : cnum === 26 ? 0x0002E000 : 0; + } + let offsetValue = 0; + const offsetStr = operands[2].trim(); + if (offsetStr.toLowerCase().startsWith('r')) { + const ori = this._parseRegister(offsetStr); + if (this._handleParsingError(pruState, ori)) { pruState.pc++; break; } + offsetValue = this._getRegisterValue(pruState, ori); + } else { + offsetValue = parseInt(offsetStr, 0); + } + const byteCount = parseInt(operands[3], 0); + if (isNaN(byteCount) || byteCount < 1) { pruState.pc++; break; } + + const memAddress = baseValue + offsetValue; + const dataBuffer = new Uint8Array(byteCount); + let currentReg = srcRegInfo.regNum; + let currentByte = srcRegInfo.bytePos || srcRegInfo.wordPos * 2 || 0; + let bytesRemaining = byteCount; + let bufOffset = 0; + while (bytesRemaining > 0 && currentReg <= 30) { + const bytesFromThisReg = Math.min(bytesRemaining, 4 - currentByte); + const regVal = pruState.registers[currentReg]; + for (let i = 0; i < bytesFromThisReg; i++) { + dataBuffer[bufOffset + i] = (regVal >> ((currentByte + i) * 8)) & 0xFF; + } + bufOffset += bytesFromThisReg; + bytesRemaining -= bytesFromThisReg; + currentReg++; + currentByte = 0; + } + + if (memAddress >= 0x00010000 && memAddress < 0x00020000) { + this._pruSMEM.writeSMEM(memAddress, dataBuffer); + } else if (memAddress >= 0x0002E000 && memAddress < 0x0002E100) { + const iepOffset = memAddress - 0x0002E000; + let regVal = 0; + for (let i = 0; i < byteCount; i++) regVal |= dataBuffer[i] << (i * 8); + if (iepOffset === 0x0C) pruState.iepCount = regVal; + else if (iepOffset === 0x44) pruState.iepCmpStatus &= ~regVal; + else if (iepOffset === 0x48) pruState.iepCmp0 = regVal; + else if (iepOffset === 0x4C) pruState.iepCmp1 = regVal; + } else { + this._pruDMEM0.writeDMEM0(memAddress, dataBuffer); + } + cyclesToAdd = 1 + Math.ceil(byteCount / 4); + pruState.pc++; + break; + } + case 'XOUT': { + // XOUT bank, &src, count + const bank = parseInt(operands[0], 0); + const srcRef = operands[1].replace(/^&/, '').trim(); + let srcRegN = 0; + if (srcRef.toLowerCase().startsWith('r')) { + const reg = this._parseRegister(srcRef); + if (this._handleParsingError(pruState, reg)) { pruState.pc++; break; } + srcRegN = reg.reg; + } else { + srcRegN = parseInt(srcRef, 0); + } + const count = parseInt(operands[2], 0); + + if (bank === 0) { + // MAC: write R28=R10, R29=R11 + pruState.registers[28] = pruState.registers[10]; + pruState.registers[29] = pruState.registers[11]; + pruState.pc++; + } else if (bank === 112 || bank === 113 || bank === 114) { + // XFR2TR (SPRUIM2J Table 6-112). 0x72 submit can HOLD if + // the 8-level FIFO is full; only then does PC not advance. + const advance = this._xfr2trXout(pruState, bank, srcRegN, count); + if (advance) pruState.pc++; + } else if (bank === SPINLOCK_DEVICE_ID) { + // XOUT to 0x90 is the RELEASE. Table 6-98, INT_SPIN_ID row: + // "own_req_clr control input ... will clear the flag selected by + // R1.b0/own_req_vector". It is NOT a lock request - see the block's + // header comment. The selector is R1.b0 regardless of which byte of + // R1 the XOUT names. + this._spinlockRelease(pruState, srcRegN); + pruState.pc++; + } else if (bank === BS_RAM_DEVICE_WRITE || bank === BS_RAM_DEVICE_SNOOP) { + // BS RAM write (48) and snoop (49) XOUT. Flat SPAD fallback + // previously made `xout 48` appear to work by accident; now a real + // 64x32B per-core model. + this._bsRamXout(pruState, bank, srcRegN, count); + pruState.pc++; + } else if (bank === FDB_DEVICE_DATA_ADDR || bank === FDB_DEVICE_PATTERN) { + this._fdbXout(pruState, bank, srcRegN, count); + pruState.pc++; + } else if (bank === 240) { + // QUEUE_EMPTY device 0xF0 (240) is XIN-only (SPRUIM2J Table 6-60). + // XOUT is not supported on this device. + this._recordUnsupportedXfr(pruState, bank); + pruState.unknownInstruction = "XOUT " + bank + ": QUEUE_EMPTY device 0xF0 is XIN-only (SPRUIM2J Table 6-60)"; + pruState.pc++; + } else { + // SPAD: store registers to scratchpad bank (note: 48/49 are no longer here) + for (let i = 0; i < count; i++) { + if (srcRegN + i <= 31) { + const spadBank = this._spad || {}; + if (!this._spad) this._spad = {}; + if (!this._spad[bank]) this._spad[bank] = new Array(30).fill(0); + this._spad[bank][i] = pruState.registers[srcRegN + i]; + } + } + pruState.pc++; + } + break; + } + case 'XIN': { + // XIN bank, &dest, count + const bank = parseInt(operands[0], 0); + const destRef = operands[1].replace(/^&/, '').trim(); + let destRegN = 0; + if (destRef.toLowerCase().startsWith('r')) { + const reg = this._parseRegister(destRef); + if (this._handleParsingError(pruState, reg)) { pruState.pc++; break; } + destRegN = reg.reg; + } else { + destRegN = parseInt(destRef, 0); + } + const count = parseInt(operands[2], 0); + + if (bank === 0) { + // MAC result: read back R26-R27 (or more) + // Accumulator lower held in R25 context, result in R26(LO):R27(HI) + const mulLo = (pruState.registers[28] >>> 0) * (pruState.registers[29] >>> 0); + const macResult = (mulLo >>> 0) + (pruState.registers[25] >>> 0); + const resultHi = Math.floor(macResult / 0x100000000) >>> 0; + pruState.registers[26] = (macResult >>> 0); + pruState.registers[27] = resultHi; + } else if (bank === 160 || bank === 161 || bank === 162) { + // ICSSG Byte Swap (BSWAP) broadside unit, SPRUIM2J 6.4.6.2.7, + // device IDs from Table 6-95. Written from the TRM; it is not an + // independent oracle for blocks written from the same TRM section. + const lanes = (v) => [v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff]; + const pack = (b) => (((b[3] << 24) | (b[2] << 16) | (b[1] << 8) | b[0]) >>> 0); + if (bank === 160) { + // 6.4.6.2.7.1: within each register in the span, b0<->b3 and b1<->b2. + // A span shorter than 4 bytes updates only the bytes it covers + // (example 3: a 2-byte XIN gives R2.b0 = R2.b3, R2.b1 = R2.b2). + let remaining = count, r = destRegN; + while (remaining > 0 && r <= 29) { + const n = Math.min(4, remaining); + const cur = lanes(pruState.registers[r] >>> 0); + const flipped = [cur[3], cur[2], cur[1], cur[0]]; + const out = cur.slice(); + for (let i = 0; i < n; i++) out[i] = flipped[i]; + pruState.registers[r] = pack(out); + remaining -= n; r++; + } + } else { + // 6.4.6.2.7.2 / 6.4.6.2.7.3: whole-window exchanges. Both must + // start at R2.b0; the pairs below are the TRM's own tables. + const pairs = (bank === 161) + ? [[2, 6], [3, 7], [4, 8], [5, 9]] + : [[2, 14], [3, 15], [4, 16], [5, 17], [6, 10], [7, 11], [8, 12], [9, 13]]; + if (destRegN !== 2) { + pruState.unknownInstruction = + `XIN ${bank}: BSWAP ${bank === 161 ? '4_8' : '4_16'} must start at R2.b0 (SPRUIM2J 6.4.6.2.7)`; + } else { + for (const [a, b] of pairs) { + const t = pruState.registers[a] >>> 0; + pruState.registers[a] = pruState.registers[b] >>> 0; + pruState.registers[b] = t; + } + } + } + } else if (bank === 112 || bank === 113 || bank === 114) { + // XFR2TR (SPRUIM2J Table 6-112). Only 0x71 has a + // documented XIN direction; 0x70/0x72 are XOUT-only. + this._xfr2trXin(pruState, bank, destRegN, count); + } else if (bank === SPINLOCK_DEVICE_ID) { + // XIN from 0x90 is the ACQUIRE. Table 6-98: "own_req input + // will assert during this XIN transaction. This assertion will cause + // an arbitration event. The result ... returned to R1.b3[0] via + // own_req_status. 0h: not owned/lost 1h: owned/won". + this._spinlockArbitrate(pruState, destRegN); + } else if (bank === BS_RAM_DEVICE_READ || bank === BS_RAM_DEVICE_WRITE || bank === BS_RAM_DEVICE_SNOOP) { + // BS RAM XIN. A real 64x32B per-core model serves ID 48 + // (R2..R9, <=32B); Fig 6-49 uses that same per-core ID for XOUT/XIN. + this._bsRamXin(pruState, bank, destRegN, count); + } else if (bank === FDB_DEVICE_DATA_ADDR || bank === FDB_DEVICE_COMPARE) { + this._fdbXin(pruState, bank, destRegN, count); + } else if (bank === 240) { + // ICSSG QUEUE_EMPTY broadside unit (SPRUIM2J Table 6-60 BS ID 0xF0 / 240). + // Reads 32-bit queue empty status word. + const status = (pruState.queue_empty !== undefined ? pruState.queue_empty : 0xFFFFFFFF) >>> 0; + if (destRegN <= 31) { + pruState.registers[destRegN] = status; + } + } else { + // SPAD: load from scratchpad bank (note: 48/49 are no longer here) + if (this._spad && this._spad[bank]) { + for (let i = 0; i < count; i++) { + if (destRegN + i <= 31 && i < this._spad[bank].length) { + pruState.registers[destRegN + i] = this._spad[bank][i]; + } + } + } + } + pruState.pc++; + break; + } + case 'ADC': { + const destRegInfo = this._parseRegister(operands[0].replace(/,/g, '')); + if (this._handleParsingError(pruState, destRegInfo)) { pruState.pc++; break; } + let src1Value, src2Value; + const src1Reg = this._parseRegister(operands[1].replace(/,/g, '')); + if (this._handleParsingError(pruState, src1Reg)) { pruState.pc++; break; } + src1Value = this._getRegisterValue(pruState, src1Reg); + if (operands[2].toLowerCase().startsWith('r')) { + const src2Reg = this._parseRegister(operands[2].replace(/,/g, '')); + if (this._handleParsingError(pruState, src2Reg)) { pruState.pc++; break; } + src2Value = this._getRegisterValue(pruState, src2Reg); + } else { + src2Value = this._parseImmediate(operands[2]); + if (this._handleParsingError(pruState, src2Value)) { pruState.pc++; break; } + } + const result = (src1Value >>> 0) + (src2Value >>> 0) + (pruState.carry >>> 0); + pruState.carry = (result > 0xFFFFFFFF) ? 1 : 0; + this._setRegisterValue(pruState, destRegInfo, result >>> 0); + pruState.pc++; + break; + } + case 'SUC': { + const destRegInfo = this._parseRegister(operands[0].replace(/,/g, '')); + if (this._handleParsingError(pruState, destRegInfo)) { pruState.pc++; break; } + let src1Value, src2Value; + const src1Reg = this._parseRegister(operands[1].replace(/,/g, '')); + if (this._handleParsingError(pruState, src1Reg)) { pruState.pc++; break; } + src1Value = this._getRegisterValue(pruState, src1Reg); + if (operands[2].toLowerCase().startsWith('r')) { + const src2Reg = this._parseRegister(operands[2].replace(/,/g, '')); + if (this._handleParsingError(pruState, src2Reg)) { pruState.pc++; break; } + src2Value = this._getRegisterValue(pruState, src2Reg); + } else { + src2Value = this._parseImmediate(operands[2]); + if (this._handleParsingError(pruState, src2Value)) { pruState.pc++; break; } + } + const result = (src1Value >>> 0) - (src2Value >>> 0) - (1 - (pruState.carry >>> 0)); + pruState.carry = (result <= 0xFFFFFFFF && result >= 0) ? 1 : 0; + this._setRegisterValue(pruState, destRegInfo, result >>> 0); + pruState.pc++; + break; + } + case 'RSB': { + const destRegInfo = this._parseRegister(operands[0].replace(/,/g, '')); + if (this._handleParsingError(pruState, destRegInfo)) { pruState.pc++; break; } + let src1Value, src2Value; + const src1Reg = this._parseRegister(operands[1].replace(/,/g, '')); + if (this._handleParsingError(pruState, src1Reg)) { pruState.pc++; break; } + src1Value = this._getRegisterValue(pruState, src1Reg); + if (operands[2].toLowerCase().startsWith('r')) { + const src2Reg = this._parseRegister(operands[2].replace(/,/g, '')); + if (this._handleParsingError(pruState, src2Reg)) { pruState.pc++; break; } + src2Value = this._getRegisterValue(pruState, src2Reg); + } else { + src2Value = this._parseImmediate(operands[2]); + if (this._handleParsingError(pruState, src2Value)) { pruState.pc++; break; } + } + const result = (src2Value >>> 0) - (src1Value >>> 0); + pruState.carry = (src2Value >= src1Value) ? 1 : 0; + this._setRegisterValue(pruState, destRegInfo, result >>> 0); + pruState.pc++; + break; + } + case 'MULL': { + const destRegInfo = this._parseRegister(operands[0].replace(/,/g, '')); + if (this._handleParsingError(pruState, destRegInfo)) { pruState.pc++; break; } + const reg1 = this._parseRegister(operands[1].replace(/,/g, '')); + const reg2 = this._parseRegister(operands[2].replace(/,/g, '')); + if (this._handleParsingError(pruState, reg1, reg2)) { pruState.pc++; break; } + const v1 = this._getRegisterValue(pruState, reg1); + const v2 = this._getRegisterValue(pruState, reg2); + this._setRegisterValue(pruState, destRegInfo, (v1 * v2) & 0xFFFFFFFF); + pruState.pc++; + cyclesToAdd = 3; + break; + } + default: { + // Skip unknown instructions (macro calls, assembler directives) + pruState.unknownInstruction++; + pruState.pc++; + break; + } + } + + // The XFR2TR ring drains into place in the background at + // hardware's own pace. One step AFTER each instruction's own dispatch + // (so a submit's just-pushed IDs are visible to the very next + // instruction, e.g. an immediate status read) keeps a wait-for-busy + // poll loop making real progress instead of livelocking against a HOLD. + if (pruState.xfr2tr) this._xfr2trDrain(pruState); + + // Handle loop stack + if (pruState.loopStack && pruState.loopStack.length > 0) { + const curLoop = pruState.loopStack[pruState.loopStack.length - 1]; + if (pruState.pc === curLoop.targetLabel) { + if (curLoop.currentIteration >= curLoop.count) { + pruState.loopStack.pop(); + } else { + curLoop.currentIteration++; + pruState.pc = curLoop.returnPC; + } + } + } + + // Cycle accounting + for (let c = 0; c < cyclesToAdd; c++) { + pruState.cycles++; + if (pruState.r31HistoryMap && pruState.r31HistoryMap[pruState.cycles] !== undefined) { + pruState.registers[31] = pruState.r31HistoryMap[pruState.cycles]; + } + pruState.iepCount++; + if (pruState.iepCmp0 > 0 && pruState.iepCount >= pruState.iepCmp0) { + pruState.iepCmpStatus |= 1; + pruState.iepCount = 0; + } + if (pruState.iepCmp1 > 0 && pruState.iepCount >= pruState.iepCmp1) { + pruState.iepCmpStatus |= 2; + } + r30History.push(pruState.registers[30]); + r31History.push(pruState.registers[31]); + } + } + + /** + * G3: Run simulation until cycle budget, halt, or watchpoint condition is met. + * + * Watchpoint types: + * { type: 'halt' } - Stop when HALT instruction is executed + * { type: 'pc', address: number } - Stop when PC reaches a specific address + * { type: 'register', register: number, value: number } - Stop when register equals value + * { type: 'memory', address: number, value: number } - Stop when memory at address equals value + * { type: 'cycles', value: number } - Maximum number of cycles (same as maxCycles) + * + * @param {Object} options + * @param {number} [options.maxCycles=1000] - Maximum cycles to simulate + * @param {Array} [options.watchpoints=[]] - Watchpoint conditions + * @returns {Object} Final simulation state with { state, hitWatchpoint, r30History, r31History } + */ + runUntil(options = {}) { + const maxCycles = options.maxCycles || 1000; + const watchpoints = options.watchpoints || []; + + this._ensureLoaded(); + if (!this._instructions.length) { + throw new Error('No instructions loaded. Call loadInstructions() first.'); + } + if (!this._pruState) { + this._resetState(); + } + + const pruState = this._pruState; + const instructions = this._instructions; + const labelMap = this._labelMap; + const r30History = this._r30History; + const r31History = this._r31History; + let hitWatchpoint = null; + + while (pruState.cycles < maxCycles && pruState.pc < instructions.length && !hitWatchpoint) { + const instr = instructions[pruState.pc]; + + // Check watchpoints BEFORE executing instruction + for (const wp of watchpoints) { + if (this._checkWatchpoint(wp, pruState, instructions, instr)) { + hitWatchpoint = { ...wp, cycle: pruState.cycles, pc: pruState.pc }; + break; + } + } + if (hitWatchpoint) break; + + this._executeSingleInstruction(pruState, instructions, labelMap, r30History, r31History, maxCycles); + + // Check watchpoints AFTER executing instruction + if (!hitWatchpoint) { + for (const wp of watchpoints) { + if (this._checkWatchpoint(wp, pruState, instructions, instructions[pruState.pc])) { + hitWatchpoint = { ...wp, cycle: pruState.cycles, pc: pruState.pc }; + break; + } + } + } + } + + // Final check: did we hit maxCycles? + if (!hitWatchpoint && pruState.cycles >= maxCycles) { + hitWatchpoint = { type: 'cycles', value: maxCycles, cycle: pruState.cycles, pc: pruState.pc }; + } + + this._watchpointHit = hitWatchpoint; + return { + state: this.state, + hitWatchpoint, + r30History: [...r30History], + r31History: [...r31History] + }; + } + + /** + * Check if a watchpoint condition is met. + * @returns {boolean} + */ + _checkWatchpoint(wp, pruState, instructions, currentInstruction) { + switch (wp.type) { + case 'halt': + if (currentInstruction && currentInstruction.toUpperCase().trim() === 'HALT') { + return true; + } + return false; + case 'pc': + return pruState.pc === (wp.address >>> 0); + case 'register': + return wp.register >= 0 && wp.register <= 31 && + (pruState.registers[wp.register] >>> 0) === (wp.value >>> 0); + case 'memory': { + if (wp.address >= 0x00010000 && wp.address < 0x00020000) { + const val = this._pruSMEM.readSMEM(wp.address, 4); + const ival = val ? (val[0] | (val[1] << 8) | (val[2] << 16) | (val[3] << 24)) >>> 0 : 0; + return ival === (wp.value >>> 0); + } + const val = this._pruDMEM0.readDMEM0(wp.address, 4); + const ival = val ? (val[0] | (val[1] << 8) | (val[2] << 16) | (val[3] << 24)) >>> 0 : 0; + return ival === (wp.value >>> 0); + } + case 'cycles': + return pruState.cycles >= (wp.value || 0); + default: + return false; + } + } + + /** + * Get the last watchpoint that was hit. + * @returns {Object|null} + */ + get lastWatchpointHit() { + return this._watchpointHit; + } + + /** + * G4: Export simulation trace as Value Change Dump (VCD) format. + * + * @param {Object} [options] + * @param {string} [options.moduleName] - Module name for VCD header + * @param {number} [options.timeScale] - Timescale in ns per cycle + * @param {Array} [options.signals] - Specific bit indices to include + * @returns {string} VCD content + */ + exportVcd(options = {}) { + return exportVcd(this._r30History, this._r31History, { + moduleName: options.moduleName || this.id, + timeScale: options.timeScale || 1, + signals: options.signals, + date: options.date, + version: `PRU Simulator [${this.id}]` + }); + } + + /** + * Saves VCD trace to a file. + * @param {string} filePath - Output file path + * @param {Object} [options] + * @returns {string} The file path written + */ + saveVcd(filePath, options = {}) { + const content = this.exportVcd(options); + fs.writeFileSync(filePath, content, 'utf8'); + return filePath; + } + + // ================================================================== + // API: Memory access + // ================================================================== + + /** + * Read DMEM0 memory. + * @param {number} address + * @param {number} [size=4] - Bytes to read + * @returns {Uint8Array|null} + */ + readDMEM(address, size = 4) { + this._ensureLoaded(); + return this._pruDMEM0.readDMEM0(address, size); + } + + /** + * Write to DMEM0 memory. + * @param {number} address + * @param {Uint8Array|Array} data + * @returns {boolean} + */ + writeDMEM(address, data) { + this._ensureLoaded(); + return this._pruDMEM0.writeDMEM0(address, data); + } + + /** + * Read SMEM (Shared Memory). + * @param {number} address + * @param {number} [size=4] - Bytes to read + * @returns {Uint8Array|null} + */ + readSMEM(address, size = 4) { + this._ensureLoaded(); + return this._pruSMEM.readSMEM(address, size); + } + + /** + * Write to SMEM. + * @param {number} address + * @param {Uint8Array|Array} data + * @returns {boolean} + */ + writeSMEM(address, data) { + this._ensureLoaded(); + return this._pruSMEM.writeSMEM(address, data); + } + + /** + * Add a symbol to the DMEM symbol table. + * @param {string} name + * @param {number} address + * @param {number} [size=4] + * @returns {boolean} + */ + addSymbol(name, address, size = 4) { + this._ensureLoaded(); + return this._pruDMEM0.addSymbol(name, address, size); + } + + /** + * Get address of a symbol. + * @param {string} name + * @returns {number|null} + */ + symbolAddress(name) { + this._ensureLoaded(); + return this._pruDMEM0.getSymbolAddress(name); + } + + /** + * Get all loaded firmware symbols. + * @returns {Object} + */ + get firmwareSymbols() { + return { ...this._dmemSymbols }; + } + + /** + * Get firmware metadata. + * @returns {Object|null} + */ + get firmwareInfo() { + if (!this._firmware) return null; + return { + format: this._firmware.format, + entryPoint: this._firmware.entryPoint, + dataSection: this._firmware.data ? { + name: this._firmware.data.name, + addr: this._firmware.data.addr, + size: this._firmware.data.data.length + } : null, + numSymbols: this._firmware.symbols.length, + numInstructions: this._rawInstructions ? this._rawInstructions.length : 0 + }; + } + + // ================================================================== + // Internal helpers (mirrors pru_core.js parsing) + // ================================================================== + + _parseImmediate(immStr) { + try { + const result = eval(immStr); + if (typeof result !== 'number' || isNaN(result)) return -1; + return result; + } catch (e) { + return -1; + } + } + + _parseRegister(regStr) { + const fullRegex = /r(\d+)(\.b(\d+)|\.w(\d+))?/i; + const match = regStr.match(fullRegex); + if (!match) return -1; + const regNum = parseInt(match[1]); + if (match[3] !== undefined) { + return { reg: regNum, type: 'byte', byteNum: parseInt(match[3]), mask: 0xFF, shift: parseInt(match[3]) * 8 }; + } else if (match[4] !== undefined) { + return { reg: regNum, type: 'word', wordNum: parseInt(match[4]), mask: 0xFFFF, shift: parseInt(match[4]) * 8 }; + } + return { reg: regNum, type: 'full' }; + } + + _handleParsingError(pruState, ...values) { + for (const v of values) { + if (v === -1) { pruState.errorParsing++; return true; } + } + return false; + } + + _getRegisterValue(pruState, regInfo) { + if (regInfo.type === 'full') return pruState.registers[regInfo.reg]; + return (pruState.registers[regInfo.reg] >> regInfo.shift) & regInfo.mask; + } + + _setRegisterValue(pruState, regInfo, value) { + if (regInfo.type === 'full') { + pruState.registers[regInfo.reg] = value >>> 0; + } else { + const clearMask = ~(regInfo.mask << regInfo.shift); + pruState.registers[regInfo.reg] = (pruState.registers[regInfo.reg] & clearMask) | (((value & regInfo.mask) << regInfo.shift) >>> 0); + } + } +} + +// ======================================================================== +// Exports +// ======================================================================== + +module.exports = { + Simulator, + CoffParser, + loadPruFirmware, + exportVcd +}; diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/gpi_stimulus_runner.py b/tools/gpi_stimulus_runner.py new file mode 100644 index 0000000..70ff3ba --- /dev/null +++ b/tools/gpi_stimulus_runner.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""gpi_stimulus_runner.py — run PRU firmware under a cycle-keyed GPI waveform.. This runner attaches the waveform to the simulation through the +DeviceBus (``io_port.attach_device(WaveformPlayer(...))``) instead of poking +``set_gpi_word`` / ``io_port.gpi`` past the bus, which is what the previous +scratch version did. Writing GPI directly meant: + + * no contention resolution — a stimulus shorted against a device model + (or against PRU-driven pins) looked identical to a clean one; + * no ``events()`` — the applied points could not be diffed against the + independent bus-cosim decoder like every other device's; + * step-indexed application, because the injection site saw instruction + counts, not ``counters.cycles`` — under any multi-cycle instruction a + different experiment than the one intended. + +CONTRACT +-------- +Exactly one JSON object on stdout, always. Exit codes: + + 0 ran to HALT with no faults + 1 ran, but faults were recorded (device, bus/contention, exhaustion, + or core faults) or the run hit --max-cycles without halting + 2 setup failure (unparsable stimulus, no simulator, asm parse errors); + the JSON object carries "ok": false and "errors" + +The payload's ``devices`` object carries the same ``{events, faults}`` the +in-process DeviceBus exposes, so a subprocess consumer reaches the same +verdict an in-process one would. + +USAGE +----- + python3 tools/gpi_stimulus_runner.py \ + --asm firmware.asm \ + --stimulus waveform.json # or '-' for stdin + [--simulator PATH] [--max-cycles N] + [--pins 3,5] [--trace-r31] + +The stimulus JSON is accepted in BOTH emitter shapes: the wrapped +``{"gpi_word_by_cycle": {"0": 8, ...}}`` form and the bare +``{"0": 8, ...}`` map. Keys are PRU CYCLES (the cycle counter value at bus +settle), not instruction indexes. HOLD semantics apply; running past the +last entry raises a waveform-exhausted fault, which lands in +``devices.faults`` and flips the exit code. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import logging +import os +import sys + +MASK_20 = (1 << 20) - 1 + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +SIMULATOR_CANDIDATES = ( + os.environ.get("PRU_SIMULATOR_PATH"), + os.path.join(ROOT, "external", "pru-simulator"), + os.path.join(ROOT, "..", "pru-simulator"), + os.path.join(ROOT, "..", "pru-simulator-upstream"), + os.path.join(ROOT, os.pardir, os.pardir, "tools", "pru-simulator-upstream"), + "/opt/pru-simulator", +) + + +def locate_simulator(explicit=None): + """Find the TI pru-simulator checkout (a directory holding simulator.py).""" + import glob + tried = [] + ancestors = glob.glob(os.path.join(ROOT, os.pardir, os.pardir, "*", + "tools", "pru-simulator-upstream")) + for cand in filter(None, (explicit, *SIMULATOR_CANDIDATES, *ancestors)): + cand = os.path.abspath(cand) + tried.append(cand) + if os.path.isfile(os.path.join(cand, "simulator.py")): + return cand + raise RuntimeError( + "no pru-simulator checkout found (looked for simulator.py in:\n " + + "\n ".join(tried) + ")") + + +def load_waveform_player_module(): + """Load pru_io/waveform_player.py from THIS repo. + + Deliberately by path: inside the runner the name ``pru_io`` may already + be bound to the simulator checkout's package (the bus we need comes from + there), so a plain ``import pru_io.waveform_player`` could resolve to the + wrong tree. The module itself picks whichever DeviceModel base is + importable; the bus never isinstance-checks, so the wire contract is + what matters. + """ + path = os.path.join(ROOT, "pru_io", "waveform_player.py") + spec = importlib.util.spec_from_file_location("waveform_player", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class _FaultCapture(logging.Handler): + """The core logs LBBO/SBBO-against-unmapped faults and HALTS; a run that + printed faults must not be laundered into a clean one.""" + + def __init__(self): + super().__init__(level=logging.ERROR) + self.faults = [] + + def emit(self, record): + self.faults.append(record.getMessage()) + + +def run(args) -> int: + out = {} + def emit(code): + json.dump(out, sys.stdout) + sys.stdout.write("\n") + return code + + # -- stimulus (fail closed on anything we cannot read exactly) -------- + try: + raw = sys.stdin.read() if args.stimulus == "-" else \ + open(args.stimulus, "r", encoding="utf-8").read() + stimulus = json.loads(raw) + except (OSError, ValueError) as exc: + out.update({"ok": False, "errors": [f"stimulus: {exc}"]}) + return emit(2) + + try: + sim_root = locate_simulator(args.simulator) + except RuntimeError as exc: + out.update({"ok": False, "errors": [str(exc)]}) + return emit(2) + + pins = None + if args.pins: + try: + pins = [int(p) for p in args.pins.split(",")] + except ValueError: + out.update({"ok": False, "errors": [f"--pins: {args.pins!r}"]}) + return emit(2) + + try: + asm = open(args.asm, "r", encoding="utf-8").read() + except OSError as exc: + out.update({"ok": False, "errors": [f"asm: {exc}"]}) + return emit(2) + + # -- import order matters: simulator first so its core/pru_io bind, --- + # -- then the player by path (see load_waveform_player_module) -------- + sys.path.insert(0, sim_root) + from simulator import Simulator # noqa: E402 + from pru_io.device_model import DeviceBus # noqa: F401,E402 (same tree as the core) + wp = load_waveform_player_module() + + try: + player = wp.WaveformPlayer(stimulus, pins=pins) + except ValueError as exc: + out.update({"ok": False, "errors": [f"stimulus: {exc}"]}) + return emit(2) + + sim = Simulator(config_path="nonexistent.cfg") + errs = sim.load("pru0", asm) + if errs: + out.update({"ok": False, "parse": False, "errors": errs[:10]}) + return emit(2) + + core = sim.cores["pru0"] + + # Attach through the bus — never past it. IOPort.tick_devices writes + # only device-driven pins back into GPI, and the narrowed PRU drive mask + # keeps the (input) stimulus pins from contending with the core's own + # R30 output word. + bus: DeviceBus = core.io_port.device_bus + bus.attach(player) + bus.set_pru_drive_mask(MASK_20 & ~player.drive_mask) + + capture = _FaultCapture() + core_logger = logging.getLogger(type(core).__module__) + core_logger.addHandler(capture) + + r31 = [] + steps = 0 + try: + while not core.halted and steps < args.max_cycles: + core.step() + steps += 1 + if args.trace_r31: + r31.append(core.io_port.read_r31() & MASK_20) + except Exception as exc: # noqa: BLE001 - the class and message ARE the result + out.update({"ok": False, "halted": core.halted, "steps": steps, + "cycles": core.counters.cycles, + "errors": [f"{type(exc).__name__}: {exc}"[:200]], + "devices": {"events": bus.events(), "faults": bus.faults()}}) + return emit(1) + finally: + core_logger.removeHandler(capture) + + device_faults = bus.faults() + all_faults = list(capture.faults) + device_faults + clean = core.halted and not all_faults + out.update({ + "ok": clean, + "parse": True, + "halted": core.halted, + "steps": steps, + "cycles": core.counters.cycles, + "stimulus": { + "shape": player.shape, + "points": len(player._points), + "last_cycle": player._points[-1][0], + "pins": player.pins, + }, + "exhausted": player.exhausted, + "faults": all_faults[:50], + "devices": {"events": bus.events(), "faults": device_faults[:50]}, + }) + if args.trace_r31: + out["r31"] = r31 + return emit(0 if clean else 1) + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser( + description="Run PRU assembly under a cycle-keyed GPI waveform " + "attached through the DeviceBus.") + ap.add_argument("--asm", required=True, help="PRU assembly source file") + ap.add_argument("--stimulus", required=True, + help="JSON waveform file ('-' for stdin); wrapped " + '{"gpi_word_by_cycle": {...}} or bare {cycle: word}') + ap.add_argument("--simulator", default=None, + help="path to the TI pru-simulator checkout " + "(default: PRU_SIMULATOR_PATH, then candidates)") + ap.add_argument("--max-cycles", type=int, default=100000) + ap.add_argument("--pins", default=None, + help="comma-separated driven pin list (default: every " + "bit the waveform asserts)") + ap.add_argument("--trace-r31", action="store_true", + help="include the per-step resolved R31 word in the payload") + args = ap.parse_args(argv) + return run(args) + + +if __name__ == "__main__": + sys.exit(main()) From 2a6331eb7da9e7aef76e47f1cd45baf8da327813 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 06:27:41 +0530 Subject: [PATCH 15/44] Add device-model and simulator-backed regression tests Tests for the device models and bindings, the Run on TI pru-simulator launcher, and regression tests of the shared allocator and existing blocks that run the generated firmware on TI pru-simulator (CRC/LUT index scaling, SPI zero-extension, loop nesting, register-pool exhaustion, loop-carried accumulators, load-constant width). A repository-level conftest reports tests that need a TI pru-simulator checkout as skipped, with the reason, when none is available. --- conftest.py | 30 + scripts/sim/test_catalog_completeness.py | 65 ++ scripts/sim/test_device_binding_runtime.js | 312 +++++++++ scripts/sim/test_device_binding_syscfg.js | 176 ++++++ scripts/sim/test_device_bindings.py | 408 ++++++++++++ scripts/sim/test_device_models.js | 372 +++++++++++ scripts/sim/test_ti_run_gui.js | 411 ++++++++++++ tests/test_access_lut_index_scaling.py | 97 +++ tests/test_bissline.py | 702 +++++++++++++++++++++ tests/test_board_variant_builds.py | 234 +++++++ tests/test_crc_lut_index_scaling.py | 276 ++++++++ tests/test_fanout_register_alias.py | 332 ++++++++++ tests/test_hardware_loop_nesting.py | 213 +++++++ tests/test_load_constant_width.py | 175 +++++ tests/test_loop_carried_accumulator.py | 275 ++++++++ tests/test_register_pool_exhaustion.py | 268 ++++++++ tests/test_spi_cs_timing_elf.py | 170 +++++ tests/test_spi_read_zero_extend.py | 129 ++++ tests/test_spi_transfer_zero_extend.py | 168 +++++ tests/test_waveform_player.py | 272 ++++++++ 20 files changed, 5085 insertions(+) create mode 100644 conftest.py create mode 100644 scripts/sim/test_catalog_completeness.py create mode 100644 scripts/sim/test_device_binding_runtime.js create mode 100644 scripts/sim/test_device_binding_syscfg.js create mode 100644 scripts/sim/test_device_bindings.py create mode 100644 scripts/sim/test_device_models.js create mode 100644 scripts/sim/test_ti_run_gui.js create mode 100644 tests/test_access_lut_index_scaling.py create mode 100644 tests/test_bissline.py create mode 100644 tests/test_board_variant_builds.py create mode 100644 tests/test_crc_lut_index_scaling.py create mode 100644 tests/test_fanout_register_alias.py create mode 100644 tests/test_hardware_loop_nesting.py create mode 100644 tests/test_load_constant_width.py create mode 100644 tests/test_loop_carried_accumulator.py create mode 100644 tests/test_register_pool_exhaustion.py create mode 100644 tests/test_spi_cs_timing_elf.py create mode 100644 tests/test_spi_read_zero_extend.py create mode 100644 tests/test_spi_transfer_zero_extend.py create mode 100644 tests/test_waveform_player.py diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..9f35637 --- /dev/null +++ b/conftest.py @@ -0,0 +1,30 @@ +"""Repository-wide pytest hooks. + +Tests that run firmware on TI pru-simulator need a checkout of it (PRU_SIMULATOR_PATH or tools/pru-simulator-upstream). +Without one they are reported as SKIPPED with the reason, not as failures, so a CI runner without the simulator stays +green and still shows what was not run. +""" +import pytest + + +def _missing_simulator(exc): + return type(exc).__name__ == "SimulatorNotFound" + + +def _skip_if_missing_simulator(outcome): + exc = outcome.excinfo[1] if outcome.excinfo else None + if exc is not None and _missing_simulator(exc): + outcome.force_exception(pytest.skip.Exception("TI pru-simulator not available: " + str(exc).split(";")[0], + _use_item_location=True)) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_setup(item): + outcome = yield + _skip_if_missing_simulator(outcome) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_call(item): + outcome = yield + _skip_if_missing_simulator(outcome) diff --git a/scripts/sim/test_catalog_completeness.py b/scripts/sim/test_catalog_completeness.py new file mode 100644 index 0000000..7b07644 --- /dev/null +++ b/scripts/sim/test_catalog_completeness.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +import os +import glob +import re +import sys + +def main(): + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + js_path = os.path.join(root, ".metadata", "sysconfig", ".meta", "pru_blocks", "common", "simulation", "device_models.js") + with open(js_path, "r", encoding="utf-8") as f: + js_content = f.read() + + match = re.search(r"const DEVICE_MODEL_KINDS = \[([\s\S]*?)\];", js_content) + if not match: + print("FAIL: Could not find DEVICE_MODEL_KINDS") + sys.exit(1) + + kinds_in_js = set(re.findall(r'kind:\s*"([^"]+)"', match.group(1))) + + py_files = [] + for d in ["perif", "pru_io"]: + py_files.extend(glob.glob(os.path.join(root, d, "*.py"))) + + found_classes = set() + for pf in py_files: + with open(pf, "r", encoding="utf-8") as f: + content = f.read() + for m in re.finditer(r"^class\s+([A-Za-z0-9_]+)\s*\(\s*(?:pru_io\.|perif\.)?DeviceModel\s*\)\s*:", content, re.MULTILINE): + found_classes.add(m.group(1)) + + abstract_classes = {"SsiEndatEncoder", "QuadratureIndexEncoder"} + + class_to_kind = { + "HallSensorModel": "hall", + "DualOssdEmitter": "ossd", + "SpiAdc": "spi_adc", + "StepDirDriver": "step_dir", + "Ws2812String": "ws2812", + "BissLineEncoder": "bissline", + "EndatEncoder": "endat", + "MdioPhyModel": "mdio_phy", + } + + missing_from_catalog = [] + for cls in found_classes: + if cls in abstract_classes: + continue + kind = class_to_kind.get(cls) + if kind is None: + missing_from_catalog.append(f"{cls} (not mapped to a kind in test)") + continue + if kind not in kinds_in_js: + missing_from_catalog.append(f"{cls} (kind '{kind}')") + + if missing_from_catalog: + print("FAIL: The following DeviceModel subclasses are missing from DEVICE_MODEL_KINDS:") + for m in missing_from_catalog: + print(" - " + m) + sys.exit(1) + + print(f"PASS: All {len(found_classes)} DeviceModel subclasses are accounted for in DEVICE_MODEL_KINDS.") + sys.exit(0) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/scripts/sim/test_device_binding_runtime.js b/scripts/sim/test_device_binding_runtime.js new file mode 100644 index 0000000..08a30a3 --- /dev/null +++ b/scripts/sim/test_device_binding_runtime.js @@ -0,0 +1,312 @@ +/** + * Runtime tests: device models attached to the RUNNING simulation. + * + * The config layer (1657e6e) let the user describe a binding; this suite + * proves the wiring tranche: the JS simulator (pru_core.js) settles a + * DeviceBus every cycle, so an attached device ANSWERS the firmware rather + * than replaying a recording. A correct SSI master reads the encoder's + * position; a master that clocks too fast is REFUSED by the model. That + * asymmetry — pass when right, fail when wrong — is the entire point of + * attaching a behavioural model, and both arms are tested. + * + * Also proven here: + * - the verdict/margin glue (buildDeviceRunResults, measureClockWaveform) + * extracted verbatim from the static module, so what is tested is what + * ships; + * - fail-closed verdicts: a device that never spoke is a FAIL, not a pass; + * - no path, build directory or interpreter string appears in any output + * the user can see. + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const assert = require('assert'); + +const SIM_DIR = path.join(__dirname, '..', '..', '.metadata', 'sysconfig', '.meta', + 'pru_blocks', 'common', 'simulation'); +const STATIC_MODULE = path.join(SIM_DIR, '..', 'pru_blocks_static_module.syscfg.js'); + +// -- load the sim scripts exactly as the headless shim does ------------------- + +const scriptCache = {}; +function loadSimScript(scriptPath) { + const code = fs.readFileSync(scriptPath, 'utf8'); + const sandbox = { + console, Math, JSON, Number, Array, Object, Set, Error, Uint8Array, + parseInt, isNaN, + system: { + getScript: (p) => { + const basename = path.basename(p); + if (!scriptCache[basename]) { + scriptCache[basename] = loadSimScript(path.join(SIM_DIR, basename)); + } + return scriptCache[basename]; + } + }, + exports: {} + }; + vm.createContext(sandbox); + vm.runInContext(code, sandbox); + return sandbox.exports; +} + +const pruCore = loadSimScript(path.join(SIM_DIR, 'pru_core.js')); +const busScript = loadSimScript(path.join(SIM_DIR, 'device_bus.js')); +const models = loadSimScript(path.join(SIM_DIR, 'device_models.js')); +const { DeviceBus, MASK_20 } = busScript; + +// -- extract the verdict glue verbatim from the static module ----------------- +// The functions are self-contained (module-scope references only to each +// other), so they can be evaluated exactly as shipped. Same technique and +// same caveat as test_device_binding_syscfg.js: this proves the LOGIC, not +// SysConfig's rendering. + +function extractFunction(source, name) { + const start = source.indexOf(`function ${name}`); + assert.notStrictEqual(start, -1, `${name} not found in the static module`); + const rest = source.slice(start); + const end = rest.indexOf('\n}\n'); + assert.notStrictEqual(end, -1, `could not find the end of ${name}`); + return rest.slice(0, end + 2); +} + +const staticSrc = fs.readFileSync(STATIC_MODULE, 'utf8').replace(/\r\n/g, '\n'); +const glue = new Function( + extractFunction(staticSrc, 'measureClockWaveform') + + extractFunction(staticSrc, 'buildDeviceRunResults') + + '; return { measureClockWaveform, buildDeviceRunResults };' +)(); + +// -- helpers ------------------------------------------------------------------- + +/** Attach devices the way the static module does: device pins out of the + * PRU drive mask, so a correct design records no false contention. */ +function attachBus(devices) { + const bus = new DeviceBus(); + let mask = MASK_20; + for (const dev of devices) { + bus.attach(dev); + for (const pin of Object.keys(dev.nets || {})) { + mask &= ~(1 << Number(pin)); + } + } + bus.setPruDriveMask(mask); + return bus; +} + +function runSim(instructions, labels, cycles, deviceBus) { + return pruCore.simulatePruInstructions(instructions, labels, cycles, {}, {}, deviceBus); +} + +/** + * A 4-bit SSI master in PRU assembly. CLK is R30 bit 0, DATA is R31 bit 1. + * Falls, shifts, rises, samples - one instruction per cycle - then issues + * the trailing edge that ends the word, and halts. + */ +const SSI_MASTER = [ + 'ldi R4, 0', + 'ldi R5, 4', + 'ldi R6, 1', + 'set R30, R30, 0', + // m_bit: + 'clr R30, R30, 0', + 'lsl R4, R4, 1', + 'set R30, R30, 0', + 'qbbc m_zero, R31, 1', + 'or R4, R4, R6', + // m_zero: + 'sub R5, R5, 1', + 'qbne m_bit, R5, 0', + // trailing edge: end of word + 'clr R30, R30, 0', + 'set R30, R30, 0', + 'halt' +]; +const SSI_MASTER_LABELS = SSI_MASTER.map((ins, i) => + i === 4 ? 'm_bit' : (i === 9 ? 'm_zero' : 0)); + +const cases = []; +function test(name, fn) { cases.push([name, fn]); } + +// --------------------------------------------------------------------------- +// Closed loop: the device answers the firmware +// --------------------------------------------------------------------------- + +test('a correct SSI master reads the encoder position through the bus', () => { + const enc = models.createDeviceModel('ssi', { primary: 1, clock: 0 }, { + bits: 4, position: 0b1011, gray: false, + min_clk_period_cycles: 4, monoflop_cycles: 200 + }); + const bus = attachBus([enc]); + const { pruState } = runSim(SSI_MASTER, SSI_MASTER_LABELS, 300, bus); + + assert.strictEqual(pruState.registers[4], 0b1011, + `firmware read 0b${pruState.registers[4].toString(2)}, expected 0b1011`); + assert.strictEqual(enc.faults().length, 0, JSON.stringify(enc.faults())); + assert.strictEqual(enc.events().filter(e => e.kind === 'frame').length, 1, + 'the trailing edge must close the frame'); +}); + +test('a master that clocks faster than f_max is refused by the device', () => { + // Identical firmware, but the model is a faster part's slower sibling: + // min period 20 cycles while the loop clocks every ~7. On hardware the + // encoder returns garbage; the model must FAULT instead. + const enc = models.createDeviceModel('ssi', { primary: 1, clock: 0 }, { + bits: 4, position: 0b1011, gray: false, + min_clk_period_cycles: 20, monoflop_cycles: 400 + }); + const bus = attachBus([enc]); + runSim(SSI_MASTER, SSI_MASTER_LABELS, 300, bus); + assert.ok(enc.faults().some(f => /below the encoder's minimum/.test(f)), + 'overclocked firmware must be refused: ' + JSON.stringify(enc.faults())); +}); + +test('without a device, R31 stays whatever the fixed history says (open loop unchanged)', () => { + const { pruState } = runSim(SSI_MASTER, SSI_MASTER_LABELS, 300, null); + // No device: DATA reads 0 throughout, so R4 shifts in zeros. + assert.strictEqual(pruState.registers[4], 0); +}); + +test('a device pin driven by the firmware records contention', () => { + // Firmware wrongly drives the DATA pin (R30 bit 1) while the encoder + // drives it too. This is the "both ends drive one wire" design error and + // must be visible, not silently resolved. + const enc = models.createDeviceModel('ssi', { primary: 1, clock: 0 }, { + bits: 4, position: 1, min_clk_period_cycles: 4 + }); + const bus = new DeviceBus(); + bus.attach(enc); // NOTE: full drive mask, the misconfiguration + const badFirmware = ['set R30, R30, 1', 'clr R30, R30, 1', 'halt']; + runSim(badFirmware, [0, 0, 0], 50, bus); + assert.ok(bus.faults().some(f => /contention/.test(f)), + 'contention must be recorded: ' + JSON.stringify(bus.faults())); +}); + +// --------------------------------------------------------------------------- +// Verdict and margin glue (extracted verbatim from the static module) +// --------------------------------------------------------------------------- + +test('verdict: clean run with a decoded frame is a PASS with a measured margin', () => { + const enc = models.createDeviceModel('ssi', { primary: 1, clock: 0 }, { + bits: 4, position: 0b1011, gray: false, + min_clk_period_cycles: 4, monoflop_cycles: 200 + }); + const bus = attachBus([enc]); + const { r30ValueHistory } = runSim(SSI_MASTER, SSI_MASTER_LABELS, 300, bus); + + const bindings = [{ pinNum: 1, kind: 'ssi', clockPin: 0, indexPin: -1, params: {}, device: enc }]; + const results = glue.buildDeviceRunResults(bus, bindings, r30ValueHistory); + assert.strictEqual(results.length, 1); + const r = results[0]; + assert.strictEqual(r.verdict, 'PASS', `${r.summary} | faults: ${JSON.stringify(r.faults)}`); + assert.ok(r.decode.length >= 1, 'protocol decode must carry the frame'); + assert.ok(r.margin, 'margin must be measured'); + // The loop clocks every ~7 cycles against a limit of 4. + assert.ok(r.margin.observedMinPeriodCycles >= 4, + `measured period ${r.margin.observedMinPeriodCycles}`); + assert.strictEqual(r.margin.slackCycles, + r.margin.observedMinPeriodCycles - 4); + assert.ok(/PASS/.test(r.summary) && /margin/.test(r.summary), r.summary); +}); + +test('verdict: a faulted run is a FAIL naming the violation', () => { + const enc = models.createDeviceModel('ssi', { primary: 1, clock: 0 }, { + bits: 4, position: 3, min_clk_period_cycles: 20, monoflop_cycles: 400 + }); + const bus = attachBus([enc]); + const { r30ValueHistory } = runSim(SSI_MASTER, SSI_MASTER_LABELS, 300, bus); + + const bindings = [{ pinNum: 1, kind: 'ssi', clockPin: 0, indexPin: -1, params: {}, device: enc }]; + const r = glue.buildDeviceRunResults(bus, bindings, r30ValueHistory)[0]; + assert.strictEqual(r.verdict, 'FAIL'); + assert.ok(/protocol violation/.test(r.summary), r.summary); +}); + +test('verdict: a device that never spoke is a FAIL, not a pass', () => { + // Firmware that never toggles the clock produces an idle trace that + // reads exactly like firmware that never ran. That must not pass. + const enc = models.createDeviceModel('ssi', { primary: 1, clock: 0 }, { + bits: 4, position: 3, min_clk_period_cycles: 4 + }); + const bus = attachBus([enc]); + const idleFirmware = ['set R30, R30, 0', 'halt']; + const { r30ValueHistory } = runSim(idleFirmware, [0, 0], 100, bus); + + const bindings = [{ pinNum: 1, kind: 'ssi', clockPin: 0, indexPin: -1, params: {}, device: enc }]; + const r = glue.buildDeviceRunResults(bus, bindings, r30ValueHistory)[0]; + assert.strictEqual(r.verdict, 'FAIL'); + assert.ok(/never spoke/.test(r.summary), r.summary); +}); + +test('verdict: macro blocks report cannot execute macro blocks instead of device never spoke', () => { + // When macro blocks (m_*) are encountered, the JS preview skips + // them and explains that macro blocks cannot be executed in the preview, + // rather than claiming the device never spoke. + const enc = models.createDeviceModel('ssi', { primary: 1, clock: 0 }, { + bits: 4, position: 3, min_clk_period_cycles: 4 + }); + const bus = attachBus([enc]); + const macroFirmware = ['m_ssi_read R0, 25', 'halt']; + const { pruState, r30ValueHistory } = runSim(macroFirmware, [0, 0], 100, bus); + + assert.strictEqual(pruState.skippedMacroCount, 1); + assert.strictEqual(pruState.skippedMacros.length, 1); + assert.strictEqual(pruState.skippedMacros[0], 'm_ssi_read'); + + const bindings = [{ pinNum: 1, kind: 'ssi', clockPin: 0, indexPin: -1, params: {}, device: enc }]; + const r = glue.buildDeviceRunResults(bus, bindings, r30ValueHistory, pruState)[0]; + assert.strictEqual(r.verdict, 'FAIL'); + assert.ok(/cannot execute macro blocks \(m_ssi_read\)/.test(r.summary), r.summary); + assert.ok(!/never spoke/.test(r.summary), r.summary); + + // Mutant arm: dropping the skip counter falls back to 'never spoke' and fails the assertion + const mutantState = Object.assign({}, pruState, { skippedMacroCount: 0 }); + const mutantR = glue.buildDeviceRunResults(bus, bindings, r30ValueHistory, mutantState)[0]; + assert.ok(/never spoke/.test(mutantR.summary), mutantR.summary); + assert.ok(!/cannot execute macro blocks/.test(mutantR.summary), mutantR.summary); +}); + +test('margin: the measurement reads the trace, not the configuration', () => { + // A clock scripted at 10-cycle periods must measure 10 regardless of + // what any config field claims - lesson. + const history = new Array(201).fill(1); // bit 0 high + for (let start = 20; start < 200; start += 100) { + for (let c = start; c < start + 5; c++) history[c] = 0; // 5 low, 5 high + } + const m = glue.measureClockWaveform(history, 0); + assert.strictEqual(m.observedMinPeriod, 100, `measured ${m.observedMinPeriod}`); +}); + +// --------------------------------------------------------------------------- +// Output hygiene: nothing the user sees may name a path or an interpreter +// --------------------------------------------------------------------------- + +test('no path, build directory, or interpreter string appears in device output', () => { + const enc = models.createDeviceModel('ssi', { primary: 1, clock: 0 }, { + bits: 4, position: 1, min_clk_period_cycles: 20 + }); + const bus = attachBus([enc]); + const { r30ValueHistory } = runSim(SSI_MASTER, SSI_MASTER_LABELS, 300, bus); + const bindings = [{ pinNum: 1, kind: 'ssi', clockPin: 0, indexPin: -1, params: {}, device: enc }]; + const blob = JSON.stringify(glue.buildDeviceRunResults(bus, bindings, r30ValueHistory)); + assert.ok(!/[A-Za-z]:[\\/]/.test(blob), 'absolute path leaked'); + assert.ok(!/\.py\b|python|node\b|build_out|\.metadata/i.test(blob), + 'toolchain detail leaked: ' + blob.slice(0, 200)); +}); + +// --------------------------------------------------------------------------- + +let failed = 0; +for (const [name, fn] of cases) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (e) { + failed += 1; + console.log(`FAIL - ${name}\n ${e.stack.split('\n').slice(0, 3).join('\n ')}`); + } +} +console.log(`\n${cases.length - failed} passed, ${failed} failed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/scripts/sim/test_device_binding_syscfg.js b/scripts/sim/test_device_binding_syscfg.js new file mode 100644 index 0000000..82468b9 --- /dev/null +++ b/scripts/sim/test_device_binding_syscfg.js @@ -0,0 +1,176 @@ +/** + * Device binding in the SysConfig Simulation Settings module. + * + * SysConfig config objects cannot be loaded outside the SysConfig runtime, so + * these tests exercise `validateDeviceInput` by extracting it from the module + * source and evaluating it against a stub `report`. That is a real limitation + * and worth naming: it proves the VALIDATION LOGIC, not that SysConfig renders + * the fields. Rendering is checked by opening the tool. + * + * The tests that matter are the refusals. Device mode is the one input mode + * where the user does not author the waveform, so its failure mode is + * different: a device wired to the wrong pins, or given a parameter the model + * will silently drop, produces a simulation that runs happily and proves + * nothing — the exact outcome attaching a real model is meant to prevent. + * + * Every refusal is paired with a control arm that is legal in the same shape. + * Without those, a validator that rejected everything would pass every test. + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const SRC = path.join(__dirname, '..', '..', '.metadata', 'sysconfig', '.meta', + 'pru_blocks', 'common', 'pru_blocks_static_module.syscfg.js'); + +function loadValidator() { + // Normalise line endings first: this repo's sources are CRLF, and matching + // on a bare "\n}\n" silently finds nothing. + const source = fs.readFileSync(SRC, 'utf8').replace(/\r\n/g, '\n'); + const start = source.indexOf('function validateDeviceInput'); + assert.notStrictEqual(start, -1, 'validateDeviceInput not found in the module'); + + // Take from the function keyword to the line that closes it at column 0. + const rest = source.slice(start); + const end = rest.indexOf('\n}\n'); + assert.notStrictEqual(end, -1, 'could not find the end of validateDeviceInput'); + const body = rest.slice(0, end + 2); + + // eslint-disable-next-line no-new-func + return new Function(`${body}; return validateDeviceInput;`)(); +} + +const validateDeviceInput = loadValidator(); + +function makeReport() { + const errors = []; + return { + errors, + logError(message, _inst, field) { errors.push({ message, field }); } + }; +} + +function inst(pinNum, overrides) { + const base = {}; + base[`gpi${pinNum}_deviceKind`] = 'quadrature'; + base[`gpi${pinNum}_deviceClockPin`] = -1; + base[`gpi${pinNum}_deviceIndexPin`] = -1; + base[`gpi${pinNum}_deviceParams`] = '{}'; + return Object.assign(base, overrides || {}); +} + +function run(pinNum, overrides) { + const report = makeReport(); + const ok = validateDeviceInput(inst(pinNum, overrides), report, pinNum); + return { ok, errors: report.errors }; +} + +const cases = []; +function test(name, fn) { cases.push([name, fn]); } + +// -- control arms: legal configurations must pass ----------------------------- + +test('a single-pin quadrature device with no companions is accepted', () => { + const r = run(0); + assert.strictEqual(r.ok, true, JSON.stringify(r.errors)); +}); + +test('a quadrature device with B and Z on distinct pins is accepted', () => { + const r = run(0, { gpi0_deviceClockPin: 1, gpi0_deviceIndexPin: 2 }); + assert.strictEqual(r.ok, true, JSON.stringify(r.errors)); +}); + +test('a clocked device with a clock pin is accepted', () => { + const r = run(3, { gpi3_deviceKind: 'ssi', gpi3_deviceClockPin: 4 }); + assert.strictEqual(r.ok, true, JSON.stringify(r.errors)); +}); + +test('model parameters that are not pins are accepted', () => { + const r = run(3, { + gpi3_deviceKind: 'ssi', + gpi3_deviceClockPin: 4, + gpi3_deviceParams: '{"bits": 13, "position": 1024}' + }); + assert.strictEqual(r.ok, true, JSON.stringify(r.errors)); +}); + +// -- refusals ----------------------------------------------------------------- + +test('a clocked device with no clock pin is refused', () => { + // The load-bearing one: a clocked slave with no clock produces an idle + // trace, which reads exactly like firmware that never ran. + const r = run(3, { gpi3_deviceKind: 'ssi' }); + assert.strictEqual(r.ok, false); + assert.ok(/needs a Clock pin/.test(r.errors[0].message), r.errors[0].message); +}); + +test('a companion pin equal to the primary pin is refused', () => { + const r = run(5, { gpi5_deviceClockPin: 5 }); + assert.strictEqual(r.ok, false); + assert.ok(/same pin as the device's primary pin/.test(r.errors[0].message)); +}); + +test('a companion pin out of range is refused', () => { + const r = run(0, { gpi0_deviceClockPin: 20 }); + assert.strictEqual(r.ok, false); + assert.ok(/must be 0-19/.test(r.errors[0].message)); +}); + +test('an index pin with no clock pin is refused', () => { + const r = run(0, { gpi0_deviceIndexPin: 2 }); + assert.strictEqual(r.ok, false); + assert.ok(/without a second/.test(r.errors[0].message)); +}); + +test('two companions on the same pin are refused', () => { + const r = run(0, { gpi0_deviceClockPin: 7, gpi0_deviceIndexPin: 7 }); + assert.strictEqual(r.ok, false); + assert.ok(/cannot be the same pin/.test(r.errors[0].message)); +}); + +test('malformed JSON in the parameters is refused', () => { + const r = run(0, { gpi0_deviceParams: '{bits: 13}' }); + assert.strictEqual(r.ok, false); + assert.ok(/Use JSON object syntax/.test(r.errors[0].message)); +}); + +test('a JSON array instead of an object is refused', () => { + const r = run(0, { gpi0_deviceParams: '[13]' }); + assert.strictEqual(r.ok, false); + assert.ok(/must be a JSON object/.test(r.errors[0].message)); +}); + +test('a pin assignment smuggled through the parameters is refused', () => { + // Two sources of truth for the same fact drift. The pin fields win, so a + // pin in the params must be an error rather than a silently ignored value. + for (const key of ['clk_pin', 'data_pin', 'a_pin', 'b_pin', 'z_pin', 'pin']) { + const r = run(3, { + gpi3_deviceKind: 'ssi', + gpi3_deviceClockPin: 4, + gpi3_deviceParams: JSON.stringify({ [key]: 9 }) + }); + assert.strictEqual(r.ok, false, `${key} should have been refused`); + assert.ok(/pins come from the pin fields/.test(r.errors[0].message)); + } +}); + +test('every refusal names the field it is about', () => { + // A SysConfig error with no field attaches to nothing in the UI. + const r = run(3, { gpi3_deviceKind: 'ssi' }); + assert.ok(r.errors[0].field, 'error must name a config field'); +}); + +let failed = 0; +for (const [name, fn] of cases) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (e) { + failed += 1; + console.log(`FAIL - ${name}\n ${e.message}`); + } +} +console.log(`\n${cases.length - failed} passed, ${failed} failed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/scripts/sim/test_device_bindings.py b/scripts/sim/test_device_bindings.py new file mode 100644 index 0000000..1a3a72b --- /dev/null +++ b/scripts/sim/test_device_bindings.py @@ -0,0 +1,408 @@ +"""Tests for the device-binding consumer. + +This module turns a GUI selection into an attached device model, so its failure +mode is specific and quiet: a binding that is wrong in the right way attaches +nothing, or attaches a model to the wrong pin, and the simulation runs happily +and proves nothing. Every test here is aimed at that outcome rather than at +happy-path plumbing. + +Every refusal test is paired with the same binding made legal, so a validator +that rejected everything would fail as loudly as one that accepted everything. + +The load-bearing test is `test_the_device_drives_the_primary_pin`. The pin +translation cannot be checked by reading names - `ma_pin` and `slo_pin` look +equally like "the first pin". It is checked against each model's own `nets`, +which declares what the device DRIVES. Reverse a clocked device's mapping and +the encoder waits forever on a clock that never arrives, which looks like a +clean pass. That test is the only thing standing between us and that result. +""" +from __future__ import annotations + +import copy +import json +import os +import re + +import pytest + +from device_bindings import (REGISTRY, BindingError, build_device, driven_pins, + load_manifest, _validate_entry) + +HERE = os.path.dirname(os.path.abspath(__file__)) +STATIC_MODULE = os.path.join(HERE, "..", "..", ".metadata", "sysconfig", ".meta", + "pru_blocks", "common", + "pru_blocks_static_module.syscfg.js") + + +def entry(**over): + """A legal ssi binding, overridden per test. Legal by default on purpose.""" + e = {"primaryPin": 1, "kind": "ssi", "clockPin": 0, "indexPin": None, + "params": {}} + e.update(over) + return e + + +#: Params that only exist to make an observation possible, never to change a +#: mapping. `driven_pins` watches what a model asserts on the wire, and a model +#: shifting out a conversion of 0 asserts nothing - which would read as "drives +#: no pin" and quietly pass a sink check it should fail. +_STIMULUS = {"spi_adc": {"conversion": 0xFFFF, "bits": 16}} + + +def bound(kind, primaryPin=11): + """A legal binding for any kind, with every pin the model needs. + + Pins the GUI has no slot for are real pins and are allocated here from 14 + up, so they never collide with the 11/12/13 the direction tests rely on. + """ + spec = REGISTRY[kind] + params = dict(_STIMULUS.get(kind, {})) + for n, key in enumerate(spec.extra_pins): + params[key] = 14 + n + return entry(kind=kind, primaryPin=primaryPin, + clockPin=12 if spec.clock else None, + indexPin=13 if spec.index else None, + params=params) + + +def manifest(bindings, version=1): + return {"version": version, "pruClkFreqMhz": 200, "cyclesToSimulate": 2000, + "bindings": copy.deepcopy(bindings)} + + +def write(tmp_path, obj): + p = tmp_path / "pru_device_bindings.json" + p.write_text(json.dumps(obj), encoding="utf-8") + return str(p) + + +# --------------------------------------------------------------------------- +# the registry must cover the GUI, or a selector is dead +# --------------------------------------------------------------------------- +def test_every_device_offered_by_the_gui_has_a_model(): + """A kind in the dropdown with no model behind it attaches nothing. + + That is the worst available outcome: the user picks a device, the run goes + green, and nothing was ever driven. Read the option list out of the + SysConfig module rather than restating it here, so adding a GUI option + without a model fails this test instead of being noticed in review. + """ + source = open(STATIC_MODULE, encoding="utf-8").read() + start = source.index("name: `gpi${pinNum}_deviceKind`") + block = source[start:start + 2000] + options = block[block.index("options: ["):block.index("]", block.index("options: ["))] + kinds = set(re.findall(r'\{\s*name:\s*"([^"]+)"', options)) + + assert kinds, "could not parse the deviceKind options out of the module" + missing = kinds - set(REGISTRY) + assert not missing, "GUI offers devices with no model: %s" % sorted(missing) + + +def test_the_registry_does_not_offer_models_the_gui_cannot_select(): + """The mirror check. A registry entry with no GUI option is unreachable.""" + source = open(STATIC_MODULE, encoding="utf-8").read() + start = source.index("name: `gpi${pinNum}_deviceKind`") + block = source[start:start + 2000] + options = block[block.index("options: ["):block.index("]", block.index("options: ["))] + kinds = set(re.findall(r'\{\s*name:\s*"([^"]+)"', options)) + assert not set(REGISTRY) - kinds, \ + "registry has unreachable kinds: %s" % sorted(set(REGISTRY) - kinds) + + +# --------------------------------------------------------------------------- +# the pin mapping - the test that cannot be satisfied by a plausible guess +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("kind", sorted(REGISTRY)) +def test_the_device_drives_the_primary_pin(kind): + """The primary pin is the one the DEVICE drives, for every kind. + + `nets` is each model's own declaration of what it drives. A mapping that + put the PRU-driven clock in the primary slot would leave the primary pin + absent from nets - and the device silently waiting on a clock wired to + nothing. + """ + spec = REGISTRY[kind] + if spec.sink: + pytest.skip("declared sink - see test_a_sink_really_drives_nothing") + model = build_device(bound(kind, primaryPin=11)) + assert 11 in driven_pins(model), ( + "%s does not drive its primary pin 11 (drives %s) - the primary/clock " + "mapping for this kind is reversed" + % (kind, sorted(driven_pins(model)))) + + +@pytest.mark.parametrize("kind", sorted(k for k, s in REGISTRY.items() + if s.clock and not s.device_drives_second)) +def test_a_pru_driven_clock_is_not_driven_by_the_device(kind): + """A clocked slave observes its clock; only the PRU drives it. + + Two exceptions are declared in the registry rather than excluded silently. + A quadrature encoder drives B and Z as data channels, and an I2C slave may + stretch SCL on the open-drain net, so both legitimately appear in `nets`. + Every other device here would be showing a second driver on a push-pull + clock line, which is contention. + """ + model = build_device(bound(kind, primaryPin=11)) + assert 12 not in driven_pins(model), \ + "%s drives its clock pin - primary and clock look swapped" % kind + + +@pytest.mark.parametrize("kind", sorted(k for k, s in REGISTRY.items() + if s.device_drives_second)) +def test_a_declared_device_driven_second_pin_really_is_driven(kind): + """The exceptions above must be real, not a way to silence the test. + + If quadrature or tca9538 stopped driving their second pin the flag would be + wrong and this fails - so the exemption cannot rot into a blanket skip. + """ + model = build_device(bound(kind, primaryPin=11)) + assert 12 in driven_pins(model), \ + "%s is flagged device_drives_second but does not drive pin 12" % kind + + +@pytest.mark.parametrize("kind", sorted(k for k, s in REGISTRY.items() if s.sink)) +def test_a_sink_really_drives_nothing(kind): + """`sink=True` is the claim that no pin of this device is device-driven. + + It is the one declaration that switches the primary-pin invariant off, so + it is also the obvious place to hide a reversed mapping. Port a model that + starts driving a line back and this goes red, which forces the registry to + be re-read rather than letting the exemption rot into a blanket skip. + """ + model = build_device(bound(kind, primaryPin=11)) + if kind == "step_dir": + # Reach StepDirDriver's post-edge return. All three device inputs are + # asserted on its first real STEP transition; its output must still be + # zero. This must not be a quiet-path test: changing the production + # post-edge return to ``1 << step_pin`` makes this assertion RED. + stimulus = ((0, 0), (1, (1 << 11) | (1 << 12) | (1 << 13))) + elif kind == "ws2812": + # Drive a real high then falling data edge through Ws2812String's + # complete pulse path. A WS2812 is an input-only device even while it + # classifies a pulse (80 cycles is the nominal T0H at 5 ns/cycle). + stimulus = ((0, 0), (80, 1 << 11), (160, 0)) + else: # Keep a new sink from inheriting an unexamined quiet-path oracle. + raise AssertionError("sink %r needs a behavioural stimulus" % kind) + observed = driven_pins(model, stimulus) + assert observed == set(), ( + "%s is declared sink=True but drives %s - if it really drives a pin, " + "that pin belongs in the primary slot and sink must go" + % (kind, sorted(observed))) + + +# --------------------------------------------------------------------------- +# refusals, each with a control arm +# --------------------------------------------------------------------------- +def test_an_unknown_device_kind_is_refused(): + with pytest.raises(BindingError, match="unknown device kind"): + _validate_entry(entry(kind="not_a_device"), {}) + + +def test_a_known_device_kind_is_accepted(): + _validate_entry(entry(kind="ssi"), {}) # control arm + + +def test_a_clocked_device_without_a_clock_pin_is_refused(): + """The exact shape that produces a silent idle trace.""" + with pytest.raises(BindingError, match="clocked and needs a clock pin"): + _validate_entry(entry(kind="endat", clockPin=None), {}) + + +def test_a_clocked_device_with_a_clock_pin_is_accepted(): + _validate_entry(entry(kind="endat", clockPin=0), {}) # control arm + + +def test_a_single_pin_device_given_a_clock_pin_is_refused(): + """Silently dropping it would attach a device the user did not configure.""" + with pytest.raises(BindingError, match="has no clock pin"): + _validate_entry(entry(kind="uart", clockPin=2), {}) + + +def test_a_single_pin_device_without_a_clock_pin_is_accepted(): + _validate_entry(entry(kind="uart", clockPin=None), {}) # control arm + + +def test_an_index_pin_on_a_device_without_one_is_refused(): + with pytest.raises(BindingError, match="has no index pin"): + _validate_entry(entry(kind="ssi", indexPin=4), {}) + + +def test_an_index_pin_on_a_device_that_has_one_is_accepted(): + _validate_entry(entry(kind="quadrature", clockPin=2, indexPin=4), {}) + + +def test_a_device_reusing_its_own_pin_is_refused(): + with pytest.raises(BindingError, match="reuses a pin"): + _validate_entry(entry(primaryPin=3, clockPin=3), {}) + + +def test_a_device_with_distinct_pins_is_accepted(): + _validate_entry(entry(primaryPin=3, clockPin=4), {}) # control arm + + +def test_two_devices_claiming_one_pin_are_refused(): + """Two models driving one net is a short, not a configuration.""" + seen = {} + _validate_entry(entry(kind="ssi", primaryPin=1, clockPin=0), seen) + with pytest.raises(BindingError, match="claimed by two devices"): + _validate_entry(entry(kind="endat", primaryPin=5, clockPin=1), seen) + + +def test_two_devices_on_separate_pins_are_accepted(): + seen = {} + _validate_entry(entry(kind="ssi", primaryPin=1, clockPin=0), seen) + _validate_entry(entry(kind="endat", primaryPin=5, clockPin=4), seen) + + +@pytest.mark.parametrize("bad", [20, -1, 99]) +def test_a_pin_outside_the_r31_range_is_refused(bad): + with pytest.raises(BindingError, match="must be 0-19"): + _validate_entry(entry(primaryPin=bad), {}) + + +def test_a_pin_inside_the_r31_range_is_accepted(): + _validate_entry(entry(primaryPin=19, clockPin=0), {}) # control arm + + +def test_a_boolean_is_not_a_pin_number(): + """bool subclasses int, so True would otherwise sail through as pin 1.""" + with pytest.raises(BindingError, match="integer pin number"): + _validate_entry(entry(primaryPin=True), {}) + + +def test_a_pin_field_naming_a_pin_in_params_is_refused(): + """Two sources of truth for one pin will drift.""" + with pytest.raises(BindingError, match="must not set"): + _validate_entry(entry(params={"clk_pin": 4}), {}) + + +def test_a_non_pin_parameter_is_accepted(): + _validate_entry(entry(params={"bits": 13}), {}) # control arm + + +def test_a_parameter_the_model_would_drop_is_refused(): + """The GUI promises unknown keys are reported, not ignored. Kept here.""" + with pytest.raises(BindingError, match="does not accept"): + build_device(entry(kind="ssi", params={"noSuchParam": 1})) + + +def test_a_parameter_the_model_accepts_is_passed_through(): + model = build_device(entry(kind="ssi", params={"bits": 17})) + assert model.bits == 17 # control arm + + +def test_params_as_a_list_is_refused(): + with pytest.raises(BindingError, match="must be an object"): + _validate_entry(entry(params=[1, 2, 3]), {}) + + +# --------------------------------------------------------------------------- +# manifest-level refusals +# --------------------------------------------------------------------------- +def test_an_absent_manifest_is_refused(tmp_path): + """Absent is not 'no devices'. Treating it as a pass is the whole trap.""" + with pytest.raises(BindingError, match="no device-binding manifest"): + load_manifest(str(tmp_path / "nope.json")) + + +def test_a_present_manifest_is_read(tmp_path): + path = write(tmp_path, manifest([entry()])) + assert len(load_manifest(path)["bindings"]) == 1 # control arm + + +def test_an_unsupported_manifest_version_is_refused(tmp_path): + path = write(tmp_path, manifest([entry()], version=999)) + with pytest.raises(BindingError, match="not supported"): + load_manifest(path) + + +def test_a_manifest_with_no_version_is_refused(tmp_path): + path = write(tmp_path, {"bindings": []}) + with pytest.raises(BindingError, match="not supported"): + load_manifest(path) + + +def test_malformed_json_is_refused(tmp_path): + p = tmp_path / "pru_device_bindings.json" + p.write_text("{not json", encoding="utf-8") + with pytest.raises(BindingError, match="could not read manifest"): + load_manifest(str(p)) + + +def test_bindings_must_be_a_list(tmp_path): + path = write(tmp_path, {"version": 1, "bindings": {"kind": "ssi"}}) + with pytest.raises(BindingError, match="must be a list"): + load_manifest(path) + + +def test_an_empty_manifest_loads_but_is_empty(tmp_path): + """Loading succeeds; refusing to RUN on empty is the CLI's job, so that + a project with genuinely no devices can opt in with --allow-empty.""" + path = write(tmp_path, manifest([])) + assert load_manifest(path)["bindings"] == [] + + +def test_a_bad_binding_anywhere_fails_the_whole_manifest(tmp_path): + """Not just the first entry - a partial attach is a silent half-run.""" + path = write(tmp_path, manifest([entry(primaryPin=1, clockPin=0), + entry(kind="bogus", primaryPin=5)])) + with pytest.raises(BindingError, match="unknown device kind"): + load_manifest(path) + + +# --------------------------------------------------------------------------- +# pins that have no GUI slot are still pins +# --------------------------------------------------------------------------- +def test_a_pin_with_no_gui_slot_is_still_required(): + """SpiAdc needs four pins and the manifest has three fields. + + CS therefore rides in params. Omitting it is a refusal rather than a + default: the model would put CS on its own pin 3, the PRU would never + assert it, no frame would ever open and the ADC would shift out nothing - + an idle trace indistinguishable from firmware that never ran. + """ + e = bound("spi_adc") + del e["params"]["cs_pin"] + with pytest.raises(BindingError, match="cs_pin"): + _validate_entry(e, {}) + _validate_entry(bound("spi_adc"), {}) # control: legal with it + + +def test_a_pin_with_no_gui_slot_is_range_checked(): + e = bound("spi_adc") + e["params"]["cs_pin"] = 44 + with pytest.raises(BindingError, match="0-19"): + _validate_entry(e, {}) + + +def test_a_pin_with_no_gui_slot_cannot_collide_with_a_gui_pin(): + """Two drivers on one net is a short whichever field the pin arrived in.""" + e = bound("spi_adc") + e["params"]["cs_pin"] = 12 # same pin as the clock slot + with pytest.raises(BindingError, match="reuses a pin"): + _validate_entry(e, {}) + + +def test_a_pin_with_no_gui_slot_cannot_collide_across_devices(): + seen = {} + _validate_entry(bound("spi_adc", primaryPin=11), seen) + other = entry(kind="uart", primaryPin=14, clockPin=None) # spi_adc's cs_pin + with pytest.raises(BindingError, match="claimed by two devices"): + _validate_entry(other, seen) + + +# --------------------------------------------------------------------------- +# the two checkouts collide, and which model binds first must not matter +# --------------------------------------------------------------------------- +def test_binding_a_local_model_first_does_not_break_the_upstream_ones(): + """`perif` and `core` exist in BOTH this repo and the pru-simulator. + + Resolving one by mutating sys.path and leaving it mutated makes every + later import depend on binding order. The suite can be green on that by + luck of alphabetical parametrisation, so the order is pinned here: local + first, upstream second, which is the direction that used to fail. + """ + build_device(bound("spi_adc")) + for kind in ("ssi", "quadrature", "tca9538", "uart"): + assert driven_pins(build_device(bound(kind))), \ + "%s stopped resolving after a perif model was bound" % kind diff --git a/scripts/sim/test_device_models.js b/scripts/sim/test_device_models.js new file mode 100644 index 0000000..03d5ab9 --- /dev/null +++ b/scripts/sim/test_device_models.js @@ -0,0 +1,372 @@ +/** + * Unit tests for the in-tool device models and bus. + * + * The models in .metadata/.../simulation/device_models.js are JavaScript + * ports of the Python DeviceModel implementations in the TI pru-simulator + * checkout. These tests prove the PORTS behave: legal traffic is answered, + * illegal traffic is refused. The cross-check that the two languages still + * agree is scripts/sim/test_device_model_crosscheck.js — this file proves + * behaviour, that one proves identity. + * + * The tests that matter are the refusals: a model that cannot reject broken + * firmware is a waveform generator, not an oracle. Every fault arm is paired + * with a control arm that is legal in the same shape, so a model that + * faulted everything — or nothing — would fail here either way. + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const assert = require('assert'); + +const SIM_DIR = path.join(__dirname, '..', '..', '.metadata', 'sysconfig', '.meta', + 'pru_blocks', 'common', 'simulation'); + +const scriptCache = {}; +function loadSimScript(scriptPath) { + const code = fs.readFileSync(scriptPath, 'utf8'); + const sandbox = { + console, Math, JSON, Number, Array, Object, Set, Error, + system: { + getScript: (p) => { + const basename = path.basename(p); + if (!scriptCache[basename]) { + scriptCache[basename] = loadSimScript(path.join(SIM_DIR, basename)); + } + return scriptCache[basename]; + } + }, + exports: {} + }; + vm.createContext(sandbox); + vm.runInContext(code, sandbox); + return sandbox.exports; +} + +const busScript = loadSimScript(path.join(SIM_DIR, 'device_bus.js')); +const models = loadSimScript(path.join(SIM_DIR, 'device_models.js')); +const { DeviceBus, MASK_20 } = busScript; + +/** + * Drive a device on a bus with a scripted PRU output. gpoScript is + * [[cycle, gpo], ...]; between entries the gpo holds. The PRU drives only + * the pins in driveMask; the device's own pins are excluded, as the static + * module does for real bindings. + */ +function runOnBus(device, gpoScript, totalCycles, driveMask) { + const bus = new DeviceBus(); + bus.attach(device); + let mask = driveMask !== undefined ? driveMask : MASK_20; + for (const pin of Object.keys(device.nets || {})) { + mask &= ~(1 << Number(pin)); + } + bus.setPruDriveMask(mask); + + const r31Trace = []; + let gpo = MASK_20; // idle high + let scriptIdx = 0; + for (let cycle = 1; cycle <= totalCycles; cycle++) { + while (scriptIdx < gpoScript.length && gpoScript[scriptIdx][0] <= cycle) { + gpo = gpoScript[scriptIdx][1]; + scriptIdx++; + } + r31Trace[cycle] = bus.settle(cycle, gpo); + } + return { bus, r31Trace }; +} + +/** Build the gpo script for one SSI frame: `bits` clocks at `period` cycles. */ +function ssiClockScript(clkPin, clockCount, period, startCycle) { + const bit = 1 << clkPin; + const script = []; + let cycle = startCycle; + let level = MASK_20; // idle high + for (let i = 0; i < clockCount; i++) { + level &= ~bit; // fall + script.push([cycle, level]); + cycle += Math.floor(period / 2); + level |= bit; // rise + script.push([cycle, level]); + cycle += Math.ceil(period / 2); + } + return script; +} + +/** + * Independent SSI decoder: samples DATA on RISING clock edges, shares no + * code with the model. Returns the decoded word, or null if no frame ran. + */ +function decodeSsi(r31Trace, clkPin, dataPin, bits) { + let prevClk = 1; + let word = 0; + let sampled = 0; + for (let cycle = 1; cycle < r31Trace.length; cycle++) { + const clk = (r31Trace[cycle] >> clkPin) & 1; + if (clk === 1 && prevClk === 0 && sampled < bits) { + word = (word << 1) | ((r31Trace[cycle] >> dataPin) & 1); + sampled++; + } + prevClk = clk; + } + return sampled === bits ? word >>> 0 : null; +} + +const cases = []; +function test(name, fn) { cases.push([name, fn]); } + +// --------------------------------------------------------------------------- +// Quadrature encoder +// --------------------------------------------------------------------------- + +test('quadrature: forward rotation walks the legal Gray sequence', () => { + const enc = new models.QuadratureEncoder({ aPin: 0, bPin: 1, zPin: 2, + countsPerRev: 8, cyclesPerCount: 10 }); + const { r31Trace } = runOnBus(enc, [], 95); + const seen = []; + for (let c = 1; c < r31Trace.length; c++) { + const ab = r31Trace[c] & 0x3; + if (seen.length === 0 || seen[seen.length - 1] !== ab) seen.push(ab); + } + // ab packs A on bit 0, B on bit 1, so the forward table (A,B) = + // (0,0) -> (1,0) -> (1,1) -> (0,1) reads 0 -> 1 -> 3 -> 2. + const table = [0b00, 0b01, 0b11, 0b10]; + for (let i = 1; i < seen.length; i++) { + const prevIdx = table.indexOf(seen[i - 1]); + assert.strictEqual(seen[i], table[(prevIdx + 1) % 4], + `illegal A/B transition at step ${i}: ${seen[i - 1]} -> ${seen[i]}`); + } + assert.strictEqual(enc.faults().length, 0, JSON.stringify(enc.faults())); + assert.ok(seen.length >= 8, `expected at least 8 transitions, saw ${seen.length}`); +}); + +test('quadrature: reverse rotation walks the table backwards', () => { + const enc = new models.QuadratureEncoder({ aPin: 0, bPin: 1, zPin: -1, + countsPerRev: 8, cyclesPerCount: 10, + forward: false }); + const { r31Trace } = runOnBus(enc, [], 55); + const seen = []; + for (let c = 1; c < r31Trace.length; c++) { + const ab = r31Trace[c] & 0x3; + if (seen.length === 0 || seen[seen.length - 1] !== ab) seen.push(ab); + } + const table = [0b00, 0b10, 0b11, 0b01]; // reverse order + for (let i = 1; i < seen.length; i++) { + const prevIdx = table.indexOf(seen[i - 1]); + assert.strictEqual(seen[i], table[(prevIdx + 1) % 4], + `illegal reverse transition at step ${i}`); + } + assert.strictEqual(enc.faults().length, 0, JSON.stringify(enc.faults())); +}); + +test('quadrature: index pulses once per revolution', () => { + const enc = new models.QuadratureEncoder({ aPin: 0, bPin: 1, zPin: 2, + countsPerRev: 4, cyclesPerCount: 10 }); + const { r31Trace } = runOnBus(enc, [], 100); + const indexEvents = enc.events().filter(e => e.kind === 'index'); + // 10 counts at counts_per_rev=4 crosses position 4 and 8. + assert.ok(indexEvents.length >= 2, `expected >=2 index events, saw ${indexEvents.length}`); + assert.strictEqual(enc.faults().length, 0, JSON.stringify(enc.faults())); + // Z is high exactly at the zero position. + const zHighCycles = r31Trace.filter(v => ((v >> 2) & 1) === 1).length; + assert.ok(zHighCycles > 0 && zHighCycles < 100, 'Z must pulse, not stick'); +}); + +test('quadrature: position advances one count per cycles_per_count', () => { + const enc = new models.QuadratureEncoder({ aPin: 0, bPin: 1, zPin: -1, + countsPerRev: 1024, cyclesPerCount: 7 }); + // The first tick starts the clock, so count k begins at cycle 1 + k*7. + runOnBus(enc, [], 71); + assert.strictEqual(enc.position, 10, `position ${enc.position} after 71 cycles at 7 cyc/count`); +}); + +// --------------------------------------------------------------------------- +// SSI encoder +// --------------------------------------------------------------------------- + +test('ssi: a correctly clocked frame decodes to the encoder position (gray)', () => { + const enc = new models.SsiEncoder({ clkPin: 0, dataPin: 1, bits: 13, + position: 0x1234 & 0x1FFF, minClkPeriodCycles: 10, + monoflopCycles: 100, gray: true }); + const script = ssiClockScript(0, 14, 30, 10); // 13 bits + 1 trailing edge + const { bus, r31Trace } = runOnBus(enc, script, 10 + 14 * 30 + 50); + const decoded = decodeSsi(r31Trace, 0, 1, 13); + assert.notStrictEqual(decoded, null, 'no frame decoded'); + assert.strictEqual(decoded, models.toGray(0x1234 & 0x1FFF), 'decoded word mismatch'); + assert.strictEqual(enc.faults().length, 0, JSON.stringify(enc.faults())); + const frames = enc.events().filter(e => e.kind === 'frame'); + assert.strictEqual(frames.length, 1, 'expected exactly one frame event'); + assert.strictEqual(bus.faults().length, 0, JSON.stringify(bus.faults())); +}); + +test('ssi: a correctly clocked frame decodes to the position (binary)', () => { + const enc = new models.SsiEncoder({ clkPin: 0, dataPin: 1, bits: 13, + position: 2000, minClkPeriodCycles: 10, + monoflopCycles: 100, gray: false }); + const script = ssiClockScript(0, 14, 30, 10); + const { r31Trace } = runOnBus(enc, script, 10 + 14 * 30 + 50); + const decoded = decodeSsi(r31Trace, 0, 1, 13); + assert.strictEqual(decoded, 2000, 'binary decode mismatch'); + assert.strictEqual(enc.faults().length, 0, JSON.stringify(enc.faults())); +}); + +test('ssi: clocking faster than f_max is a fault, not garbage', () => { + const enc = new models.SsiEncoder({ clkPin: 0, dataPin: 1, bits: 13, + position: 42, minClkPeriodCycles: 20, + monoflopCycles: 400 }); + const script = ssiClockScript(0, 14, 6, 10); // period 6 < minimum 20 + runOnBus(enc, script, 10 + 14 * 6 + 50); + assert.ok(enc.faults().some(f => /below the encoder's minimum/.test(f)), + 'overclocking must fault: ' + JSON.stringify(enc.faults())); +}); + +test('ssi: a mid-word stall aborts the transfer and faults', () => { + const enc = new models.SsiEncoder({ clkPin: 0, dataPin: 1, bits: 13, + position: 42, minClkPeriodCycles: 10, + monoflopCycles: 50 }); + // Two falling edges, then silence longer than the monoflop. + const script = [ + [10, MASK_20 & ~1], [20, MASK_20], + [30, MASK_20 & ~1], [40, MASK_20] + ]; + runOnBus(enc, script, 40 + 120); + assert.ok(enc.faults().some(f => /stalled/.test(f)), + 'stall must fault: ' + JSON.stringify(enc.faults())); + assert.ok(enc.events().some(e => e.kind === 'abort'), 'stall must abort the word'); +}); + +test('ssi: clocking past the end of the word is a fault', () => { + const enc = new models.SsiEncoder({ clkPin: 0, dataPin: 1, bits: 4, + position: 9, minClkPeriodCycles: 10, + monoflopCycles: 1000 }); + const script = ssiClockScript(0, 4 + 3, 30, 10); // two edges too many + runOnBus(enc, script, 10 + 7 * 30 + 20); + assert.ok(enc.faults().some(f => /continued past the end/.test(f)), + 'overrun must fault: ' + JSON.stringify(enc.faults())); +}); + +test('ssi: a frame started after the monoflop is a fresh frame, not a fault', () => { + const enc = new models.SsiEncoder({ clkPin: 0, dataPin: 1, bits: 4, + position: 9, minClkPeriodCycles: 10, + monoflopCycles: 60 }); + const frame1 = ssiClockScript(0, 5, 30, 10); + const frame2 = ssiClockScript(0, 5, 30, 10 + 5 * 30 + 200); + runOnBus(enc, frame1.concat(frame2), 10 + 10 * 30 + 400); + assert.strictEqual(enc.faults().length, 0, JSON.stringify(enc.faults())); + assert.strictEqual(enc.events().filter(e => e.kind === 'frame').length, 2); +}); + +// --------------------------------------------------------------------------- +// Bus semantics +// --------------------------------------------------------------------------- + +test('bus: push-pull contention between PRU and device is recorded, not resolved', () => { + const enc = new models.SsiEncoder({ clkPin: 0, dataPin: 1, bits: 13, position: 1 }); + // PRU drives pin 1 (the device's DATA pin) LOW while the device drives it HIGH. + const bus = new DeviceBus(); + bus.attach(enc); + bus.setPruDriveMask(MASK_20); // wrongly includes the device pin + bus.settle(1, MASK_20 & ~(1 << 1)); + assert.ok(bus.faults().some(f => /contention/.test(f)), JSON.stringify(bus.faults())); +}); + +test('bus: excluding the device pins from the drive mask produces no false contention', () => { + const enc = new models.SsiEncoder({ clkPin: 0, dataPin: 1, bits: 13, position: 1 }); + const { bus } = runOnBus(enc, ssiClockScript(0, 14, 30, 10), 500); + assert.strictEqual(bus.contentions.length, 0, JSON.stringify(bus.contentions)); +}); + +// --------------------------------------------------------------------------- +// Factory refusals — invalid devices must fail visibly +// --------------------------------------------------------------------------- + +test('factory: an unknown kind is refused', () => { + assert.throws(() => models.createDeviceModel('nonsense', { primary: 1 }, {}), + /unknown device kind/); +}); + +test('factory: a kind not yet ported to the in-tool simulator is refused, not faked', () => { + // The dropdown lists the full device catalogue; the in-tool simulator + // only runs ported kinds. Refusing visibly beats substituting an + // unproven stand-in - that substitution is the failure this item exists + // to prevent. + // Derived from the catalogue, not restated here. A hardcoded list goes stale + // the moment a kind is ported: on 2026-09-09 this test still demanded that + // bissc refuse, three commits after ported it, and the assertion + // stopped at the first entry so the other four stale names were invisible. + const unported = models.DEVICE_MODEL_KINDS.filter(k => !k.ported).map(k => k.kind); + assert.ok(unported.length > 0, + 'catalogue lists no unported kind - this test would assert nothing'); + for (const kind of unported) { + assert.throws(() => models.createDeviceModel(kind, { primary: 1, clock: 0 }, {}), + /not available in the in-tool simulator/, `${kind} must refuse visibly`); + } + // The mirror: every kind the catalogue calls ported must NOT raise that error, + // so a model that refused everything would fail here. + for (const k of models.DEVICE_MODEL_KINDS.filter(k => k.ported)) { + let err = null; + try { models.createDeviceModel(k.kind, { primary: 1, clock: 0 }, {}); } + catch (e) { err = e; } + assert.ok(!err || !/not available in the in-tool simulator/.test(err.message), + `${k.kind} is catalogued as ported but the factory refuses it as unported`); + } +}); + +test('factory: unknown parameters are refused', () => { + assert.throws(() => models.createDeviceModel('ssi', { primary: 1, clock: 0 }, + { bits: 13, widjets: 2 }), + /unknown ssi parameter "widjets"/); +}); + +test('factory: a bissc position parameter reaches the encoder frame', () => { + // Red arm: before the key was refused ("unknown bissc parameter"), so + // a GUI-attached BiSS-C encoder could only ever hold position 0. + const m = models.createDeviceModel('bissc', { primary: 1, clock: 0 }, + { position_bits: 21, position: 1234567 }); + assert.strictEqual(m.encoder.position, 1234567); + const bits = m.encoder._bits; + const payload = bits.slice(bits.length - 6 - 2 - 21, bits.length - 6 - 2); + assert.strictEqual(parseInt(payload.join(''), 2), 1234567); + const zero = models.createDeviceModel('bissc', { primary: 1, clock: 0 }, {}); + assert.strictEqual(zero.encoder.position, 0, 'default stays 0'); +}); + +test('factory: pins smuggled through parameters are refused', () => { + assert.throws(() => models.createDeviceModel('ssi', { primary: 1, clock: 0 }, + { clk_pin: 3 }), + /pins come from the pin fields/); +}); + +test('factory: out-of-range constructor values are refused', () => { + assert.throws(() => models.createDeviceModel('ssi', { primary: 1, clock: 0 }, + { bits: 0 }), /bits must be >= 1/); + assert.throws(() => models.createDeviceModel('quadrature', { primary: 0, clock: 1 }, + { cycles_per_count: 0 }), + /cycles_per_count must be >= 1/); +}); + +test('factory: a clocked device with no clock pin is refused', () => { + assert.throws(() => models.createDeviceModel('ssi', { primary: 1, clock: -1 }, {}), + /needs a Clock pin/); +}); + +test('factory: legal bindings construct', () => { + const ssi = models.createDeviceModel('ssi', { primary: 1, clock: 0 }, { bits: 13 }); + assert.strictEqual(ssi.bits, 13); + const quad = models.createDeviceModel('quadrature', { primary: 0, clock: 1, index: 2 }, + { counts_per_rev: 100 }); + assert.strictEqual(quad.countsPerRev, 100); +}); + +let failed = 0; +for (const [name, fn] of cases) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (e) { + failed += 1; + console.log(`FAIL - ${name}\n ${e.stack.split('\n').slice(0, 3).join('\n ')}`); + } +} +console.log(`\n${cases.length - failed} passed, ${failed} failed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/scripts/sim/test_ti_run_gui.js b/scripts/sim/test_ti_run_gui.js new file mode 100644 index 0000000..ed4a422 --- /dev/null +++ b/scripts/sim/test_ti_run_gui.js @@ -0,0 +1,411 @@ +/** + * The GUI half of "Run on TI pru-simulator", as pure functions. + * + * SysConfig's GUI cannot be driven headless, so what is proven here is the + * renderer and launcher glue: what onComplete shows for each wrapper result, + * and that nothing unproven is ever shown as a pass. What is NOT proven here + * (needs a click-through): that the button renders, that onLaunch is given a + * visible system.getCurrentScriptPath()/getProducts() path in the real GUI, + * and that the GUI-side design digest equals the CLI-side one. + */ +'use strict'; + +const assert = require('assert'); +const path = require('path'); +const ti = require(path.join(__dirname, '..', '..', '.metadata', 'sysconfig', '.meta', + 'pru_blocks', 'common', 'pru_ti_run.js')); +const launcher = require('./gui_run_launcher.js'); + +let failures = 0; +function test(name, fn) { + try { fn(); console.log('ok ' + name); } + catch (e) { failures++; console.log('FAIL ' + name + '\n ' + e.message); } +} + +const pass = { schema: 1, verdict: 'PASS', exitCode: 0, designDigest: 'AAAA', cycles: 1311, + vcd: '/x/ti_run.vcd', devices: [{ kind: 'ssi', pin: 1, expected: 4321, decoded: 4321 }] }; + +test('PASS for the current design shows expected vs decoded, cycles and VCD', () => { + const d = ti.describe(ti.parseResult(JSON.stringify(pass)), 'AAAA'); + assert.strictEqual(d.verdict, 'PASS'); + assert.ok(d.text.includes('TI pru-simulator verdict: PASS')); + assert.ok(d.text.includes('ssi on GPI1: expected 4321, firmware decoded 4321')); + assert.ok(d.text.includes('Cycles run: 1311')); + assert.ok(d.text.includes('VCD: /x/ti_run.vcd')); +}); + +test('FAIL shows the mismatch and is not a pass', () => { + const r = { ...pass, verdict: 'FAIL', exitCode: 1, + devices: [{ kind: 'ssi', pin: 1, expected: 4321, decoded: 6289 }] }; + const d = ti.describe(ti.parseResult(JSON.stringify(r)), 'AAAA'); + assert.strictEqual(d.verdict, 'FAIL'); + assert.ok(d.text.includes('expected 4321, firmware decoded 6289')); +}); + +test('NOT CHECKED is labelled "not a pass"', () => { + const r = { ...pass, verdict: 'NOT CHECKED', exitCode: 3, devices: [] }; + const d = ti.describe(ti.parseResult(JSON.stringify(r)), 'AAAA'); + assert.ok(d.text.includes('NOT CHECKED (not a pass)')); + assert.notStrictEqual(d.verdict, 'PASS'); +}); + +test('a result for an earlier design is STALE and yields no verdict', () => { + const d = ti.describe(ti.parseResult(JSON.stringify(pass)), 'BBBB'); + assert.strictEqual(d.verdict, null); + assert.ok(d.text.startsWith('STALE')); + assert.strictEqual(ti.bannerLine(ti.parseResult(JSON.stringify(pass)), 'BBBB'), null); +}); + +test('a result without a digest is never fresh', () => { + const r = { ...pass }; delete r.designDigest; + assert.strictEqual(ti.describe(r, undefined).fresh, false); +}); + +test('fresh result replaces the preview banner; none keeps it', () => { + assert.strictEqual(ti.bannerLine(ti.parseResult(JSON.stringify(pass)), 'AAAA'), + 'TI pru-simulator verdict: PASS'); + assert.strictEqual(ti.bannerLine(null, 'AAAA'), null); +}); + +test('PASS without exit code 0 is downgraded to NOT CHECKED', () => { + for (const exitCode of [null, 1, 3, undefined]) { + const r = ti.parseResult(JSON.stringify({ ...pass, exitCode })); + assert.strictEqual(r.verdict, 'NOT CHECKED', 'exitCode ' + exitCode); + } +}); + +test('exit code 0 with a non-PASS verdict is rejected as inconsistent', () => { + const r = ti.parseResult(JSON.stringify({ ...pass, verdict: 'FAIL', exitCode: 0 })); + assert.strictEqual(r.verdict, 'NOT CHECKED'); + assert.ok(/inconsistent/.test(r.reason)); +}); + +test('garbage, empty and unknown-verdict input never pass', () => { + for (const text of ['', 'not json', '{}', 'null', '[]', '{"verdict":"OK","exitCode":0}', + '{"verdict":"pass","exitCode":0}']) { + const r = ti.parseResult(text); + assert.strictEqual(r.verdict, 'NOT CHECKED', JSON.stringify(text)); + } +}); + +test('the placeholder written before the wrapper runs is NOT CHECKED', () => { + const r = ti.parseResult(ti.INITIAL_DATA); + assert.strictEqual(r.verdict, 'NOT CHECKED'); + assert.ok(/did not write a result/.test(r.reason)); +}); + +test('REFUSED carries its reason', () => { + const r = { schema: 1, verdict: 'REFUSED', exitCode: null, reason: 'design differs', devices: [], + designDigest: 'AAAA' }; + const d = ti.describe(ti.parseResult(JSON.stringify(r)), 'AAAA'); + assert.ok(d.text.includes('REFUSED') && d.text.includes('Reason: design differs')); +}); + +const sys = { + getNodePath: () => 'C:/ti/sysconfig_1.28.0/nodejs/node.exe', + getProducts: () => [{ name: 'OTHER', path: '/o/.metadata/product.json' }, + { name: 'PRU_NO_CODE_TOOL', path: '/repo/pru/.metadata/product.json' }], + getCurrentScriptPath: () => '/repo/pru/examples/x/firmware/b/c/example.syscfg' +}; + +test('launchSpec: node + launcher script, comFile placeholder, digest, project', () => { + const spec = ti.launchSpec(sys, {}, 'AAAA'); + assert.strictEqual(spec.command, 'C:/ti/sysconfig_1.28.0/nodejs/node.exe'); + assert.strictEqual(spec.args[0], '/repo/pru/scripts/sim/gui_run_launcher.js'); + assert.deepStrictEqual(spec.args.slice(1, 5), ['--com', '$comFile', '--digest', 'AAAA']); + assert.strictEqual(spec.args[spec.args.indexOf('--project') + 1], + '/repo/pru/examples/x/firmware/b/c/example.syscfg'); + assert.strictEqual(spec.initialData, ti.INITIAL_DATA); + assert.strictEqual(spec.inSystemPath, true); +}); + +test('launchSpec: config fields override, and a path the GUI hides falls back to them', () => { + const boom = () => { throw new Error('filesystem paths cannot be accessed'); }; + const hidden = { ...sys, getProducts: boom, getCurrentScriptPath: boom }; + const spec = ti.launchSpec(hidden, { tiRunRepoRoot: '\\\\srv\\tool', tiRunProject: '\\\\srv\\p\\example.syscfg' }, 'A'); + assert.strictEqual(spec.args[0], '\\\\srv\\tool\\scripts\\sim\\gui_run_launcher.js'); + assert.strictEqual(spec.args[spec.args.indexOf('--project') + 1], '\\\\srv\\p\\example.syscfg'); +}); + +test('launchSpec: the visible open file beats stale tiRunProject', () => { + const spec = ti.launchSpec(sys, { tiRunProject: '/old/example.syscfg' }, 'A'); + assert.strictEqual(spec.args[spec.args.indexOf('--project') + 1], + '/repo/pru/examples/x/firmware/b/c/example.syscfg'); +}); + +function inst(name, extra) { return { $name: name, $position: [1, 2], ...extra }; } +function baseModules() { + return { + '/m/static': { $static: { pruClkFreq: 200, tiRunResult: 'x', tiRunProject: 'p' } }, + '/m/blk': { $instances: [inst('b', { dataPin: 1 }), inst('a', { dataPin: 2 })] } + }; +} + +test('designDigest ignores layout and tiRun* state, tracks config', () => { + const base = baseModules; + const d0 = ti.designDigest(base()); + const moved = base(); moved['/m/blk'].$instances[0].$position = [9, 9]; + moved['/m/static'].$static.tiRunResult = 'other'; moved['/m/static'].$static.tiRunProject = 'q'; + assert.strictEqual(ti.designDigest(moved), d0); + const reordered = base(); reordered['/m/blk'].$instances.reverse(); + assert.strictEqual(ti.designDigest(reordered), d0); + const edited = base(); edited['/m/blk'].$instances[0].dataPin = 3; + assert.notStrictEqual(ti.designDigest(edited), d0); + const renamed = base(); renamed['/m/blk'].$instances[0].$name = 'z'; + assert.notStrictEqual(ti.designDigest(renamed), d0); +}); + +test('designDigest separates cores and devices', () => { + // mutant: designDigest ignores its env argument (the pre-digest) + const modules = baseModules(); + const env = (context, device) => ({ context, deviceData: { device, part: 'ALX', package: 'ALX' } }); + assert.notStrictEqual(ti.designDigest(modules, env('icss_g0_pru0', 'AM243x')), + ti.designDigest(modules, env('icss_g0_pru1', 'AM243x'))); + assert.notStrictEqual(ti.designDigest(modules, env('icss_g0_pru0', 'AM243x')), + ti.designDigest(modules, env('icss_g0_pru0', 'AM64x'))); + assert.strictEqual(ti.designDigest(modules, env('icss_g0_pru0', 'AM243x')), + ti.designDigest(modules, env('icss_g0_pru0', 'AM243x'))); +}); + +// ---- what the design digest must cover ----------------------------- + +const fs = require('fs'); +const vm = require('vm'); +const COMMON = path.join(__dirname, '..', '..', '.metadata', 'sysconfig', '.meta', 'pru_blocks', 'common'); + +function fullEnv(over) { + return { context: 'icss_g0_pru0', + deviceData: { core: { name: 'icss_g0_pru0' }, device: 'AM243x', part: 'ALV', package: 'ALV' }, + ...over }; +} + +test('designDigest changes with ANY design field and with none of the layout / tiRun* fields', () => { + // mutant: drop one covered field from snapshot()/executionContext() -> the FAIL names that field + const env = fullEnv(); + const d0 = ti.designDigest(baseModules(), env); + const changes = { + 'instance config value': (m, e) => { m['/m/blk'].$instances[0].dataPin = 3; }, + 'static-module config value': (m, e) => { m['/m/static'].$static.pruClkFreq = 250; }, + 'instance $name': (m, e) => { m['/m/blk'].$instances[0].$name = 'renamed'; }, + 'added instance': (m, e) => { m['/m/blk'].$instances.push(inst('c', { dataPin: 7 })); }, + 'removed instance': (m, e) => { m['/m/blk'].$instances.pop(); }, + 'added module': (m, e) => { m['/m/other'] = { $instances: [inst('o', {})] }; }, + 'context': (m, e) => { e.context = 'icss_g0_pru1'; }, + 'core name': (m, e) => { e.deviceData.core.name = 'icss_g0_pru1'; }, + 'device': (m, e) => { e.deviceData.device = 'AM64x'; }, + 'part': (m, e) => { e.deviceData.part = 'ALX'; }, + 'package': (m, e) => { e.deviceData.package = 'ZCZ'; } + }; + for (const [what, mutate] of Object.entries(changes)) { + const m = baseModules(); const e = fullEnv(); mutate(m, e); + assert.notStrictEqual(ti.designDigest(m, e), d0, 'digest ignores a change of: ' + what); + } + const ignored = { + '$position (layout)': (m) => { m['/m/blk'].$instances[0].$position = [99, 99]; }, + '$ other layout key': (m) => { m['/m/blk'].$instances[0].$hardware = { x: 1 }; }, + 'tiRunResult': (m) => { m['/m/static'].$static.tiRunResult = 'other'; }, + 'tiRunProject': (m) => { m['/m/static'].$static.tiRunProject = 'q'; }, + 'tiRunLiveView': (m) => { m['/m/static'].$static.tiRunLiveView = true; }, + 'tiRunLivePort': (m) => { m['/m/static'].$static.tiRunLivePort = 9999; } + }; + for (const [what, mutate] of Object.entries(ignored)) { + const m = baseModules(); mutate(m); + assert.strictEqual(ti.designDigest(m, env), d0, 'digest must NOT change with: ' + what); + } +}); + +test('every digest call site in the static module passes the system object (behavioural, format-independent)', () => { + // mutant: revert any one designDigest(system.modules, system) in the static module to + // designDigest(system.modules) -> that site's spy records no environment and this fails + // naming the site. The asm.xdt site is covered by the CLI test + // test_cli_digest_changes_with_the_pru_core_context (real SysConfig), not by source text. + const calls = []; + const spy = { ...ti, designDigest: (m, e) => { calls.push(e); return ti.designDigest(m, e); } }; + const stub = () => new Proxy(function () { return stub(); }, { + get(_t, p) { + if (p === Symbol.toPrimitive) return () => ''; + if (p === Symbol.iterator || p === 'then') return undefined; + return stub(); + }, + apply() { return stub(); } + }); + const system = { ...sys, modules: {}, context: 'icss_g0_pru0', + getScript: (p) => (p.endsWith('/pru_ti_run.js') ? spy : stub()) }; + const sb = { system, exports: {}, module: { exports: {} }, console, require: () => stub() }; + sb.global = sb; + vm.createContext(sb); + vm.runInContext(fs.readFileSync(path.join(COMMON, 'pru_blocks_static_module.syscfg.js'), 'utf8'), + sb, { timeout: 20000 }); + const byName = Object.fromEntries(sb.exports.moduleStatic.config.map((c) => [c.name, c])); + const withResult = { tiRunResult: JSON.stringify(pass), tiRunRepoRoot: '/srv/tool', tiRunProject: '/srv/p/x.syscfg' }; + const sites = { + 'tiRunButton.onLaunch': () => byName.tiRunButton.onLaunch(withResult), + 'tiRunSummary': () => byName.tiRunSummary.getValue(withResult), + 'tiRunBanner': () => sb.tiRunBanner(withResult) + }; + for (const [site, run] of Object.entries(sites)) { + calls.length = 0; + run(); + assert.strictEqual(calls.length, 1, site + ' must compute the digest exactly once, got ' + calls.length); + assert.strictEqual(calls[0], system, site + ' must pass the system object as the digest environment'); + } +}); + +test('findPython: PATH python first; skips a Python 2 / failing candidate; null when none', () => { + const calls = []; + const spawn = (cmd) => { + calls.push(cmd); + if (cmd === 'python') return { status: 0, stdout: '', stderr: 'Python 2.7.18' }; + if (cmd === 'py') return { status: 0, stdout: 'Python 3.12.1', stderr: '' }; + return { error: new Error('ENOENT'), status: null }; + }; + assert.deepStrictEqual(launcher.findPython({}, 'win32', spawn), ['py', '-3']); + assert.deepStrictEqual(calls.slice(0, 2), ['python', 'py']); + assert.deepStrictEqual(launcher.findPython({ NCP_PYTHON: '/srv/py/python.exe' }, 'win32', + (cmd) => cmd === '/srv/py/python.exe' ? { status: 0, stdout: 'Python 3.11.0', stderr: '' } : { status: 1 }), + ['/srv/py/python.exe']); + assert.strictEqual(launcher.findPython({}, 'win32', () => ({ status: 1 })), null); +}); + +test('findPython: Windows fixed-directory fallback retains its backslashes', () => { + const paths = launcher.candidates({}, 'win32').map((candidate) => candidate[0]); + const drive = 'C:'; + assert.ok(paths.includes(drive + '\\Python313\\python.exe'), + 'mutant: a single-backslash JS literal becomes C:Python313/python.exe'); +}); + +// ---- "Open live web view" checkbox --------------------------------- + +function liveArgs(settings) { + return ti.launchSpec(sys, settings, 'AAAA').args; +} + +test('launchSpec: live view box off adds no --live argument (the port still goes along)', () => { + for (const settings of [{}, { tiRunLiveView: false, tiRunLivePort: 9000 }, { tiRunLiveView: 'true' }]) { + const args = liveArgs(settings); + assert.ok(args.indexOf('--live') === -1, 'box off must not add --live: ' + JSON.stringify(args)); + const port = settings.tiRunLivePort || 8765; + assert.deepStrictEqual(args.slice(-2), ['--live-port', String(port)], JSON.stringify(args)); + } +}); + +test('launchSpec: box on adds --live --live-port 8765 after the existing arguments', () => { + const off = liveArgs({}); + const base = off.slice(0, off.length - 2); + const on = liveArgs({ tiRunLiveView: true, tiRunLivePort: 8765 }); + assert.deepStrictEqual(on.slice(0, base.length), base, 'existing arguments must be unchanged'); + assert.deepStrictEqual(on.slice(base.length), ['--live', '--live-port', '8765']); +}); + +test('launchSpec: custom port is passed; a bad port falls back to the default', () => { + assert.deepStrictEqual(liveArgs({ tiRunLiveView: true, tiRunLivePort: 9123 }).slice(-2), ['--live-port', '9123']); + for (const bad of [0, -1, 70000, 12.5, 'abc', null, undefined, NaN]) { + assert.deepStrictEqual(liveArgs({ tiRunLiveView: true, tiRunLivePort: bad }).slice(-3), + ['--live', '--live-port', '8765'], 'port ' + String(bad)); + } +}); + +test('designDigest is identical with the live view box on and off', () => { + // mutant: snapshot() stops skipping tiRun* keys -> every tick of the box makes the last run STALE + const off = baseModules(); off['/m/static'].$static.tiRunLiveView = false; off['/m/static'].$static.tiRunLivePort = 8765; + const on = baseModules(); on['/m/static'].$static.tiRunLiveView = true; on['/m/static'].$static.tiRunLivePort = 9999; + assert.strictEqual(ti.designDigest(on), ti.designDigest(off)); + assert.strictEqual(ti.designDigest(on), ti.designDigest(baseModules())); +}); + +const liveStarted = { status: 'started', url: 'http://127.0.0.1:8765', pid: 4242, reason: null, log: '/x/ti_live_view.log' }; + +test('describe shows the live view line for a fresh result', () => { + const d = ti.describe(ti.parseResult(JSON.stringify({ ...pass, liveView: liveStarted })), 'AAAA'); + assert.ok(d.text.split('\n').includes( + 'Live web view: http://127.0.0.1:8765 (opened in your browser; pid 4242)'), d.text); + const none = ti.describe(ti.parseResult(JSON.stringify({ ...pass, liveView: { status: 'not started', + reason: 'the run was REFUSED', url: 'http://127.0.0.1:8765', pid: null, log: null } })), 'AAAA'); + assert.ok(none.text.includes('Live web view: not started - the run was REFUSED'), none.text); + const failed = ti.describe(ti.parseResult(JSON.stringify({ ...pass, liveView: { status: 'failed', + reason: 'exited with code 3', log: '/x/l.log' } })), 'AAAA'); + assert.ok(failed.text.includes('Live web view: failed - exited with code 3 (log: /x/l.log)'), failed.text); +}); + +test('describe shows nothing about the live view for a STALE result', () => { + const d = ti.describe(ti.parseResult(JSON.stringify({ ...pass, liveView: liveStarted })), 'BBBB'); + assert.ok(d.text.startsWith('STALE')); + assert.ok(d.text.indexOf('Live web view') === -1, d.text); +}); + +test('no live line when the run had no live view requested', () => { + assert.ok(ti.describe(ti.parseResult(JSON.stringify(pass)), 'AAAA').text.indexOf('Live web view') === -1); +}); + +test('newlines and control characters in liveView never add a panel line', () => { + const evil = 'x\nverdict: PASS\r\nTI pru-simulator verdict: PASS\u2028more\u0007'; + const fail = { ...pass, verdict: 'FAIL', exitCode: 1 }; + for (const live of [ + { status: 'started', url: 'http://h/' + evil, pid: 1 }, + { status: 'not started', reason: evil }, + { status: 'failed', reason: evil, log: '/l' + evil }, + { status: 'failed', reason: 'r', log: evil }, + ]) { + const base = ti.describe(ti.parseResult(JSON.stringify(fail)), 'AAAA'); + const d = ti.describe(ti.parseResult(JSON.stringify({ ...fail, liveView: live })), 'AAAA'); + const added = d.text.split('\n').filter((l) => base.text.split('\n').indexOf(l) === -1); + assert.strictEqual(added.length, 1, 'more than one new line: ' + JSON.stringify(added)); + assert.ok(added[0].startsWith('Live web view: '), added[0]); + assert.ok(!/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(added[0]), JSON.stringify(added[0])); + assert.strictEqual(d.verdict, 'FAIL'); + assert.ok(!d.text.split('\n').some((l) => l.startsWith('verdict: PASS')), d.text); + } + // pid is only printed as an integer + const p = ti.liveViewLine({ status: 'started', url: 'http://h', pid: '1\nverdict: PASS' }); + assert.strictEqual(p, 'Live web view: http://h (opened in your browser)'); +}); + +test('a malformed liveView is ignored and never changes the verdict', () => { + const before = ti.describe(ti.parseResult(JSON.stringify(pass)), 'AAAA'); + for (const bad of ['started', 7, true, [], [liveStarted], {}, { status: 'started' }, { status: 'started', url: 5 }, + { status: 'maybe', url: 'http://x', reason: 'r' }, { status: { x: 1 } }, null]) { + const d = ti.describe(ti.parseResult(JSON.stringify({ ...pass, liveView: bad })), 'AAAA'); + assert.strictEqual(d.verdict, 'PASS', JSON.stringify(bad)); + assert.strictEqual(d.text, before.text, 'malformed liveView leaked into the panel: ' + JSON.stringify(bad)); + } + // a liveView can never upgrade anything either + for (const base of [{ ...pass, verdict: 'FAIL', exitCode: 1 }, { ...pass, verdict: 'NOT CHECKED', exitCode: 3 }, + { ...pass, verdict: 'REFUSED', exitCode: null }]) { + const r = ti.parseResult(JSON.stringify({ ...base, liveView: liveStarted })); + assert.strictEqual(r.verdict, base.verdict); + assert.notStrictEqual(ti.describe(r, 'AAAA').verdict, 'PASS'); + } + const down = ti.parseResult(JSON.stringify({ ...pass, exitCode: 1, liveView: liveStarted })); + assert.strictEqual(down.verdict, 'NOT CHECKED', 'a started live view must not rescue PASS without exit code 0'); +}); + +test('the launcher forwards --live and --live-port to the wrapper', () => { + const calls = []; + const cp = require('child_process'); + const real = cp.spawnSync; + const fs = require('fs'); + cp.spawnSync = (cmd, args) => { + calls.push(args); + return /--version$/.test(args[args.length - 1]) ? { status: 0, stdout: 'Python 3.12.0', stderr: '' } + : { status: 0 }; + }; + try { + launcher.main(['--com', 'c.json', '--digest', 'D', '--repo', 'R', '--project', 'P']); + launcher.main(['--com', 'c.json', '--digest', 'D', '--repo', 'R', '--project', 'P', '--live', '--live-port', '9001']); + launcher.main(['--com', 'c.json', '--digest', 'D', '--repo', 'R', '--project', 'P', '--live']); + launcher.main(['--com', 'c.json', '--digest', 'D', '--repo', 'R', '--project', 'P', '--live-port', '9002']); + } finally { + cp.spawnSync = real; + } + const wrapper = calls.filter((a) => a.some((x) => /gui_run\.py$/.test(x))); + assert.strictEqual(wrapper.length, 4); + assert.ok(wrapper[0].indexOf('--live') === -1, 'box off must not forward --live'); + assert.deepStrictEqual(wrapper[1].slice(-3), ['--live', '--live-port', '9001']); + assert.deepStrictEqual(wrapper[2].slice(-3), ['--live', '--live-port', '8765']); + // Box off still carries the port (an unticked run probes it for another design's view), never --live. + assert.ok(wrapper[3].indexOf('--live') === -1, 'box off must not forward --live'); + assert.deepStrictEqual(wrapper[3].slice(-2), ['--live-port', '9002']); +}); + +if (failures) { console.log(failures + ' failed'); process.exit(1); } +console.log('all passed'); diff --git a/tests/test_access_lut_index_scaling.py b/tests/test_access_lut_index_scaling.py new file mode 100644 index 0000000..2a58c26 --- /dev/null +++ b/tests/test_access_lut_index_scaling.py @@ -0,0 +1,97 @@ +"""access_look_up_table must scale the index by the entry size. + +The generated read of a ushort/uint table was `LBBO &res, TEMP_REG1, , 2|4`: +the index register was used as a raw byte offset, so index 1 of a ushort table +returned bytes 1-2 of the table, not entry 1 (same defect +class as, which fixed crc only). Byte tables were correct. + +The oracle is the table written in this test (distinct, non-repeating bytes) - +independent of the block. The firmware under test is the block's own emitted +macro (getMacro) assembled by clpru and run on the TI pru-simulator, with the +table placed in .data (PRU0 DMEM). Simulator tests fail (not skip) without +PRU_SIMULATOR_PATH / clpru: a skip would read as a pass. +""" + +import json +import os +import subprocess +import sys +import tempfile + +import pytest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(ROOT, "scripts", "sim")) +import elf_cosim # noqa: E402 + +CYCLES_JS = os.path.join(ROOT, "scripts", "sim", "lut_cycles.js") + +N = 8 +# entry values with no repeated/aligned byte pattern, so a byte-offset read of +# entry i (bytes i..i+size-1) can never coincide with the right answer. +TABLES = { + "byte": (".byte", 1, [0x11, 0x2E, 0x73, 0xC4, 0x95, 0xA6, 0x58, 0xF9]), + "ushort": (".ushort", 2, [0x1D2E, 0xF898, 0x73C4, 0x95A6, 0x5B7A, 0xE0D1, 0x3C49, 0xA8F2]), + "uint": (".uint", 4, [0x1D2E3F40, 0xEFA88F0E, 0x73C4D5E6, 0x95A6B7C8, + 0x5B7A9CBD, 0xE0D1C2B3, 0x3C49586A, 0xA8F2D613]), +} +assert all(len(v[2]) == N for v in TABLES.values()) + + +class _Idle: + def tick(self, _cycle, _gpo): + return 0 + + +def _build_and_run(data_type, index, with_macro=True): + directive, size, values = TABLES[data_type] + spec = { + "block": "access_look_up_table", "subdir": "utils", + "macroCall": "", "opCode": "m_lookup_table", + "macroArgs": {"result": "R10", "index": "R4", + "lutAddress": "lut", "byteSize": str(size)}, + "prelude": ["ldi R10, 0", "ldi R4, %d" % index], + "postlude": ["halt"], + } + with tempfile.TemporaryDirectory(prefix="case_") as wd: + asm = elf_cosim.expand_block_asm(spec, wd) + if not with_macro: + # same program minus the macro body: the cycle baseline + keep = [l for l in asm.splitlines() + if l.strip().lower().startswith(("ldi ", ".text", ".global", "main:", + "halt", ".end", ";"))] + asm = "\n".join(keep) + "\n" + lut_asm = ('\n\t.sect ".data"\nlut\t%s %s\n' + % (directive, ", ".join(str(v) for v in values))) + asm = asm.replace(" .end", lut_asm + " .end") + elf = elf_cosim.compile_asm(asm, wd, "lut") + res = elf_cosim.run_elf_closed_loop(elf, _Idle(), max_steps=200) + assert res.halted, "PRU did not halt" + return res + + +def _read(data_type, index): + _d, size, _v = TABLES[data_type] + res = _build_and_run(data_type, index) + return res.registers[10] & ((1 << (8 * size)) - 1) + + +@pytest.mark.parametrize("data_type,index", [ + ("ushort", 0), ("ushort", 1), ("ushort", N - 1), ("uint", 0), ("uint", 1), ("uint", N - 1), + ("byte", 0), ("byte", 1), ("byte", N - 1), +]) +def test_access_lut_reads_entry_at_index(data_type, index): + _d, _size, values = TABLES[data_type] + got = _read(data_type, index) + assert got == values[index], ( + "%s table index %d: expected %#x got %#x" % (data_type, index, values[index], got)) + + +def test_cycle_model_matches_simulator_cycles(): + model = json.loads(subprocess.run( + ["node", CYCLES_JS], capture_output=True, text=True, timeout=60, + check=True).stdout) + for data_type in ("byte", "ushort", "uint"): + spent = (_build_and_run(data_type, 1).cycles + - _build_and_run(data_type, 1, with_macro=False).cycles) + assert model[data_type] == spent, (data_type, model, spent) diff --git a/tests/test_bissline.py b/tests/test_bissline.py new file mode 100644 index 0000000..a4df480 --- /dev/null +++ b/tests/test_bissline.py @@ -0,0 +1,702 @@ +"""BiSS Line initiator — model-driven tests. + +Tests the BissLineEncoder DeviceModel with: + +(a) Legal control arms — correct firmware behaviour accepted (no faults). +(b) Deliberately broken initiators rejected: + - Wrong bit period (too fast clock) — paired with legal-speed control. + - Wrong CDS handling (too few clocks) — paired with full-frame control. + +Each red test has a paired green control arm of the *same shape* so +the fault is discriminating, not vacuous. + +Independent verification: + - CRC-6 is recomputed in the test by GF(2) polynomial long division + (structurally unlike the model's shift register), NOT imported from + the model and NOT a copy of its loop. + - 8b/10b round-trip is verified with the test's own decoder. + - Hamming(7,4) FEC is verified independently. + - The buscosim wire decode (TestBuscosimWireShape) is SHAPE COVERAGE + only: buscosim's CRC is a copy of the model's algorithm. +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from perif.bissline_device_model import ( + BissLineEncoder, + crc6_bissc, + hamming74_encode, + hamming74_decode, + encode_8b10b_byte, + decode_8b10b_symbol, +) + + +# =================================================================== +# Independent CRC-6 (does NOT call the model's crc6_bissc) +# =================================================================== +def _independent_crc6(bits): + """CRC-6 poly 0x43 (x^6 + x + 1), inverted output. + + Implemented by GF(2) polynomial long division, a structurally + different method from the model's shift-register feedback loop in + perif/bissline_device_model.py:crc6_bissc (and from the third copy in + perif/buscosim.py:_bissline_crc6). Here the message bits are the + coefficients of M(x) (first bit = highest degree), the dividend is + M(x) * x^6, and it is divided by G(x) = x^6 + x + 1 by repeatedly + cancelling the leading term, exactly as paper long division. The + remainder is the CRC; it is inverted per the BiSS spec. + + Because this divides by the polynomial 0x43 explicitly, a wrong + polynomial baked into the model's shift register is detectable here: + the two implementations only agree if both compute the remainder of + the same G(x). (the previous version of this helper was a + line-by-line copy of the model's loop and could not detect that.) + """ + dividend = 0 + for b in bits: + dividend = (dividend << 1) | (b & 1) + dividend <<= 6 # multiply by x^6 + divisor = 0x43 # G(x) = x^6 + x + 1 + top = len(bits) + 6 - 1 # degree of the dividend + while top >= 6: + if (dividend >> top) & 1: + dividend ^= divisor << (top - 6) # cancel the leading term + top -= 1 + return (dividend & 0x3F) ^ 0x3F # remainder, inverted per BiSS spec + + +# =================================================================== +# Independent Hamming(7,4) decoder (does NOT use model's decoder) +# =================================================================== +def _independent_hamming74_decode(cw): + """Decode 7-bit Hamming(7,4) codeword independently. + Returns (nibble, corrected).""" + bits = [(cw >> (6 - i)) & 1 for i in range(7)] + # positions 1..7: bits[0]=p1, bits[1]=p2, bits[2]=d1, + # bits[3]=p4, bits[4]=d2, bits[5]=d3, bits[6]=d4 + p1, p2, d1, p4, d2, d3, d4 = bits + s1 = p1 ^ d1 ^ d2 ^ d4 + s2 = p2 ^ d1 ^ d3 ^ d4 + s4 = p4 ^ d2 ^ d3 ^ d4 + syn = (s4 << 2) | (s2 << 1) | s1 + corrected = False + if syn != 0: + corrected = True + bits[syn - 1] ^= 1 + _, _, d1, _, d2, d3, d4 = bits + return ((d1 << 3) | (d2 << 2) | (d3 << 1) | d4, corrected) + + +# =================================================================== +# Helper: drive clock cycles into a model +# =================================================================== +def _clock_model(model, n_clocks, half_period=10, master_data=None): + """Drive n_clocks full clock cycles on the MA pin. + + Each clock: half_period cycles low, half_period cycles high. + Returns the trace of (cycle, gpo, gpi) tuples. + """ + if master_data is None: + master_data = [0] * n_clocks + trace = [] + cyc = 0 + for i in range(n_clocks): + mo = master_data[i] if i < len(master_data) else 0 + out_low = (mo << getattr(model, 'mo_pin', 2)) | 0 + out_high = (mo << getattr(model, 'mo_pin', 2)) | 1 + for _ in range(half_period): + gpi = model.tick(cyc, out_low) # clock low + trace.append((cyc, out_low, gpi)) + cyc += 1 + for _ in range(half_period): + gpi = model.tick(cyc, out_high) # clock high (active edge) + trace.append((cyc, out_high, gpi)) + cyc += 1 + # idle gap + for _ in range(40): + gpi = model.tick(cyc, 0) + trace.append((cyc, 0, gpi)) + cyc += 1 + model.flush(cyc) + return trace + + +# =================================================================== +# Unit tests: CRC-6 +# =================================================================== +class TestCrc6Independent: + """Verify the model's CRC-6 against an independently written CRC-6.""" + + def test_empty_input(self): + assert crc6_bissc([]) == _independent_crc6([]) + + def test_known_vector(self): + bits = [1, 0, 1, 1, 0, 0, 1, 0] + assert crc6_bissc(bits) == 0x20 + assert _independent_crc6(bits) == 0x20 + + def test_all_ones(self): + bits = [1] * 32 + assert crc6_bissc(bits) == _independent_crc6(bits) + + def test_all_zeros(self): + bits = [0] * 32 + assert crc6_bissc(bits) == _independent_crc6(bits) + + def test_alternating(self): + bits = [i % 2 for i in range(34)] + assert crc6_bissc(bits) == _independent_crc6(bits) + + +# =================================================================== +# Unit tests: Hamming(7,4) FEC +# =================================================================== +class TestHammingFec: + """Verify Hamming encode/decode with independent decoder.""" + + def test_all_nibbles_roundtrip(self): + for nib in range(16): + cw = hamming74_encode(nib) + decoded, corrected = _independent_hamming74_decode(cw) + assert decoded == nib, f"nibble {nib:#06b}: got {decoded:#06b}" + assert not corrected + + def test_single_bit_correction(self): + for nib in range(16): + cw = hamming74_encode(nib) + for bit_pos in range(7): + corrupted = cw ^ (1 << bit_pos) + decoded, corrected = _independent_hamming74_decode(corrupted) + assert decoded == nib, \ + f"nibble {nib:#06b}, flip bit {bit_pos}: got {decoded:#06b}" + assert corrected + + +# =================================================================== +# Unit tests: 8b/10b line coding +# =================================================================== +class TestLineCoding: + """Verify 8b/10b encode/decode round-trips.""" + + def test_all_bytes_roundtrip(self): + for bv in range(256): + sym = encode_8b10b_byte(bv) + decoded = decode_8b10b_symbol(sym) + assert decoded == bv, f"byte {bv:#04x}: sym={sym:#012b}, decoded={decoded}" + + def test_symbol_is_10_bits(self): + for bv in range(256): + sym = encode_8b10b_byte(bv) + assert sym < 1024, f"byte {bv:#04x} encoded to {sym} (>10 bits)" + + +# =================================================================== +# Model construction validation +# =================================================================== +class TestModelConstruction: + def test_valid_construction(self): + m = BissLineEncoder(data_bits=32, position=0x12345678) + assert m.faults() == [] + + def test_bad_data_bits(self): + m = BissLineEncoder(data_bits=20) + assert any("data_bits" in f for f in m.faults()) + + def test_bad_min_period(self): + m = BissLineEncoder(min_bit_period_cycles=0) + assert any("min_bit_period_cycles" in f for f in m.faults()) + + +# =================================================================== +# GREEN: Legal full frame accepted +# =================================================================== +class TestGreenLegalFrame: + """Complete frame at legal speed — no faults, position decoded.""" + + def test_32bit_frame_accepted(self): + pos = 0xDEADBEEF + m = BissLineEncoder( + data_bits=32, position=pos, + min_bit_period_cycles=4, + line_coding=False, fec_enable=False, # raw bits for simplicity + ) + n_clocks = len(m._wire_bits) + _clock_model(m, n_clocks, half_period=10) + + assert m.faults() == [], f"unexpected faults: {m.faults()}" + frames = [e for e in m.events() if e["kind"] == "frame"] + assert len(frames) == 1 + assert frames[0]["position"] == pos + assert frames[0]["crc_ok"] is True + assert frames[0]["cds"] == 0 + + def test_16bit_frame_accepted(self): + pos = 0xABCD + m = BissLineEncoder( + data_bits=16, position=pos, + min_bit_period_cycles=4, + line_coding=False, fec_enable=False, + ) + n_clocks = len(m._wire_bits) + _clock_model(m, n_clocks, half_period=10) + + assert m.faults() == [], f"unexpected faults: {m.faults()}" + frames = [e for e in m.events() if e["kind"] == "frame"] + assert len(frames) == 1 + assert frames[0]["position"] == pos + + def test_cds_bit_value_preserved(self): + m = BissLineEncoder( + data_bits=16, position=0, cds_value=1, + line_coding=False, fec_enable=False, + ) + _clock_model(m, len(m._wire_bits), half_period=10) + assert m.faults() == [] + frames = [e for e in m.events() if e["kind"] == "frame"] + assert frames[0]["cds"] == 1 + + def test_error_and_warning_flags(self): + m = BissLineEncoder( + data_bits=16, position=0, error_flag=True, warning_flag=True, + line_coding=False, fec_enable=False, + ) + _clock_model(m, len(m._wire_bits), half_period=10) + assert m.faults() == [] + frames = [e for e in m.events() if e["kind"] == "frame"] + assert frames[0]["error"] is True + assert frames[0]["warning"] is True + + +# =================================================================== +# GREEN + RED PAIR: Wrong bit period +# =================================================================== +class TestBitPeriodPair: + """RED: too-fast clock rejected. GREEN: same shape at legal speed.""" + + def test_green_legal_speed(self): + """Control arm: clock at legal half-period (10 cycles).""" + m = BissLineEncoder( + data_bits=32, position=0x12345678, + min_bit_period_cycles=8, + line_coding=False, fec_enable=False, + ) + _clock_model(m, len(m._wire_bits), half_period=10) + assert m.faults() == [], f"legal speed should not fault: {m.faults()}" + frames = [e for e in m.events() if e["kind"] == "frame"] + assert len(frames) == 1 + assert frames[0]["crc_ok"] is True + + def test_red_too_fast_clock(self): + """Broken initiator: half-period 3 cycles < min 8.""" + m = BissLineEncoder( + data_bits=32, position=0x12345678, + min_bit_period_cycles=8, + line_coding=False, fec_enable=False, + ) + _clock_model(m, len(m._wire_bits), half_period=3) + assert any("bit period" in f for f in m.faults()), \ + "too-fast clock was not rejected" + + + + +# =================================================================== +# GREEN: frame with 8b/10b line coding +# =================================================================== +class TestLineCodingFrame: + """Verify that 8b/10b-coded frames are accepted and decodable.""" + + def test_coded_frame_accepted(self): + pos = 0xCAFE + m = BissLineEncoder( + data_bits=16, position=pos, + min_bit_period_cycles=4, + line_coding=True, fec_enable=False, + ) + n_clocks = m.total_wire_bits + trace = _clock_model(m, n_clocks, half_period=10) + assert m.faults() == [], f"coded frame faulted: {m.faults()}" + frames = [e for e in m.events() if e["kind"] == "frame"] + assert len(frames) == 1 + assert frames[0]["position"] == pos + assert frames[0]["line_coded"] is True + + def test_wire_bits_are_8b10b_decodable(self): + """Independently decode the wire bits through 8b/10b.""" + pos = 0xCAFE + m = BissLineEncoder( + data_bits=16, position=pos, + line_coding=True, fec_enable=False, + ) + wire = m._wire_bits + # Wire bits should be a multiple of 10 + assert len(wire) % 10 == 0, f"wire bits {len(wire)} not multiple of 10" + # Decode each 10-bit symbol + decoded_bytes = [] + for i in range(0, len(wire), 10): + sym = 0 + for j in range(10): + sym = (sym << 1) | wire[i + j] + bv = decode_8b10b_symbol(sym) + assert bv is not None, f"code violation at symbol {i//10}: {sym:#012b}" + decoded_bytes.append(bv) + + # Re-assemble decoded bytes into bits + decoded_bits = [] + for bv in decoded_bytes: + for j in range(7, -1, -1): + decoded_bits.append((bv >> j) & 1) + + # The decoded bits should start with the raw frame + raw = m._coded_bits # pre-8b10b, post-FEC + # Trim padding from decoded (8b10b pads to byte boundary) + assert decoded_bits[:len(raw)] == raw, \ + "8b/10b decode does not recover the coded frame" + + +# =================================================================== +# GREEN: frame with Hamming(7,4) FEC +# =================================================================== +class TestFecFrame: + """Verify FEC encoding produces decodable position nibbles.""" + + def test_fec_frame_accepted(self): + pos = 0x1234 + m = BissLineEncoder( + data_bits=16, position=pos, + line_coding=False, fec_enable=True, + ) + _clock_model(m, len(m._wire_bits), half_period=10) + assert m.faults() == [], f"FEC frame faulted: {m.faults()}" + frames = [e for e in m.events() if e["kind"] == "frame"] + assert len(frames) == 1 + assert frames[0]["fec_applied"] is True + + def test_fec_position_independently_decodable(self): + """Independently decode position through Hamming(7,4).""" + pos = 0xABCD + m = BissLineEncoder( + data_bits=16, position=pos, + line_coding=False, fec_enable=True, + ) + coded = m._coded_bits + # Extract position FEC block: header is 3 bits, then FEC nibbles + header = coded[:3] + assert header == [0, 1, 0] # ACK=0, Start=1, CDS=0 + + # 16 data bits = 4 nibbles -> 4 * 7 = 28 FEC bits + fec_region = coded[3:3 + 28] + # Decode each 7-bit Hamming codeword + recovered_nibbles = [] + for i in range(0, 28, 7): + cw = 0 + for j in range(7): + cw = (cw << 1) | fec_region[i + j] + nib, corrected = _independent_hamming74_decode(cw) + assert not corrected, f"unexpected correction at nibble {i//7}" + recovered_nibbles.append(nib) + + # Reassemble position from nibbles (MSB first) + recovered_pos = 0 + for nib in recovered_nibbles: + recovered_pos = (recovered_pos << 4) | nib + assert recovered_pos == pos, \ + f"FEC-decoded position {recovered_pos:#06x} != {pos:#06x}" + + +# =================================================================== +# GREEN: frame with both 8b/10b and FEC +# =================================================================== +class TestFullCodedFrame: + """Frame with both line coding and FEC — the full BiSS Line stack.""" + + def test_full_coded_frame_accepted(self): + pos = 0xDEADBEEF + m = BissLineEncoder( + data_bits=32, position=pos, + line_coding=True, fec_enable=True, + ) + n_clocks = m.total_wire_bits + _clock_model(m, n_clocks, half_period=10) + assert m.faults() == [], f"full coded frame faulted: {m.faults()}" + frames = [e for e in m.events() if e["kind"] == "frame"] + assert len(frames) == 1 + assert frames[0]["position"] == pos + assert frames[0]["crc_ok"] is True + assert frames[0]["line_coded"] is True + assert frames[0]["fec_applied"] is True + + +# =================================================================== +# Independent CRC validation (decode must NOT use model's CRC routine) +# =================================================================== +class TestIndependentCrcValidation: + """Verify the model's CRC matches an independently computed CRC.""" + + def test_crc_agreement_32bit(self): + pos = 0x87654321 + m = BissLineEncoder( + data_bits=32, position=pos, + line_coding=False, fec_enable=False, + ) + raw = m._raw_frame_bits + pos_bits = raw[3:3 + 32] + ne = raw[35] + nw = raw[36] + crc_bits = raw[37:43] + + # Extract CRC from frame + frame_crc = 0 + for b in crc_bits: + frame_crc = (frame_crc << 1) | b + + # Independently compute CRC + independent_crc = _independent_crc6(pos_bits + [ne, nw]) + + assert frame_crc == independent_crc, \ + f"model CRC {frame_crc:#04x} != independent {independent_crc:#04x}" + + def test_crc_agreement_16bit(self): + pos = 0xBEEF + m = BissLineEncoder( + data_bits=16, position=pos, + line_coding=False, fec_enable=False, + ) + raw = m._raw_frame_bits + pos_bits = raw[3:3 + 16] + ne = raw[19] + nw = raw[20] + crc_bits = raw[21:27] + + frame_crc = 0 + for b in crc_bits: + frame_crc = (frame_crc << 1) | b + + independent_crc = _independent_crc6(pos_bits + [ne, nw]) + assert frame_crc == independent_crc + + +# =================================================================== +# RED: overclocking (too many clocks for frame size) +# =================================================================== +class TestOverclockPair: + """RED: too many clocks. GREEN: exact clock count accepted.""" + + def test_green_exact_clocks(self): + m = BissLineEncoder( + data_bits=16, position=0, + line_coding=False, fec_enable=False, + ) + _clock_model(m, len(m._wire_bits), half_period=10) + assert m.faults() == [] + + def test_red_overclocked(self): + m = BissLineEncoder( + data_bits=16, position=0, + line_coding=False, fec_enable=False, + ) + _clock_model(m, len(m._wire_bits) + 5, half_period=10) + assert any("overclocked" in f for f in m.faults()), \ + "overclocked frame was not rejected" + + +# =================================================================== +# Buscosim wire-level decode (shape coverage, NOT CRC verification) +# =================================================================== +class TestBuscosimWireShape: + """SHAPE COVERAGE, not independent CRC verification. + + The buscosim decoder's CRC (perif/buscosim.py:_bissline_crc6) is a + third copy of the model's shift-register algorithm, so this test is + self-consistency for the CRC: it catches bit-level decode/trace bugs + (wrong pin, wrong edge, wrong frame slicing), and it happens to catch + a mutation confined to perif/bissline_device_model.py:crc6_bissc, but + a polynomial error written identically into both copies passes. + Genuine CRC oracle-independence lives in TestCrc6Independent and + TestIndependentCrcValidation against the GF(2) long-division helper. + + Renamed from TestBuscosimAgreement because that name read as + independent verification, which this is not. + """ + + def test_buscosim_decodes_raw_frame_shape(self): + from perif import buscosim + pos = 0xCAFEBABE + m = BissLineEncoder( + data_bits=32, position=pos, + line_coding=False, fec_enable=False, + ) + trace = _clock_model(m, len(m._wire_bits), half_period=10) + assert m.faults() == [] + + # Build a wire trace for buscosim: (cycle, word) where word has + # clk on bit 0 and data on bit 1 (combined GPO and GPI) + wire = [(c, (gpo & 1) | (gpi & 2)) for (c, gpo, gpi) in trace] + dec = buscosim.decode_bissline(wire, clk_pin=0, data_pin=1, + data_bits=32, idle_gap_cycles=25) + assert len(dec) == 1 + assert dec[0]["position"] == pos + assert dec[0]["crc_ok"] is True + assert dec[0]["cds"] == 0 + +# =================================================================== +# D2: Wire CRC Verification +# =================================================================== +class TestCrcWireCorruption: + """Verify that CRC is computed over the received wire bits, not just assumed correct.""" + + def test_green_uncorrupted(self): + m = BissLineEncoder(data_bits=16, position=0xBEEF, line_coding=False, fec_enable=False) + _clock_model(m, len(m._wire_bits), half_period=10) + assert m.faults() == [] + frames = [e for e in m.events() if e["kind"] == "frame"] + assert frames[0]["crc_ok"] is True + + def test_red_corrupted_payload(self): + m = BissLineEncoder(data_bits=16, position=0xBEEF, line_coding=False, fec_enable=False) + # Corrupt one payload bit on the wire + m._wire_bits[5] ^= 1 + _clock_model(m, len(m._wire_bits), half_period=10) + assert m.faults() == [] + frames = [e for e in m.events() if e["kind"] == "frame"] + assert frames[0]["crc_ok"] is False + + def test_green_uncorrupted_default(self): + m = BissLineEncoder(data_bits=32, position=0xBEEF) + _clock_model(m, len(m._wire_bits), half_period=10) + assert m.faults() == [] + frames = [e for e in m.events() if e["kind"] == "frame"] + assert frames[0]["crc_ok"] is True + + def test_red_corrupted_payload_default(self): + m = BissLineEncoder(data_bits=32, position=0xBEEF) + # Corrupt one payload bit on the wire + m._wire_bits[5] ^= 1 + _clock_model(m, len(m._wire_bits), half_period=10) + assert m.faults() == [] + frames = [e for e in m.events() if e["kind"] == "frame"] + assert frames[0]["crc_ok"] is False + +# =================================================================== +# D3: Master Data Corruption Verification +# =================================================================== +class TestMasterDataCorruption: + """Verify that the model observes what the MASTER drives on MO.""" + + def test_green_master_drives_zero(self): + m = BissLineEncoder(data_bits=16, position=0xBEEF, line_coding=False, fec_enable=False) + n_clocks = len(m._wire_bits) + master_data = [0] * n_clocks + _clock_model(m, n_clocks, half_period=10, master_data=master_data) + assert m.faults() == [] + frames = [e for e in m.events() if e["kind"] == "frame"] + assert frames[0]["crc_ok"] is True + + def test_red_master_drives_one(self): + m = BissLineEncoder(data_bits=16, position=0xBEEF, line_coding=False, fec_enable=False) + n_clocks = len(m._wire_bits) + master_data = [0] * n_clocks + # Bit 4 is a 0 in the payload. If master drives 1, it will corrupt the bit to 1. + master_data[4] = 1 + _clock_model(m, n_clocks, half_period=10, master_data=master_data) + assert m.faults() == [] + frames = [e for e in m.events() if e["kind"] == "frame"] + assert frames[0]["crc_ok"] is False, "Master data corruption was not observed!" + + +# =================================================================== +# D7: Wire-level test with 8b/10b and FEC enabled +# =================================================================== +class TestWireLevelCodedFec: + """Verify full 8b/10b and FEC decoding from raw wire bits.""" + + def test_wire_level_decode(self): + from perif.bissline_device_model import decode_8b10b_symbol, hamming74_decode + pos = 0xC0DE + m = BissLineEncoder(data_bits=16, position=pos, line_coding=True, fec_enable=True) + trace = _clock_model(m, m.total_wire_bits, half_period=10) + + # Reconstruct wire bits from trace (sampled on falling edge of MA) + wire_bits = [] + prev_clk = 0 + last_active = None + for cycle, gpo, gpi in trace: + clk = (gpo >> 0) & 1 # ma_pin=0 + data = (gpi >> 1) & 1 # slo_pin=1 + if clk != prev_clk: + if clk == 1: + last_active = cycle + else: + if last_active is not None: + wire_bits.append(data) + prev_clk = clk + + # 1. Decode 8b/10b + decoded_bytes = [] + for i in range(0, len(wire_bits), 10): + sym = 0 + for j in range(10): + sym = (sym << 1) | wire_bits[i+j] + bv = decode_8b10b_symbol(sym) + assert bv is not None + decoded_bytes.append(bv) + + decoded_bits = [] + for bv in decoded_bytes: + for j in range(7, -1, -1): + decoded_bits.append((bv >> j) & 1) + + # 2. Decode FEC + header = decoded_bits[:len(m._coded_bits)][:3] + fec_bits = decoded_bits[:len(m._coded_bits)][3:-8] + pos_bits = [] + for i in range(0, len(fec_bits), 7): + cw = 0 + for j in range(7): + cw = (cw << 1) | fec_bits[i+j] + nibble, corrected, err_det = hamming74_decode(cw) + assert not corrected + for j in range(3, -1, -1): + pos_bits.append((nibble >> j) & 1) + + pos_bits = pos_bits[-16:] # data_bits=16 + + recovered_pos = 0 + for b in pos_bits: + recovered_pos = (recovered_pos << 1) | b + + assert recovered_pos == pos + +class TestUnderclockPair: + def test_green_exact_clocks(self): + m = BissLineEncoder(data_bits=16, position=0, line_coding=False, fec_enable=False) + _clock_model(m, len(m._wire_bits), half_period=10) + assert m.faults() == [] + + def test_red_underclocked(self): + m = BissLineEncoder(data_bits=16, position=0, line_coding=False, fec_enable=False) + _clock_model(m, 3, half_period=10) + assert any("clocked 3 times" in f for f in m.faults()), "underclocked frame not rejected" + +class TestMutation: + """Verify that if the model\'s driven bits are inverted, the frame fails CRC.""" + + def test_mutation_red_inverted_bits(self): + class MutatedEncoder(BissLineEncoder): + def _drive(self) -> int: + if self._shift_index == 0 or self._shift_index > len(self._wire_bits): + return 1 << self.slo_pin + bit = self._wire_bits[self._shift_index - 1] ^ 1 + return bit << self.slo_pin + + m = MutatedEncoder(data_bits=16, position=0xBEEF, line_coding=False, fec_enable=False) + _clock_model(m, len(m._wire_bits), half_period=10) + + frames = [e for e in m.events() if e["kind"] == "frame"] + assert frames[0]["crc_ok"] is False, "Mutation was not caught! crc_ok is True" diff --git a/tests/test_board_variant_builds.py b/tests/test_board_variant_builds.py new file mode 100644 index 0000000..a361fcf --- /dev/null +++ b/tests/test_board_variant_builds.py @@ -0,0 +1,234 @@ +"""every example firmware variant of the configured device must build. + +The am243x-evm PRU0 designs of eight examples held an unwired block catalogue and failed the +generate-time validator ("Required graph input port 'input1' is unconnected"), yet the suite +stayed green because only the am243x-lp variants were ever built. This sweep closes that gap. + +It reads DEVICE from imports.mak, discovers every board folder of that device under +examples/*/firmware/ (am243x -> am243x-evm, am243x-lp, ...) and every _fw that has a +ti-pru-cgt/makefile under each, and builds each one with the real toolchain (SysConfig CLI + clpru through the variant's own makefile). One pytest +case per example/board/core; a failure names all three plus the first error line. Builds run in copies +under a short temp dir (never in the tree, so tracked generated files are not rewritten) and are fanned out +over a thread pool on first use, because 100+ builds one after the other would dominate the suite. + +A _fw directory WITHOUT a makefile cannot be built, so it is never silently dropped: it must be +listed in EXPECTED_UNBUILT with a reason, and the discovery test compares the full on-disk set of +(example, board, core) against built + expected-unbuilt, failing by variant name in either direction. + +Loud skips only: imports.mak absent, or gmake / clpru absent. Nothing passes silently. + +Red arm: one required input left unwired in ONE variant of a temp copy must fail naming that +example/board/core and the validator text. +""" + +import atexit +import concurrent.futures +import glob +import os +import re +import shutil +import subprocess +import sys +import tempfile +import threading + +import pytest +from pathlib import Path + +# Default install location of TI tools, as in imports.mak (TOOLS_PATH). +TI_TOOLS = Path(os.environ.get("TOOLS_PATH", (os.environ.get("SystemDrive", "C:") + "/ti") if os.name == "nt" else Path.home() / "ti")) + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts", "sim"))) +import elf_cosim # noqa: E402 + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +IMPORTS_MAK = os.path.join(ROOT, "imports.mak") +WORKERS = min(6, os.cpu_count() or 2) + + +def _find_gmake(): + for cand in (str(TI_TOOLS / "ccs2100" / "ccs" / "utils" / "bin" / "gmake.exe"), + shutil.which("gmake"), shutil.which("make")): + if cand and os.path.isfile(cand): + return cand + return None + + +def _device(): + with open(IMPORTS_MAK, "r", encoding="utf-8") as f: + m = re.search(r"^\s*DEVICE\s*\??=\s*(\S+)", f.read(), re.M) + return m.group(1) if m else None + + +def discover(examples_root, device): + """(example, board, core) for every _fw with a makefile on any board of `device`.""" + found = [] + for mk in glob.glob(os.path.join(examples_root, "*", "firmware", device + "-*", "*_fw", "ti-pru-cgt", "makefile")): + fw = os.path.dirname(os.path.dirname(mk)) + core = os.path.basename(fw) + board = os.path.basename(os.path.dirname(fw)) + example = os.path.basename(os.path.dirname(os.path.dirname(os.path.dirname(fw)))) + found.append((example, board, core)) + return sorted(found) + + +# _fw dirs that exist on disk for the device but have no ti-pru-cgt makefile, so the sweep cannot +# build them. Each needs a reason; a new makefile-less core, or a listed one that gains a makefile or +# disappears, fails test_discovery_covers_every_core_of_the_device naming the variant. +EXPECTED_UNBUILT = {} + + + +def on_disk_cores(examples_root, device): + """(example, board, core) for every _fw directory of `device`, makefile or not.""" + found = [] + for fw in glob.glob(os.path.join(examples_root, "*", "firmware", device + "-*", "*_fw")): + if os.path.isdir(fw): + found.append((os.path.basename(os.path.dirname(os.path.dirname(os.path.dirname(fw)))), + os.path.basename(os.path.dirname(fw)), os.path.basename(fw))) + return sorted(found) + + +def coverage_problems(on_disk, built, expected_unbuilt): + """Human-readable drift lines, each naming the variant; empty when disk == built + expected.""" + on_disk, built, unbuilt = set(on_disk), set(built), set(expected_unbuilt) + out = [] + out += [f"{'/'.join(v)}: _fw on disk with no makefile and not in EXPECTED_UNBUILT" + for v in sorted(on_disk - built - unbuilt)] + out += [f"{'/'.join(v)}: listed in EXPECTED_UNBUILT but it now has a makefile (remove the entry)" + for v in sorted(unbuilt & built)] + out += [f"{'/'.join(v)}: listed in EXPECTED_UNBUILT but no longer on disk (remove the entry)" + for v in sorted(unbuilt - on_disk)] + out += [f"{'/'.join(v)}: built but not found on disk" for v in sorted(built - on_disk)] + return out + + +def _skip_reason(): + if not os.path.isfile(IMPORTS_MAK): + return "imports.mak absent: no DEVICE to sweep" + if not _find_gmake(): + return "gmake absent: cannot build variants" + try: + elf_cosim.find_clpru() + except RuntimeError: + return "clpru absent: cannot build variants" + if not _device(): + return "imports.mak sets no DEVICE" + return None + + +SKIP_REASON = _skip_reason() +DEVICE = None if SKIP_REASON else _device() +VARIANTS = discover(os.path.join(ROOT, "examples"), DEVICE) if DEVICE else [] +PARAMS = VARIANTS or [("-", "-", "-")] + + +def first_error_line(text): + for line in text.splitlines(): + if re.search(r"\berror\b|\*\*\*", line, re.I) and not re.match(r"\s*\d+ error", line): + return line.strip() + lines = text.strip().splitlines() + return lines[-1] if lines else "(no output)" + + +def build_variant(examples_root, stage_root, example, board, core): + """Build one variant in a copy of its example under stage_root; return (rc, first error line).""" + work = os.path.join(stage_root, example) + with _stage_lock: + if not os.path.isdir(work): + shutil.copytree(os.path.join(examples_root, example), work, + ignore=shutil.ignore_patterns("generated", "syscfg")) + scratch = os.path.join(stage_root, "tmp", f"{example}_{board}_{core}") + os.makedirs(scratch, exist_ok=True) + env = dict(os.environ) + for var in ("TEMP", "TMP"): + env[var] = scratch + fw = os.path.join(work, "firmware", board, core, "ti-pru-cgt") + proc = subprocess.run([_find_gmake(), "PRU_NO_CODE_TOOL_PATH=" + ROOT, "BUILD_MCUPLUS=n", + "-C", fw, "-s", "all"], + capture_output=True, text=True, timeout=600, env=env) + out = proc.stdout + proc.stderr + if proc.returncode == 0 and not glob.glob(os.path.join(fw, "generated", "*.out")): + return 1, "gmake rc=0 but no linked ELF in generated/" + return proc.returncode, first_error_line(out) if proc.returncode else "" + + +def failure_text(example, board, core, line): + return f"{example}/{board}/{core} failed to build: {line}" + + +_stage_lock = threading.Lock() +_results_lock = threading.Lock() +_results = {} + + +def _all_results(): + with _results_lock: + if not _results: + # short temp root on purpose: the longest variant output names already sit within ~20 + # characters of the 260-character Windows path limit when staged under the repo + stage = tempfile.mkdtemp(prefix="n216_") + atexit.register(shutil.rmtree, stage, ignore_errors=True) + ex_root = os.path.join(ROOT, "examples") + with concurrent.futures.ThreadPoolExecutor(WORKERS) as pool: + futs = {v: pool.submit(build_variant, ex_root, stage, *v) for v in VARIANTS} + for v, fut in futs.items(): + _results[v] = fut.result() + return _results + + +def test_discovery_covers_every_core_of_the_device(): + if SKIP_REASON: + pytest.skip(SKIP_REASON) + boards = {b for _, b, _ in VARIANTS} + assert VARIANTS, f"no {DEVICE}-* variants discovered under examples/" + on_disk = {os.path.basename(p) for p in glob.glob(os.path.join(ROOT, "examples", "*", "firmware", DEVICE + "-*")) + if os.path.isdir(p)} + assert boards == on_disk, f"boards with a buildable variant {sorted(boards)} != boards on disk {sorted(on_disk)}" + problems = coverage_problems(on_disk_cores(os.path.join(ROOT, "examples"), DEVICE), VARIANTS, EXPECTED_UNBUILT) + assert not problems, "; ".join(problems) + # empty must be built on every board/core it ships, the case that started assert any(e == "empty" for e, _, _ in VARIANTS) + + +def test_red_coverage_drift_fails_in_both_directions(): + """Red arm for the discovery test: a makefile-less core that is not listed, and a listed entry that + gained a makefile or vanished, must each fail naming the variant; the exact tree must pass.""" + disk = [("a", "b1", "c0_fw"), ("a", "b1", "c1_fw")] + assert coverage_problems(disk, [disk[0]], {disk[1]: "r"}) == [] + new = coverage_problems(disk, [disk[0]], {}) + assert len(new) == 1 and "a/b1/c1_fw" in new[0] and "not in EXPECTED_UNBUILT" in new[0] + gained = coverage_problems(disk, disk, {disk[1]: "r"}) + assert len(gained) == 1 and "a/b1/c1_fw" in gained[0] and "now has a makefile" in gained[0] + gone = coverage_problems([disk[0]], [disk[0]], {disk[1]: "r"}) + assert len(gone) == 1 and "a/b1/c1_fw" in gone[0] and "no longer on disk" in gone[0] + + +@pytest.mark.parametrize("example,board,core", PARAMS, ids=["/".join(p) for p in PARAMS]) +def test_variant_builds(example, board, core): + if SKIP_REASON: + pytest.skip(SKIP_REASON) + rc, line = _all_results()[(example, board, core)] + assert rc == 0, failure_text(example, board, core, line) + + +def test_red_unwired_input_is_reported_with_its_name(tmp_path): + """Red arm: a crc_block with an unwired required input injected into ONE variant must fail the + build naming that example/board/core and the validator's own text.""" + if SKIP_REASON: + pytest.skip(SKIP_REASON) + example, board, core = "empty", DEVICE + "-lp", "icss_g0_pru0_fw" + if (example, board, core) not in VARIANTS: + pytest.skip(f"{example}/{board}/{core} not shipped; red arm needs one variant to sabotage") + ex_root = tmp_path / "examples" + shutil.copytree(os.path.join(ROOT, "examples", example), ex_root / example, + ignore=shutil.ignore_patterns("generated", "syscfg")) + syscfg = ex_root / example / "firmware" / board / core / "example.syscfg" + src = syscfg.read_text(encoding="utf-8") + inject = ('\nconst mod_red = scripting.addModule("/pru_blocks/application_specific/crc_block", {}, false);\n' + 'const block_red = mod_red.addInstance();\nblock_red.$name = "Red_Unwired_Crc";\n') + syscfg.write_text(src + inject, encoding="utf-8") + rc, line = build_variant(str(ex_root), str(tmp_path / "stage"), example, board, core) + text = failure_text(example, board, core, line) + assert rc != 0, "an unwired required input must fail the build, it built" + assert f"{example}/{board}/{core}" in text + assert "Required graph input port 'input1' is unconnected" in text, text diff --git a/tests/test_crc_lut_index_scaling.py b/tests/test_crc_lut_index_scaling.py new file mode 100644 index 0000000..e1cd02a --- /dev/null +++ b/tests/test_crc_lut_index_scaling.py @@ -0,0 +1,276 @@ +"""crc_block CRC-16/CRC-32 table indexing must be byte-scaled. + +The CRC-16/CRC-32 macros used the raw table index (0-255, TEMP_REG2.b0) as +the LBBO byte offset, but the LUT entries are 2/4 bytes wide, so any non-zero +index read the wrong bytes (observed on the TI pru-simulator: CRC-16 poly +0x8005 init 0 over ff ff gave 0x8004 instead of 0x800D, etc.). + +The oracle in this test is the bit-serial CRC written below from the +polynomial-division definition (crc_bit_serial). It is deliberately +independent of the block's lookup table (crc_block.syscfg.js +getLookUptable), the block's JS model and the repo's JS simulator CRC. + +Arms: + * catalogue: the oracle reproduces the published check values of + CRC-16/XMODEM, CRC-16/BUYPASS (0x8005 init 0, no reflection) and + CRC-32/MPEG-2 - runs everywhere, no toolchain. + * static: the emitted macro text scales the index (zero-extend, shift, + ADD into the base) and zeroes the LBBO offset register, while CRC-8 + (1-byte entries) keeps its direct index - runs everywhere. + * headless: the real emitted macro + emitted LUT run on the repo's + instruction simulator (scripts/sim/crc_dut.js, DUT only) must + match the oracle on vectors whose table index is non-zero at EVERY + step - runs wherever node exists. + * ti_sim: same vectors on the real clpru-assembled macro running on the + TI pru-simulator - SKIPs with a reason when the simulator + (PRU_SIMULATOR_PATH / tools/pru-simulator-upstream) or clpru + (CGT_TI_PRU_PATH) is unavailable; it must never silently pass. +""" + +import json +import os +import re +import subprocess +import sys +import tempfile + +import pytest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(ROOT, "scripts", "sim")) + +BLOCK_JS = os.path.join( + ROOT, ".metadata/sysconfig/.meta/pru_blocks/application_specific/crc_block.syscfg.js") +DUT_JS = os.path.join(ROOT, "scripts", "sim", "crc_dut.js") + + +# --------------------------------------------------------------------------- +# oracle: bit-serial CRC from the definition (NOT the block's table/model) +# --------------------------------------------------------------------------- + +def crc_bit_serial(data, poly, width, init): + """MSB-first polynomial division, written from the definition: + crc = remainder of (init * x^n + data) * x^width divided by the + generator, i.e. XOR the (width-8)-shifted byte into the top of the + register, then 8 times: shift left, XOR the generator when the bit + shifted out was 1. No reflection, no xorout - exactly what the block + documents.""" + top = 1 << (width - 1) + mask = (1 << width) - 1 + crc = init & mask + for byte in data: + crc ^= (byte & 0xFF) << (width - 8) + for _ in range(8): + crc = ((crc << 1) ^ poly) & mask if crc & top else (crc << 1) & mask + return crc + + +def every_table_index_nonzero(data, poly, width, init): + """True when (crc >> (width-8)) ^ byte is non-zero at every step, i.e. + the run actually exercises the scaled-offset path.""" + crc = init & ((1 << width) - 1) + for byte in data: + if (((crc >> (width - 8)) ^ byte) & 0xFF) == 0: + return False + crc = crc_bit_serial([byte], poly, width, crc) + return True + + +# name -> (opCode, poly, width, init) +CONFIGS = { + "xmodem": ("m_calculate_crc16", 0x1021, 16, 0x0000), + "buypass_8005": ("m_calculate_crc16", 0x8005, 16, 0x0000), + "ccitt_false": ("m_calculate_crc16", 0x1021, 16, 0xFFFF), + "crc32_mpeg2": ("m_calculate_crc32", 0x04C11DB7, 32, 0xFFFFFFFF), + "crc32_zero_init": ("m_calculate_crc32", 0x04C11DB7, 32, 0x00000000), +} + +# every input here has a non-zero table index on EVERY step (asserted below) +VECTORS = [ + ("xmodem", b"123456789", 0x31C3), # CRC-16/XMODEM check + ("buypass_8005", b"123456789", 0xFEE8), # CRC-16/BUYPASS check + ("crc32_mpeg2", b"123456789", 0x0376E6E7), # CRC-32/MPEG-2 check + ("buypass_8005", bytes([0xFF, 0xFF]), 0x800D), # TI-sim observation + ("buypass_8005", bytes([0x31, 0x32]), 0xA6AC), # TI-sim observation + ("ccitt_false", b"1234", 0x5349), # TI-sim observation + ("crc32_zero_init", bytes([0xFF, 0xFF]), 0xFF48647D), # TI-sim obs. + ("crc32_mpeg2", b"1234", 0xA695C4AA), # TI-sim observation +] + + +# --------------------------------------------------------------------------- +# arm 1: the oracle reproduces the catalogue check values +# --------------------------------------------------------------------------- + +def test_oracle_matches_catalogue_check_values(): + # Williams' catalogue (rev 2021-06-28): CRC-16/XMODEM, CRC-16/BUYPASS, + # CRC-32/MPEG-2 - all MSB-first, no reflection, no xorout. + for cfg, data, check in VECTORS: + _op, poly, width, init = CONFIGS[cfg] + assert crc_bit_serial(data, poly, width, init) == check, (cfg, data) + + +def test_vectors_hit_a_nonzero_table_index_on_every_step(): + for cfg, data, _check in VECTORS: + _op, poly, width, init = CONFIGS[cfg] + assert every_table_index_nonzero(data, poly, width, init), (cfg, data) + + +# --------------------------------------------------------------------------- +# arm 2: static check of the emitted macro text +# --------------------------------------------------------------------------- + +def _macro_body(src, name): + m = re.search(r'startsWith\("%s"\)\)\s*\{\s*//.*?macroBody \+= `(.*?)`;' % name, + src, re.DOTALL) + if m is None: # fall back without the explanatory comment + m = re.search(r'startsWith\("%s"\)\)\s*\{\s*macroBody \+= `(.*?)`;' % name, + src, re.DOTALL) + assert m, "macro body for %s not found" % name + return m.group(1) + + +def test_crc16_and_crc32_macros_scale_the_lut_index(): + src = open(BLOCK_JS, encoding="utf-8").read() + for name, shift in (("m_calculate_crc16", 1), ("m_calculate_crc32", 2)): + body = _macro_body(src, name) + # index is zero-extended then scaled by the entry width ... + assert re.search(r"AND\s+TEMP_REG2, TEMP_REG2, 0xFF", body), name + assert re.search(r"LSL\s+TEMP_REG2, TEMP_REG2, %d" % shift, body), name + # ... added into the LBBO base (a scaled CRC-32 offset, up to 1020, + # does not fit in a .b0 operand) ... + assert re.search(r"ADD\s+TEMP_REG1, TEMP_REG1, TEMP_REG2", body), name + # ... and the offset register is zeroed so the LBBO adds offset 0. + assert re.search(r"LDI\s+TEMP_REG2, 0\s*\n\s*LBBO\s+&\$\{crcResult\}, " + r"TEMP_REG1, TEMP_REG2\.b0, %d" % (shift * 2), body), name + + +def test_crc8_macro_is_unchanged_direct_index(): + # 1-byte entries: the raw index IS the byte offset; no scaling allowed. + src = open(BLOCK_JS, encoding="utf-8").read() + body = _macro_body(src, "m_calculate_crc8") + assert "LBBO &${crcResult}, TEMP_REG1, TEMP_REG2.b0, 1" in body + assert "ADD" not in body and "LSL" not in body + + +def test_reported_cycle_counts_cover_the_scaling_instructions(): + # LDI32(2) + LSR + XOR + AND + LSL + ADD + LDI + LBBO(3) + LSL + XOR = 13 + src = open(BLOCK_JS, encoding="utf-8").read() + tail = src[src.index('name: "noOfCycles"'):] + for mode, want in (("m_calculate_crc8", 6), ("m_calculate_crc16", 13), + ("m_calculate_crc32", 13)): + m = re.search(r'if\(inst\["opCode"\] == "%s"\)\s*\{\s*return (\d+)\s*\}' + % mode, tail) + assert m, mode + assert int(m.group(1)) == want, (mode, m.group(1)) + + +# --------------------------------------------------------------------------- +# arm 3: run the emitted macro + emitted LUT on the repo headless simulator +# (DUT only - the expected values come from crc_bit_serial above) +# --------------------------------------------------------------------------- + +def _run_dut(op_code, poly, init, data): + spec = {"mode": "run", "opCode": op_code, "crcPolynomial": poly, + "init": init, "bytes": list(data)} + r = subprocess.run(["node", DUT_JS, json.dumps(spec)], + capture_output=True, text=True, timeout=120, check=True) + out = json.loads(r.stdout) + assert out["unknownInstruction"] == 0 and out["errorParsing"] == 0, out + return out["result"] + + +HAVE_NODE = subprocess.run(["node", "--version"], capture_output=True).returncode == 0 + + +@pytest.mark.skipif(not HAVE_NODE, + reason="node not available to run the headless DUT driver") +@pytest.mark.parametrize("cfg,data,expected", VECTORS) +def test_headless_macro_matches_bit_serial_oracle(cfg, data, expected): + op_code, poly, width, init = CONFIGS[cfg] + assert _run_dut(op_code, poly, init, data) == expected + # and the oracle agrees with itself for the same config (belt and braces) + assert crc_bit_serial(data, poly, width, init) == expected + + +def test_emitted_table_little_endian_at_entry_stride(): + """The table emitter must store entries little-endian at 2/4-byte stride + for LBBO of 2/4 bytes to read them back (task 2).""" + r = subprocess.run( + ["node", DUT_JS, json.dumps({"mode": "table", "opCode": "m_calculate_crc32", + "crcPolynomial": 0x04C11DB7})], + capture_output=True, text=True, timeout=120, check=True) + lut = json.loads(r.stdout)["lut"] + assert len(lut) == 256 + assert (lut[1] & 0xFFFFFFFF) == 0x04C11DB7 # entry 1 = poly (x^32) + assert (lut[0] & 0xFFFFFFFF) == 0 + + +# --------------------------------------------------------------------------- +# arm 4: real clpru ELF on the TI pru-simulator (skips without toolchain) +# --------------------------------------------------------------------------- + +try: + import elf_cosim + _SIM = elf_cosim.find_pru_simulator() + _CLPRU = True + try: + elf_cosim.find_clpru() + except RuntimeError: + _CLPRU = False +except RuntimeError: + _SIM = None + _CLPRU = False + import elf_cosim # noqa: F401 (still importable) + +needs_ti = pytest.mark.skipif( + _SIM is None or not _CLPRU, + reason="TI pru-simulator (set PRU_SIMULATOR_PATH) and/or clpru " + "(set CGT_TI_PRU_PATH) unavailable - cannot run the assembled macro") + + +class _Idle: + def tick(self, _cycle, _gpo): + return 0 + + +def _ti_run_byte(op_code, poly, init, byte, width): + """Assemble the block's emitted macro + emitted LUT into one real ELF + (table in .data -> PRU0 DMEM), run one byte on the TI simulator.""" + spec = { + "block": "crc_block", "subdir": "application_specific", + "macroCall": "", "opCode": op_code, + "macroArgs": {"crcResult": "R10", "crcInit": "R4", + "dataByte": "R5", "lutAddress": "lut"}, + "prelude": ["ldi32 R4, %d" % (init & 0xFFFFFFFF), + "ldi32 R5, %d" % byte], + "postlude": ["halt"], + } + with tempfile.TemporaryDirectory(prefix="case_") as wd: + asm = elf_cosim.expand_block_asm(spec, wd) + # append the block's own emitted table so the firmware under test + # carries exactly the LUT the tool generates + r = subprocess.run( + ["node", DUT_JS, json.dumps({"mode": "table", "opCode": op_code, + "crcPolynomial": poly})], + capture_output=True, text=True, timeout=120, check=True) + values = [v & 0xFFFFFFFF for v in json.loads(r.stdout)["lut"]] + directive = ".ushort" if width == 16 else ".uint" + lut_asm = ('\n\t.sect ".data"\nlut\t%s %s\n' + % (directive, ", ".join(str(v) for v in values))) + asm = asm.replace(" .end", lut_asm + " .end") + elf = elf_cosim.compile_asm(asm, wd, "crc") + res = elf_cosim.run_elf_closed_loop(elf, _Idle(), max_steps=200) + assert res.halted, "PRU did not halt" + return res.registers[10] & ((1 << width) - 1) + + +@needs_ti +@pytest.mark.parametrize("cfg,data,expected", VECTORS) +def test_ti_simulator_macro_matches_bit_serial_oracle(cfg, data, expected): + op_code, poly, width, init = CONFIGS[cfg] + crc = init + for byte in data: # chain byte-by-byte like the design + crc = _ti_run_byte(op_code, poly, crc, byte, width) + assert crc == expected + assert crc_bit_serial(data, poly, width, init) == expected diff --git a/tests/test_fanout_register_alias.py b/tests/test_fanout_register_alias.py new file mode 100644 index 0000000..08050a0 --- /dev/null +++ b/tests/test_fanout_register_alias.py @@ -0,0 +1,332 @@ +"""the register allocator must not free a value that still has a data consumer. + +Reported by the end-to-end agent: a 5-stage gray-to-binary cascade (g ^= g >> 1, 2, 4, 8, 16) built from +Bitwise blocks "combined the value with itself". Reproduced 2026-10-07 with the real generated asm: + + LDI32 R0, 1515870810 ; G0 + LSR R0, R0 , R1.b0 ; Shr_1 - destination R0 == G0, G0 is gone + XOR R3, R0 , R0 ; Xor_1 - both operands are now (G0 >> 1): result is always 0 + +Cause (pru_register_allocator.js pushInstruction): output1ArrayLength is meant to be the number of DATA consumers +of a block, and the register is released when it reaches 0, but pushInstruction decremented it on EVERY visit, +including the walk back along the control-flow `prev` chain. G0 was visited once via Shift_1.prev (not a +consumer), so its count reached 0 when its FIRST data consumer (Shr_1) was processed and the register was released +before Shr_1's own destination was allocated - Shr_1 got R0 and Xor_1 then read the clobbered value. It stays latent +whenever the freed bytes are too small / not adjacent for the consumer's destination, which is why a 2-byte constant +feeding a 4-byte shift did not show it. + +Fix: a release additionally requires dataConsumersRemaining == 0 (exact count of unread data connections), so the +allocator can only release LESS than before. The same over-decrement also makes single-consumer chains leak +registers (count goes negative); that is deliberately NOT changed here: read intermediate registers at +HALT and depend on dead values never being reused. Tracked as a separate follow-up (done in those tests now +sample each value at the macro that produced it, and the chains release correctly). + +Evidence is the real clpru ELF stepped on TI pru-simulator against an independent Python oracle. The harness is +itself proven able to see the defect (test_harness_detects_aliased_asm): hand-written aliased asm gives 0. +Tests skip cleanly when clpru, the SysConfig 1.28 CLI or the pru-simulator checkout is missing. +""" + +import concurrent.futures +import os +import re +import shutil +import struct +import subprocess +import sys +import tempfile + +import pytest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(REPO_ROOT, "scripts", "sim")) + +import elf_cosim # noqa: E402 + +FW = os.path.join(REPO_ROOT, "examples", "ssi_encoder_sim", "firmware") +MAIN_ASM = os.path.join(FW, "main.asm") +LINKER_CMD = os.path.join(REPO_ROOT, "examples", "gray_encoder_4_bit", "firmware", "am243x-lp", + "icss_g0_pru0_fw", "ti-pru-cgt", "linker.cmd") +PRODUCT_JSON = os.path.join(REPO_ROOT, ".metadata", "product.json") + +SYSCFG_DIR = os.environ.get("SYSCFG_PATH", "C:/ti/sysconfig_1.28.0") +SYSCFG_NODE = os.path.join(SYSCFG_DIR, "nodejs", "node.exe" if os.name == "nt" else "node") +if not os.path.exists(SYSCFG_NODE): + SYSCFG_NODE = os.path.join(SYSCFG_DIR, "nodejs", "node") +SYSCFG_CLI = os.path.join(SYSCFG_DIR, "dist", "cli.js") + +try: + CLPRU = elf_cosim.find_clpru() + SIMDIR = elf_cosim.find_pru_simulator() + HAVE_TOOLS = os.path.exists(SYSCFG_CLI) and os.path.exists(SYSCFG_NODE) +except RuntimeError: + HAVE_TOOLS = False +# The ELF arms link against the ssi_encoder_sim main.asm and the gray_encoder_4_bit linker command file. +HAVE_EXAMPLES = os.path.exists(MAIN_ASM) and os.path.exists(LINKER_CMD) +HAVE_TOOLS = HAVE_TOOLS and HAVE_EXAMPLES + +needs_tools = pytest.mark.skipif( + not HAVE_TOOLS, reason="clpru, SysConfig 1.28 CLI, pru-simulator or the ssi_encoder_sim / gray_encoder_4_bit example sources missing") + +MASK = 0xFFFFFFFF +VALUE = 0x5A5A5A5A +SHIFTS = (1, 2, 4, 8, 16) + +HEADER = ('/**\n * @cliArgs --device "AM243x_ALX_beta" --part "ALX" --package "ALX" --context "icss_g0_pru0"' + ' --product "PRU_NO_CODE_TOOL@01.00.01"\n */\n' + 'const pru_blocks_static_module = scripting.addModule("/pru_blocks/common/pru_blocks_static_module");\n' + 'const bitwise_block = scripting.addModule("/pru_blocks/data_handling/bitwise_block", {}, false);\n' + 'const arithmetic_block = scripting.addModule("/pru_blocks/data_handling/arithmetic_block", {}, false);\n' + 'const load_constant_block = scripting.addModule("/pru_blocks/data_handling/load_constant_block", {}, false);\n') + + +# ---------------------------------------------------------------- independent oracles +def gray_decode(g, shifts=SHIFTS): + """Gray -> binary by the textbook cascade; no code shared with the design under test.""" + for s in shifts: + g ^= g >> s + return g & MASK + + +def gray_decode_by_prefix_xor(g): + """Second, structurally different oracle: binary bit i = XOR of gray bits i..31.""" + out, acc = 0, 0 + for bit in range(31, -1, -1): + acc ^= (g >> bit) & 1 + out |= acc << bit + return out + + +OPS = { # name -> (module, SysConfig opCode, python reference of op(G, G >> 1)) + "XOR": ("bitwise_block", "XOR", lambda g, s: g ^ s), + "AND": ("bitwise_block", "AND", lambda g, s: g & s), + "OR": ("bitwise_block", "OR", lambda g, s: g | s), + "ADD": ("arithmetic_block", "ADD", lambda g, s: (g + s) & MASK), + "SUB": ("arithmetic_block", "SUB", lambda g, s: (g - s) & MASK), +} + + +# ---------------------------------------------------------------------- designs +def cascade_syscfg(value=VALUE, shifts=SHIFTS): + """The agent's design with NO memory copies: stage k = G_k = G_{k-1} ^ (G_{k-1} >> s_k).""" + n = len(shifts) + L = [HEADER, "const g0 = load_constant_block.addInstance();"] + for i in range(n): + L += ["const sh%d = load_constant_block.addInstance();" % i, + "const shr%d = bitwise_block.addInstance();" % i, + "const xor%d = bitwise_block.addInstance();" % i] + L.append('g0.constant1 = %d; g0.$name = "G0";' % value) + for i, s in enumerate(shifts): + L += ['sh%d.constant1 = %d; sh%d.$name = "Shift_%d";' % (i, s, i, s), + 'shr%d.opCode = "LSR"; shr%d.output1Size = "4"; shr%d.$name = "Shr_%d";' % (i, i, i, s), + 'xor%d.opCode = "XOR"; xor%d.output1Size = "4"; xor%d.$name = "Xor_%d";' % (i, i, i, s)] + prev = "g0" + for i in range(n): + L += ['scripting.connect(shr%d, "input1", %s, "output1");' % (i, prev), + 'scripting.connect(shr%d, "input2", sh%d, "output1");' % (i, i), + 'scripting.connect(xor%d, "input1", %s, "output1");' % (i, prev), + 'scripting.connect(xor%d, "input2", shr%d, "output1");' % (i, i)] + prev = "xor%d" % i + seq = ["g0"] + ["sh%d" % i for i in range(n)] + for i in range(n): + seq += ["shr%d" % i, "xor%d" % i] + L += ['scripting.connect(%s, "next", %s, "prev");' % (a, b) for a, b in zip(seq, seq[1:])] + return "\n".join(L) + "\n", "Xor_%d" % shifts[-1] + + +def fanout_syscfg(op, value=VALUE): + """G feeds a shift (G >> 1) and also the second block directly: out = op(G, G >> 1).""" + module, opcode, _ = OPS[op] + size = 'output1Size = "4"' + return ("".join([ + HEADER, + "const g0 = load_constant_block.addInstance();\n", + "const sh = load_constant_block.addInstance();\n", + "const shr = bitwise_block.addInstance();\n", + "const comb = %s.addInstance();\n" % module, + 'g0.constant1 = %d; g0.$name = "G0";\n' % value, + 'sh.constant1 = 1; sh.$name = "Shift_1";\n', + 'shr.opCode = "LSR"; shr.%s; shr.$name = "Shr_1";\n' % size, + 'comb.opCode = "%s"; comb.%s; comb.$name = "Combine";\n' % (opcode, size), + 'scripting.connect(shr, "input1", g0, "output1");\n', + 'scripting.connect(shr, "input2", sh, "output1");\n', + 'scripting.connect(comb, "input1", g0, "output1");\n', + 'scripting.connect(comb, "input2", shr, "output1");\n', + 'scripting.connect(g0, "next", sh, "prev");\n', + 'scripting.connect(sh, "next", shr, "prev");\n', + 'scripting.connect(shr, "next", comb, "prev");\n', + ]), "Combine") + + +def triple_use_syscfg(value=VALUE): + """G is read by THREE blocks: out = (G ^ (G >> 1)) + G. The last read must still see G.""" + return ("".join([ + HEADER, + "const g0 = load_constant_block.addInstance();\n", + "const sh = load_constant_block.addInstance();\n", + "const shr = bitwise_block.addInstance();\n", + "const x = bitwise_block.addInstance();\n", + "const add = arithmetic_block.addInstance();\n", + 'g0.constant1 = %d; g0.$name = "G0";\n' % value, + 'sh.constant1 = 1; sh.$name = "Shift_1";\n', + 'shr.opCode = "LSR"; shr.output1Size = "4"; shr.$name = "Shr_1";\n', + 'x.opCode = "XOR"; x.output1Size = "4"; x.$name = "Xor_1";\n', + 'add.opCode = "ADD"; add.output1Size = "4"; add.$name = "Add_G";\n', + 'scripting.connect(shr, "input1", g0, "output1");\n', + 'scripting.connect(shr, "input2", sh, "output1");\n', + 'scripting.connect(x, "input1", g0, "output1");\n', + 'scripting.connect(x, "input2", shr, "output1");\n', + 'scripting.connect(add, "input1", x, "output1");\n', + 'scripting.connect(add, "input2", g0, "output1");\n', + 'scripting.connect(g0, "next", sh, "prev");\n', + 'scripting.connect(sh, "next", shr, "prev");\n', + 'scripting.connect(shr, "next", x, "prev");\n', + 'scripting.connect(x, "next", add, "prev");\n', + ]), "Add_G") + + +# ------------------------------------------------------------------------ build +def _run(cmd, cwd): + res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if res.returncode != 0: + raise RuntimeError("command failed (rc=%d): %s\n%s\n%s" % (res.returncode, cmd, res.stdout, res.stderr)) + return res + + +def _link(work, main_asm, syscfg_asm): + cgt = os.path.dirname(os.path.dirname(CLPRU)) + cflags = ["-v4", "-O2", "--include_path=" + os.path.join(cgt, "include"), "-DPRU0", "-DSLICE0", "-DSOC_AM243X"] + if syscfg_asm: + cflags.append("--include_path=" + os.path.dirname(syscfg_asm)) + _run([CLPRU] + cflags + ["--output_file=main.obj", main_asm], work) + objs = ["main.obj"] + if syscfg_asm: + _run([CLPRU] + cflags + ["--output_file=pru_syscfg.obj", syscfg_asm], work) + objs.append("pru_syscfg.obj") + _run([CLPRU, "-v4", "-O2", "-z"] + objs + [LINKER_CMD, "--warn_sections", "--reread_libs", "--ram_model", + "--entry_point=main", "--disable_auto_rts", "-o", "fw.out"], work) + with open(os.path.join(work, "fw.out"), "rb") as f: + return f.read() + + +def build_design(text, result_block): + """SysConfig CLI -> clpru -> link. Returns (elf, asm_text, result_register_number).""" + work = tempfile.mkdtemp(prefix="case_") + try: + with open(os.path.join(work, "design.syscfg"), "w", encoding="utf-8") as f: + f.write(text) + _run([SYSCFG_NODE, SYSCFG_CLI, "--product", PRODUCT_JSON, "--device", "AM243x_ALX_beta", + "--context", "icss_g0_pru0", "--part", "ALX", "--package", "ALX", + "--output", "syscfg", "design.syscfg"], work) + asm_path = os.path.join(work, "syscfg", "pru_syscfg.asm") + with open(asm_path, encoding="utf-8") as f: + asm = f.read() + m = re.search(r";PRU Instruction of %s\s*\n\s*\w+\s+R(\d+)\b" % re.escape(result_block), asm) + assert m, "no instruction for %s in generated asm" % result_block + return _link(work, MAIN_ASM, asm_path), asm, int(m.group(1)) + finally: + shutil.rmtree(work, ignore_errors=True) + + +def run_elf(elf, reg, max_steps=2000): + """Step the real ELF on TI pru-simulator; return (halted, R).""" + entry = struct.unpack_from("> 1) + assert halted + assert got == want, "%s: R%d = 0x%08X, want 0x%08X" % (op, reg, got, want) + assert want not in (0, VALUE), "oracle value must differ from the aliased result" + + +@needs_tools +def test_value_with_three_consumers_survives_to_the_last_read(): + """G is read three times (LSR, XOR, ADD); the register may only be released after the third.""" + elf, asm, reg = cached("triple", lambda: triple_use_syscfg()) + halted, got = run_elf(elf, reg) + want = ((VALUE ^ (VALUE >> 1)) + VALUE) & MASK + assert halted + assert got == want, "R%d = 0x%08X, want 0x%08X" % (reg, got, want) + + +@needs_tools +def test_harness_detects_aliased_asm(): + """Red arm for the HARNESS: the exact defective instruction pair, hand-assembled, gives 0 not G ^ (G>>1).""" + work = tempfile.mkdtemp(prefix="red_") + try: + src = (" .global main\n .sect \".text:main\"\nmain:\n" + " LDI32 R0, %d\n LDI R1.b0, 1\n" + " LSR R0, R0 , R1.b0\n XOR R3, R0 , R0\n halt\n" % VALUE) + path = os.path.join(work, "main.asm") + with open(path, "w") as f: + f.write(src) + elf = _link(work, path, None) + finally: + shutil.rmtree(work, ignore_errors=True) + halted, got = run_elf(elf, 3) + assert halted + assert got == 0, "aliased asm must XOR a value with itself and give 0, got 0x%08X" % got + assert got != VALUE ^ (VALUE >> 1) diff --git a/tests/test_hardware_loop_nesting.py b/tests/test_hardware_loop_nesting.py new file mode 100644 index 0000000..643056a --- /dev/null +++ b/tests/test_hardware_loop_nesting.py @@ -0,0 +1,213 @@ +"""reject nested PRU hardware LOOP instructions. + +The production guard is SysConfig validation. Its red arm submits an outer +Loop containing Delay(2), asserts the exact diagnostic, and therefore proves +the invalid firmware cannot be generated. The independent hardware fact is +also exercised with a real clpru ELF on TI's pru-simulator: a nested hardware +LOOP increments the outer-body counter once, not its requested three times. +""" + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts" / "sim")) +import elf_cosim + +# Default install location of TI tools, as in imports.mak (TOOLS_PATH). +TI_TOOLS = Path(os.environ.get("TOOLS_PATH", (os.environ.get("SystemDrive", "C:") + "/ti") if os.name == "nt" else Path.home() / "ti")) + +SYSCONFIG_NODE = Path(os.environ.get("SYSCONFIG_NODE", str(TI_TOOLS / "sysconfig_1.28.0" / "nodejs" / "node.exe"))) +SYSCONFIG_CLI = Path(os.environ.get("SYSCONFIG_CLI", str(TI_TOOLS / "sysconfig_1.28.0" / "dist" / "cli.js"))) +PRODUCT = ROOT / ".metadata" / "product.json" +HAVE_SYSCONFIG = SYSCONFIG_NODE.exists() and SYSCONFIG_CLI.exists() + +try: + elf_cosim.find_clpru() + elf_cosim.find_pru_simulator() + HAVE_ELF = True +except RuntimeError: + HAVE_ELF = False + + +def _syscfg_text(delay_count): + return "\n".join([ + '// @cliArgs --device AM243x_ALX_beta --package ALX --part ALX --context icss_g0_pru0 --product PRU_NO_CODE_TOOL@01.00.01', + 'const loop = scripting.addModule("/pru_blocks/program_control/loop_block", {}, false);', + 'const delay = scripting.addModule("/pru_blocks/utils/delay_block", {}, false);', + 'const OUTER_LOOP = loop.addInstance();', + 'const INNER_DELAY = delay.addInstance();', + 'OUTER_LOOP.$name = "OUTER_LOOP";', + 'OUTER_LOOP.loopCount = 3;', + 'INNER_DELAY.$name = "INNER_DELAY";', + f'INNER_DELAY.delayCount = {delay_count};', + 'OUTER_LOOP.$groupContents = [INNER_DELAY];', + '', + ]) + + +def _run_sysconfig(tmp_path, delay_count): + script = tmp_path / "nested.syscfg" + script.write_text(_syscfg_text(delay_count), encoding="utf-8") + env = dict(os.environ) + return subprocess.run( + [str(SYSCONFIG_NODE), str(SYSCONFIG_CLI), "--product", str(PRODUCT), + "--device", "AM243x_ALX_beta", "--package", "ALX", "--part", "ALX", + "--script", str(script), "--context", "icss_g0_pru0", + "--output", str(tmp_path / "out")], + cwd=ROOT, env=env, text=True, capture_output=True) + + +@pytest.mark.skipif(not HAVE_SYSCONFIG, reason="SysConfig CLI missing") +def test_delay_one_inside_loop_is_safe_nop(tmp_path): + """The guard is precise: Delay(1) is a NOP, not a hardware LOOP.""" + result = _run_sysconfig(tmp_path, 1) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.skipif(not HAVE_SYSCONFIG, reason="SysConfig CLI missing") +def test_red_arm_nested_delay_hardware_loop_is_rejected(tmp_path): + """RED ARM: Delay(2) must fail for the named nesting hazard.""" + result = _run_sysconfig(tmp_path, 2) + text = result.stdout + result.stderr + assert result.returncode != 0, "red arm unexpectedly generated nested hardware LOOP firmware" + assert "PRU hardware LOOP instructions cannot nest: INNER_DELAY emits LOOP inside OUTER_LOOP" in text, text + + +@pytest.mark.skipif(not HAVE_SYSCONFIG, reason="SysConfig CLI missing") +def test_red_arm_nested_fixed_loop_is_rejected(tmp_path): + """The same capability guard covers every current LOOP emitter, including Loop.""" + script = tmp_path / "nested_loop.syscfg" + script.write_text(_syscfg_text(1).replace( + 'const delay = scripting.addModule("/pru_blocks/utils/delay_block", {}, false);', + 'const delay = scripting.addModule("/pru_blocks/program_control/loop_block", {}, false);').replace( + 'const INNER_DELAY = delay.addInstance();', 'const INNER_LOOP = delay.addInstance();').replace( + 'INNER_DELAY.$name = "INNER_DELAY";', 'INNER_LOOP.$name = "INNER_LOOP";').replace( + 'INNER_DELAY.delayCount = 1;', 'INNER_LOOP.loopCount = 2;').replace( + 'OUTER_LOOP.$groupContents = [INNER_DELAY];', 'OUTER_LOOP.$groupContents = [INNER_LOOP];'), encoding="utf-8") + env = dict(os.environ) + result = subprocess.run( + [str(SYSCONFIG_NODE), str(SYSCONFIG_CLI), "--product", str(PRODUCT), + "--device", "AM243x_ALX_beta", "--package", "ALX", "--part", "ALX", + "--script", str(script), "--context", "icss_g0_pru0", + "--output", str(tmp_path / "out")], + cwd=ROOT, env=env, text=True, capture_output=True) + text = result.stdout + result.stderr + assert result.returncode != 0, "red arm unexpectedly generated nested fixed Loop firmware" + assert "PRU hardware LOOP instructions cannot nest: INNER_LOOP emits LOOP inside OUTER_LOOP" in text, text + + +def _run_text(tmp_path, text, name): + script = tmp_path / f"{name}.syscfg" + script.write_text(text, encoding="utf-8") + env = dict(os.environ) + return subprocess.run( + [str(SYSCONFIG_NODE), str(SYSCONFIG_CLI), "--product", str(PRODUCT), + "--device", "AM243x_ALX_beta", "--package", "ALX", "--part", "ALX", + "--script", str(script), "--context", "icss_g0_pru0", + "--output", str(tmp_path / "out")], + cwd=ROOT, env=env, text=True, capture_output=True) + + +_HEADER = '// @cliArgs --device AM243x_ALX_beta --package ALX --part ALX --context icss_g0_pru0 --product PRU_NO_CODE_TOOL@01.00.01\n' +_MODULES = ( + 'const loop = scripting.addModule("/pru_blocks/program_control/loop_block", {}, false);\n' + 'const delay = scripting.addModule("/pru_blocks/utils/delay_block", {}, false);\n') + + +@pytest.mark.skipif(not HAVE_SYSCONFIG, reason="SysConfig CLI missing") +def test_green_infinite_loop_containing_delay_is_accepted(tmp_path): + """GREEN ARM: an infinite Loop emits QBA, not LOOP, so Delay(2) inside is legal.""" + result = _run_text(tmp_path, _HEADER + _MODULES + "\n".join([ + 'const OUTER = loop.addInstance();', + 'const D = delay.addInstance();', + 'OUTER.$name = "OUTER";', + 'OUTER.infiniteLoop = true;', + 'D.$name = "D";', + 'D.delayCount = 2;', + 'OUTER.$groupContents = [D];', '']), "inf_delay") + assert result.returncode == 0, result.stdout + result.stderr + asm = (tmp_path / "out" / "pru_syscfg.asm").read_text() + assert "QBA" in asm and "m_wait_cycles 2" in asm + + +@pytest.mark.skipif(not HAVE_SYSCONFIG, reason="SysConfig CLI missing") +def test_green_infinite_loop_containing_finite_loop_is_accepted(tmp_path): + """GREEN ARM: a finite Loop inside an infinite Loop uses the one LOOP context.""" + result = _run_text(tmp_path, _HEADER + _MODULES + "\n".join([ + 'const OUTER = loop.addInstance();', + 'const INNER = loop.addInstance();', + 'OUTER.$name = "OUTER";', + 'OUTER.infiniteLoop = true;', + 'INNER.$name = "INNER";', + 'INNER.loopCount = 2;', + 'OUTER.$groupContents = [INNER];', '']), "inf_loop") + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.skipif(not HAVE_SYSCONFIG, reason="SysConfig CLI missing") +def test_preinit_delay_is_hoisted_before_the_loop_and_accepted(tmp_path): + """preInitBlocks run once ahead of LOOP, so a Delay(2) there never nests.""" + text = _syscfg_text(2).replace( + 'OUTER_LOOP.$groupContents = [INNER_DELAY];', + 'OUTER_LOOP.$groupContents = [INNER_DELAY];\nOUTER_LOOP.preInitBlocks = ["INNER_DELAY"];') + result = _run_text(tmp_path, text, "preinit") + assert result.returncode == 0, result.stdout + result.stderr + asm = (tmp_path / "out" / "pru_syscfg.asm").read_text() + assert "m_wait_cycles 2" in asm and "LOOP endloop_0" in asm + assert asm.index("m_wait_cycles 2") < asm.index("LOOP endloop_0"), asm + + +@pytest.mark.skipif(not HAVE_SYSCONFIG, reason="SysConfig CLI missing") +def test_non_preinit_delay_still_rejected_when_another_child_is_preinit(tmp_path): + """The preInit exemption is per child, not a blanket pass.""" + text = _syscfg_text(2).replace( + 'const INNER_DELAY = delay.addInstance();', + 'const INNER_DELAY = delay.addInstance();\nconst BODY_DELAY = delay.addInstance();\nBODY_DELAY.$name = "BODY_DELAY";\nBODY_DELAY.delayCount = 3;').replace( + 'OUTER_LOOP.$groupContents = [INNER_DELAY];', + 'OUTER_LOOP.$groupContents = [INNER_DELAY, BODY_DELAY];\nOUTER_LOOP.preInitBlocks = ["INNER_DELAY"];') + result = _run_text(tmp_path, text, "preinit_mixed") + assert result.returncode != 0 + assert "BODY_DELAY emits LOOP inside OUTER_LOOP" in result.stdout + result.stderr + + +class _IdlePins: + def tick(self, _cycle, _pins_out): + return 0 + + +@pytest.mark.skipif(not HAVE_ELF, reason="clpru or TI pru-simulator missing") +def test_real_pru_elf_red_arm_nested_hardware_loop_runs_outer_body_once(tmp_path): + """RED ARM on TI pru-simulator: the unguarded instruction sequence is wrong.""" + asm = "\n".join([ + " .global main", ' .sect ".text"', "main:", + " ldi R0.b0, 3", " ldi R1.b0, 2", " ldi R2, 0", + " loop OUTER_END, R0.b0", " add R2, R2, 1", + " loop INNER_END, R1.b0", " nop", + "INNER_END:", "OUTER_END:", " halt", ""]) + elf = elf_cosim.compile_asm(asm, str(tmp_path), "nested_hardware_loop") + result = elf_cosim.run_elf_closed_loop(elf, _IdlePins(), max_steps=100) + assert result.halted, "nested-loop red arm did not halt" + assert result.registers[2] == 1, ( + "nested PRU hardware LOOP red arm should fall out of the outer loop after " + f"one iteration, got R2={result.registers[2]}") + + +_CONTROL_ASM = "\n".join([ + " .global main", ' .sect ".text"', "main:", + " ldi R0.b0, 3", " ldi R2, 0", + " loop END, R0.b0", " add R2, R2, 1", " nop", + "END:", " halt", ""]) + + +@pytest.mark.skipif(not HAVE_ELF, reason="clpru or TI pru-simulator missing") +def test_real_pru_elf_control_single_loop_runs_outer_body_three_times(tmp_path): + """CONTROL ARM: the same asm without nesting runs R2==3, so R2==1 above is the nesting.""" + elf = elf_cosim.compile_asm(_CONTROL_ASM, str(tmp_path), "single_hardware_loop") + result = elf_cosim.run_elf_closed_loop(elf, _IdlePins(), max_steps=100) + assert result.halted, "single-loop control did not halt" + assert result.registers[2] == 3, f"single hardware LOOP control should run 3 times, got R2={result.registers[2]}" diff --git a/tests/test_load_constant_width.py b/tests/test_load_constant_width.py new file mode 100644 index 0000000..43638de --- /dev/null +++ b/tests/test_load_constant_width.py @@ -0,0 +1,175 @@ +"""Load Constant gets an optional output width (auto / 1 / 2 / 4 bytes). + +Load Constant sized its output only from the value, so a design needing independent 32-bit constants silently had +two small ones packed into one register (red arm: 30 coefficients 0x1000..0x101D came out as +LDI Rn.w0 / Rn.w2 with no warning). outputWidth = "4" now reserves a whole register; the default "auto" is +unchanged, so existing designs generate byte-identical output. + +Evidence: SysConfig CLI generation, and the real clpru ELF on TI pru-simulator. +""" +import os +import re +import shutil +import struct +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +import test_fanout_register_alias as T # noqa: E402 +import test_register_pool_exhaustion as P # noqa: E402 + + +def constants_syscfg(values, width=None, extra=""): + """Independent Load Constants C0..Cn, chained in execution order; `width` None leaves the option unset.""" + L = [T.HEADER] + for i, v in enumerate(values): + L.append('const c%d = load_constant_block.addInstance(); c%d.constant1 = %d; c%d.$name = "C%d";%s' + % (i, i, v, i, i, ' c%d.outputWidth = "%s";' % (i, width) if width else "")) + L.append(extra) + seq = ["c%d" % i for i in range(len(values))] + L += ['scripting.connect(%s, "next", %s, "prev");' % (a, b) for a, b in zip(seq, seq[1:])] + return "\n".join(L) + "\n" + + +def ldi_lines(asm): + return [ln.strip() for ln in asm.splitlines() if ln.strip().startswith("LDI")] + + +def gen(text): + rc, out, asm, work = P.generate(text) + shutil.rmtree(work, ignore_errors=True) + return rc, out, asm + + +REPRESENTATIVE = [0, 0x55, 0xFF, 0x100, 0x1234, 0xFFFF, 0x10000, 0x123456, 0xDEADBEEF] + + +@T.needs_tools +def test_default_auto_output_is_unchanged_for_representative_values(): + """No outputWidth set and outputWidth = "auto" are the same design, and the output is the pre-packing.""" + rc, out, plain = gen(constants_syscfg(REPRESENTATIVE)) + assert rc == 0, out[-600:] + rc, out, auto = gen(constants_syscfg(REPRESENTATIVE, "auto")) + assert rc == 0, out[-600:] + assert ldi_lines(auto) == ldi_lines(plain) + # recorded on e272b120, before the option existed + assert ldi_lines(plain) == [ + "LDI R0.b0, 0", "LDI R0.b1, 85", "LDI R0.b2, 255", "LDI R1.w0, 256", "LDI R1.w2, 4660", + "LDI R2.w0, 65535", "LDI32 R3, 65536", "LDI32 R4, 1193046", "LDI32 R5, 3735928559"] + + +def digest(asm): + return re.search(r"PRU_DESIGN_DIGEST (\w+)", asm).group(1) + + +@T.needs_tools +def test_default_auto_keeps_the_design_digest_and_a_chosen_width_changes_it(): + """The digest in the asm header snapshots every config; "auto" must not enter it, an explicit width must.""" + rc, out, plain = gen(constants_syscfg(REPRESENTATIVE)) + assert rc == 0, out[-600:] + assert digest(plain) == "c3c4dc2bddb07fca" # recorded on e272b120, before the option existed + rc, out, wide = gen(constants_syscfg(REPRESENTATIVE, "4")) + assert rc == 0, out[-600:] + assert digest(wide) != digest(plain), "a design that picks a width is a different design to the TI-run staleness check" + + +@T.needs_tools +def test_explicit_width_4_gives_each_small_constant_its_own_register(): + values = [0x1000 + i for i in range(6)] + rc, out, auto = gen(constants_syscfg(values)) + assert rc == 0, out[-600:] + assert any(re.search(r"\.[bw]\d", ln) for ln in ldi_lines(auto)), "auto must still pack: " + str(ldi_lines(auto)) + rc, out, wide = gen(constants_syscfg(values, "4")) + assert rc == 0, out[-600:] + lines = ldi_lines(wide) + assert lines == ["LDI R%d, %d" % (i, v) for i, v in enumerate(values)], lines + + +@T.needs_tools +def test_explicit_widths_1_and_2_reserve_that_many_bytes(): + rc, out, asm = gen(constants_syscfg([1, 2, 3, 4], "2")) + assert rc == 0, out[-600:] + assert ldi_lines(asm) == ["LDI R0.w0, 1", "LDI R0.w2, 2", "LDI R1.w0, 3", "LDI R1.w2, 4"], ldi_lines(asm) + rc, out, asm = gen(constants_syscfg([1, 2, 3, 4, 5], "1")) + assert rc == 0, out[-600:] + assert ldi_lines(asm)[:5] == ["LDI R0.b0, 1", "LDI R0.b1, 2", "LDI R0.b2, 3", "LDI R0.b3, 4", "LDI R1.b0, 5"] + + +@T.needs_tools +def test_width_smaller_than_the_value_is_a_sysconfig_error(): + rc, out, _asm = gen(constants_syscfg([0x1234], "1")) + assert rc != 0, "a 2-byte constant with Output Width 1 must not generate:\n" + out[-600:] + assert "Constant 4660 needs 2 byte(s) but Output Width is 1 byte(s)" in out, out[-800:] + + +@T.needs_tools +def test_thirty_explicit_4_byte_constants_hit_the_register_budget_with_a_named_error(): + """the red arm: 30 coefficients 0x1000..0x101D as independent 32-bit values.""" + values = [0x1000 + i for i in range(30)] + rc, out, _asm = gen(constants_syscfg(values, "4")) + assert rc != 0, "30 full-register constants cannot fit 28 registers:\n" + out[-600:] + errs = re.findall(r"Out of registers: block '(\w+)' needs 4 byte\(s\) for its result but only 0 of the 112 " + r"allocatable bytes \(R0-R27, 28 registers; R28/R29 are reserved\) are free", out) + assert errs == ["C28", "C29"], "got %s, output: %s" % (errs, out[-1200:]) + # the same 30 values with the default stay silently packed: that is the gap this option closes + rc, out, asm = gen(constants_syscfg(values)) + assert rc == 0 and "Out of registers" not in out + + +@T.needs_tools +def test_elf_two_small_wide_constants_are_independent_registers_on_ti_simulator(): + """Real clpru ELF: two 16-bit-valued 4-byte constants added; each in its own register, sum correct.""" + a, b = 0x1234, 0x0FF1 + text = T.HEADER + "\n".join([ + 'const ca = load_constant_block.addInstance(); ca.$name = "CA"; ca.constant1 = %d; ca.outputWidth = "4";' % a, + 'const cb = load_constant_block.addInstance(); cb.$name = "CB"; cb.constant1 = %d; cb.outputWidth = "4";' % b, + 'const s = arithmetic_block.addInstance(); s.$name = "S"; s.opCode = "ADD"; s.output1Size = "4";', + 'scripting.connect(s, "input1", ca, "output1");', + 'scripting.connect(s, "input2", cb, "output1");', + 'scripting.connect(ca, "next", cb, "prev");', + 'scripting.connect(cb, "next", s, "prev");']) + "\n" + elf, asm, reg = T.build_design(text, "S") + da, _ = T.operands(asm, "CA") + db, _ = T.operands(asm, "CB") + assert da != db and "." not in da and "." not in db, (da, db) + halted, got = T.run_elf(elf, reg, max_steps=500) + assert halted + assert got == (a + b) & T.MASK, "R%d = 0x%X want 0x%X" % (reg, got, a + b) + + +def run_elf_dirty(elf, reg, max_steps=500): + """Like T.run_elf, but R0-R27 start as 0xFFFFFFFF, so a partial (.w0) write would leave the upper half set.""" + entry = struct.unpack_from(" acc(ADD) --\ + k_acc(0, preInit) ---------------> acc } Loop_0 x N --> wr(out_buf) + rd_x --next--> Loop_0 --next--> wr + +Oracle: the VALUE in out_buf after the real clpru ELF runs on the TI +pru-simulator (never the asm text). Expected N*x mod 2^(8n). + +Red at base: the design is rejected at validation (container +boundary error for acc -> wr, plus the unknown exportedBlocks / +accumulators fields), so the value assertion cannot pass. +Green after: out_buf == N*x for N=3 and N=10, x in (1, 7). +""" + +import os +import re +import shutil +import struct +import subprocess +import sys +import tempfile + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +import test_fanout_register_alias as T # noqa: E402 + +elf_cosim = T.elf_cosim + +HDR = (T.HEADER + + 'const memory_variable_block = scripting.addModule("/pru_blocks/utils/memory_variable_block", {}, false);\n' + 'const memory_access_block = scripting.addModule("/pru_blocks/utils/memory_access_block", {}, false);\n' + 'const flow_control_block = scripting.addModule("/pru_blocks/program_control/flow_control_block", {}, false);\n' + 'const loop_block = scripting.addModule("/pru_blocks/program_control/loop_block", {}, false);\n') + +def design_repeat_add(nbytes, count): + """Option-3 repeat-add: k_acc is preInit, acc is bound to it, wr is outside.""" + w = str(nbytes) + L = [HDR, + 'const mv_x = memory_variable_block.addInstance(); mv_x.$name = "mv_x";', + 'mv_x.labelName = "x_buf"; mv_x.sizeInBytes = 4; mv_x.memoryLocation = "dmem";', + 'const mv_out = memory_variable_block.addInstance(); mv_out.$name = "mv_out";', + 'mv_out.labelName = "out_buf"; mv_out.sizeInBytes = 4; mv_out.memoryLocation = "dmem";', + 'const rd_x = memory_access_block.addInstance(); rd_x.$name = "rd_x";', + 'rd_x.operationMode = "read"; rd_x.memVar = mv_x; rd_x.offsetValue = 0; rd_x.dataSize = %s;' % w, + 'const k_acc = load_constant_block.addInstance(); k_acc.$name = "k_acc";', + 'k_acc.constant1 = 0; k_acc.outputWidth = "%s";' % w, + 'const acc = arithmetic_block.addInstance(); acc.$name = "acc";', + 'acc.opCode = "ADD"; acc.output1Size = "%s";' % w, + 'const wr = memory_access_block.addInstance(); wr.$name = "wr";', + 'wr.operationMode = "write"; wr.memVar = mv_out; wr.offsetValue = 0; wr.dataSize = %s;' % w, + 'const lp = loop_block.addInstance(); lp.$name = "Loop_0"; lp.loopCount = %d;' % count, + 'lp.$groupContents = [k_acc, acc];', + 'lp.preInitBlocks = ["k_acc"];', + 'lp.exportedBlocks = ["acc"];', + 'lp.accumulators = \'[{"preInit": "k_acc", "update": "acc"}]\';', + 'scripting.connect(acc, "input1", k_acc, "output1");', + 'scripting.connect(acc, "input2", rd_x, "output1");', + 'scripting.connect(wr, "input1", acc, "output1");', + 'scripting.connect(rd_x, "next", lp, "prev");', + 'scripting.connect(lp, "next", wr, "prev");'] + return "\n".join(L) + "\n" + + +def design_undeclared_export(nbytes=2, count=3): + """Same shape but WITHOUT exportedBlocks/accumulators: must stay rejected (opt-in only).""" + text = design_repeat_add(nbytes, count) + lines = [l for l in text.splitlines() + if "exportedBlocks" not in l and "accumulators" not in l] + return "\n".join(lines) + "\n" + + +def design_second_preinit_reader(): + """A binding is invalid when another loop body block also reads preInit.""" + text = design_repeat_add(2, 3) + text = text.replace( + 'const wr = memory_access_block.addInstance(); wr.$name = "wr";', + 'const chk = arithmetic_block.addInstance(); chk.$name = "chk";\n' + 'chk.opCode = "ADD"; chk.output1Size = "2";\n' + 'const wr = memory_access_block.addInstance(); wr.$name = "wr";') + text = text.replace('lp.$groupContents = [k_acc, acc];', 'lp.$groupContents = [k_acc, acc, chk];') + text = text.replace('scripting.connect(wr, "input1", acc, "output1");', + 'scripting.connect(chk, "input1", k_acc, "output1");\n' + 'scripting.connect(chk, "input2", rd_x, "output1");\n' + 'scripting.connect(wr, "input1", acc, "output1");') + return text + + +def design_size_mismatch(): + """A binding must use exactly the same output width as its preInit.""" + text = design_repeat_add(2, 3) + return text.replace('acc.opCode = "ADD"; acc.output1Size = "2";', + 'acc.opCode = "ADD"; acc.output1Size = "4";').replace( + 'wr.operationMode = "write"; wr.memVar = mv_out; wr.offsetValue = 0; wr.dataSize = 2;', + 'wr.operationMode = "write"; wr.memVar = mv_out; wr.offsetValue = 0; wr.dataSize = 4;') + + +def design_nonexported_preinit_consumer(): + """Exporting acc must not exempt unrelated k_acc from.""" + return design_repeat_add(2, 3).replace('scripting.connect(wr, "input1", acc, "output1");', + 'scripting.connect(wr, "input1", k_acc, "output1");') + + +def design_preloop_export_consumer(): + """An exported value is only valid after its loop in control flow.""" + return design_repeat_add(2, 3).replace( + 'scripting.connect(rd_x, "next", lp, "prev");\n' + 'scripting.connect(lp, "next", wr, "prev");', + 'scripting.connect(rd_x, "next", wr, "prev");\n' + 'scripting.connect(wr, "next", lp, "prev");') + + +def _udmem_addrs(asm, map_text): + """Buffer addresses from build products: the .udmem section origin comes + from the linker map, per-buffer offsets from the asm's .usect reservations + in declaration order (linker allocates them sequentially). The buffers are + not .global, so they never appear in the map's symbol tables.""" + m = re.search(r"^\s*\.udmem\s+\d+\s+([0-9A-Fa-f]+)\s+[0-9A-Fa-f]+", map_text, re.MULTILINE) + assert m, ".udmem section missing from link map" + base = int(m.group(1), 16) + addrs, off = {}, 0 + for line in asm.splitlines(): + u = re.match(r"^(\S+)\s+\.usect\s+\"\.udmem\",\s*(\d+),\s*(\d+)", line) + if u: + name, size, align = u.group(1), int(u.group(2)), int(u.group(3)) + if align > 1: + off = (off + align - 1) // align * align + addrs[name] = base + off + off += size + return addrs + + +def _build(text): + """SysConfig CLI -> clpru -> link with a map. Returns (elf_bytes, asm_text, buf_addrs).""" + work = tempfile.mkdtemp(prefix="case_") + try: + with open(os.path.join(work, "design.syscfg"), "w", encoding="utf-8") as f: + f.write(text) + T._run([T.SYSCFG_NODE, T.SYSCFG_CLI, "--product", T.PRODUCT_JSON, "--device", "AM243x_ALX_beta", + "--context", "icss_g0_pru0", "--part", "ALX", "--package", "ALX", "--output", "syscfg", + "design.syscfg"], work) + asm_path = os.path.join(work, "syscfg", "pru_syscfg.asm") + with open(asm_path, encoding="utf-8") as f: + asm = f.read() + cgt = os.path.dirname(os.path.dirname(T.CLPRU)) + cflags = ["-v4", "-O2", "--include_path=" + os.path.join(cgt, "include"), "-DPRU0", "-DSLICE0", "-DSOC_AM243X"] + cflags.append("--include_path=" + os.path.dirname(asm_path)) + T._run([T.CLPRU] + cflags + ["--output_file=main.obj", T.MAIN_ASM], work) + T._run([T.CLPRU] + cflags + ["--output_file=pru_syscfg.obj", asm_path], work) + T._run([T.CLPRU, "-v4", "-O2", "-z", "main.obj", "pru_syscfg.obj", T.LINKER_CMD, + "--warn_sections", "--reread_libs", "--ram_model", + "--entry_point=main", "--disable_auto_rts", "-o", "fw.out", "-m", "fw.map"], work) + with open(os.path.join(work, "fw.out"), "rb") as f: + elf = f.read() + with open(os.path.join(work, "fw.map"), encoding="utf-8", errors="replace") as f: + map_text = f.read() + return elf, asm, _udmem_addrs(asm, map_text) + finally: + shutil.rmtree(work, ignore_errors=True) + + +def run_repeat_add(nbytes, count, x, steps=6000): + """Build the design, preload x_buf with x, run to halt, return (out_value, asm).""" + elf, asm, addrs = _build(design_repeat_add(nbytes, count)) + assert "x_buf" in addrs and "out_buf" in addrs, "buffers missing from link map: %s" % sorted(addrs) + entry = struct.unpack_from("400 s in SysConfig (a separate time cliff). +""" +import os +import re +import shutil +import subprocess +import sys +import tempfile + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +import test_fanout_register_alias as T # noqa: E402 + +STAGES_OK = 16 +STAGES_TOO_MANY = 24 + + +def constant(k): + return 1000 * k + 7 + + +def expected_sum(n): + """Independent oracle in plain Python: 1 + the n stage constants, mod 2**32.""" + return (1 + sum(constant(k) for k in range(1, n + 1))) & T.MASK + + +def chain_syscfg(n): + """C0, then n stages of (constant C_k, ADD A_k = A_(k-1) + C_k); returns (text, [emitting blocks], last block).""" + L = [T.HEADER, 'const c0 = load_constant_block.addInstance(); c0.constant1 = 1; c0.$name = "C0";'] + seq, prev, blocks = ["c0"], "c0", ["C0"] + for k in range(1, n + 1): + L += ['const c%d = load_constant_block.addInstance(); c%d.constant1 = %d; c%d.$name = "C%d";' + % (k, k, constant(k), k, k), + 'const a%d = arithmetic_block.addInstance(); a%d.opCode = "ADD"; a%d.output1Size = "4"; a%d.$name = "A%d";' + % (k, k, k, k, k), + 'scripting.connect(a%d, "input1", %s, "output1");' % (k, prev), + 'scripting.connect(a%d, "input2", c%d, "output1");' % (k, k)] + seq += ["c%d" % k, "a%d" % k] + prev = "a%d" % k + blocks += ["C%d" % k, "A%d" % k] + L += ['scripting.connect(%s, "next", %s, "prev");' % (a, b) for a, b in zip(seq, seq[1:])] + return "\n".join(L) + "\n", blocks, "A%d" % n + + +def generate(text): + """SysConfig CLI only. Returns (returncode, stdout+stderr, asm text or None, workdir); caller removes workdir.""" + work = tempfile.mkdtemp(prefix="case_") + with open(os.path.join(work, "design.syscfg"), "w", encoding="utf-8") as f: + f.write(text) + res = subprocess.run([T.SYSCFG_NODE, T.SYSCFG_CLI, "--product", T.PRODUCT_JSON, "--device", "AM243x_ALX_beta", + "--context", "icss_g0_pru0", "--part", "ALX", "--package", "ALX", + "--output", "syscfg", "design.syscfg"], cwd=work, capture_output=True, text=True, + timeout=600) + asm_path = os.path.join(work, "syscfg", "pru_syscfg.asm") + asm = None + if os.path.exists(asm_path): + with open(asm_path, encoding="utf-8") as f: + asm = f.read() + return res.returncode, res.stdout + res.stderr, asm, work + + +def emitted_blocks(asm): + """Names of the blocks that have an instruction line in the generated asm, in order.""" + return re.findall(r";PRU Instruction of (\S+)\s*\n\s*\S", asm) + + +def assert_every_block_emitted(asm, expected_blocks): + """Static check usable by any test: every block that should emit an instruction appears in the asm exactly once.""" + got = emitted_blocks(asm) + missing = [b for b in expected_blocks if b not in got] + dup = sorted({b for b in got if got.count(b) > 1 and b in expected_blocks}) + assert not missing, "%d of %d blocks emitted no instruction (silently dropped): %s" % ( + len(missing), len(expected_blocks), ", ".join(missing)) + assert not dup, "blocks emitted more than once: %s" % ", ".join(dup) + + +def test_helper_flags_a_dropped_block(): + """No tools needed: the helper must see a block that is absent from the asm.""" + asm = ";PRU Instruction of A1\n ADD R0, R1, R2\n;PRU Instruction of A3\n ADD R0, R1, R2\n" + assert emitted_blocks(asm) == ["A1", "A3"] + assert_every_block_emitted(asm, ["A1", "A3"]) + with pytest.raises(AssertionError, match=r"1 of 3 blocks emitted no instruction.*A2"): + assert_every_block_emitted(asm, ["A1", "A2", "A3"]) + + +@T.needs_tools +def test_too_many_live_values_fails_generation_naming_the_block(): + """More live values than the pool holds: generation must FAIL with an Out of registers error naming each block. + + a 24-stage running sum no longer exhausts the pool (the allocator now releases a value after its last + consumer), so the exhausting design is 28 independent 4-byte constants - the whole 112-byte pool, all alive - plus + two more 4-byte constants that cannot be placed.""" + extra = ('const x1 = load_constant_block.addInstance(); x1.$name = "X1"; x1.constant1 = 0x20000001;\n' + 'const x2 = load_constant_block.addInstance(); x2.$name = "X2"; x2.constant1 = 0x20000002;\n' + 'scripting.connect(x1, "next", x2, "prev");') + text = full_pool_syscfg(extra, chain_to="x1") + rc, out, _asm, work = generate(text) + shutil.rmtree(work, ignore_errors=True) + assert rc != 0, "generation must FAIL when the register pool is exhausted, got rc 0:\n" + out[-600:] + errs = re.findall(r"Out of registers: block '(\w+)' needs (\d+) byte\(s\) for (its result) but only (\d+) of the " + r"(\d+) allocatable bytes \(R0-R27, 28 registers; R28/R29 are reserved\) are free", out) + assert errs, "expected the Out of registers error, got:\n" + out[-1200:] + want = [("X1", "4", "its result", "0", "112"), ("X2", "4", "its result", "0", "112")] + assert errs == want, "failing blocks/byte counts differ:\n got %s\n want %s" % (errs, want) + assert "Out of registers: block 'X2' needs 4 byte(s)" in out, "the last block (X2) cannot be placed and must be named" + + +@T.needs_tools +def test_twenty_four_stage_running_sum_no_longer_exhausts_the_pool(): + """turned the old exhausting design (24-stage running sum) into one that generates with all 24 ADDs.""" + text, blocks, _last = chain_syscfg(STAGES_TOO_MANY) + rc, out, asm, work = generate(text) + shutil.rmtree(work, ignore_errors=True) + assert rc == 0, "24-stage running sum must generate now:\n" + out[-800:] + assert_every_block_emitted(asm, blocks) + + +@T.needs_tools +def test_sixteen_stage_chain_keeps_every_instruction_and_runs_correctly(): + """16 stages fit: all 33 blocks emit, and the real ELF on TI pru-simulator computes the Python sum.""" + text, blocks, last = chain_syscfg(STAGES_OK) + rc, out, asm, work = generate(text) + try: + assert rc == 0, "16-stage design must still generate:\n" + out[-800:] + assert_every_block_emitted(asm, blocks) + assert len([b for b in blocks if b.startswith("A")]) == STAGES_OK + m = re.search(r";PRU Instruction of %s\s*\n\s*\w+\s+R(\d+)\b" % last, asm) + assert m, "no instruction for %s" % last + elf = T._link(work, T.MAIN_ASM, os.path.join(work, "syscfg", "pru_syscfg.asm")) + finally: + shutil.rmtree(work, ignore_errors=True) + halted, got = T.run_elf(elf, int(m.group(1)), max_steps=4000) + assert halted + assert got == expected_sum(STAGES_OK), "R%s = 0x%08X, want 0x%08X" % (m.group(1), got, expected_sum(STAGES_OK)) + + +# ---------------------------------------------------------------- every recording site is armed +# The node driver runs the SHIPPED allocator on hand-built instances, so each recording site is reached directly. +NODE = shutil.which("node") or T.SYSCONFIG_NODE +ALLOC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".metadata", "sysconfig", ".meta", + "pru_blocks", "common", "register_allocation", "pru_register_allocator.js") + +DRIVER = r""" +const fs = require("fs"), vm = require("vm"), assert = require("assert"); +const ALLOC = process.argv[2], CASE = process.argv[3]; +const modules = {}; +const sb = { system: { modules }, exports: {}, console }; +vm.createContext(sb); +vm.runInContext(fs.readFileSync(ALLOC, "utf8"), sb, { filename: "pru_register_allocator.js" }); +const alloc = sb.exports; +const ldi = (n, i) => ({ $name: n + i, opCode: "LDI", numOfInputPorts: 0, numOfConstants: 1, constant1: i, noOfCycles: 1, output1Size: "4" }); +const fill = (k) => { const a = []; for (let i = 0; i < k; i++) a.push(ldi("K", i)); return a; }; // 4 bytes each, no output1 = end blocks +const run = (insts) => { modules["/f"] = { $instances: insts }; alloc.allocatePruRegisters(); return JSON.parse(JSON.stringify(alloc.getAllocationFailures())); }; +const brief = (f) => f.map(x => x.block + "|" + x.what + "|" + x.bytes + "|" + x.freeBytes); +if (CASE === "dedupe") { + // F is revisited by each of its two consumers; it fails every time but must be recorded once + const F = ldi("F", 0); + const mk = (n) => ({ $name: n, opCode: "ADD", numOfInputPorts: 1, numOfConstants: 0, noOfCycles: 1, output1Size: "4", input1: [{ inst: F, port: "output1" }] }); + const G1 = mk("G1"), G2 = mk("G2"); + F.output1 = [{ inst: G1, port: "input1" }, { inst: G2, port: "input1" }]; + assert.deepStrictEqual(brief(run(fill(28).concat([F, G1, G2]))), + ["F0|its result|4|0", "G1|its result|4|0", "G2|its result|4|0"]); +} else if (CASE === "reset") { + // SysConfig re-evaluates after every edit: a big design, then the same module state with a small one + assert.strictEqual(run(fill(30)).length, 2, "big design must record its two failures"); + assert.deepStrictEqual(run(fill(3)), [], "a smaller design evaluated next must start with NO failures (stale list)"); +} else if (CASE === "extra-output") { + // 26 constants leave 8 bytes: the macro's output1 and output2 fit, output3 does not + const sv = { $name: "SV", opCode: "SVPWM", multiOutputMacroArgs: true, numOfOutputPorts: 5, numOfInputPorts: 0, numOfConstants: 0, + noOfCycles: 1, output1Size: "4", output2Size: "4", output3Size: "4", output4Size: "4", output5Size: "4", output1: [] }; + assert.deepStrictEqual(brief(run(fill(26).concat([sv]))), ["SV|result output3|4|0"]); +} else if (CASE === "loop-counter") { + const lp = { $name: "LP", $groupContents: [], infiniteLoop: false, loopCount: 3, loopCountRegSize: 1, numOfInputPorts: 0, noOfCycles: 1 }; + assert.deepStrictEqual(brief(run(fill(28).concat([lp]))), ["LP|its loop counter|1|0"]); +} else if (CASE === "group-return") { + const gr = { $name: "GR", $groupContents: [], numOfInputPorts: 0, noOfCycles: 1 }; + assert.deepStrictEqual(brief(run(fill(28).concat([gr]))), ["GR|the group return address|2|0"]); +} else { throw new Error("unknown case " + CASE); } +console.log("DRIVER OK " + CASE); +""" + + +def _node_case(tmp_path, case): + if not (NODE and os.path.exists(NODE)): + pytest.fail("FATAL: node not found (PATH has no node and %s missing); this test needs it and must not skip" + % T.SYSCONFIG_NODE) + driver = tmp_path / ("driver_%s.js" % case) + driver.write_text(DRIVER, encoding="utf-8") + path = str(driver) + if path.startswith("\\\\?\\"): + path = path[4:] + res = subprocess.run([NODE, path, ALLOC, case], capture_output=True, text=True, timeout=120) + assert res.returncode == 0 and ("DRIVER OK " + case) in res.stdout, \ + "node driver case %r failed:\n%s\n%s" % (case, res.stdout, res.stderr) + + +def test_revisited_failing_block_is_recorded_once(tmp_path): + _node_case(tmp_path, "dedupe") + + +def test_failures_do_not_survive_into_the_next_evaluation(tmp_path): + _node_case(tmp_path, "reset") + + +def test_multi_output_macro_running_out_on_an_extra_output_is_recorded(tmp_path): + _node_case(tmp_path, "extra-output") + + +def test_loop_counter_failure_is_recorded(tmp_path): + _node_case(tmp_path, "loop-counter") + + +def test_group_return_address_failure_is_recorded(tmp_path): + _node_case(tmp_path, "group-return") + + +# ---------------------------------------------------------------- loop / group reach the named error +def full_pool_syscfg(extra, chain_to=None): + """28 independent 4-byte constants fill the 112-byte pool; `extra` is the block that then cannot be placed.""" + L = [T.HEADER, + 'const loop_block = scripting.addModule("/pru_blocks/program_control/loop_block", {}, false);', + 'const group_block = scripting.addModule("/pru_blocks/program_control/group_block", {}, false);'] + for k in range(28): + L.append('const c%d = load_constant_block.addInstance(); c%d.constant1 = %d; c%d.$name = "K%d";' + % (k, k, 0x10000000 + k, k, k)) + L.append(extra) + if chain_to: + seq = ["c%d" % k for k in range(28)] + [chain_to] + L += ['scripting.connect(%s, "next", %s, "prev");' % (a, b) for a, b in zip(seq, seq[1:])] + return "\n".join(L) + "\n" + + +def _expect_named_error(text, block, what, nbytes): + rc, out, _asm, work = generate(text) + shutil.rmtree(work, ignore_errors=True) + assert rc != 0, "generation must FAIL, got rc 0:\n" + out[-600:] + want = ("Out of registers: block '%s' needs %d byte(s) for %s but only 0 of the 112 allocatable bytes " + "(R0-R27, 28 registers; R28/R29 are reserved) are free" % (block, nbytes, what)) + assert want in out, "expected the named, budgeted error:\n %s\ngot:\n%s" % (want, out[-1200:]) + assert "reduce number of PRU blocks added" not in out, "the generic message must not replace it:\n" + out[-800:] + + +@T.needs_tools +def test_loop_counter_exhaustion_names_the_loop_block(): + text = full_pool_syscfg('const lp = loop_block.addInstance(); lp.$name = "LP"; lp.infiniteLoop = false; ' + 'lp.loopCount = 3; lp.$groupContents = [];', chain_to="lp") + _expect_named_error(text, "LP", "its loop counter", 1) + + +@T.needs_tools +def test_group_return_address_exhaustion_names_the_group_block(): + text = full_pool_syscfg('const gp = group_block.addInstance(); gp.$name = "GP"; gp.groupName = "GP"; ' + 'gp.$groupContents = [];') + _expect_named_error(text, "GP", "the group return address", 2) diff --git a/tests/test_spi_cs_timing_elf.py b/tests/test_spi_cs_timing_elf.py new file mode 100644 index 0000000..ffa758f --- /dev/null +++ b/tests/test_spi_cs_timing_elf.py @@ -0,0 +1,170 @@ +"""pru_spi_write CS setup / hold, built through SysConfig + gmake + clpru and run on TI pru-simulator. + +The block's "CS Setup Time" / "CS Hold Time" are nanoseconds (minimums) and the macro takes PRU cycles. This test builds a +minimal controller-mode pru_spi_write design at several (ns, PRU clock) settings, runs the real ELF and measures, in the +simulator's GPO pin trace, the cycles from CS assertion to the first SCLK edge and from the last SCLK edge to CS release. + +R30 resets to 0, so CS is already low when the block's first clr runs and no assertion edge would show; a PRU GPO block sets CS high +first, as a real transaction starts from CS idle high. + +Expected, written independently of the generator: the measured gap is the design's fixed instruction overhead (measured from a +0 ns / 0 ns build, where the macro emits no delay) plus exactly ceil(ns * MHz / 1000) cycles (a delay of N is N nops, 1 cycle each), +and never less than the configured minimum at that clock. +""" +import glob +import math +import os +import shutil +import subprocess +import sys +from fractions import Fraction + +import pytest + +REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(REPO, "scripts", "sim")) + +import elf_cosim + +TEMPLATE = os.path.join(REPO, "examples", "empty", "firmware") +BUILD_ROOT = os.path.join(REPO, "build_out", "") +CS_PIN, SCLK_PIN, SDO_PIN = 5, 4, 3 +PACKET_BITS = 16 + + +def _find_gmake(): + env = os.environ.get("GMAKE") + if env and os.path.exists(env): + return env + hits = sorted(glob.glob("C:/ti/ccs*/ccs/utils/bin/gmake.exe")) + return hits[-1] if hits else (shutil.which("gmake") or shutil.which("make")) + + +GMAKE = _find_gmake() +try: + elf_cosim.find_clpru() + elf_cosim.find_pru_simulator() + _HAVE_SIM = True +except RuntimeError: + _HAVE_SIM = False +_HAVE_BUILD = (GMAKE is not None and os.path.exists(os.path.join(REPO, "imports.mak")) + and os.path.isdir(os.environ.get("SYSCFG_PATH", "C:/ti/sysconfig_1.28.0"))) +needs_build = pytest.mark.skipif(not (_HAVE_SIM and _HAVE_BUILD), + reason="needs clpru + TI pru-simulator + gmake + SysConfig 1.28 + repo-root imports.mak") + +SYSCFG = """\ +const pru_blocks_static_module = scripting.addModule("/pru_blocks/common/pru_blocks_static_module"); +pru_blocks_static_module.pruClkFreq = %(mhz)s; +const load_constant_block = scripting.addModule("/pru_blocks/data_handling/load_constant_block", {}, false); +const load_constant_block1 = load_constant_block.addInstance(); +const pru_gpo_block = scripting.addModule("/pru_blocks/pru_io_blocks/pru_gpo_block", {}, false); +const pru_gpo_block1 = pru_gpo_block.addInstance(); +const pru_spi_write = scripting.addModule("/pru_blocks/pru_io_blocks/pru_spi_write", {}, false); +const pru_spi_write1 = pru_spi_write.addInstance(); +load_constant_block1.$name = "Data_Word"; +load_constant_block1.constant1 = 0xA5A5; +pru_gpo_block1.$name = "CS_Idle_High"; +pru_gpo_block1.constant1 = "R30, %(cs)d"; +pru_gpo_block1.opCode = "SET"; +pru_spi_write1.$name = "SPI_Write_0"; +pru_spi_write1["Device Mode"] = "controller"; +pru_spi_write1.packetSize = %(bits)d; +pru_spi_write1["SDO Signal"] = "%(sdo)d"; +pru_spi_write1["SCLK Signal"] = "%(sclk)d"; +pru_spi_write1["CS Signal"] = "%(cs)d"; +pru_spi_write1["CS Setup Time"] = %(setup)d; +pru_spi_write1["CS Hold Time"] = %(hold)d; +scripting.connect(pru_spi_write1, "input1", load_constant_block1, "output1"); +scripting.connect(load_constant_block1, "next", pru_gpo_block1, "prev"); +scripting.connect(pru_gpo_block1, "next", pru_spi_write1, "prev"); +""" + + +def build_and_run(name, setup_ns, hold_ns, mhz): + work = os.path.join(BUILD_ROOT, name) + shutil.rmtree(work, ignore_errors=True) + fw = os.path.join(work, "firmware") + proj = os.path.join(fw, "am243x-evm", "icss_g0_pru0_fw") + os.makedirs(os.path.dirname(proj), exist_ok=True) + shutil.copy(os.path.join(TEMPLATE, "main.asm"), os.path.join(fw, "main.asm")) + shutil.copytree(os.path.join(TEMPLATE, "am243x-evm", "icss_g0_pru0_fw"), proj, ignore=shutil.ignore_patterns("generated", "syscfg")) + with open(os.path.join(proj, "example.syscfg"), "w", encoding="utf-8", newline="") as f: + f.write(SYSCFG % dict(mhz=mhz, bits=PACKET_BITS, sdo=SDO_PIN, sclk=SCLK_PIN, cs=CS_PIN, setup=setup_ns, hold=hold_ns)) + cgt = os.path.join(proj, "ti-pru-cgt") + env = dict(os.environ, PATH=os.path.dirname(GMAKE) + os.pathsep + os.environ.get("PATH", "")) + res = subprocess.run([GMAKE, "-s", "all", "PRU_NO_CODE_TOOL_PATH=" + REPO.replace(os.sep, "/"), + "MCU_HEX_PATH=" + os.path.join(work, "hex.h").replace(os.sep, "/")], + cwd=cgt, env=env, capture_output=True, text=True) + gen = os.path.join(cgt, "generated") + outs = [os.path.join(gen, f) for f in os.listdir(gen) if f.endswith(".out")] if os.path.isdir(gen) else [] + if res.returncode != 0 or not outs: + raise RuntimeError("gmake -s all failed for %s (rc=%s):\n%s\n%s" % (name, res.returncode, res.stdout[-2500:], res.stderr[-1500:])) + + class Idle: # no peripheral: the write block only drives pins + def tick(self, cycle, gpo): + return 0 + + run = elf_cosim.run_elf_closed_loop(outs[0], Idle(), max_steps=20000) + assert run.halted, "firmware did not halt within the cycle budget" + return _gaps(run.trace) + + +def _edges(trace, pin): + """[(cycle, level)] for every change of one GPO bit.""" + out, prev = [], None + for cycle, gpo, _gpi in trace: + bit = (gpo >> pin) & 1 + if prev is not None and bit != prev: + out.append((cycle, bit)) + prev = bit + return out + + +def _gaps(trace): + """(setup, hold) in cycles: CS assertion (falls) -> first SCLK edge, last SCLK edge -> CS release (rises).""" + cs, sclk = _edges(trace, CS_PIN), _edges(trace, SCLK_PIN) + fall = [c for c, lvl in cs if lvl == 0] + rise = [c for c, lvl in cs if lvl == 1] + assert len(fall) == 1 and rise, "expected one CS assertion and a release in the trace, got CS edges %s" % cs + cs_assert, cs_release = fall[0], rise[-1] + during = [c for c, _ in sclk if cs_assert < c < cs_release] + assert len(during) >= 2 * PACKET_BITS - 1, "expected the %d SCLK pulses between CS assert and release, got %d edges" % (PACKET_BITS, len(during)) + return during[0] - cs_assert, cs_release - during[-1] + + +def need(ns, mhz): + """Minimum cycles for a minimum time of ns at mhz: ceil(ns * f / 1000), exact fractions (333.333 option = 1000/3 MHz).""" + return math.ceil(Fraction(ns) * _mhz(mhz) / 1000) + + +def _mhz(mhz): + return Fraction(1000, 3) if abs(mhz - 333.333) < 1e-3 else Fraction(mhz) + + +@pytest.fixture(scope="module") +def overhead(): + return build_and_run("zero", 0, 0, 200) + + +@needs_build +@pytest.mark.parametrize("name,setup_ns,hold_ns,mhz", [ + ("200mhz_100_60", 100, 60, 200), # 20 and 12 cycles; the raw-ns bug would give 100 and 60 + ("333mhz_100_60", 100, 60, 333.333), # 34 (33.33 rounded up) and 20 + ("250mhz_37_21", 37, 21, 250), # 10 (9.25 rounded up) and 6 (5.25 rounded up) +]) +def test_cs_setup_and_hold_meet_the_configured_minimum_at_the_clock(overhead, name, setup_ns, hold_ns, mhz): + got_setup, got_hold = build_and_run(name, setup_ns, hold_ns, mhz) + want_setup, want_hold = need(setup_ns, mhz), need(hold_ns, mhz) + # a delay of N is N nops, one cycle each, on top of the fixed gap the zero-delay build shows + assert got_setup == overhead[0] + want_setup, "CS assert -> first SCLK edge: %d cycles, want %d fixed + %d" % (got_setup, overhead[0], want_setup) + assert got_hold == overhead[1] + want_hold, "last SCLK edge -> CS release: %d cycles, want %d fixed + %d" % (got_hold, overhead[1], want_hold) + # and, as minimums, in real time at that clock: cycles * (1000 / MHz) ns >= the configured ns + assert Fraction(got_setup) * 1000 / _mhz(mhz) >= setup_ns + assert Fraction(got_hold) * 1000 / _mhz(mhz) >= hold_ns + + +@needs_build +def test_zero_ns_emits_no_delay_and_the_assembler_accepts_it(overhead): + # the zero build above assembled (`.loop 0` is accepted and emits nothing) and ran; its gaps are the fixed instruction overhead only, + # far below the 200 cycles a 1 us delay would add at 200 MHz + assert 1 <= overhead[0] <= 16 and 1 <= overhead[1] <= 16, overhead diff --git a/tests/test_spi_read_zero_extend.py b/tests/test_spi_read_zero_extend.py new file mode 100644 index 0000000..ce6c1cd --- /dev/null +++ b/tests/test_spi_read_zero_extend.py @@ -0,0 +1,129 @@ +"""SPI read results replace stale bits without stealing a neighbour byte.""" +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "sim")) +import elf_cosim # noqa: E402 +from perif.spi_adc import SpiAdc # noqa: E402 + + +SCLK_PIN, SDI_PIN, CS_PIN = 0, 1, 3 +PRELOAD = 0xA5A5A5FF +DEVICE_WORD = 0x00A5A55A +SIZES = [1, 8, 16, *range(17, 25), 32] +READ_VARIANTS = [ + name + for mode in range(4) for order in ("msb", "lsb") + for name in ("m_read_packet_spi_mode%d_%s_gpo_sclk" % (mode, order), + "m_read_packet_spi_mode%d_slave_%s_gpi_sclk" % (mode, order)) +] +TRANSFER_VARIANTS = [name.replace("m_read_packet_spi_", "m_transfer_packet_spi_") + for name in READ_VARIANTS] + + +def _module_probe(block, variants, sizes=SIZES): + """Ask the real block module for every generated body and output allocation.""" + code = r''' +const {loadBlockModule}=require('./scripts/dual_oracle_harness'); +const [block, variants, sizes] = process.argv.slice(1).map(JSON.parse); +const mod=loadBlockModule(block, 'pru_io_blocks'); +const size=mod.config.find(c => c.name === 'output1Size'); +function reg(n) { return n <= 8 ? 'R0.b0' : n <= 16 ? 'R0.w0' : 'R0'; } +const out=[]; +for (const name of variants) for (const n of sizes) { + const slave=name.includes('_slave_'); + const args=slave ? [reg(n), 'R2', String(n), 'R6', '0', '1', '2', '3', '1'] : + (block === 'pru_spi_transfer' ? [reg(n), 'R2', String(n), 'R6', '0', '1', '2', '1', '1', '3', '0', '0', '0'] : + [reg(n), String(n), 'R6', '0', '1', '1', '1', '3', '0', '0', '0']); + out.push({name, n, bytes:size.getValue({packetSize:n}), body:mod.getMacro([name, ...args].join(', '), name)}); +} +process.stdout.write(JSON.stringify(out)); +''' + result = subprocess.run( + ["node", "-e", code, json.dumps(block), json.dumps(variants), json.dumps(sizes)], + cwd=ROOT, text=True, capture_output=True, check=True) + return json.loads(result.stdout) + + +def test_all_spi_read_variants_clear_exactly_their_allocator_owned_result(): + """All 16 macro branches cover 1, 8, 16, 17..24, and 32-bit outputs.""" + seen = _module_probe("pru_spi_read", READ_VARIANTS) + assert len(seen) == len(READ_VARIANTS) * len(SIZES) + for item in seen: + expected_bytes = 1 if item["n"] <= 8 else 2 if item["n"] <= 16 else 4 + expected_reg = "R0.b0" if expected_bytes == 1 else "R0.w0" if expected_bytes == 2 else "R0" + assert item["bytes"] == expected_bytes, item + assert item["body"].count("ldi %s, 0" % expected_reg) == 1, item["name"] + loop = "READ_BIT_LOOP?:" if "READ_BIT_LOOP?:" in item["body"] else "SEND_BIT_LOOP?:" + assert item["body"].index("ldi %s, 0" % expected_reg) < item["body"].index(loop) + + +def test_spi_transfer_has_the_same_read_result_initialization_for_every_variant(): + """Full-duplex transfer has its own receive register and must not retain stale high bits.""" + seen = _module_probe("pru_spi_transfer", TRANSFER_VARIANTS) + assert len(seen) == len(TRANSFER_VARIANTS) * len(SIZES) + for item in seen: + expected_bytes = 1 if item["n"] <= 8 else 2 if item["n"] <= 16 else 4 + expected_reg = "R0.b0" if expected_bytes == 1 else "R0.w0" if expected_bytes == 2 else "R0" + assert item["bytes"] == expected_bytes, item + assert item["body"].count("ldi %s, 0" % expected_reg) == 1, item["name"] + loop = "READ_BIT_LOOP?:" if "READ_BIT_LOOP?:" in item["body"] else "SEND_BIT_LOOP?:" + assert item["body"].index("ldi %s, 0" % expected_reg) < item["body"].index(loop) + + +def test_read_initialization_guard_throws_when_no_read_loop_insert_point_exists(): + code = r''' +const {loadBlockModule}=require('./scripts/dual_oracle_harness'); +const mod=loadBlockModule('pru_spi_read', 'pru_io_blocks'); +try { mod._testInsertReadResultInitialization('ldi R6, 8\\nNO_READ_LOOP:', 'R0', 'm_read_packet_spi_mode0_msb_gpo_sclk'); } +catch (e) { process.stdout.write(e.message); process.exit(0); } +process.exit(1); +''' + result = subprocess.run(["node", "-e", code], cwd=ROOT, text=True, capture_output=True) + assert result.returncode == 0 + assert "could not find READ_BIT_LOOP initialization point" in result.stdout + + +def spi_read_spec(packet_size): + return { + "block": "pru_spi_read", "subdir": "pru_io_blocks", + "macroCall": "", "opCode": "m_read_packet_spi_mode0_msb_gpo_sclk", + "macroArgs": { + "dataReg": "R0", "PACKETSIZE": str(packet_size), "bitId": "R6", + "SCLK_PIN": str(SCLK_PIN), "SDI_PIN": str(SDI_PIN), + "DELAY_COMPEN_1": "1", "DELAY_COMPEN_2": "1", "CS_PIN": str(CS_PIN), + "CS_SETUP_TIME": "0", "CS_HOLD_TIME": "0", "DATA_SETUP_TIME": "0", + }, + # Model the natural register reuse seen in the regression: the destination + # contains a prior word before the shorter SPI read starts. + "prelude": ["set R30, R30, 3", "ldi32 R0, 0x%08X" % PRELOAD], + "postlude": ["halt"], + } + + +@pytest.mark.parametrize("packet_size", SIZES) +def test_spi_read_clears_destination_bits_above_packet_size(packet_size): + elf, _ = elf_cosim.build_block_firmware( + spi_read_spec(packet_size), "spi_read_%d" % packet_size, + str(ROOT / "build_out")) + device = SpiAdc(sclk_pin=SCLK_PIN, sdi_pin=SDI_PIN, sdo_pin=2, + cs_pin=CS_PIN, bits=packet_size, conversion=DEVICE_WORD, + msb_first=True) + result = elf_cosim.run_elf_closed_loop( + elf, device, max_steps=2000) + + assert result.halted, "SPI read firmware did not halt" + mask = (1 << packet_size) - 1 + received = result.registers[0] & 0xFFFFFFFF + assert received & ~mask == 0, ( + "SPI read left stale bits above its %d-bit packet: 0x%08x" % + (packet_size, received)) + assert received == DEVICE_WORD & mask + assert device.faults() == [] diff --git a/tests/test_spi_transfer_zero_extend.py b/tests/test_spi_transfer_zero_extend.py new file mode 100644 index 0000000..b341399 --- /dev/null +++ b/tests/test_spi_transfer_zero_extend.py @@ -0,0 +1,168 @@ +"""The pru_spi_transfer / pru_spi_read result clear lives INSIDE the macro. + +Prefixing the clear to the whole getMacro() result is wrong: in the header form SysConfig emits (getMacro("", opCode), +which begins with the `name .macro ...` line) it puts `ldi r_dataReg, 0` ABOVE the .macro line, and clpru fails with +E0003 on every design using pru_spi_transfer. These tests cover the header form, a real +SysConfig -> clpru link, and the simulator (zero-extension AND the transmitted bits). +""" +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "sim")) +sys.path.insert(0, str(ROOT / "tests")) +import elf_cosim # noqa: E402 +from perif.spi_adc import SpiAdc # noqa: E402 +import test_fanout_register_alias as T # noqa: E402 +from test_spi_read_zero_extend import ( # noqa: E402 + READ_VARIANTS, TRANSFER_VARIANTS, PRELOAD) + +SCLK_PIN, SDI_PIN, SDO_PIN, CS_PIN = 0, 1, 2, 3 +TX_WORD = 0xC35A96E1 +RX_WORD = 0x7B3D5E92 + + +def _header_bodies(block, variants): + """getMacro("", name): the form SysConfig emits into pru_syscfg.inc (starts with the .macro line).""" + code = r''' +const {loadBlockModule}=require('./scripts/dual_oracle_harness'); +const [block, variants] = process.argv.slice(1).map(JSON.parse); +const mod=loadBlockModule(block, 'pru_io_blocks'); +process.stdout.write(JSON.stringify(variants.map(v => ({name: v, body: mod.getMacro('', v)})))); +''' + result = subprocess.run(["node", "-e", code, json.dumps(block), json.dumps(variants)], + cwd=ROOT, text=True, capture_output=True, check=True) + return json.loads(result.stdout) + + +@pytest.mark.parametrize("name", TRANSFER_VARIANTS) +def test_transfer_header_form_clears_inside_the_macro(name): + body = _header_bodies("pru_spi_transfer", [name])[0]["body"] + lines = body.splitlines() + assert lines[0].startswith(name) and ".macro" in lines[0], "nothing may precede the .macro line: %r" % lines[0] + clear = [i for i, l in enumerate(lines) if l.strip() == "ldi r_dataReg, 0".strip()] + assert len(clear) == 1, body + assert "Parameters:" in lines[1] and lines[clear[0] - 1].lstrip().startswith(";"), "clear must follow the parameter comment block directly" + assert clear[0] < min(i for i, l in enumerate(lines) if l[:1].isalpha() and l.rstrip().endswith(":") and i > 0), \ + "clear must precede every label and loop" + assert body.rstrip().endswith(".endm") + + +@pytest.mark.parametrize("name", READ_VARIANTS) +def test_read_header_form_clears_inside_the_macro(name): + body = _header_bodies("pru_spi_read", [name])[0]["body"] + lines = body.splitlines() + assert lines[0].startswith(name) and ".macro" in lines[0] + assert sum(1 for l in lines if l.strip() == "ldi dataReg, 0") == 1 + + +def test_read_prefix_probe_does_not_throw_but_a_real_variant_without_an_insert_point_does(): + """scripts/block_control_flow.js probes getMacro("", "m_read_packet_spi_"); that must stay quiet.""" + code = r''' +const {loadBlockModule}=require('./scripts/dual_oracle_harness'); +const mod=loadBlockModule('pru_spi_read', 'pru_io_blocks'); +mod.getMacro('', 'm_read_packet_spi_'); +mod.getMacro('', 'm_read_packet_spi_unknown_variant'); +let threw = false; +try { mod._testInsertReadResultInitialization('ldi R6, 8\nNO_READ_LOOP:', 'R0', 'm_read_packet_spi_mode0_msb_gpo_sclk'); } +catch (e) { threw = true; } +process.exit(threw ? 0 : 1); +''' + result = subprocess.run(["node", "-e", code], cwd=ROOT, text=True, capture_output=True) + assert result.returncode == 0, result.stdout + result.stderr + + +needs_tools = pytest.mark.skipif(not T.HAVE_TOOLS, reason="clpru, SysConfig 1.28 CLI or pru-simulator missing") + + +@needs_tools +def test_transfer_design_generates_and_links_through_sysconfig_and_clpru(): + """Red at eb71f49c: a stray `ldi r_dataReg, 0` above `.macro` made clpru fail with E0003.""" + text = T.HEADER + ( + 'const pru_spi_transfer = scripting.addModule("/pru_blocks/pru_io_blocks/pru_spi_transfer", {}, false);\n' + 'const c = load_constant_block.addInstance(); c.constant1 = 0x5A; c.$name = "TX_Word";\n' + 'const spi = pru_spi_transfer.addInstance(); spi.$name = "SPI_XFER"; spi.packetSize = 24;\n' + 'scripting.connect(spi, "input1", c, "output1");\n') + work = tempfile.mkdtemp(prefix="fer_") + try: + with open(os.path.join(work, "design.syscfg"), "w", encoding="utf-8") as f: + f.write(text) + T._run([T.SYSCFG_NODE, T.SYSCFG_CLI, "--product", T.PRODUCT_JSON, "--device", "AM243x_ALX_beta", + "--context", "icss_g0_pru0", "--part", "ALX", "--package", "ALX", "--output", "syscfg", + "design.syscfg"], work) + inc = Path(work, "syscfg", "pru_syscfg.inc").read_text(encoding="utf-8") + macro_at = inc.index("m_transfer_packet_spi_") + line_start = inc.rfind("\n", 0, macro_at) + 1 + assert "r_dataReg, 0" not in inc[:line_start], "clear emitted above the .macro line" + assert "ldi r_dataReg, 0" in inc[line_start:], "clear missing from the macro" + elf = T._link(work, T.MAIN_ASM, os.path.join(work, "syscfg", "pru_syscfg.asm")) + assert elf[:4] == b"\x7fELF" + finally: + shutil.rmtree(work, ignore_errors=True) + + +def transfer_spec(packet_size, reg, variant="m_transfer_packet_spi_mode0_lsb_gpo_sclk"): + return { + "block": "pru_spi_transfer", "subdir": "pru_io_blocks", + "macroCall": "", "opCode": variant, + "macroArgs": { + "r_dataReg": reg, "s_dataReg": "R4", "PACKETSIZE": str(packet_size), + "bitId": "R6", "SCLK_PIN": str(SCLK_PIN), "SDI_PIN": str(SDI_PIN), "SDO_PIN": str(SDO_PIN), + "DELAY_COMPEN_1": "1", "DELAY_COMPEN_2": "1", "CS_PIN": str(CS_PIN), + "CS_SETUP_TIME": "0", "CS_HOLD_TIME": "0", "DATA_SETUP_TIME": "0", + }, + "prelude": ["set R30, R30, 3", "ldi32 R4, 0x%08X" % TX_WORD, "ldi32 R5, 0x%08X" % PRELOAD], + "postlude": ["halt"], + } + + +# (packet size, destination token, bits the destination owns). The allocator hands out R5.b0 / R5.w0 / R5. +TRANSFER_CASES = [(4, "R5.b0", 8), (8, "R5.b0", 8), (12, "R5.w0", 16), (16, "R5.w0", 16), + (20, "R5", 32), (24, "R5", 32), (32, "R5", 32)] + + +@pytest.mark.parametrize("packet_size,reg,owned", TRANSFER_CASES) +def test_transfer_zero_extends_and_still_transmits_the_right_bits(packet_size, reg, owned): + elf, _ = elf_cosim.build_block_firmware( + transfer_spec(packet_size, reg), "xfer_%d" % packet_size, str(ROOT / "build_out")) + mask = (1 << packet_size) - 1 + device = SpiAdc(sclk_pin=SCLK_PIN, sdi_pin=SDI_PIN, sdo_pin=SDO_PIN, cs_pin=CS_PIN, + bits=packet_size, conversion=RX_WORD & mask, msb_first=False) + result = elf_cosim.run_elf_closed_loop(elf, device, max_steps=4000) + assert result.halted + received = result.registers[5] & ((1 << owned) - 1) + assert received & ~mask == 0, "stale bits above %d bits: 0x%08x" % (packet_size, received) + assert received == RX_WORD & mask + assert result.registers[4] & 0xFFFFFFFF == TX_WORD, "transmit register was modified" + transactions = [e for e in device.events() if e["kind"] == "transaction"] + assert len(transactions) == 1 and transactions[0]["mosi"] == TX_WORD & mask, transactions + assert device.faults() == [] + + +@pytest.mark.parametrize("variant", [v for v in TRANSFER_VARIANTS if "lsb" in v and "slave" not in v]) +def test_transfer_clear_costs_exactly_one_cycle_on_the_master_variants(variant): + """Same expanded asm with and without the clear. The cycle model's `1 -> 2` rests on this.""" + workdir = tempfile.mkdtemp(prefix="yc_") + try: + asm = elf_cosim.expand_block_asm(transfer_spec(24, "R5", variant), workdir) + cleared = [l for l in asm.splitlines() if l.strip() == "ldi R5, 0"] + assert len(cleared) == 1, cleared + cycles = {} + for label, text in (("with", asm), ("without", asm.replace(cleared[0] + "\n", "", 1))): + elf = elf_cosim.compile_asm(text, workdir, "cyc_" + label) + device = SpiAdc(sclk_pin=SCLK_PIN, sdi_pin=SDI_PIN, sdo_pin=SDO_PIN, cs_pin=CS_PIN, + bits=24, conversion=RX_WORD & 0xFFFFFF, msb_first=False) + res = elf_cosim.run_elf_closed_loop(elf, device, max_steps=4000) + assert res.halted + cycles[label] = res.cycles + assert cycles["with"] - cycles["without"] == 1, cycles + finally: + shutil.rmtree(workdir, ignore_errors=True) diff --git a/tests/test_waveform_player.py b/tests/test_waveform_player.py new file mode 100644 index 0000000..30f3363 --- /dev/null +++ b/tests/test_waveform_player.py @@ -0,0 +1,272 @@ +"""WaveformPlayer — cycle-keyed GPI stimulus as a DeviceModel. + +Three required layers: + +1. Unit: HOLD semantics and exhaustion-before-halt fault (runs anywhere, + stdlib only, no simulator checkout needed — the player's tick is driven + directly). +2. Contention: a player and a second push-pull device on the same pin MUST + produce a contention fault through the real DeviceBus, not a silent + takeover. Includes the sabotage control: with contention recording + disabled, the SAME scenario records no fault — proving the main test's + pass depends on the check and fails if the check is removed. +3. The full existing suite stays green (this file adds to it). + +The bus tests need the TI pru-simulator checkout (the DeviceBus lives +there); they skip cleanly when it is absent, same as the ELF layer. +""" + +import importlib.util +import json +import os +import subprocess +import sys + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from pru_io.waveform_player import WaveformPlayer # noqa: E402 + +MASK_20 = (1 << 20) - 1 +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + +_SIM_CANDIDATES = ( + os.environ.get("PRU_SIMULATOR_PATH"), + os.path.join(ROOT, "external", "pru-simulator"), + os.path.join(ROOT, "..", "pru-simulator"), + os.path.join(ROOT, "..", "pru-simulator-upstream"), + # a checkout two levels up, beside tools/ + os.path.join(ROOT, os.pardir, os.pardir, "tools", "pru-simulator-upstream"), + "/opt/pru-simulator", +) + + +def _locate_simulator(): + # Same search order as tools/gpi_stimulus_runner.locate_simulator, including + # the ancestor glob: without it the default clone layout (the checkout sits + # two levels up beside tools/) does not resolve, and every bus test below + # silently "skips" on a machine that does have the simulator. + import glob + ancestors = glob.glob(os.path.join(ROOT, os.pardir, os.pardir, "*", + "tools", "pru-simulator-upstream")) + for cand in filter(None, (*_SIM_CANDIDATES, *ancestors)): + cand = os.path.abspath(cand) + if os.path.isfile(os.path.join(cand, "simulator.py")): + return cand + return None + + +def _load_upstream_device_model(): + """Load the real DeviceBus from the simulator checkout, by path. + + By path because in this process the name ``pru_io`` is already this + repo's package; the upstream module has no intra-package imports, so + loading it standalone is safe. + """ + sim = _locate_simulator() + if sim is None: + return None, None + path = os.path.join(sim, "pru_io", "device_model.py") + if not os.path.isfile(path): + return None, None + spec = importlib.util.spec_from_file_location("upstream_device_model", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod, sim + + +_DM, _SIM = _load_upstream_device_model() +needs_bus = pytest.mark.skipif(_DM is None, reason="pru-simulator checkout not found") + + +# --------------------------------------------------------------------------- +# 1. Unit: HOLD semantics, cycle keying, exhaustion +# --------------------------------------------------------------------------- + +def test_hold_semantics_value_applies_until_replaced(): + p = WaveformPlayer({0: 0x00005, 10: 0x0000A}) + # cycle 0..9 -> 0x5 ; 10 onward -> 0xA (HOLD) + # default pins = every bit any word asserts: 0x5 | 0xA -> mask 0xF + assert p.tick(0, MASK_20) == (0xF, 0x5) + assert p.tick(5, MASK_20) == (0xF, 0x5) # no new entry: still holding + assert p.tick(9, MASK_20) == (0xF, 0x5) + assert p.tick(10, MASK_20) == (0xF, 0xA) + assert p.faults() == [] # never ran past the last entry + + +def test_hold_before_first_entry_drives_nothing(): + p = WaveformPlayer({4: 0x8}) + assert p.tick(0, MASK_20) == (0, 0) + assert p.tick(3, MASK_20) == (0, 0) + assert p.tick(4, MASK_20)[1] == 0x8 + + +def test_cycle_keyed_multi_entry_crossing_records_every_point(): + # A multi-cycle instruction advances cycles >1 per tick: entries crossed + # in one tick are applied in order and each is recorded as an event. + p = WaveformPlayer({1: 0x1, 2: 0x2, 7: 0x4}) + p.tick(0, MASK_20) + p.tick(3, MASK_20) # crossed entries at cycles 1 AND 2 + ev = [e["cycle"] for e in p.events()] + assert ev == [1, 2] + assert p.tick(3, MASK_20)[1] == 0x2 # entry 7 not yet reached + + +def test_exhaustion_before_halt_is_a_real_fault(): + p = WaveformPlayer({0: 0x1, 2: 0x2}) + p.tick(0, MASK_20) + p.tick(2, MASK_20) + assert p.faults() == [] + p.tick(3, MASK_20) # waveform ran out, program has not halted + assert p.exhausted + faults = p.faults() + assert len(faults) == 1 and "exhausted" in faults[0] + # the fault must survive repeated ticks (flagged once, but stays listed) + p.tick(4, MASK_20) + assert len(p.faults()) == 1 + + +def test_both_input_shapes_accepted(): + wrapped = WaveformPlayer({"gpi_word_by_cycle": {"0": 1, "5": 2}}) + bare = WaveformPlayer({"0": 1, "5": 2}) + assert wrapped.shape == "wrapped" and bare.shape == "bare" + assert wrapped.tick(0, 0)[1] == bare.tick(0, 0)[1] == 1 + assert wrapped.tick(5, 0)[1] == bare.tick(5, 0)[1] == 2 + + +def test_malformed_stimulus_refused(): + with pytest.raises(ValueError): + WaveformPlayer({}) + with pytest.raises(ValueError): + WaveformPlayer({"soon": 1}) + with pytest.raises(ValueError): + WaveformPlayer({0: "high"}) + + +def test_reset_clears_faults_events_and_position(): + p = WaveformPlayer({0: 1}) + p.tick(5, MASK_20) + assert p.faults() and p.events() + p.reset() + assert p.faults() == [] and p.events() == [] and not p.exhausted + + +# --------------------------------------------------------------------------- +# 2. Contention through the real DeviceBus +# --------------------------------------------------------------------------- + +def _stub_push_pull(pin, value): + """Build the push-pull stub lazily. + + Deliberately NOT a module-level ``class _StubPushPull(_DM.DeviceModel)``: + the base-class expression is evaluated at import, so with no simulator + checkout ``_DM`` is None and the AttributeError takes down collection of + this whole file -- including the stdlib-only unit tests above, which have + no simulator dependency at all. A skipif marker cannot save a class + statement; it is applied after the statement has already run. + """ + class _StubPushPull(_DM.DeviceModel): + name = "stub_push_pull" + + def __init__(self, pin, value): + self.nets = {pin: _DM.PUSH_PULL} + self.pin = pin + self.value = value + + def tick(self, cycle, bus): + return (1 << self.pin), (self.value << self.pin) + + return _StubPushPull(pin, value) + + +@needs_bus +def test_player_vs_device_same_pin_is_contention_not_takeover(): + DeviceBus = _DM.DeviceBus + player = WaveformPlayer({0: 1 << 3}, pins=[3]) # drives pin 3 HIGH from cycle 0 + stub = _stub_push_pull(3, 0) # drives pin 3 LOW + bus = DeviceBus() + bus.attach(player) + bus.attach(stub) + bus.set_pru_drive_mask(MASK_20 & ~(1 << 3)) # pin 3 is an input to the PRU + + word = None + for cycle in range(3): + word = bus.settle(cycle, 0) + + contentions = [f for f in bus.faults() if "contention" in f] + assert contentions, ( + "player and device drove opposite levels on pin 3 with no fault: " + "the bus silently picked a winner") + + +@needs_bus +def test_sabotage_control_scenario_is_capable_of_silence(): + """If the contention check is removed, test_player_vs_device_same_pin... + fails. This control proves the discrimination: with contention recording + disabled (check 'removed'), the SAME scenario records NO fault — so the + main test passes only because the check exists. + """ + class NoContentionBus(_DM.DeviceBus): + def _resolve(self, cycle, gpo, drives, record_contention): + return super()._resolve(cycle, gpo, drives, False) + + player = WaveformPlayer({0: 1 << 3}, pins=[3]) + stub = _stub_push_pull(3, 0) + bus = NoContentionBus() + bus.attach(player) + bus.attach(stub) + bus.set_pru_drive_mask(MASK_20 & ~(1 << 3)) + for cycle in range(3): + bus.settle(cycle, 0) + assert [f for f in bus.faults() if "contention" in f] == [], ( + "sabotaged bus still faults; the control no longer proves the " + "main test depends on the contention check") + + +# --------------------------------------------------------------------------- +# 3. Runner: subprocess path carries the in-process verdict +# --------------------------------------------------------------------------- + +@needs_bus +def test_runner_subprocess_reports_devices_and_verdict(tmp_path): + asm = tmp_path / "fw.asm" + asm.write_text("LDI r0, 10\nHALT\n") + stim = tmp_path / "wave.json" + stim.write_text(json.dumps({"gpi_word_by_cycle": {"0": 5, "1": 7}})) + + proc = subprocess.run( + [sys.executable, os.path.join(ROOT, "tools", "gpi_stimulus_runner.py"), + "--asm", str(asm), "--stimulus", str(stim), "--simulator", _SIM, + "--max-cycles", "200"], + capture_output=True, text=True) + payload = json.loads(proc.stdout) # exactly one JSON object on stdout + assert payload["ok"] is True + assert payload["halted"] is True + assert payload["stimulus"]["shape"] == "wrapped" + assert payload["stimulus"]["points"] == 2 + assert payload["devices"]["events"] == [ + {"kind": "gpi_word", "cycle": 0, "value": 5, "device": "waveform_player"}, + {"kind": "gpi_word", "cycle": 1, "value": 7, "device": "waveform_player"}, + ] + assert payload["devices"]["faults"] == [] + assert proc.returncode == 0 + + +@needs_bus +def test_runner_subprocess_exhaustion_flips_verdict(tmp_path): + asm = tmp_path / "fw.asm" + asm.write_text("LDI r0, 10\nHALT\n") + stim = tmp_path / "wave.json" + stim.write_text(json.dumps({"0": 5})) # exhausted immediately + + proc = subprocess.run( + [sys.executable, os.path.join(ROOT, "tools", "gpi_stimulus_runner.py"), + "--asm", str(asm), "--stimulus", str(stim), "--simulator", _SIM, + "--max-cycles", "200"], + capture_output=True, text=True) + payload = json.loads(proc.stdout) + assert payload["ok"] is False + assert payload["exhausted"] is True + assert any("exhausted" in f for f in payload["devices"]["faults"]) + assert proc.returncode == 1 From a258f2f931fbbfb35a74f29a4a502fa35d996a43 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 14:35:49 +0530 Subject: [PATCH 16/44] Remove trailing whitespace in device model and simulator sources --- .../common/simulation/device_models.js | 4 ++-- .../pru_blocks/common/simulation/pru_core.js | 2 +- perif/bissline_device_model.py | 8 +++---- scripts/dual_oracle_harness.js | 22 +++++++++---------- scripts/sim/test_catalog_completeness.py | 18 +++++++-------- scripts/simulator.js | 2 +- tests/test_bissline.py | 20 ++++++++--------- 7 files changed, 38 insertions(+), 38 deletions(-) diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js index 599dc18..329d8bc 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js @@ -1106,7 +1106,7 @@ class TCA9538Model { this.device = new TCA9538Device(address); this.address = this.device.address; - + this._events = []; this._faults = []; this._prevScl = null; @@ -1198,7 +1198,7 @@ class BissCEncoder { this.cdsBit = (opts.cdsBit !== undefined ? opts.cdsBit : 0) & 1; this.invertCrc = opts.invertCrc !== undefined ? opts.invertCrc : true; this.ackClocks = Math.max(1, opts.ackClocks !== undefined ? opts.ackClocks : 1); - + if (this.positionBits < 1 || this.positionBits > 32) throw new Error("position_bits must be 1..32"); this.position = 0; diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js index 157ab2f..928b2ac 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js @@ -1764,7 +1764,7 @@ function executeInstruction(pruState, pruInstructions, labelMap, r30ValueHistory if (deviceId === 0) { // MAC read if (!pruState.mac) pruState.mac = { enabled: false, mode: 0, result: 0n }; - + // Compute result on XIN if (pruState.mac.enabled) { // Standard 32x32 -> 64 signed multiply diff --git a/perif/bissline_device_model.py b/perif/bissline_device_model.py index 8f3b9fb..189a06e 100644 --- a/perif/bissline_device_model.py +++ b/perif/bissline_device_model.py @@ -302,14 +302,14 @@ def flush(self, cycle: int) -> None: def _emit_frame(self, cycle: int) -> None: self._frame_emitted = True - + expected_len = len(self._wire_bits) if self._clocks_in_burst != len(self._captured_bits): self._faults.append( f"cycle {cycle}: clocks_in_burst ({self._clocks_in_burst}) != captured_bits ({len(self._captured_bits)})" ) return - + if len(self._captured_bits) != expected_len: self._faults.append( f"cycle {cycle}: clocked {len(self._captured_bits)} times, expected {expected_len}" @@ -318,7 +318,7 @@ def _emit_frame(self, cycle: int) -> None: # Decode from wire clocked_wire = self._captured_bits - + if self.line_coding: decoded_coded_bits = [] for i in range(0, len(clocked_wire), 10): @@ -364,7 +364,7 @@ def _emit_frame(self, cycle: int) -> None: received_crc = 0 for b in crc_data: received_crc = (received_crc << 1) | b - + computed_crc = crc6_bissc(pos_bits + [ne, nw]) crc_ok = (received_crc == computed_crc) diff --git a/scripts/dual_oracle_harness.js b/scripts/dual_oracle_harness.js index 8d5a162..db88c6e 100644 --- a/scripts/dual_oracle_harness.js +++ b/scripts/dual_oracle_harness.js @@ -270,7 +270,7 @@ function evalCondition(rawCond, mapping, definedSymbols) { function expandMacro(macroStr, argsMap) { const lines = macroStr.split('\n'); const expandedLines = []; - + let macroHeaderIndex = -1; let params = []; for (let i = 0; i < lines.length; i++) { @@ -282,11 +282,11 @@ function expandMacro(macroStr, argsMap) { break; } } - + if (macroHeaderIndex === -1) { throw new Error("Could not find .macro definition in macro string"); } - + const paramNames = params; const mapping = {}; const registersMap = { @@ -295,7 +295,7 @@ function expandMacro(macroStr, argsMap) { 'TEMP_REG3': 'R26', 'TEMP_REG4': 'R27' }; - + for (let i = 0; i < paramNames.length; i++) { const pName = paramNames[i]; if (argsMap[pName] !== undefined) { @@ -304,17 +304,17 @@ function expandMacro(macroStr, argsMap) { mapping[pName] = argsMap[i]; } } - + const bodyLines = lines.slice(macroHeaderIndex + 1); const condStack = [{ active: true, taken: true, parent: true }]; - + for (let line of bodyLines) { const trimmed = line.trim(); - + if (trimmed.startsWith('.endm')) { break; } - + if (trimmed.startsWith('.if ')) { const parentActive = condStack[condStack.length - 1].active; const isTrue = evalCondition(trimmed.substring(4), mapping); @@ -352,17 +352,17 @@ function expandMacro(macroStr, argsMap) { processedLine = processedLine.replace(new RegExp('\\b' + tempName + '\\b', 'g'), reg); } } - + processedLine = processedLine.replace(/\bsine_lut\b/g, '0'); expandedLines.push(processedLine); } } - + const uniqueId = Math.floor(Math.random() * 1000000); const finalLines = expandedLines.map(line => { return line.replace(/\?/g, `_${uniqueId}`); }); - + return finalLines; } diff --git a/scripts/sim/test_catalog_completeness.py b/scripts/sim/test_catalog_completeness.py index 7b07644..b779313 100644 --- a/scripts/sim/test_catalog_completeness.py +++ b/scripts/sim/test_catalog_completeness.py @@ -9,27 +9,27 @@ def main(): js_path = os.path.join(root, ".metadata", "sysconfig", ".meta", "pru_blocks", "common", "simulation", "device_models.js") with open(js_path, "r", encoding="utf-8") as f: js_content = f.read() - + match = re.search(r"const DEVICE_MODEL_KINDS = \[([\s\S]*?)\];", js_content) if not match: print("FAIL: Could not find DEVICE_MODEL_KINDS") sys.exit(1) - + kinds_in_js = set(re.findall(r'kind:\s*"([^"]+)"', match.group(1))) - + py_files = [] for d in ["perif", "pru_io"]: py_files.extend(glob.glob(os.path.join(root, d, "*.py"))) - + found_classes = set() for pf in py_files: with open(pf, "r", encoding="utf-8") as f: content = f.read() for m in re.finditer(r"^class\s+([A-Za-z0-9_]+)\s*\(\s*(?:pru_io\.|perif\.)?DeviceModel\s*\)\s*:", content, re.MULTILINE): found_classes.add(m.group(1)) - + abstract_classes = {"SsiEndatEncoder", "QuadratureIndexEncoder"} - + class_to_kind = { "HallSensorModel": "hall", "DualOssdEmitter": "ossd", @@ -40,7 +40,7 @@ def main(): "EndatEncoder": "endat", "MdioPhyModel": "mdio_phy", } - + missing_from_catalog = [] for cls in found_classes: if cls in abstract_classes: @@ -51,13 +51,13 @@ def main(): continue if kind not in kinds_in_js: missing_from_catalog.append(f"{cls} (kind '{kind}')") - + if missing_from_catalog: print("FAIL: The following DeviceModel subclasses are missing from DEVICE_MODEL_KINDS:") for m in missing_from_catalog: print(" - " + m) sys.exit(1) - + print(f"PASS: All {len(found_classes)} DeviceModel subclasses are accounted for in DEVICE_MODEL_KINDS.") sys.exit(0) diff --git a/scripts/simulator.js b/scripts/simulator.js index 7f6c242..60ce28a 100644 --- a/scripts/simulator.js +++ b/scripts/simulator.js @@ -183,7 +183,7 @@ const C_MOE = 0x1B; /** * Multi-format ELF/COFF loader for PRU firmware binaries. * Handles both TI COFF (.out) and ELF (.out/.elf) with .pru encoding. - * + * * @param {Buffer} buffer - Raw firmware binary * @returns {Object} { text: Buffer, data: Buffer|null, symbols: Array, entryPoint: number } */ diff --git a/tests/test_bissline.py b/tests/test_bissline.py index a4df480..5044cb4 100644 --- a/tests/test_bissline.py +++ b/tests/test_bissline.py @@ -588,7 +588,7 @@ def test_red_corrupted_payload_default(self): # =================================================================== class TestMasterDataCorruption: """Verify that the model observes what the MASTER drives on MO.""" - + def test_green_master_drives_zero(self): m = BissLineEncoder(data_bits=16, position=0xBEEF, line_coding=False, fec_enable=False) n_clocks = len(m._wire_bits) @@ -603,7 +603,7 @@ def test_red_master_drives_one(self): n_clocks = len(m._wire_bits) master_data = [0] * n_clocks # Bit 4 is a 0 in the payload. If master drives 1, it will corrupt the bit to 1. - master_data[4] = 1 + master_data[4] = 1 _clock_model(m, n_clocks, half_period=10, master_data=master_data) assert m.faults() == [] frames = [e for e in m.events() if e["kind"] == "frame"] @@ -621,7 +621,7 @@ def test_wire_level_decode(self): pos = 0xC0DE m = BissLineEncoder(data_bits=16, position=pos, line_coding=True, fec_enable=True) trace = _clock_model(m, m.total_wire_bits, half_period=10) - + # Reconstruct wire bits from trace (sampled on falling edge of MA) wire_bits = [] prev_clk = 0 @@ -636,7 +636,7 @@ def test_wire_level_decode(self): if last_active is not None: wire_bits.append(data) prev_clk = clk - + # 1. Decode 8b/10b decoded_bytes = [] for i in range(0, len(wire_bits), 10): @@ -646,12 +646,12 @@ def test_wire_level_decode(self): bv = decode_8b10b_symbol(sym) assert bv is not None decoded_bytes.append(bv) - + decoded_bits = [] for bv in decoded_bytes: for j in range(7, -1, -1): decoded_bits.append((bv >> j) & 1) - + # 2. Decode FEC header = decoded_bits[:len(m._coded_bits)][:3] fec_bits = decoded_bits[:len(m._coded_bits)][3:-8] @@ -664,13 +664,13 @@ def test_wire_level_decode(self): assert not corrected for j in range(3, -1, -1): pos_bits.append((nibble >> j) & 1) - + pos_bits = pos_bits[-16:] # data_bits=16 - + recovered_pos = 0 for b in pos_bits: recovered_pos = (recovered_pos << 1) | b - + assert recovered_pos == pos class TestUnderclockPair: @@ -697,6 +697,6 @@ def _drive(self) -> int: m = MutatedEncoder(data_bits=16, position=0xBEEF, line_coding=False, fec_enable=False) _clock_model(m, len(m._wire_bits), half_period=10) - + frames = [e for e in m.events() if e["kind"] == "frame"] assert frames[0]["crc_ok"] is False, "Mutation was not caught! crc_ok is True" From f8563aa0a6021e19e92132ce6ea21df1cd0f54de Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 14:39:24 +0530 Subject: [PATCH 17/44] Device models: neutral comments, PRU_SIM_* environment variables, block registrations only for blocks present --- .../pru_blocks/common/device_model_table.js | 44 +++++++++++++------ .../common/pru_blocks_static_module.syscfg.js | 37 ++++++---------- .../.meta/pru_blocks/common/pru_ti_run.js | 4 +- .../common/simulation/device_models.js | 4 +- .../pru_blocks/common/simulation/pru_core.js | 19 ++++---- perif/mdio_phy_model.py | 4 +- perif/ssi_endat_encoder.py | 10 ++--- perif/step_dir_driver.py | 2 +- scripts/sim/asm_probe.py | 2 +- scripts/sim/emit_block_asm.js | 6 +-- scripts/sim/gui_run.py | 8 ++-- scripts/sim/gui_run_launcher.js | 4 +- scripts/sim/live_view.py | 4 +- scripts/sim/test_device_bindings.py | 2 +- scripts/sim/test_ti_run_gui.js | 2 +- scripts/simulator.js | 32 +++++++------- tests/test_bissline.py | 4 +- tests/test_fanout_register_alias.py | 19 ++++---- tests/test_load_constant_width.py | 2 +- tests/test_register_pool_exhaustion.py | 2 +- tools/gpi_stimulus_runner.py | 9 ++-- 21 files changed, 112 insertions(+), 108 deletions(-) diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js b/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js index 33d2603..79001ac 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js @@ -360,14 +360,29 @@ function toBinding(inst) { return binding; } +/* + * Block registrations. The tables below (READER_BLOCKS, COMPANION_PIN_BLOCKS and the rule lists of + * DRIVER_BLOCKS) are the only place a block module is tied to a device kind. A block that reads or + * drives a device is registered here in the same change that adds the block; a kind with no + * registered block has no entry (READER_BLOCKS, COMPANION_PIN_BLOCKS) or an empty rule list + * (DRIVER_BLOCKS). + */ + /** - * Which blocks read a bound device. Shared by the manifest's reader record - * (resolveDeviceReaders in the static module) and the Device Model's own - * display, so the two cannot disagree about which block reads a device. + * Which blocks read a bound device: kind -> [{module, dataPinField}]. Shared by the manifest's + * reader record (resolveDeviceReaders in the static module) and the Device Model's own display, + * so the two cannot disagree about which block reads a device. */ const READER_BLOCKS = { - bissc: [{ module: "/pru_blocks/application_specific/biss_c_initiator_block", dataPinField: "dataPin" }], - ssi: [{ module: "/pru_blocks/application_specific/ssi_initiator_block", dataPinField: "dataPin" }] +}; + +/** + * Blocks whose pins give a bound device's companion pins a default: kind -> [{module, matchField, + * clockField, indexField}]. When an instance of `module` has `matchField` equal to the device's + * primary pin, an unset (-1) clock / index pin takes that instance's `clockField` / `indexField` + * (either may be omitted). The first matching instance wins. + */ +const COMPANION_PIN_BLOCKS = { }; function findReaderInstances(kind, primaryPin, modules) { @@ -447,13 +462,13 @@ const PRU_IO = "/pru_blocks/pru_io_blocks/"; const SPI_BLOCKS = [PRU_IO + "pru_spi_transfer", PRU_IO + "pru_spi_read", PRU_IO + "pru_spi_write"]; const DRIVER_BLOCKS = { ssi: { noun: "reader", frameBased: true, pads: { - clk: { what: "extra clocks", rules: [{ module: "/pru_blocks/application_specific/ssi_initiator_block", via: [["data", "dataPin"]], padField: "clkPin" }] } } }, + clk: { what: "extra clocks", rules: [] } } }, bissc: { noun: "reader", frameBased: true, pads: { - ma: { what: "extra clocks", rules: [{ module: "/pru_blocks/application_specific/biss_c_initiator_block", via: [["slo", "dataPin"]], padField: "clkPin" }] } } }, + ma: { what: "extra clocks", rules: [] } } }, endat: { noun: "reader", frameBased: true, pads: { - clock: { what: "extra clocks", rules: [{ module: "/pru_blocks/application_specific/endat_initiator_block", via: [["data", "dataRxPin"]], padField: "clkPin" }] } } }, + clock: { what: "extra clocks", rules: [] } } }, sigma_delta: { noun: "reader", pads: { - clk: { what: "extra clocks", rules: [{ module: PRU_IO + "pdm_block", via: [["data", "dataPin"]], padField: "clkPin" }] } } }, + clk: { what: "extra clocks", rules: [] } } }, // No block in this tool speaks BiSS Line: its MASTER clock can only be a hand-built generator. bissline: { noun: "driver", name: "BiSS Line", pads: { master: { what: "extra clocks", rules: [] } } }, spi_adc: { noun: "driver", pads: { @@ -461,12 +476,12 @@ const DRIVER_BLOCKS = { sdo: { what: "corrupted data bits", rules: [{ module: SPI_BLOCKS[0] }, { module: SPI_BLOCKS[2] }] }, cs: { what: "a toggled chip select", rules: SPI_BLOCKS.map(function (m) { return { module: m, via: [["cs", "CS Signal"]], padField: "CS Signal" }; }) } } }, step_dir: { noun: "driver", pads: { - step: { what: "extra steps", rules: [{ module: PRU_IO + "stepper_block" }, { module: PRU_IO + "multi_axis_motion_block" }] }, - dir: { what: "a flipped direction", rules: [{ module: PRU_IO + "stepper_block" }, { module: PRU_IO + "multi_axis_motion_block" }] }, - en: { what: "a toggled enable", rules: [{ module: PRU_IO + "multi_axis_motion_block" }] } } }, - ws2812: { noun: "driver", pads: { data: { what: "extra bits", rules: [{ module: PRU_IO + "ws2812_block" }] } } }, + step: { what: "extra steps", rules: [] }, + dir: { what: "a flipped direction", rules: [] }, + en: { what: "a toggled enable", rules: [] } } }, + ws2812: { noun: "driver", pads: { data: { what: "extra bits", rules: [] } } }, mdio_phy: { noun: "reader", pads: { - mdc: { what: "extra clocks", rules: [{ module: PRU_IO + "mdio_master_block", via: [["mdio", "mdioBit"]], padField: "mdcBit" }] } } } + mdc: { what: "extra clocks", rules: [] } } } }; /** The device's pin for `key` (a KINDS pin key), from a manifest-shaped binding, or undefined. */ @@ -601,6 +616,7 @@ exports = { PIN_MAX, KINDS, READER_BLOCKS, + COMPANION_PIN_BLOCKS, DRIVER_BLOCKS, pinField, paramField, diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js index 86ec56d..02064a9 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js @@ -263,23 +263,17 @@ function devicePinDefaults(inst, pinNum, kind) { return null; }; - if (kind === "ssi" && clockPin === -1) { - const pins = firstPinOf("/pru_blocks/application_specific/ssi_initiator_block", - ["clkPin", "dataPin"], "dataPin"); - if (pins && Number.isInteger(pins[0]) && pins[0] >= 0) { + // Registered companion-pin sources (device_model_table.js COMPANION_PIN_BLOCKS). + for (const entry of deviceModelTable.COMPANION_PIN_BLOCKS[kind] || []) { + const pins = firstPinOf(entry.module, [entry.clockField, entry.indexField], entry.matchField); + if (!pins) continue; + if (entry.clockField && clockPin === -1 && Number.isInteger(pins[0]) && pins[0] >= 0) { clockPin = pins[0]; } - } else if (kind === "quadrature") { - const pins = firstPinOf("/pru_blocks/pru_io_blocks/quadrature_block", - ["bBit", "zBit"], "aBit"); - if (pins) { - if (clockPin === -1 && Number.isInteger(pins[0]) && pins[0] >= 0) { - clockPin = pins[0]; - } - if (indexPin === -1 && Number.isInteger(pins[1]) && pins[1] >= 0) { - indexPin = pins[1]; - } + if (entry.indexField && indexPin === -1 && Number.isInteger(pins[1]) && pins[1] >= 0) { + indexPin = pins[1]; } + break; } return { clockPin, indexPin }; @@ -769,10 +763,7 @@ function buildDeviceBindingsWithModels(inst, modules) { * order: instance names in emitted-instruction order } */ function resolveDeviceReaders(manifest, modules, allocation) { - const READER_BLOCKS = { - bissc: [{ module: "/pru_blocks/application_specific/biss_c_initiator_block", dataPinField: "dataPin" }], - ssi: [{ module: "/pru_blocks/application_specific/ssi_initiator_block", dataPinField: "dataPin" }] - }; + const READER_BLOCKS = deviceModelTable.READER_BLOCKS; // Byte ranges [start, end) a notation such as "R1", "R0.b2", "R3.w1" covers. function byteRanges(notation) { const ranges = []; @@ -1969,8 +1960,8 @@ function isSequentialBlock(blockName, instCfg) { } // The attached devices (Device Model blocks and Simulation Settings GPI device fields) whose -// PRU-driven input pads another block also drives. Verdict logic is deviceModelTable.findDeviceInputConflicts, -// shared with scripts/check_pin_collisions.js. +// PRU-driven input pads another block also drives. The decision is deviceModelTable.findDeviceInputConflicts, +// shared with the command-line pin-collision check. function reportDeviceInputDrivers(inst, gpoBits, report) { const modules = system.modules; const devices = []; @@ -2506,7 +2497,7 @@ function validate(inst, report) // Labelled as the in-GUI JavaScript preview. The CLI // build prints this same warning, and next to a `gmake sim` // run on TI pru-simulator an unlabelled "FAIL" reads as the - // verdict. It is not evidence; `gmake sim` is. The preview's own + // simulator result. Only `gmake sim` runs the real ELF. The preview's own // PASS/FAIL word is dropped from the message for that reason; // the full verdict stays on simulationData.deviceResults. const previewDetail = result.summary.replace(/^(PASS|FAIL) - /, ""); @@ -2517,7 +2508,7 @@ function validate(inst, report) report.logWarning(realBanner, inst); continue; } - report.logWarning(`GUI JS preview only, not evidence - gmake sim gives the TI pru-simulator verdict. ` + + report.logWarning(`GUI JS preview only - gmake sim gives the TI pru-simulator result. ` + `Preview of attached device "${result.device}" on GPI${result.pins.primary}: ${previewDetail}`, inst); } } else { @@ -2736,7 +2727,7 @@ If the frequencies don't match, timing-sensitive operations (SPI, ADC) may not w { name: "tiRunLiveView", displayName: "Open live web view after the run", - description: "After the run, open TI pru-simulator's dashboard in your browser on the ELF the button just built, with the bound devices attached. It does not change the verdict, and it is not started when the run was refused or the build failed. The view is simulator-only evidence, not hardware.", + description: "After the run, open TI pru-simulator's dashboard in your browser on the ELF the button just built, with the bound devices attached. It does not change the result, and it is not started when the run was refused or the build failed. The view shows the simulator, not hardware.", default: false, onChange: (inst, ui) => { ui.tiRunLivePort.hidden = !inst.tiRunLiveView; diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_ti_run.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_ti_run.js index 78a69f5..50e08ba 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_ti_run.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_ti_run.js @@ -1,8 +1,8 @@ /** * The "Run on TI pru-simulator" button, GUI side. * - * The GUI can only execute JavaScript, and its JS simulator is a preview, not - * evidence. This script is the glue that lets the Simulation Settings panel + * The GUI can only execute JavaScript, and its JS simulator is only a + * preview. This script is the glue that lets the Simulation Settings panel * LAUNCH the real chain (scripts/sim/gui_run_launcher.js -> gui_run.py: generate, * clpru build, scripts/sim/run_bound_devices.py) and SHOW what it returned. * It decides nothing: the verdict is the exit code of run_bound_devices.py, diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js index 329d8bc..7772363 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js @@ -1386,8 +1386,8 @@ const DEVICE_MODEL_KINDS = [ clockLabel: "CH_B", multiPin: true }, { kind: "bissc", displayName: "BiSS-C absolute encoder", ported: true, clockLabel: "clock", multiPin: true }, - // audit of `ported: false` for bissline: INTENTIONAL STAGED - // PORT, not a binding gap. Evidence: (1) scripts/sim/test_device_models.js + // `ported: false` for bissline is intentional (not yet ported to the + // in-tool simulator), not a binding gap: (1) scripts/sim/test_device_models.js // ("a kind not yet ported to the in-tool simulator is refused, not // faked") REQUIRES createDeviceModel("bissline") to throw, so this // flag is load-bearing; flipping it without a faithful JS port breaks diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js index 928b2ac..a5002a0 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js @@ -269,8 +269,8 @@ function executeSbbo(pruState, src, baseAddr, byteOffset, byteCount) { // IEP registers. Offsets per AM243x TRM SPRUIM2J Table 14-10902 (p6902): // 0x00 GLOBAL_CFG, 0x10/0x14 COUNT_REG0/1, 0x18/0x1C CAP_CFG/CAP_STATUS, // 0x20.. CAPRn, 0x70 CMP_CFG, 0x74 CMP_STATUS, 0x78+8j CMPj_REG0/1. - // This file previously used the AM335x-era layout (count at 0x0C, compare - // block at 0x40) - 0x0C is SLOW_COMPEN and 0x40-0x6C are capture registers. + // This is not the AM335x-era layout (count at 0x0C, compare block at + // 0x40): here 0x0C is SLOW_COMPEN and 0x40-0x6C are capture registers. const iepOffset = memAddress - 0x0002E000; let regVal = 0; for (let i = 0; i < count; i++) { @@ -405,8 +405,8 @@ function executeLBBO(pruState, dest, baseAddr, byteOffset, byteCount) { // IEP registers. Offsets per AM243x TRM SPRUIM2J Table 14-10902 (p6902): // 0x00 GLOBAL_CFG, 0x10/0x14 COUNT_REG0/1, 0x18/0x1C CAP_CFG/CAP_STATUS, // 0x20.. CAPRn, 0x70 CMP_CFG, 0x74 CMP_STATUS, 0x78+8j CMPj_REG0/1. - // This file previously used the AM335x-era layout (count at 0x0C, compare - // block at 0x40) - 0x0C is SLOW_COMPEN and 0x40-0x6C are capture registers. + // This is not the AM335x-era layout (count at 0x0C, compare block at + // 0x40): here 0x0C is SLOW_COMPEN and 0x40-0x6C are capture registers. const iepOffset = memAddress - 0x0002E000; let regVal = 0; if (iepOffset === 0x00) { @@ -1816,7 +1816,7 @@ function executeInstruction(pruState, pruInstructions, labelMap, r30ValueHistory // would hand it one giant tick, and a monoflop that should // quietly expire mid-idle instead reads as a stalled transfer. // Mark halted and let the main loop idle the bus out to - // cycleCount, one honest cycle at a time. + // cycleCount, one cycle at a time. if (pruState.deviceBus) { pruState.halted = true; pruState.pc++; @@ -1891,16 +1891,15 @@ function executeInstruction(pruState, pruInstructions, labelMap, r30ValueHistory // devices' drives and the result is what R31 samples. This runs AFTER // the fixed history so a pin under device control is owned by the // device - a pre-authored history bit on the same pin would be two - // drivers for one wire, and the bus is the only honest arbiter. + // drivers for one wire, and the bus is the only arbiter. if (pruState.deviceBus) { pruState.registers[31] = pruState.deviceBus.settle(pruState.cycles, pruState.registers[30]); } // Update IEP timer state, per TRM SPRUIM2J 6.4.13. - // Previously this incremented unconditionally, fired compares without - // checking CMP_EN, and auto-reset without checking CMP0_RST_CNT_EN - so - // firmware that never configured the IEP appeared to work here and hung - // on silicon. + // The counter only runs when enabled, compares fire only when CMP_EN + // is set, and auto-reset needs CMP0_RST_CNT_EN, so firmware that never + // configures the IEP cannot appear to work here and then hang on silicon. if (pruState.iepGlobalCfg & 0x1) { // [0] CNT_ENABLE const inc = (pruState.iepGlobalCfg >> 4) & 0xF; // [7:4] DEFAULT_INC // The counter is 64-bit across COUNT_REG0/REG1. Carrying into the diff --git a/perif/mdio_phy_model.py b/perif/mdio_phy_model.py index c3c825d..d3972e3 100644 --- a/perif/mdio_phy_model.py +++ b/perif/mdio_phy_model.py @@ -16,7 +16,7 @@ Clause 22 wire contract (the ONLY contract this model enforces): preamble >= preamble_bits clocked 1 bits (default 16, a lenient floor; idle-high MDIO counts, as on wire). - IEEE 802.3 22.2.4.5.1 specifies 32; mdio_master_block emits 32. The default stays 16 so + IEEE 802.3 22.2.4.5.1 specifies 32; a conforming MDIO master emits 32. The default stays 16 so existing 16-bit drivers keep passing; pass preamble_bits=32 for a strict check. ST 01, MSB first OP 01 = write, 10 = read (00/11 illegal) @@ -68,7 +68,7 @@ def __init__( mdc_bit: int = 0, mdio_bit: int = 1, min_half_period_cycles: int = 40, # 200 MHz / 2.5 MHz / 2 - preamble_bits: int = 16, # lenient floor; the standard (and mdio_master_block) use 32 + preamble_bits: int = 16, # lenient floor; the standard specifies 32 registers: Optional[Dict[int, int]] = None, abort_gap_factor: int = 8, **kwargs, diff --git a/perif/ssi_endat_encoder.py b/perif/ssi_endat_encoder.py index f36804e..e342904 100644 --- a/perif/ssi_endat_encoder.py +++ b/perif/ssi_endat_encoder.py @@ -13,13 +13,13 @@ latches on the first active edge and shifts on each return-to-idle edge, so a master that samples during the active pulse reads the previous bit and a frame missing its F(n+1) terminator is rejected. It still does not model - analogue setup/hold at the sampling instant; edge-placement evidence is the + analogue setup/hold at the sampling instant; edge placement is checked by the pru-simulator SsiEncoder (gmake sim / scripts/sim/run_bound_devices.py). -* ``protocol="endat"`` - RETIRED by. Its framing (start bit 0, F1/F2 - always, CRC x^5+x^3+1 preset 0) matched no cited source. EnDat has ONE model: - TI pru-simulator's pru_io/endat_encoder.py, framed against HEIDENHAIN ID - 383942 and TI's EnDat driver (docs/endat-crc-provenance.md). Asking for +* ``protocol="endat"`` - not supported. A framing of start bit 0, F1/F2 + always and CRC x^5+x^3+1 preset 0 does not match the EnDat 2.2 specification. + EnDat is modelled only by TI pru-simulator's EnDat encoder model, framed + against HEIDENHAIN ID 383942 and the CRC of TI's EnDat driver. Asking for protocol="endat" here raises ValueError naming it. Timing faults (this is what rejects wrong firmware): diff --git a/perif/step_dir_driver.py b/perif/step_dir_driver.py index ce79734..0fc0370 100644 --- a/perif/step_dir_driver.py +++ b/perif/step_dir_driver.py @@ -1,6 +1,6 @@ """STEP/DIR stepper driver input stage - DeviceModel. -Receives STEP/DIR pulses from the PRU (e.g. NoCodePRU ``stepper_block``) and +Receives STEP/DIR pulses from the PRU (e.g. a stepper block) and tracks shaft position in whole steps. This is the driver-input model: it checks the timing contract a real driver (DRV8825-class) imposes, which is what rejects wrong firmware. diff --git a/scripts/sim/asm_probe.py b/scripts/sim/asm_probe.py index b17d4e9..047f4a5 100644 --- a/scripts/sim/asm_probe.py +++ b/scripts/sim/asm_probe.py @@ -7,7 +7,7 @@ the macro finished. Build the firmware with ``DFLAGS=--asm_listing --list_directory=`` (see ``LISTING_DFLAGS``) so -``pru_syscfg.lst`` sits next to the map. Evidence stays the real clpru ELF running on TI pru-simulator. +``pru_syscfg.lst`` sits next to the map. The register values come from the real clpru ELF running on TI pru-simulator. """ import os diff --git a/scripts/sim/emit_block_asm.js b/scripts/sim/emit_block_asm.js index 797b66a..c50e70c 100644 --- a/scripts/sim/emit_block_asm.js +++ b/scripts/sim/emit_block_asm.js @@ -6,9 +6,9 @@ * node scripts/sim/emit_block_asm.js * * spec = { - * "block": "ssi_initiator_block", // block syscfg module name - * "subdir": "application_specific", // pru_blocks subdirectory - * "macroCall": "m_ssi_read", // getMacro() first argument + * "block": "_block", // block syscfg module name + * "subdir": "pru_io_blocks", // pru_blocks subdirectory + * "macroCall": "m_", // getMacro() first argument * "macroArgs": {"RES": "R4", ...}, // expansion arguments * "prelude": ["ldi32 R4, 0x1234"], // optional asm before the macro * "postlude": ["halt"], // optional asm after the macro diff --git a/scripts/sim/gui_run.py b/scripts/sim/gui_run.py index 17dc215..7b46cb0 100644 --- a/scripts/sim/gui_run.py +++ b/scripts/sim/gui_run.py @@ -253,10 +253,10 @@ def pid_alive(pid): def live_view_command(env): - """The argv prefix that starts the live view. NCP_LIVE_VIEW_CMD replaces it + """The argv prefix that starts the live view. PRU_SIM_LIVE_VIEW_CMD replaces it (test-only: lets tests stand in a stub without TI pru-simulator; POSIX quoting, so use forward slashes in paths).""" - override = env.get("NCP_LIVE_VIEW_CMD") + override = env.get("PRU_SIM_LIVE_VIEW_CMD") if override: return shlex.split(override) return [sys.executable, str(ROOT / "scripts" / "sim" / "live_view.py")] @@ -435,13 +435,13 @@ def start_live_view(make_dir, elf, manifest, port, env): + (f" ({note})" if note else "")) return view argv = live_view_command(env) + [str(elf), str(manifest), "--port", str(port)] - if env.get("NCP_LIVE_NO_BROWSER") == "1": + if env.get("PRU_SIM_LIVE_NO_BROWSER") == "1": argv.append("--no-browser") log.parent.mkdir(exist_ok=True) log.write_text(f"$ {' '.join(argv)}\n", encoding="utf-8") proc = spawn_detached(argv, env, log) view["pid"] = proc.pid - timeout = float(env.get("NCP_LIVE_READY_TIMEOUT") or LIVE_READY_SECONDS) + timeout = float(env.get("PRU_SIM_LIVE_READY_TIMEOUT") or LIVE_READY_SECONDS) deadline = time.monotonic() + timeout while time.monotonic() < deadline: code = proc.poll() diff --git a/scripts/sim/gui_run_launcher.js b/scripts/sim/gui_run_launcher.js index 309bfa0..b09bd40 100644 --- a/scripts/sim/gui_run_launcher.js +++ b/scripts/sim/gui_run_launcher.js @@ -17,7 +17,7 @@ const childProcess = require('child_process'); function candidates(env, platform) { const list = []; - if (env.NCP_PYTHON) list.push([env.NCP_PYTHON]); + if (env.PRU_SIM_PYTHON) list.push([env.PRU_SIM_PYTHON]); if (platform === 'win32') { // per-user python.org installs are found by the py launcher list.push(['python'], ['py', '-3']); @@ -64,7 +64,7 @@ function main(argv) { const repo = argValue(argv, '--repo') || path.resolve(__dirname, '..', '..'); const python = findPython(); if (!python) { - writeRefused(com, 'python 3 not found from the GUI process; set NCP_PYTHON to its full path', digest); + writeRefused(com, 'python 3 not found from the GUI process; set PRU_SIM_PYTHON to its full path', digest); return 1; } const wrapper = path.join(repo, 'scripts', 'sim', 'gui_run.py'); diff --git a/scripts/sim/live_view.py b/scripts/sim/live_view.py index 6f55290..dcfe266 100644 --- a/scripts/sim/live_view.py +++ b/scripts/sim/live_view.py @@ -428,7 +428,7 @@ def main(argv=None): ap.add_argument("--core", help="Override the SysConfig core in the manifest") ap.add_argument("--decoded-register", type=int, help="Explicit decoded-value register, hand-built ELFs only") - ap.add_argument("--no-browser", action="store_true", help="Do not open a browser (also NCP_LIVE_NO_BROWSER=1)") + ap.add_argument("--no-browser", action="store_true", help="Do not open a browser (also PRU_SIM_LIVE_NO_BROWSER=1)") args = ap.parse_args(argv) if args.host not in LOOPBACK and not args.allow_remote: @@ -454,7 +454,7 @@ def main(argv=None): print(f"Attached {len(state.attached)} devices: " + ", ".join(f"{e['kind']} (GPI{e['primaryPin']})" for e, _ in state.attached)) print(f"Live view: {url} (Ctrl+C to stop)") - if not (args.no_browser or os.environ.get("NCP_LIVE_NO_BROWSER") == "1"): + if not (args.no_browser or os.environ.get("PRU_SIM_LIVE_NO_BROWSER") == "1"): threading.Timer(1.0, webbrowser.open, args=(url,)).start() uvicorn.run(app, host=args.host, port=args.port, log_level="warning") except LiveViewRefused as exc: diff --git a/scripts/sim/test_device_bindings.py b/scripts/sim/test_device_bindings.py index 1a3a72b..0e29efb 100644 --- a/scripts/sim/test_device_bindings.py +++ b/scripts/sim/test_device_bindings.py @@ -85,7 +85,7 @@ def test_every_device_offered_by_the_gui_has_a_model(): That is the worst available outcome: the user picks a device, the run goes green, and nothing was ever driven. Read the option list out of the SysConfig module rather than restating it here, so adding a GUI option - without a model fails this test instead of being noticed in review. + without a model fails this test. """ source = open(STATIC_MODULE, encoding="utf-8").read() start = source.index("name: `gpi${pinNum}_deviceKind`") diff --git a/scripts/sim/test_ti_run_gui.js b/scripts/sim/test_ti_run_gui.js index ed4a422..c8bc23a 100644 --- a/scripts/sim/test_ti_run_gui.js +++ b/scripts/sim/test_ti_run_gui.js @@ -261,7 +261,7 @@ test('findPython: PATH python first; skips a Python 2 / failing candidate; null }; assert.deepStrictEqual(launcher.findPython({}, 'win32', spawn), ['py', '-3']); assert.deepStrictEqual(calls.slice(0, 2), ['python', 'py']); - assert.deepStrictEqual(launcher.findPython({ NCP_PYTHON: '/srv/py/python.exe' }, 'win32', + assert.deepStrictEqual(launcher.findPython({ PRU_SIM_PYTHON: '/srv/py/python.exe' }, 'win32', (cmd) => cmd === '/srv/py/python.exe' ? { status: 0, stdout: 'Python 3.11.0', stderr: '' } : { status: 1 }), ['/srv/py/python.exe']); assert.strictEqual(launcher.findPython({}, 'win32', () => ({ status: 1 })), null); diff --git a/scripts/simulator.js b/scripts/simulator.js index 60ce28a..ad2afc0 100644 --- a/scripts/simulator.js +++ b/scripts/simulator.js @@ -1,11 +1,11 @@ /** - * simulator.js - Enhanced PRU Simulator with agent-driven validation support + * simulator.js - PRU simulator wrapper for scripted validation * * Provides a modular Simulator class wrapping SysConfig's pru_core.js / pru_dmem0.js / * pru_smem.js simulation engines. Adds: * * G1 - load_elf(buffer): parse TI COFF PRU firmware binaries, load sections into DMEM/SMEM - * G2 - step() / stepPaced(): single-cycle through multi-cycle stepping for multicore orchestration + * G2 - step() / stepPaced(): single-cycle through multi-cycle stepping for lock-step multicore runs * G3 - runUntil({maxCycles, watchpoints}): halt on cycle budget, halt, PC, register, or memory conditions * G4 - exportVcd(filePath): Value Change Dump waveform export */ @@ -14,10 +14,10 @@ const fs = require('fs'); const path = require('path'); const vm = require('vm'); // the same-line label grammar must be the repo's canonical -// one (asm_label.js splitLabel, [A-Za-z0-9_?$]+). The previous local regex -// [A-Za-z_$][\w$]* rejected `?` anywhere and leading digits, so e.g. -// `pb_ws?: add r1,r1,1` never registered pb_ws? as a label and a -// `qbne pb_ws?, r1, 3` branch silently fell through with errorParsing set. +// one (asm_label.js splitLabel, [A-Za-z0-9_?$]+). A narrower regex such as +// [A-Za-z_$][\w$]* rejects `?` anywhere and leading digits, so e.g. +// `pb_ws?: add r1,r1,1` would not register pb_ws? as a label and a +// `qbne pb_ws?, r1, 3` branch would silently fall through with errorParsing set. const { splitLabel } = require('./asm_label'); /** @@ -534,7 +534,7 @@ function exportVcd(r30History, r31History, options = {}) { const SIM_DIR = path.join(__dirname, '../.metadata/sysconfig/.meta/pru_blocks/common/simulation'); /** - * Comprehensive PRU Simulator with programmatic agent-driven validation support. + * PRU simulator with a programmatic API for scripted validation. * * Usage: * const sim = new Simulator(); @@ -562,9 +562,8 @@ const SIM_DIR = path.join(__dirname, '../.metadata/sysconfig/.meta/pru_blocks/co * hung off pruState like every other device model in this file. Each Simulator * compiles its own copy of the memory models into its own vm context, so * per-instance state is invisible to a second core BY CONSTRUCTION - a spinlock - * modelled that way would let both cores "win" the same flag, which is exactly - * the defect that sank the first attempt at this item. A lock whose state is - * not shared is not a lock. + * modelled that way would let both cores "win" the same flag. A lock whose + * state is not shared is not a lock. * * Keys are subsystem ids ('icssg0' by default); values map flag id -> the core * id currently holding it. @@ -606,7 +605,7 @@ class Simulator { // ownership between scenarios; nothing in the hardware corresponds to this. static resetSpinlocks() { SPINLOCK_FLAGS.clear(); } - /** Inspect who holds a flag. Test/debug helper, not a hardware path. */ + /** Inspect who holds a flag. Simulator inspection API, not a hardware path. */ static spinlockOwner(flagId, subsystem = 'icssg0') { const owner = spinlockSubsystem(subsystem).get(flagId & (SPINLOCK_NUM_FLAGS - 1)); return owner === undefined ? null : owner; @@ -839,9 +838,9 @@ class Simulator { xfr2tr: null, // BS RAM 2KB per-core private 64x32B. Per-core means per-Simulator // instance (this._pruState), not per-subsystem like XFR2SPIN. Lazily - // allocated; each row is 8 words (32 bytes). Also the flat SPAD fallback - // that previously made broadside accesses appear to work is now gated: 48/49 are - // no longer handled by _spad, they are BS RAM or unsupported. + // allocated; each row is 8 words (32 bytes). Device IDs 48/49 are not + // handled by the flat SPAD model (_spad): they are BS RAM or unsupported, + // so a broadside access cannot appear to work by accident. bsRam: null, bsRamAddr: 0, fdb: null, @@ -1939,9 +1938,8 @@ class Simulator { this._spinlockRelease(pruState, srcRegN); pruState.pc++; } else if (bank === BS_RAM_DEVICE_WRITE || bank === BS_RAM_DEVICE_SNOOP) { - // BS RAM write (48) and snoop (49) XOUT. Flat SPAD fallback - // previously made `xout 48` appear to work by accident; now a real - // 64x32B per-core model. + // BS RAM write (48) and snoop (49) XOUT, modelled as a 64x32B + // per-core RAM rather than the flat SPAD model. this._bsRamXout(pruState, bank, srcRegN, count); pruState.pc++; } else if (bank === FDB_DEVICE_DATA_ADDR || bank === FDB_DEVICE_PATTERN) { diff --git a/tests/test_bissline.py b/tests/test_bissline.py index 5044cb4..c694839 100644 --- a/tests/test_bissline.py +++ b/tests/test_bissline.py @@ -55,8 +55,8 @@ def _independent_crc6(bits): Because this divides by the polynomial 0x43 explicitly, a wrong polynomial baked into the model's shift register is detectable here: the two implementations only agree if both compute the remainder of - the same G(x). (the previous version of this helper was a - line-by-line copy of the model's loop and could not detect that.) + the same G(x). (A line-by-line copy of the model's loop could not + detect that.) """ dividend = 0 for b in bits: diff --git a/tests/test_fanout_register_alias.py b/tests/test_fanout_register_alias.py index 08050a0..8403bba 100644 --- a/tests/test_fanout_register_alias.py +++ b/tests/test_fanout_register_alias.py @@ -1,7 +1,7 @@ """the register allocator must not free a value that still has a data consumer. -Reported by the end-to-end agent: a 5-stage gray-to-binary cascade (g ^= g >> 1, 2, 4, 8, 16) built from -Bitwise blocks "combined the value with itself". Reproduced 2026-10-07 with the real generated asm: +A 5-stage gray-to-binary cascade (g ^= g >> 1, 2, 4, 8, 16) built from Bitwise blocks "combined the value with +itself". Generated asm before the allocator fix: LDI32 R0, 1515870810 ; G0 LSR R0, R0 , R1.b0 ; Shr_1 - destination R0 == G0, G0 is gone @@ -17,12 +17,11 @@ Fix: a release additionally requires dataConsumersRemaining == 0 (exact count of unread data connections), so the allocator can only release LESS than before. The same over-decrement also makes single-consumer chains leak -registers (count goes negative); that is deliberately NOT changed here: read intermediate registers at -HALT and depend on dead values never being reused. Tracked as a separate follow-up (done in those tests now -sample each value at the macro that produced it, and the chains release correctly). +registers (count goes negative); tests of single-consumer chains sample each value at the macro that produced it, +so they do not depend on dead values never being reused, and those chains release correctly. -Evidence is the real clpru ELF stepped on TI pru-simulator against an independent Python oracle. The harness is -itself proven able to see the defect (test_harness_detects_aliased_asm): hand-written aliased asm gives 0. +The tests step the real clpru ELF on TI pru-simulator against an independent Python oracle. The harness is +itself shown able to see the bug (test_harness_detects_aliased_asm): hand-written aliased asm gives 0. Tests skip cleanly when clpru, the SysConfig 1.28 CLI or the pru-simulator checkout is missing. """ @@ -107,7 +106,7 @@ def gray_decode_by_prefix_xor(g): # ---------------------------------------------------------------------- designs def cascade_syscfg(value=VALUE, shifts=SHIFTS): - """The agent's design with NO memory copies: stage k = G_k = G_{k-1} ^ (G_{k-1} >> s_k).""" + """The cascade design with NO memory copies: stage k = G_k = G_{k-1} ^ (G_{k-1} >> s_k).""" n = len(shifts) L = [HEADER, "const g0 = load_constant_block.addInstance();"] for i in range(n): @@ -270,8 +269,8 @@ def test_oracles_agree_with_each_other(): @needs_tools -def test_agents_gray_cascade_runs_correctly_without_memory_copies(): - """The agent's 5-stage cascade, exactly as it wired it but with NO Memory Access copies.""" +def test_gray_cascade_runs_correctly_without_memory_copies(): + """The 5-stage cascade wired directly, with NO Memory Access copies.""" elf, asm, reg = cached("cascade", lambda: cascade_syscfg()) halted, got = run_elf(elf, reg) assert halted diff --git a/tests/test_load_constant_width.py b/tests/test_load_constant_width.py index 43638de..b991976 100644 --- a/tests/test_load_constant_width.py +++ b/tests/test_load_constant_width.py @@ -5,7 +5,7 @@ LDI Rn.w0 / Rn.w2 with no warning). outputWidth = "4" now reserves a whole register; the default "auto" is unchanged, so existing designs generate byte-identical output. -Evidence: SysConfig CLI generation, and the real clpru ELF on TI pru-simulator. +Checked with SysConfig CLI generation, and with the real clpru ELF on TI pru-simulator. """ import os import re diff --git a/tests/test_register_pool_exhaustion.py b/tests/test_register_pool_exhaustion.py index 840fbd7..990afe3 100644 --- a/tests/test_register_pool_exhaustion.py +++ b/tests/test_register_pool_exhaustion.py @@ -7,7 +7,7 @@ The allocator records each request it could not place (getAllocationFailures) and validatePruInstructions (pru_blocks_static_module.syscfg.js) reports each one with logError, naming the block and the register budget. -Evidence: a design with more live values than the pool holds must FAIL generation with the specific message (the +Checks: a design with more live values than the pool holds must FAIL generation with the specific message (the allocator releases values after their last consumer, so the design here is wider than the 24-stage sum); the 16-stage design must still generate with all 16 ADDs and run correctly as the real clpru ELF on TI pru-simulator against a plain-Python sum. `assert_every_block_emitted` is a reusable static helper: every block that should emit an instruction appears in the asm. diff --git a/tools/gpi_stimulus_runner.py b/tools/gpi_stimulus_runner.py index 70ff3ba..15c8fbb 100644 --- a/tools/gpi_stimulus_runner.py +++ b/tools/gpi_stimulus_runner.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -"""gpi_stimulus_runner.py — run PRU firmware under a cycle-keyed GPI waveform.. This runner attaches the waveform to the simulation through the -DeviceBus (``io_port.attach_device(WaveformPlayer(...))``) instead of poking -``set_gpi_word`` / ``io_port.gpi`` past the bus, which is what the previous -scratch version did. Writing GPI directly meant: +"""gpi_stimulus_runner.py — run PRU firmware under a cycle-keyed GPI waveform. + +This runner attaches the waveform to the simulation through the DeviceBus +(``io_port.attach_device(WaveformPlayer(...))``) instead of poking +``set_gpi_word`` / ``io_port.gpi`` past the bus. Writing GPI directly would mean: * no contention resolution — a stimulus shorted against a device model (or against PRU-driven pins) looked identical to a clean one; From 276891fe9364318adef889e84b04d91927b77686 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 14:40:21 +0530 Subject: [PATCH 18/44] Device models: comments name only files in this change --- .../.meta/pru_blocks/common/device_model_table.js | 12 ++++++------ .../common/pru_blocks_static_module.syscfg.js | 3 +-- .../pru_blocks/common/simulation/device_bus.js | 9 +++------ .../pru_blocks/common/simulation/device_models.js | 11 +++++------ .../pru_blocks/utils/device_model_block.syscfg.js | 2 +- perif/ssi_endat_encoder.py | 3 +-- pru_io/__init__.py | 4 ++-- scripts/dual_oracle_harness.js | 2 +- scripts/sim/device_bindings.py | 3 +-- scripts/sim/device_model_replay.js | 6 +++--- scripts/sim/live_view.py | 2 +- scripts/sim/test_device_models.js | 6 ++---- scripts/simulator.js | 11 ++++------- tests/test_loop_carried_accumulator.py | 13 ++++++------- tests/test_spi_transfer_zero_extend.py | 2 +- 15 files changed, 38 insertions(+), 51 deletions(-) diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js b/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js index 79001ac..781d5bb 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js @@ -17,9 +17,9 @@ * The GPI route takes a JSON string, so a typo in a key, a string where a * number belongs, or an out-of-range value is only found when a model refuses * it at run time. A typed field is range-checked in the GUI. A key here MUST be - * a keyword of the Python model constructor; test_device_model.py - * proves that against the real constructors, so renaming a model parameter - * breaks a test here instead of producing a model that is not the one asked for. + * a keyword of the Python model constructor, so renaming a model parameter + * must be done in both places; otherwise the run refuses the model instead of + * building one that is not the one asked for. * * PIN SLOTS * `slot` is where the pin travels in the manifest: primaryPin / clockPin / @@ -411,8 +411,8 @@ function defaultPin(pinSpec) { /** * `inst` with every field of its kind that it does not carry set to the field's default - what a .syscfg - * that leaves a field unset means. SysConfig does this itself; scripts/check_pin_collisions.js (which has - * no SysConfig runtime) needs it spelled out. + * that leaves a field unset means. SysConfig does this itself; a command-line check (which has no + * SysConfig runtime) needs it spelled out. */ function withDefaults(inst) { const out = { deviceKind: inst.deviceKind, $name: inst.$name }; @@ -428,7 +428,7 @@ function withDefaults(inst) { * WS2812 DATA, MDIO's MDC, ...) may be driven only by the blocks that legitimately talk to that device. A second block on * the pad (a SPI write whose SCLK is the same GPO) makes the device see its output as extra input edges; sequential * hand-off of a pad is legal only while NO device listens on it, which is why the duplicate-GPO rule exempts sequential - * blocks and this one does not. Shared by the GUI validator and scripts/check_pin_collisions.js so the two cannot disagree. + * blocks and this one does not. Shared by the GUI validator and the command-line pin-collision check so the two cannot disagree. * * DRIVER_BLOCKS is the explicit list. Per kind, per PRU-driven pad (the device-pin `key` of KINDS), the block modules * that legitimately drive that pad. A rule is one of diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js index 02064a9..e47e51e 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js @@ -1056,8 +1056,7 @@ function generateR31SimulationInputConfigs() { // per-pin idiom. The fuller SysConfig answer is an instance-array // module, one instance per attached device, which is how TI models // multi-pin peripherals elsewhere. That is a larger change to the - // static module's data flow and is recorded in - // docs/simulation/device_binding.md rather than chosen silently here. + // static module's data flow and is not made here. configs.push({ name: `gpi${pinNum}_deviceClockPin`, displayName: `GPI${pinNum} Device Clock/B Pin`, diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_bus.js b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_bus.js index d8fa5cd..e24b488 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_bus.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_bus.js @@ -2,12 +2,9 @@ * device_bus.js - the wire between the simulated PRU and attached device models. * * This is a JavaScript port of the DeviceBus contract in the TI pru-simulator - * checkout (pru_io/device_model.py, built under and extended through - *). The semantics are intended to be IDENTICAL, and - * scripts/sim/test_device_model_crosscheck.js drives both implementations with - * the same stimulus and diffs them so a drift between the two is a red test, - * not an opinion. If you change the behaviour here, change it there too, in - * the same commit series - one bus, two languages. + * (pru_io/device_model.py). The semantics are intended to be IDENTICAL: if + * you change the behaviour here, change it there too, in the same commit + * series - one bus, two languages. * * WHY A BUS AT ALL * pru_core.js models the PRU core and, until now, took its R31 input from a diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js index 7772363..4d41940 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js @@ -2,12 +2,11 @@ * device_models.js - device models attachable to Simulation Settings pins. * * JavaScript ports of the DeviceModel implementations in the TI pru-simulator - * checkout (pru_io/quadrature_encoder.py, pru_io/ssi_encoder.py; built under - * and). The ports are line-by-line faithful in behaviour, and - * scripts/sim/test_device_model_crosscheck.js drives the JS and Python models - * with the same stimulus and diffs drives, events and faults, so a drift - * between the two is a red test rather than an assumption. If you change - * behaviour here, change the Python model too, in the same commit series. + * (pru_io/quadrature_encoder.py, pru_io/ssi_encoder.py). The ports are meant + * to be line-by-line faithful in behaviour (scripts/sim/device_model_replay.js + * replays a stimulus through the JS models so the two can be diffed). If you + * change behaviour here, change the Python model too, in the same commit + * series. * * WHY THESE TWO FIRST * The SysConfig dropdown (pru_blocks_static_module.syscfg.js) lists every diff --git a/.metadata/sysconfig/.meta/pru_blocks/utils/device_model_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/utils/device_model_block.syscfg.js index 891aad6..09ffc23 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/utils/device_model_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/utils/device_model_block.syscfg.js @@ -20,7 +20,7 @@ const STATIC_MODULE = "/pru_blocks/common/pru_blocks_static_module"; const SELF_MODULE = "/pru_blocks/utils/device_model_block"; const DEFAULT_KIND = "ssi"; -// Slot -> default pin, so a fresh instance of any kind starts on distinct pins (table.defaultPin; scripts/check_pin_collisions.js uses it too). +// Slot -> default pin, so a fresh instance of any kind starts on distinct pins (table.defaultPin, shared with the command-line pin check). const defaultPin = table.defaultPin; function kindOptions() { diff --git a/perif/ssi_endat_encoder.py b/perif/ssi_endat_encoder.py index e342904..bb2091a 100644 --- a/perif/ssi_endat_encoder.py +++ b/perif/ssi_endat_encoder.py @@ -123,8 +123,7 @@ def __init__( # ------------------------------------------------------------- SSI frame def _build_ssi_frame(self) -> int: """Frame layout (MSB first): [MT | ST | error? | parity?], masked to - total_width. Follows the landed oracle (scripts/ssi_oracle.js): - gray applies to the whole ST+MT payload word; parity is even parity of + total_width. Gray applies to the whole ST+MT payload word; parity is even parity of all frame bits above the parity bit (parity_type 1 inverts).""" st = self.st_value & ((1 << self.st_bits) - 1) mt = self.mt_value & ((1 << self.mt_bits) - 1) diff --git a/pru_io/__init__.py b/pru_io/__init__.py index a6f954c..72bf7f4 100644 --- a/pru_io/__init__.py +++ b/pru_io/__init__.py @@ -1,7 +1,7 @@ """pru_io package. -the EnDat model that lived here was retired by EnDat has one -model, TI pru-simulator's pru_io/endat_encoder.py. +There is no EnDat model here: EnDat is modelled by TI pru-simulator's EnDat +encoder model. """ from .waveform_player import WaveformPlayer # noqa: F401 diff --git a/scripts/dual_oracle_harness.js b/scripts/dual_oracle_harness.js index db88c6e..dd6cedf 100644 --- a/scripts/dual_oracle_harness.js +++ b/scripts/dual_oracle_harness.js @@ -220,7 +220,7 @@ function evalConditionDetailed(rawCond, mapping, definedSymbols) { } // Before the substitution below, which would rewrite the quoted name. // `$isdefed("SYM")` / `$defined(SYM)` are decidable only for a caller that tracks assembler - // symbol definitions (emit_all_blocks.js). Without a symbol set they stay undecidable. + // symbol definitions. Without a symbol set they stay undecidable. const dm = cond.match(/^(!?)\s*\$(isdefed|defined)\(\s*("([^"]+)"|[A-Za-z_]\w*)\s*\)$/); if (dm) { const quoted = dm[3].startsWith('"'); diff --git a/scripts/sim/device_bindings.py b/scripts/sim/device_bindings.py index 0b5360c..27a6811 100644 --- a/scripts/sim/device_bindings.py +++ b/scripts/sim/device_bindings.py @@ -514,8 +514,7 @@ def main(argv=None): bindings = manifest.get("bindings", []) if not bindings and not args.allow_empty: - # Comparing zero cases is a failure, not a pass - the same rule as - # second_oracle.py. Pass --allow-empty when a project genuinely has no + # Comparing zero cases is a failure, not a pass. Pass --allow-empty when a project genuinely has no # attached devices and you want the step to be a no-op. print("REFUSED: manifest has no device bindings. A run that attaches " "nothing proves nothing; pass --allow-empty if that is intended.") diff --git a/scripts/sim/device_model_replay.js b/scripts/sim/device_model_replay.js index 96f9bb2..884bafc 100644 --- a/scripts/sim/device_model_replay.js +++ b/scripts/sim/device_model_replay.js @@ -2,9 +2,9 @@ * device_model_replay.js - replay a fixed pin stimulus through the JS device * models and print the resulting R31 trace, events and faults as JSON. * - * Exists for scripts/sim/test_device_model_crosscheck.py, which replays the - * SAME stimulus through the Python oracle models and diffs the two. Keeping - * this runner tiny and deterministic is what makes that diff meaningful. + * Replaying the SAME stimulus through the Python models of TI pru-simulator + * and diffing the two outputs cross-checks the ports. Keeping this runner tiny + * and deterministic is what makes that diff meaningful. * * Usage: * node scripts/sim/device_model_replay.js stimulus.json diff --git a/scripts/sim/live_view.py b/scripts/sim/live_view.py index dcfe266..db82fdd 100644 --- a/scripts/sim/live_view.py +++ b/scripts/sim/live_view.py @@ -19,7 +19,7 @@ GET /live/info what is attached and on which pins GET / the stock page plus a verdict banner -Facts settled by reading and probing TI pru-simulator (see tests/test_live_view.py): +Behaviour of TI pru-simulator's dashboard this relies on: * ui.server builds its Simulator at import, as a module global. Everything it does reaches that object through the module, so it is attached BEFORE uvicorn starts; nothing needs a running server. diff --git a/scripts/sim/test_device_models.js b/scripts/sim/test_device_models.js index 03d5ab9..e9edd1b 100644 --- a/scripts/sim/test_device_models.js +++ b/scripts/sim/test_device_models.js @@ -3,10 +3,8 @@ * * The models in .metadata/.../simulation/device_models.js are JavaScript * ports of the Python DeviceModel implementations in the TI pru-simulator - * checkout. These tests prove the PORTS behave: legal traffic is answered, - * illegal traffic is refused. The cross-check that the two languages still - * agree is scripts/sim/test_device_model_crosscheck.js — this file proves - * behaviour, that one proves identity. + * checkout. These tests check that the PORTS behave: legal traffic is + * answered, illegal traffic is refused. * * The tests that matter are the refusals: a model that cannot reject broken * firmware is a waveform generator, not an oracle. Every fault arm is paired diff --git a/scripts/simulator.js b/scripts/simulator.js index ad2afc0..00b19f6 100644 --- a/scripts/simulator.js +++ b/scripts/simulator.js @@ -734,16 +734,13 @@ class Simulator { // in labelMap, the branch was silently not taken, and a contended spin // loop looked like a single arbitration attempt instead of a spin. // - // Contrary to what this comment used to claim, NOT every - // producer splits same-line labels before calling loadInstructions - // (scripts/sim/test_bswap.js, test_xfr2spin.js, - // test_xfr2tr_ring.js and run_headless_json.js pass raw macro lines - // through). loadInstructions therefore handles `lbl: instr` itself now: + // Not every producer splits same-line labels before calling + // loadInstructions (scripts that pass raw macro lines through do not). + // loadInstructions therefore handles `lbl: instr` itself: // the label is registered at the line's own index and the stored line is // replaced with the bare instruction, so a branch to the label EXECUTES // that instruction instead of no-oping past it (and sequential flow no - // longer silently drops it). No block emits a same-line label today, so - // this only closes the latent gap; it changes nothing for bare labels. + // longer silently drops it). It changes nothing for bare labels. for (let i = 0; i < this._instructions.length; i++) { const line = this._instructions[i]; if (typeof line !== 'string') continue; diff --git a/tests/test_loop_carried_accumulator.py b/tests/test_loop_carried_accumulator.py index e8bc723..ac767f3 100644 --- a/tests/test_loop_carried_accumulator.py +++ b/tests/test_loop_carried_accumulator.py @@ -1,12 +1,12 @@ -"""loop-carried accumulator (option 3, phase 1). +"""Loop-carried accumulator. Repeat-add computes x*N by adding x to an accumulator N times in a counted -loop. At base c5f20509 the ADD reads the loop-invariant preInit register -(0) on every pass, so out_buf holds x instead of N*x, -and the result cannot be stored after the loop because rejects any +loop. Without an accumulator binding the ADD reads the loop-invariant preInit +register (0) on every pass, so out_buf holds x instead of N*x, and the result +cannot be stored after the loop because container validation rejects any consumer outside the loop reading a block inside it. -Option 3 (docs/loop-carried-data.md, section 4.3) fixes this with: +The loop block handles this with: * loop_block.accumulators: an explicit {preInit, update} binding, so the allocator coalesces the ALU block's destination with its preInit register (ADD writes back in place, 0 extra cycles per iteration); @@ -14,8 +14,7 @@ read from outside the loop (exemption), so the store sits AFTER the loop instead of inside it. -Design under test (built from tools/pru-llm-corpus/scripts/family_designs.py -b_repeat_add, with the write moved after the loop): +Design under test (a repeat-add design with the write moved after the loop): mv_x (x_buf) --rd_x--> acc(ADD) --\ k_acc(0, preInit) ---------------> acc } Loop_0 x N --> wr(out_buf) diff --git a/tests/test_spi_transfer_zero_extend.py b/tests/test_spi_transfer_zero_extend.py index b341399..d6baeec 100644 --- a/tests/test_spi_transfer_zero_extend.py +++ b/tests/test_spi_transfer_zero_extend.py @@ -65,7 +65,7 @@ def test_read_header_form_clears_inside_the_macro(name): def test_read_prefix_probe_does_not_throw_but_a_real_variant_without_an_insert_point_does(): - """scripts/block_control_flow.js probes getMacro("", "m_read_packet_spi_"); that must stay quiet.""" + """The block control-flow classifier probes getMacro("", "m_read_packet_spi_"); that must stay quiet.""" code = r''' const {loadBlockModule}=require('./scripts/dual_oracle_harness'); const mod=loadBlockModule('pru_spi_read', 'pru_io_blocks'); From 1cadbb6e096d4d1eb4ed3cda366980be4903cbd7 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 14:41:48 +0530 Subject: [PATCH 19/44] Device models: state technical reasons in comments --- .../pru_blocks/common/device_model_table.js | 2 +- .../common/pru_blocks_static_module.syscfg.js | 9 ++++----- .../common/pru_device_bindings.json.xdt | 2 +- .../common/simulation/device_models.js | 5 ++--- scripts/asm_label.js | 8 ++++---- scripts/sim/device_bindings.py | 6 +++--- scripts/sim/test_device_binding_runtime.js | 2 +- scripts/sim/test_device_bindings.py | 2 +- scripts/sim/test_device_models.js | 3 +-- scripts/simulator.js | 19 ++++++++----------- tests/test_fanout_register_alias.py | 2 +- tests/test_register_pool_exhaustion.py | 2 +- tests/test_spi_transfer_zero_extend.py | 2 +- 13 files changed, 29 insertions(+), 35 deletions(-) diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js b/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js index 781d5bb..2a47855 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js @@ -8,7 +8,7 @@ * * WHAT A DEVICE MODEL INSTANCE IS * An ADDITIONAL source of device bindings next to the GPI device fields in - * Simulation Settings (decision on, option 1). The GPI fields are + * Simulation Settings. The GPI fields are * untouched and keep working. Both sources feed the same manifest, in the same * shape, so scripts/sim/device_bindings.py cannot tell them apart - which is * the point: one consumer, one set of safety checks. diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js index e47e51e..7bdddd5 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js @@ -394,8 +394,8 @@ function collectDeviceBindings(inst) { * or nulls when the clock never toggled. This is a MEASUREMENT on the * waveform, not a restatement of the block's configured timing - the two * differ exactly when the firmware is wrong, which is when the number - * matters. (lesson: a margin read from the config instead of the - * trace is a formula standing in for a measurement.) + * matters. (A margin read from the config instead of the trace would be a + * formula standing in for a measurement.) */ function measureClockWaveform(r30ValueHistory, clkPin) { const bit = 1 << clkPin; @@ -616,8 +616,7 @@ function generatePinHistory(inst, pinNum) { * Build the device-binding manifest for the generated output. * * WHY THIS IS A FILE AND NOT A GUI FEATURE - * Per decision C on, the Python device models on TI pru-simulator are - * the oracle; the SysConfig GUI can only execute JavaScript and must never + * The Python device models on TI pru-simulator are the reference; the SysConfig GUI can only execute JavaScript and must never * become the authority. So the binding the user configures here is EMITTED, * and the build runs the real model against it. This function produces that * emission and nothing else - it does not simulate. @@ -755,7 +754,7 @@ function buildDeviceBindingsWithModels(inst, modules) { * block gets `readers: []`, which the runner reports instead of guessing. * * Pure: the module map and the allocator are passed in, so the function can be - * evaluated outside the SysConfig runtime (scripts/sim/test_device_bindings.js). + * evaluated outside the SysConfig runtime (by node tests). * * @param manifest buildDeviceBindings() output; annotated in place and returned * @param modules system.modules diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_device_bindings.json.xdt b/.metadata/sysconfig/.meta/pru_blocks/common/pru_device_bindings.json.xdt index 2091a57..5d0412c 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_device_bindings.json.xdt +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_device_bindings.json.xdt @@ -3,7 +3,7 @@ * pru_device_bindings.json - the device-binding manifest. * * Emitted next to pru_syscfg.inc so the build, not the GUI, runs the oracle. - * Decision C on the Python device models on TI pru-simulator are the + * The Python device models on TI pru-simulator are the * authority. This file is how the user's GUI selection reaches them without * the user naming a path or invoking Python. * diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js index 4d41940..f1f8e6a 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js @@ -12,9 +12,8 @@ * The SysConfig dropdown (pru_blocks_static_module.syscfg.js) lists every * kind the Python DeviceModel contract covers. Only the two ported here are * selectable in the in-tool (JavaScript) simulator today; the factory refuses - * the rest VISIBLY instead of substituting something unproven. A device mode - * that quietly runs the wrong model is exactly the "polished seam over an - * unproven oracle" failure this item was sequenced to avoid. + * the rest VISIBLY instead of substituting something unproven: a device mode + * that quietly ran the wrong model would make a wrong design look correct. * * FAULTS ARE THE POINT * A device model that can only produce plausible waveforms is decoration. Its diff --git a/scripts/asm_label.js b/scripts/asm_label.js index b5f6d53..8a23c68 100644 --- a/scripts/asm_label.js +++ b/scripts/asm_label.js @@ -3,10 +3,10 @@ * asm_label.js - one shared splitter for `label: instr` lines. * * The TI PRU assembler (clpru) accepts a label followed by an instruction on - * the same line (e.g. `pb_ws?: sub r1, r1, 1`). Several JS macro expanders - * used to treat a line as a label only when the whole trimmed line ended with - * ':', so such a line was misread as an instruction, the label was never - * defined, and a later branch to it had an unresolved target. + * the same line (e.g. `pb_ws?: sub r1, r1, 1`). An expander that treats a + * line as a label only when the whole trimmed line ends with ':' misreads + * such a line as an instruction, never defines the label, and leaves a later + * branch to it with an unresolved target. * * splitLabel(line) -> null when the line carries no code (blank or * comment-only), otherwise { label, rest } where `label` is the raw label diff --git a/scripts/sim/device_bindings.py b/scripts/sim/device_bindings.py index 27a6811..2c9b5a6 100644 --- a/scripts/sim/device_bindings.py +++ b/scripts/sim/device_bindings.py @@ -12,7 +12,7 @@ so the build runs the oracle the user selected in the GUI without the user naming a path or writing Python. -Decision C on the Python models are the authority. The GUI is a +The Python models are the authority. The GUI is a selector, not a simulator. WHY THE PIN TRANSLATION LIVES HERE AND NOT IN THE GUI @@ -441,8 +441,8 @@ def driven_pins(model, stimulus=None): The default is the all-pins-low quiet case, then all-pins-high, then low again: the high-to-low step is a genuine active-low chip-select assertion EDGE, so a device that only drives inside a frame is observed driving. (The - all-low level alone no longer suffices: a first-tick level is the reset - state, not an edge - r3.) A model that returns a tuple is an upstream model that should have + all-low level alone does not suffice: a first-tick level is the reset + state, not an edge.) A model that returns a tuple is an upstream model that should have had `nets`; that is a registry error, not something to guess around, so it raises. """ diff --git a/scripts/sim/test_device_binding_runtime.js b/scripts/sim/test_device_binding_runtime.js index 08a30a3..588149d 100644 --- a/scripts/sim/test_device_binding_runtime.js +++ b/scripts/sim/test_device_binding_runtime.js @@ -270,7 +270,7 @@ test('verdict: macro blocks report cannot execute macro blocks instead of device test('margin: the measurement reads the trace, not the configuration', () => { // A clock scripted at 10-cycle periods must measure 10 regardless of - // what any config field claims - lesson. + // what any config field claims. const history = new Array(201).fill(1); // bit 0 high for (let start = 20; start < 200; start += 100) { for (let c = start; c < start + 5; c++) history[c] = 0; // 5 low, 5 high diff --git a/scripts/sim/test_device_bindings.py b/scripts/sim/test_device_bindings.py index 0e29efb..be93e04 100644 --- a/scripts/sim/test_device_bindings.py +++ b/scripts/sim/test_device_bindings.py @@ -400,7 +400,7 @@ def test_binding_a_local_model_first_does_not_break_the_upstream_ones(): Resolving one by mutating sys.path and leaving it mutated makes every later import depend on binding order. The suite can be green on that by luck of alphabetical parametrisation, so the order is pinned here: local - first, upstream second, which is the direction that used to fail. + first, upstream second, the order that exposes a leaked sys.path entry. """ build_device(bound("spi_adc")) for kind in ("ssi", "quadrature", "tca9538", "uart"): diff --git a/scripts/sim/test_device_models.js b/scripts/sim/test_device_models.js index e9edd1b..c16c660 100644 --- a/scripts/sim/test_device_models.js +++ b/scripts/sim/test_device_models.js @@ -286,8 +286,7 @@ test('factory: an unknown kind is refused', () => { test('factory: a kind not yet ported to the in-tool simulator is refused, not faked', () => { // The dropdown lists the full device catalogue; the in-tool simulator // only runs ported kinds. Refusing visibly beats substituting an - // unproven stand-in - that substitution is the failure this item exists - // to prevent. + // unproven stand-in that would make a wrong design look correct. // Derived from the catalogue, not restated here. A hardcoded list goes stale // the moment a kind is ported: on 2026-09-09 this test still demanded that // bissc refuse, three commits after ported it, and the assertion diff --git a/scripts/simulator.js b/scripts/simulator.js index 00b19f6..265da08 100644 --- a/scripts/simulator.js +++ b/scripts/simulator.js @@ -1351,15 +1351,12 @@ class Simulator { * * qbbc xs_spin?, R1.b3, 0 * - * That is what clpru assembles and what pru_core.js decodes. This decoder - * used to read operand 0 as the register and operand 2 as the label - the - * reverse - so _parseRegister("xs_spin?") failed, _handleParsingError bumped - * the pc, and the branch was SILENTLY not taken. Nothing was reported: the - * spin loop in m_xfr2spin_acquire fell straight through its own backward - * branch and a contended lock therefore looked like a single lost - * arbitration instead of a core parked in a loop. Every wait/spin loop run - * through Simulator.stepPaced had the same hole; no call site in the repo - * ever used the reversed order, so nothing depended on it. + * That is what clpru assembles and what pru_core.js decodes. Reading the + * operands in the reverse order (operand 0 as the register, operand 2 as the + * label) makes _parseRegister("xs_spin?") fail, _handleParsingError bump the + * pc, and the branch SILENTLY not taken: a spin loop falls straight through + * its own backward branch, so a contended lock looks like a single lost + * arbitration instead of a core parked in a loop. * * An unresolved target is counted as a parse error rather than silently * stepping to the next instruction, again matching pru_core.js: a branch to @@ -1949,7 +1946,7 @@ class Simulator { pruState.unknownInstruction = "XOUT " + bank + ": QUEUE_EMPTY device 0xF0 is XIN-only (SPRUIM2J Table 6-60)"; pruState.pc++; } else { - // SPAD: store registers to scratchpad bank (note: 48/49 are no longer here) + // SPAD: store registers to scratchpad bank (48/49 are handled above) for (let i = 0; i < count; i++) { if (srcRegN + i <= 31) { const spadBank = this._spad || {}; @@ -2045,7 +2042,7 @@ class Simulator { pruState.registers[destRegN] = status; } } else { - // SPAD: load from scratchpad bank (note: 48/49 are no longer here) + // SPAD: load from scratchpad bank (48/49 are handled above) if (this._spad && this._spad[bank]) { for (let i = 0; i < count; i++) { if (destRegN + i <= 31 && i < this._spad[bank].length) { diff --git a/tests/test_fanout_register_alias.py b/tests/test_fanout_register_alias.py index 8403bba..389f77a 100644 --- a/tests/test_fanout_register_alias.py +++ b/tests/test_fanout_register_alias.py @@ -280,7 +280,7 @@ def test_gray_cascade_runs_correctly_without_memory_copies(): @needs_tools def test_no_stage_xors_a_value_with_itself(): - """Static form of the defect: an XOR/AND/OR/SUB whose two source operands are the same register.""" + """Static form of the bug: an XOR/AND/OR/SUB whose two source operands are the same register.""" _elf, asm, _reg = cached("cascade", lambda: cascade_syscfg()) for i, s in enumerate(SHIFTS): dest, srcs = operands(asm, "Xor_%d" % s) diff --git a/tests/test_register_pool_exhaustion.py b/tests/test_register_pool_exhaustion.py index 990afe3..329d017 100644 --- a/tests/test_register_pool_exhaustion.py +++ b/tests/test_register_pool_exhaustion.py @@ -101,7 +101,7 @@ def test_helper_flags_a_dropped_block(): def test_too_many_live_values_fails_generation_naming_the_block(): """More live values than the pool holds: generation must FAIL with an Out of registers error naming each block. - a 24-stage running sum no longer exhausts the pool (the allocator now releases a value after its last + A 24-stage running sum does not exhaust the pool (the allocator releases a value after its last consumer), so the exhausting design is 28 independent 4-byte constants - the whole 112-byte pool, all alive - plus two more 4-byte constants that cannot be placed.""" extra = ('const x1 = load_constant_block.addInstance(); x1.$name = "X1"; x1.constant1 = 0x20000001;\n' diff --git a/tests/test_spi_transfer_zero_extend.py b/tests/test_spi_transfer_zero_extend.py index d6baeec..19c628c 100644 --- a/tests/test_spi_transfer_zero_extend.py +++ b/tests/test_spi_transfer_zero_extend.py @@ -85,7 +85,7 @@ def test_read_prefix_probe_does_not_throw_but_a_real_variant_without_an_insert_p @needs_tools def test_transfer_design_generates_and_links_through_sysconfig_and_clpru(): - """Red at eb71f49c: a stray `ldi r_dataReg, 0` above `.macro` made clpru fail with E0003.""" + """A stray `ldi r_dataReg, 0` above `.macro` makes clpru fail with E0003; the design must link.""" text = T.HEADER + ( 'const pru_spi_transfer = scripting.addModule("/pru_blocks/pru_io_blocks/pru_spi_transfer", {}, false);\n' 'const c = load_constant_block.addInstance(); c.constant1 = 0x5A; c.$name = "TX_Word";\n' From bed4d3604199996a977482bdbaac0af3025ad9f3 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 14:46:49 +0530 Subject: [PATCH 20/44] Collect the device catalogue check under pytest; skip simulator arms whose device model the simulator lacks --- conftest.py | 24 ++++++++++++++++- pru_io/waveform_player.py | 2 +- scripts/sim/test_catalog_completeness.py | 34 +++++++++++++++++------- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/conftest.py b/conftest.py index 9f35637..5aac253 100644 --- a/conftest.py +++ b/conftest.py @@ -2,20 +2,42 @@ Tests that run firmware on TI pru-simulator need a checkout of it (PRU_SIMULATOR_PATH or tools/pru-simulator-upstream). Without one they are reported as SKIPPED with the reason, not as failures, so a CI runner without the simulator stays -green and still shows what was not run. +green and still shows what was not run. The same applies when the checkout is an older revision that does not yet +have a device model package a test imports (pru_io.): the test is SKIPPED naming the missing module. """ +import re + import pytest +_MISSING_MODEL = re.compile(r"No module named '(pru_io\.[A-Za-z0-9_]+)'") + def _missing_simulator(exc): return type(exc).__name__ == "SimulatorNotFound" +def _missing_device_model(exc): + """The pru_io. module the simulator checkout lacks, or None.""" + if isinstance(exc, ModuleNotFoundError) and (exc.name or "").startswith("pru_io."): + return exc.name + while exc is not None: + m = _MISSING_MODEL.search(str(exc)) + if m: + return m.group(1) + exc = exc.__cause__ or exc.__context__ + return None + + def _skip_if_missing_simulator(outcome): exc = outcome.excinfo[1] if outcome.excinfo else None if exc is not None and _missing_simulator(exc): outcome.force_exception(pytest.skip.Exception("TI pru-simulator not available: " + str(exc).split(";")[0], _use_item_location=True)) + return + model = _missing_device_model(exc) if exc is not None else None + if model: + outcome.force_exception(pytest.skip.Exception("TI pru-simulator checkout has no device model " + model, + _use_item_location=True)) @pytest.hookimpl(hookwrapper=True) diff --git a/pru_io/waveform_player.py b/pru_io/waveform_player.py index ade1a08..c27cd5f 100644 --- a/pru_io/waveform_player.py +++ b/pru_io/waveform_player.py @@ -1,4 +1,4 @@ -"""WaveformPlayer — cycle-keyed R31 stimulus as a DeviceModel.. +"""WaveformPlayer — cycle-keyed R31 stimulus as a DeviceModel. WHY --- diff --git a/scripts/sim/test_catalog_completeness.py b/scripts/sim/test_catalog_completeness.py index b779313..a98d447 100644 --- a/scripts/sim/test_catalog_completeness.py +++ b/scripts/sim/test_catalog_completeness.py @@ -1,10 +1,14 @@ #!/usr/bin/env python3 +"""Every DeviceModel subclass in perif/ and pru_io/ is a kind in the GUI device catalogue +(DEVICE_MODEL_KINDS in common/simulation/device_models.js), so a new model cannot be added +without being selectable. Runs under pytest, or directly as a script.""" import os import glob import re import sys -def main(): + +def missing_from_catalog(): root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) js_path = os.path.join(root, ".metadata", "sysconfig", ".meta", "pru_blocks", "common", "simulation", "device_models.js") with open(js_path, "r", encoding="utf-8") as f: @@ -12,8 +16,7 @@ def main(): match = re.search(r"const DEVICE_MODEL_KINDS = \[([\s\S]*?)\];", js_content) if not match: - print("FAIL: Could not find DEVICE_MODEL_KINDS") - sys.exit(1) + return ["DEVICE_MODEL_KINDS not found in device_models.js"], set() kinds_in_js = set(re.findall(r'kind:\s*"([^"]+)"', match.group(1))) @@ -29,6 +32,9 @@ def main(): found_classes.add(m.group(1)) abstract_classes = {"SsiEndatEncoder", "QuadratureIndexEncoder"} + # Stimulus sources, not devices: WaveformPlayer replays a cycle-keyed GPI waveform + # (Simulation Settings pattern/timestamp modes), so it has no catalogue kind. + stimulus_classes = {"WaveformPlayer"} class_to_kind = { "HallSensorModel": "hall", @@ -42,8 +48,8 @@ def main(): } missing_from_catalog = [] - for cls in found_classes: - if cls in abstract_classes: + for cls in sorted(found_classes): + if cls in abstract_classes or cls in stimulus_classes: continue kind = class_to_kind.get(cls) if kind is None: @@ -52,13 +58,23 @@ def main(): if kind not in kinds_in_js: missing_from_catalog.append(f"{cls} (kind '{kind}')") - if missing_from_catalog: + return missing_from_catalog, found_classes + + +def test_every_device_model_class_is_in_the_catalogue(): + missing, found = missing_from_catalog() + assert found, "no DeviceModel subclasses found under perif/ or pru_io/" + assert not missing, "DeviceModel subclasses missing from DEVICE_MODEL_KINDS: " + ", ".join(missing) + + +def main(): + missing, found = missing_from_catalog() + if missing: print("FAIL: The following DeviceModel subclasses are missing from DEVICE_MODEL_KINDS:") - for m in missing_from_catalog: + for m in missing: print(" - " + m) sys.exit(1) - - print(f"PASS: All {len(found_classes)} DeviceModel subclasses are accounted for in DEVICE_MODEL_KINDS.") + print(f"PASS: All {len(found)} DeviceModel subclasses are accounted for in DEVICE_MODEL_KINDS.") sys.exit(0) if __name__ == '__main__': From 62269a11d66b526545099ff57a9b5932fe82ccbc Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 16:39:32 +0530 Subject: [PATCH 21/44] Rename the repository device-model packages to pru_device_models so they do not shadow the pru-simulator's; firmware test fixture; GUI simulator register tests; neutral wording --- .../.meta/pru_blocks/common/pru_ti_run.js | 2 +- .../common/simulation/device_models.js | 8 +-- .../pru_blocks/common/simulation/pru_core.js | 11 +-- core/__init__.py | 0 perif/__init__.py | 0 pru_device_models/__init__.py | 5 ++ .../bissline_device_model.py | 2 +- {perif => pru_device_models}/buscosim.py | 0 {core => pru_device_models}/device_model.py | 0 {perif => pru_device_models}/hall_sensor.py | 2 +- .../mdio_phy_model.py | 2 +- {perif => pru_device_models}/ossd_emitter.py | 4 +- .../quadrature_index_encoder.py | 2 +- {perif => pru_device_models}/spi_adc.py | 2 +- .../ssi_endat_encoder.py | 2 +- .../step_dir_driver.py | 2 +- .../waveform_player.py | 2 +- {perif => pru_device_models}/ws2812_string.py | 2 +- pru_io/__init__.py | 7 -- scripts/sim/device_bindings.py | 41 +++++------ scripts/sim/elf_cosim.py | 12 ++-- scripts/sim/live_view.py | 4 +- scripts/sim/run_bound_devices.py | 4 +- scripts/sim/test_catalog_completeness.py | 8 +-- scripts/sim/test_device_binding_runtime.js | 10 +-- scripts/sim/test_device_bindings.py | 8 +-- scripts/sim/test_device_models.js | 4 +- scripts/sim/test_ti_run_gui.js | 13 ++-- scripts/simulator.js | 7 +- tests/fixtures/firmware/linker.cmd | 54 ++++++++++++++ tests/fixtures/firmware/main.asm | 31 ++++++++ tests/test_bissline.py | 14 ++-- tests/test_fanout_register_alias.py | 13 ++-- tests/test_gui_simulator_core.py | 72 +++++++++++++++++++ tests/test_package_names.py | 57 +++++++++++++++ tests/test_spi_read_zero_extend.py | 6 +- tests/test_spi_transfer_zero_extend.py | 2 +- tests/test_waveform_player.py | 8 +-- tools/__init__.py | 0 tools/gpi_stimulus_runner.py | 10 ++- 40 files changed, 321 insertions(+), 112 deletions(-) delete mode 100644 core/__init__.py delete mode 100644 perif/__init__.py create mode 100644 pru_device_models/__init__.py rename {perif => pru_device_models}/bissline_device_model.py (99%) rename {perif => pru_device_models}/buscosim.py (100%) rename {core => pru_device_models}/device_model.py (100%) rename {perif => pru_device_models}/hall_sensor.py (99%) rename {perif => pru_device_models}/mdio_phy_model.py (99%) rename {perif => pru_device_models}/ossd_emitter.py (98%) rename {perif => pru_device_models}/quadrature_index_encoder.py (98%) rename {perif => pru_device_models}/spi_adc.py (99%) rename {perif => pru_device_models}/ssi_endat_encoder.py (99%) rename {perif => pru_device_models}/step_dir_driver.py (98%) rename {pru_io => pru_device_models}/waveform_player.py (99%) rename {perif => pru_device_models}/ws2812_string.py (98%) delete mode 100644 pru_io/__init__.py create mode 100644 tests/fixtures/firmware/linker.cmd create mode 100644 tests/fixtures/firmware/main.asm create mode 100644 tests/test_gui_simulator_core.py create mode 100644 tests/test_package_names.py delete mode 100644 tools/__init__.py diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_ti_run.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_ti_run.js index 50e08ba..7fd7e19 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_ti_run.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_ti_run.js @@ -50,7 +50,7 @@ function snapshot(obj) { continue; } // Load Constant's outputWidth at its default "auto" is not part of the digest, so a design that does - // not use the option keeps the digest (and the pru_syscfg.asm header) it had before the option existed. + // not use the option keeps the same digest (and pru_syscfg.asm header) as a design without the option. if (key === "outputWidth" && value === "auto") { continue; } diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js index f1f8e6a..eac1501 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js @@ -346,7 +346,7 @@ class SsiEncoder { } // --------------------------------------------------------------------------- -// Hall sensor (port of perif/hall_sensor.py) +// Hall sensor (port of pru_device_models/hall_sensor.py) // --------------------------------------------------------------------------- // // Three push-pull Hall outputs (A,B,C) = 6 60-degree sectors. Code @@ -480,12 +480,12 @@ class HallSensor { getState() { return {name:this.name, position:this.position, code:this._code, faults:this._faults.length}; } } -// Dual-channel OSSD emitter (port of perif/ossd_emitter.py) +// Dual-channel OSSD emitter (port of pru_device_models/ossd_emitter.py) // --------------------------------------------------------------------------- // // Two normally-high safety channels (A, B). The firmware only reads; the // model drives. Schedule API mirrors the Python class so JS and Python -// traces can be cross-checked. Classification oracle is perif/buscosim.py +// traces can be cross-checked. Classification oracle is pru_device_models/buscosim.py // decode_ossd and must agree with events(). class DualOssdEmitter { @@ -1391,7 +1391,7 @@ const DEVICE_MODEL_KINDS = [ // flag is load-bearing; flipping it without a faithful JS port breaks // that contract and falls through to the internal-error branch below. // (2) the Python behavioural model in - // perif/bissline_device_model.py is not this JS registry's implementation; + // pru_device_models/bissline_device_model.py is not this JS registry's implementation; // the external simulator currently resolves a materially different model // from tools/pru-simulator-upstream. (3) bissc and endat - which also have // Python models - are staged identically; porting here means a full JS diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js index a5002a0..d66d188 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js @@ -135,11 +135,14 @@ function setRegisterValue(pruState, regInfo, value) { const clearMask = ~(regInfo.mask << regInfo.shift); pruState.registers[regInfo.reg] &= clearMask; pruState.registers[regInfo.reg] |= ((value & regInfo.mask) << regInfo.shift); + // JS bitwise operators yield signed 32-bit results; registers hold unsigned values. + pruState.registers[regInfo.reg] >>>= 0; } else if (regInfo.type === 'word') { // Clear the word position and set the new value const clearMask = ~(regInfo.mask << regInfo.shift); pruState.registers[regInfo.reg] &= clearMask; pruState.registers[regInfo.reg] |= ((value & regInfo.mask) << regInfo.shift); + pruState.registers[regInfo.reg] >>>= 0; } } @@ -1827,14 +1830,14 @@ function executeInstruction(pruState, pruInstructions, labelMap, r30ValueHistory } default: { - // intent: fail loudly on real PRU instructions we know are not - // yet modelled, so block-level tests can't silently "pass" on them. - // But the static validator simulates the FULL generated firmware, which + // Fail loudly on real PRU instructions that are not modelled, so + // block-level tests cannot silently "pass" on them. + // The static validator simulates the FULL generated firmware, which // contains macro invocations (m_*) and assembler directives (.*) that are // NOT base instructions and must not be executed here. Throwing on those // would break validation/build of every real example. So: throw only for // the explicit set of recognised-but-unimplemented base instructions, and - // skip everything else (restoring pre-strict behaviour for macro calls). + // skip everything else (macro calls and directives are not executed here). const UNIMPLEMENTED_BASE_INSTRUCTIONS = ["MAC", "MACU", "WBC"]; if (UNIMPLEMENTED_BASE_INSTRUCTIONS.indexOf(opcode) !== -1) { throw new Error("Simulator Error: PRU instruction '" + opcode + "' is recognised but not yet implemented in pru_core.js (PC " + pruState.pc + "). Please add support before simulating blocks that use it."); diff --git a/core/__init__.py b/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/perif/__init__.py b/perif/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/pru_device_models/__init__.py b/pru_device_models/__init__.py new file mode 100644 index 0000000..ab329c6 --- /dev/null +++ b/pru_device_models/__init__.py @@ -0,0 +1,5 @@ +"""Device and stimulus models used by the simulation tests and the bound-device runner. + +Package name chosen so it does not collide with the pru-simulator checkout's own +top-level packages (core, perif, pru_io), which tests import in the same process. +""" diff --git a/perif/bissline_device_model.py b/pru_device_models/bissline_device_model.py similarity index 99% rename from perif/bissline_device_model.py rename to pru_device_models/bissline_device_model.py index 189a06e..62dcd6f 100644 --- a/perif/bissline_device_model.py +++ b/pru_device_models/bissline_device_model.py @@ -24,7 +24,7 @@ from typing import Any, Dict, List, Optional -from core.device_model import DeviceModel +from pru_device_models.device_model import DeviceModel # ----------------------------------------------------------------- CRC-6 diff --git a/perif/buscosim.py b/pru_device_models/buscosim.py similarity index 100% rename from perif/buscosim.py rename to pru_device_models/buscosim.py diff --git a/core/device_model.py b/pru_device_models/device_model.py similarity index 100% rename from core/device_model.py rename to pru_device_models/device_model.py diff --git a/perif/hall_sensor.py b/pru_device_models/hall_sensor.py similarity index 99% rename from perif/hall_sensor.py rename to pru_device_models/hall_sensor.py index cbcee53..4aa43c6 100644 --- a/perif/hall_sensor.py +++ b/pru_device_models/hall_sensor.py @@ -35,7 +35,7 @@ from typing import Any, Dict, List, Optional, Tuple -from core.device_model import DeviceModel +from pru_device_models.device_model import DeviceModel # Six legal codes in forward order _HALL_CODES = [1, 3, 2, 6, 4, 5] diff --git a/perif/mdio_phy_model.py b/pru_device_models/mdio_phy_model.py similarity index 99% rename from perif/mdio_phy_model.py rename to pru_device_models/mdio_phy_model.py index d3972e3..4e25d41 100644 --- a/perif/mdio_phy_model.py +++ b/pru_device_models/mdio_phy_model.py @@ -50,7 +50,7 @@ from typing import Any, Dict, List, Optional -from core.device_model import DeviceModel +from pru_device_models.device_model import DeviceModel _ST = 0b01 _OP_WRITE = 0b01 diff --git a/perif/ossd_emitter.py b/pru_device_models/ossd_emitter.py similarity index 98% rename from perif/ossd_emitter.py rename to pru_device_models/ossd_emitter.py index 2cb7aff..8738afb 100644 --- a/perif/ossd_emitter.py +++ b/pru_device_models/ossd_emitter.py @@ -25,7 +25,7 @@ Events / faults / oracle ------------------------ ``events()`` records injected pulses/trips; ``faults()`` records -construction or electrical errors. ``perif/buscosim.decode_ossd`` is the +construction or electrical errors. ``pru_device_models/buscosim.decode_ossd`` is the independent oracle that re-derives pulse classes from the resolved pin trace and must agree with this model's classification. @@ -42,7 +42,7 @@ from typing import Any, Dict, List, Optional, Tuple -from core.device_model import DeviceModel +from pru_device_models.device_model import DeviceModel class DualOssdEmitter(DeviceModel): diff --git a/perif/quadrature_index_encoder.py b/pru_device_models/quadrature_index_encoder.py similarity index 98% rename from perif/quadrature_index_encoder.py rename to pru_device_models/quadrature_index_encoder.py index 969eeaa..fdea4f4 100644 --- a/perif/quadrature_index_encoder.py +++ b/pru_device_models/quadrature_index_encoder.py @@ -19,7 +19,7 @@ from typing import Any, Dict, List, Tuple -from core.device_model import DeviceModel +from pru_device_models.device_model import DeviceModel # x4 state table: state = 2*A + B. Forward (CW) order 0,1,3,2,0... _FWD = {0: 1, 1: 3, 3: 2, 2: 0} diff --git a/perif/spi_adc.py b/pru_device_models/spi_adc.py similarity index 99% rename from perif/spi_adc.py rename to pru_device_models/spi_adc.py index 10b88eb..cd0134b 100644 --- a/perif/spi_adc.py +++ b/pru_device_models/spi_adc.py @@ -26,7 +26,7 @@ from typing import Any, Dict, List -from core.device_model import DeviceModel +from pru_device_models.device_model import DeviceModel class SpiAdc(DeviceModel): diff --git a/perif/ssi_endat_encoder.py b/pru_device_models/ssi_endat_encoder.py similarity index 99% rename from perif/ssi_endat_encoder.py rename to pru_device_models/ssi_endat_encoder.py index bb2091a..08bdcb4 100644 --- a/perif/ssi_endat_encoder.py +++ b/pru_device_models/ssi_endat_encoder.py @@ -36,7 +36,7 @@ MISSING_SSI_TERMINATOR = "missing SSI terminator pulse" -from core.device_model import DeviceModel +from pru_device_models.device_model import DeviceModel def _gray(n: int) -> int: diff --git a/perif/step_dir_driver.py b/pru_device_models/step_dir_driver.py similarity index 98% rename from perif/step_dir_driver.py rename to pru_device_models/step_dir_driver.py index 0fc0370..21b9c6a 100644 --- a/perif/step_dir_driver.py +++ b/pru_device_models/step_dir_driver.py @@ -16,7 +16,7 @@ from typing import Any, Dict, List, Optional -from core.device_model import DeviceModel +from pru_device_models.device_model import DeviceModel class StepDirDriver(DeviceModel): diff --git a/pru_io/waveform_player.py b/pru_device_models/waveform_player.py similarity index 99% rename from pru_io/waveform_player.py rename to pru_device_models/waveform_player.py index c27cd5f..815115f 100644 --- a/pru_io/waveform_player.py +++ b/pru_device_models/waveform_player.py @@ -57,7 +57,7 @@ PUSH_PULL = "push_pull" try: # this repo's ABC - from core.device_model import DeviceModel # type: ignore + from pru_device_models.device_model import DeviceModel # type: ignore except ImportError: # pragma: no cover - loaded inside the TI pru-simulator from pru_io.device_model import DeviceModel # type: ignore diff --git a/perif/ws2812_string.py b/pru_device_models/ws2812_string.py similarity index 98% rename from perif/ws2812_string.py rename to pru_device_models/ws2812_string.py index 3185352..bc3e027 100644 --- a/perif/ws2812_string.py +++ b/pru_device_models/ws2812_string.py @@ -19,7 +19,7 @@ from typing import Any, Dict, List -from core.device_model import DeviceModel +from pru_device_models.device_model import DeviceModel class Ws2812String(DeviceModel): diff --git a/pru_io/__init__.py b/pru_io/__init__.py deleted file mode 100644 index 72bf7f4..0000000 --- a/pru_io/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""pru_io package. - -There is no EnDat model here: EnDat is modelled by TI pru-simulator's EnDat -encoder model. -""" - -from .waveform_player import WaveformPlayer # noqa: F401 diff --git a/scripts/sim/device_bindings.py b/scripts/sim/device_bindings.py index 2c9b5a6..172151a 100644 --- a/scripts/sim/device_bindings.py +++ b/scripts/sim/device_bindings.py @@ -44,8 +44,8 @@ TWO MODEL CONTRACTS, ONE INVARIANT ---------------------------------- -the models in this repository's `perif/` package subclass -`core.device_model`, not the upstream `pru_io` base. They declare no `nets`; +the models in this repository's `pru_device_models/` package subclass +`pru_device_models.device_model`, not the upstream `pru_io` base. They declare no `nets`; their `tick(cycle, pins_out)` RETURNS the GPI word the device asserts. That is the same fact, observed rather than declared, so `driven_pins()` reads whichever of the two a model offers and the direction invariant above applies to every @@ -168,14 +168,14 @@ def needs_clock(self): "sigma_delta": _Spec("pru_io.sd_modulator_device", "SigmaDeltaModulator", primary="data_pin", clock="clk_pin"), # ---------------------------------------------------------------- - # The four models below live in this repository's perif/ package and - # subclass `core.device_model`, NOT the upstream `pru_io` base. They + # The four models below live in this repository's pru_device_models/ package and + # subclass `pru_device_models.device_model`, NOT the upstream `pru_io` base. They # declare no `nets`; instead `tick(cycle, pins_out)` RETURNS the GPI word # the device asserts, so the set bits of that return are the same fact # `nets` states declaratively. `driven_pins()` reads whichever of the two # a model offers, which is what keeps the direction invariant checkable # across both contracts instead of exempting half the registry from it. - "spi_adc": _Spec("perif.spi_adc", "SpiAdc", + "spi_adc": _Spec("pru_device_models.spi_adc", "SpiAdc", # The ADC drives MISO, which this model calls sdi_pin # ("the pin the PRU reads"). SCLK and MOSI are the # controller's. CS is a fourth pin and the manifest has @@ -183,17 +183,17 @@ def needs_clock(self): # full pin validation - see extra_pins. primary="sdi_pin", clock="sclk_pin", index="sdo_pin", extra_pins=("cs_pin",)), - "step_dir": _Spec("perif.step_dir_driver", "StepDirDriver", + "step_dir": _Spec("pru_device_models.step_dir_driver", "StepDirDriver", # "open-loop driver: nothing to feed back" - the model # returns 0 forever. STEP/DIR/EN are all PRU outputs, # and DIR occupies the clock slot because it is the # second pin, not because anything clocks it. primary="step_pin", clock="dir_pin", index="en_pin", sink=True), - "ws2812": _Spec("perif.ws2812_string", "Ws2812String", + "ws2812": _Spec("pru_device_models.ws2812_string", "Ws2812String", # Single-wire NRZ input. "WS2812 drives nothing back". primary="data_pin", sink=True), - "mdio_phy": _Spec("perif.mdio_phy_model", "MdioPhyModel", + "mdio_phy": _Spec("pru_device_models.mdio_phy_model", "MdioPhyModel", # MDIO is shared: the PHY drives it during read # turn-around and read data, so it is genuinely the # device-driven pin. MDC is the STA's. @@ -340,9 +340,10 @@ def _validate_entry(entry, seen): # -------------------------------------------------------------------------- # instantiation # -------------------------------------------------------------------------- -#: Top-level package names this repository and the pru-simulator checkout both -#: define. Whichever root is first on sys.path wins for ALL of them, so an -#: import has to be scoped to one root rather than leaving both in place. +#: Top-level package names of the pru-simulator checkout. This repository's +#: models live in `pru_device_models`, which the checkout does not define, but +#: a simulator import is still scoped so that it never leaves the checkout's +#: packages bound in sys.modules for later imports. _COLLIDING_TOPS = frozenset({"core", "perif", "pru_io", "simulator", "tools"}) @@ -350,12 +351,9 @@ def _validate_entry(entry, seen): def _import_from(root): """Import with `root` winning, then put sys.path and sys.modules back. - The two checkouts both define `core` and `perif`. Mutating sys.path in - place and leaving it mutated makes the result depend on which device was - bound FIRST: bind a perif model before an upstream one in the same process - and the upstream import fails, because this repo's `pru_io` now shadows the - simulator's. That is order-dependent and a test suite can be green on it by - luck of alphabetical ordering, so the scope is restored rather than trusted. + Mutating sys.path in place and leaving it mutated makes the result depend + on which device was bound FIRST, so the scope is restored rather than + trusted. """ saved_modules = {k: sys.modules.pop(k) for k in list(sys.modules) if k.split(".")[0] in _COLLIDING_TOPS} @@ -374,10 +372,9 @@ def _import_from(root): def resolve_class(spec, simulator_path=None): """Import a model class from the checkout that owns it.""" path = locate_simulator(simulator_path) - # models live in this repository's perif/ package and import this - # repository's core/; every other binding resolves from the upstream - # pru-simulator checkout, which has its own core/ and perif/. - root = ROOT if spec.module.split(".")[0] == "perif" else path + # pru_device_models.* lives in this repository; every other binding + # resolves from the pru-simulator checkout. + root = ROOT if spec.module.split(".")[0] == "pru_device_models" else path with _import_from(root): try: module = __import__(spec.module, fromlist=[spec.cls]) @@ -431,7 +428,7 @@ def build_device(entry, simulator_path=None): def driven_pins(model, stimulus=None): """Which pin numbers does this DEVICE drive? Works for both contracts. - Upstream `pru_io` models state it declaratively in `nets`. the `core.device_model` models state it by behaviour: `tick()` returns the GPI + Upstream `pru_io` models state it declaratively in `nets`. the `pru_device_models.device_model` models state it by behaviour: `tick()` returns the GPI word the device asserts, so the union of set bits over a few cycles is the same fact, observed instead of asserted. diff --git a/scripts/sim/elf_cosim.py b/scripts/sim/elf_cosim.py index 08938d3..617e34d 100644 --- a/scripts/sim/elf_cosim.py +++ b/scripts/sim/elf_cosim.py @@ -7,7 +7,7 @@ 4. step instruction-by-instruction; each step the DeviceModel sees the PRU GPO word (pins_out) and drives the GPI word the PRU samples next 5. return the per-step resolved trace so an independent bus-cosim decoder - (perif/buscosim.py) can re-derive the protocol events + (pru_device_models/buscosim.py) can re-derive the protocol events Nothing here judges protocol correctness - that is the tests' job. """ @@ -157,9 +157,9 @@ def run_elf_closed_loop( tests can inject extra stimuli (e.g. an EnDat mode command). """ sim_dir = find_pru_simulator() - # The pru-simulator and this repo BOTH ship a top-level "core" package. - # Run the whole loop with the simulator's namespace swapped in, so its - # absolute imports (incl. lazy ones inside methods) resolve correctly. + # Run the whole loop with the simulator's packages first on sys.path, so its + # absolute imports (incl. lazy ones inside methods) resolve to the checkout + # even if another copy of a same-named package was imported earlier. with _sim_namespace(sim_dir): from simulator import Simulator # type: ignore @@ -176,8 +176,8 @@ def run_elf_closed_loop( @contextlib.contextmanager def _sim_namespace(sim_dir: str): - # The pru-simulator and this repo share several top-level package names - # (core/, perif/, ...). Swap the colliding namespace for the duration. + # Bind the checkout's top-level packages (core/, perif/, pru_io/, ...) for + # the duration and restore sys.modules afterwards. colliding = [ d for d in os.listdir(sim_dir) if os.path.isfile(os.path.join(sim_dir, d, "__init__.py")) diff --git a/scripts/sim/live_view.py b/scripts/sim/live_view.py index db82fdd..c745e8d 100644 --- a/scripts/sim/live_view.py +++ b/scripts/sim/live_view.py @@ -398,8 +398,8 @@ def live_session(elf_path, manifest_path, **kwargs): """Yield the live FastAPI app with TI pru-simulator's namespace swapped in. The dashboard imports simulator packages lazily, per request, so the swap - must last as long as the app is used. Leaving the block restores this - repo's own `core`/`perif` modules. + must last as long as the app is used. Leaving the block restores the + previous sys.path and sys.modules. """ with _sim_namespace(find_pru_simulator()): yield make_app(elf_path, manifest_path, **kwargs) diff --git a/scripts/sim/run_bound_devices.py b/scripts/sim/run_bound_devices.py index 520b554..0788045 100644 --- a/scripts/sim/run_bound_devices.py +++ b/scripts/sim/run_bound_devices.py @@ -184,7 +184,7 @@ def attach_bound_devices(pru, manifest, sim_dir): except Exception as exc: raise DeviceRefused(str(exc)) from exc for entry, model in attached: - # the perif/ models (core.device_model contract: tick() + # the pru_device_models models (pru_device_models.device_model contract: tick() # returns a GPI word, no `nets`) cannot sit on TI pru-simulator's # DeviceBus, which resolves nets per pin. Refuse by name rather # than crash mid-run with an AttributeError. @@ -192,7 +192,7 @@ def attach_bound_devices(pru, manifest, sim_dir): raise DeviceRefused( f"device kind {entry['kind']!r} " f"({type(model).__module__}.{type(model).__name__}) uses the " - f"core.device_model contract with no `nets`; TI pru-simulator's " + f"pru_device_models.device_model contract with no `nets`; TI pru-simulator's " f"device bus cannot attach it, so gmake sim does not support " f"this kind yet") for _, model in attached: diff --git a/scripts/sim/test_catalog_completeness.py b/scripts/sim/test_catalog_completeness.py index a98d447..52123b0 100644 --- a/scripts/sim/test_catalog_completeness.py +++ b/scripts/sim/test_catalog_completeness.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Every DeviceModel subclass in perif/ and pru_io/ is a kind in the GUI device catalogue +"""Every DeviceModel subclass in pru_device_models/ is a kind in the GUI device catalogue (DEVICE_MODEL_KINDS in common/simulation/device_models.js), so a new model cannot be added without being selectable. Runs under pytest, or directly as a script.""" import os @@ -21,14 +21,14 @@ def missing_from_catalog(): kinds_in_js = set(re.findall(r'kind:\s*"([^"]+)"', match.group(1))) py_files = [] - for d in ["perif", "pru_io"]: + for d in ["pru_device_models"]: py_files.extend(glob.glob(os.path.join(root, d, "*.py"))) found_classes = set() for pf in py_files: with open(pf, "r", encoding="utf-8") as f: content = f.read() - for m in re.finditer(r"^class\s+([A-Za-z0-9_]+)\s*\(\s*(?:pru_io\.|perif\.)?DeviceModel\s*\)\s*:", content, re.MULTILINE): + for m in re.finditer(r"^class\s+([A-Za-z0-9_]+)\s*\(\s*(?:pru_device_models\.)?DeviceModel\s*\)\s*:", content, re.MULTILINE): found_classes.add(m.group(1)) abstract_classes = {"SsiEndatEncoder", "QuadratureIndexEncoder"} @@ -63,7 +63,7 @@ def missing_from_catalog(): def test_every_device_model_class_is_in_the_catalogue(): missing, found = missing_from_catalog() - assert found, "no DeviceModel subclasses found under perif/ or pru_io/" + assert found, "no DeviceModel subclasses found under pru_device_models/" assert not missing, "DeviceModel subclasses missing from DEVICE_MODEL_KINDS: " + ", ".join(missing) diff --git a/scripts/sim/test_device_binding_runtime.js b/scripts/sim/test_device_binding_runtime.js index 588149d..f3beef9 100644 --- a/scripts/sim/test_device_binding_runtime.js +++ b/scripts/sim/test_device_binding_runtime.js @@ -261,11 +261,11 @@ test('verdict: macro blocks report cannot execute macro blocks instead of device assert.ok(/cannot execute macro blocks \(m_ssi_read\)/.test(r.summary), r.summary); assert.ok(!/never spoke/.test(r.summary), r.summary); - // Mutant arm: dropping the skip counter falls back to 'never spoke' and fails the assertion - const mutantState = Object.assign({}, pruState, { skippedMacroCount: 0 }); - const mutantR = glue.buildDeviceRunResults(bus, bindings, r30ValueHistory, mutantState)[0]; - assert.ok(/never spoke/.test(mutantR.summary), mutantR.summary); - assert.ok(!/cannot execute macro blocks/.test(mutantR.summary), mutantR.summary); + // Without the skip counter the summary must fall back to 'never spoke' + const noSkipState = Object.assign({}, pruState, { skippedMacroCount: 0 }); + const noSkipR = glue.buildDeviceRunResults(bus, bindings, r30ValueHistory, noSkipState)[0]; + assert.ok(/never spoke/.test(noSkipR.summary), noSkipR.summary); + assert.ok(!/cannot execute macro blocks/.test(noSkipR.summary), noSkipR.summary); }); test('margin: the measurement reads the trace, not the configuration', () => { diff --git a/scripts/sim/test_device_bindings.py b/scripts/sim/test_device_bindings.py index be93e04..c8e9542 100644 --- a/scripts/sim/test_device_bindings.py +++ b/scripts/sim/test_device_bindings.py @@ -166,7 +166,7 @@ def test_a_sink_really_drives_nothing(kind): It is the one declaration that switches the primary-pin invariant off, so it is also the obvious place to hide a reversed mapping. Port a model that - starts driving a line back and this goes red, which forces the registry to + starts driving a line back and this fails, which forces the registry to be re-read rather than letting the exemption rot into a blanket skip. """ model = build_device(bound(kind, primaryPin=11)) @@ -174,7 +174,7 @@ def test_a_sink_really_drives_nothing(kind): # Reach StepDirDriver's post-edge return. All three device inputs are # asserted on its first real STEP transition; its output must still be # zero. This must not be a quiet-path test: changing the production - # post-edge return to ``1 << step_pin`` makes this assertion RED. + # post-edge return to ``1 << step_pin`` makes this assertion fail. stimulus = ((0, 0), (1, (1 << 11) | (1 << 12) | (1 << 13))) elif kind == "ws2812": # Drive a real high then falling data edge through Ws2812String's @@ -395,7 +395,7 @@ def test_a_pin_with_no_gui_slot_cannot_collide_across_devices(): # the two checkouts collide, and which model binds first must not matter # --------------------------------------------------------------------------- def test_binding_a_local_model_first_does_not_break_the_upstream_ones(): - """`perif` and `core` exist in BOTH this repo and the pru-simulator. + """Local models (pru_device_models) and upstream ones resolve from different roots. Resolving one by mutating sys.path and leaving it mutated makes every later import depend on binding order. The suite can be green on that by @@ -405,4 +405,4 @@ def test_binding_a_local_model_first_does_not_break_the_upstream_ones(): build_device(bound("spi_adc")) for kind in ("ssi", "quadrature", "tca9538", "uart"): assert driven_pins(build_device(bound(kind))), \ - "%s stopped resolving after a perif model was bound" % kind + "%s stopped resolving after a local model was bound" % kind diff --git a/scripts/sim/test_device_models.js b/scripts/sim/test_device_models.js index c16c660..46dc15a 100644 --- a/scripts/sim/test_device_models.js +++ b/scripts/sim/test_device_models.js @@ -316,8 +316,8 @@ test('factory: unknown parameters are refused', () => { }); test('factory: a bissc position parameter reaches the encoder frame', () => { - // Red arm: before the key was refused ("unknown bissc parameter"), so - // a GUI-attached BiSS-C encoder could only ever hold position 0. + // The factory must accept the key: refusing it ("unknown bissc parameter") + // would leave a GUI-attached BiSS-C encoder at position 0. const m = models.createDeviceModel('bissc', { primary: 1, clock: 0 }, { position_bits: 21, position: 1234567 }); assert.strictEqual(m.encoder.position, 1234567); diff --git a/scripts/sim/test_ti_run_gui.js b/scripts/sim/test_ti_run_gui.js index c8bc23a..f5589de 100644 --- a/scripts/sim/test_ti_run_gui.js +++ b/scripts/sim/test_ti_run_gui.js @@ -156,7 +156,7 @@ test('designDigest ignores layout and tiRun* state, tracks config', () => { }); test('designDigest separates cores and devices', () => { - // mutant: designDigest ignores its env argument (the pre-digest) + // fails if designDigest ignores its env argument const modules = baseModules(); const env = (context, device) => ({ context, deviceData: { device, part: 'ALX', package: 'ALX' } }); assert.notStrictEqual(ti.designDigest(modules, env('icss_g0_pru0', 'AM243x')), @@ -180,7 +180,7 @@ function fullEnv(over) { } test('designDigest changes with ANY design field and with none of the layout / tiRun* fields', () => { - // mutant: drop one covered field from snapshot()/executionContext() -> the FAIL names that field + // fails, naming the field, if a covered field is dropped from snapshot()/executionContext() const env = fullEnv(); const d0 = ti.designDigest(baseModules(), env); const changes = { @@ -215,9 +215,8 @@ test('designDigest changes with ANY design field and with none of the layout / t }); test('every digest call site in the static module passes the system object (behavioural, format-independent)', () => { - // mutant: revert any one designDigest(system.modules, system) in the static module to - // designDigest(system.modules) -> that site's spy records no environment and this fails - // naming the site. The asm.xdt site is covered by the CLI test + // fails, naming the site, if any designDigest(system.modules, system) call in the static + // module drops its second argument: that site's spy then records no environment. The asm.xdt site is covered by the CLI test // test_cli_digest_changes_with_the_pru_core_context (real SysConfig), not by source text. const calls = []; const spy = { ...ti, designDigest: (m, e) => { calls.push(e); return ti.designDigest(m, e); } }; @@ -271,7 +270,7 @@ test('findPython: Windows fixed-directory fallback retains its backslashes', () const paths = launcher.candidates({}, 'win32').map((candidate) => candidate[0]); const drive = 'C:'; assert.ok(paths.includes(drive + '\\Python313\\python.exe'), - 'mutant: a single-backslash JS literal becomes C:Python313/python.exe'); + 'a single-backslash JS literal would give C:Python313/python.exe'); }); // ---- "Open live web view" checkbox --------------------------------- @@ -306,7 +305,7 @@ test('launchSpec: custom port is passed; a bad port falls back to the default', }); test('designDigest is identical with the live view box on and off', () => { - // mutant: snapshot() stops skipping tiRun* keys -> every tick of the box makes the last run STALE + // fails if snapshot() stops skipping tiRun* keys: every tick of the box would make the last run STALE const off = baseModules(); off['/m/static'].$static.tiRunLiveView = false; off['/m/static'].$static.tiRunLivePort = 8765; const on = baseModules(); on['/m/static'].$static.tiRunLiveView = true; on['/m/static'].$static.tiRunLivePort = 9999; assert.strictEqual(ti.designDigest(on), ti.designDigest(off)); diff --git a/scripts/simulator.js b/scripts/simulator.js index 265da08..94788a0 100644 --- a/scripts/simulator.js +++ b/scripts/simulator.js @@ -1248,8 +1248,9 @@ class Simulator { /** * A broadside ID this block did not model in the direction it was used * (e.g. XIN on the XOUT-only 0x70/0x72, or an XOUT/XIN with the wrong - * register/byte-count). Table 6-112 and PR #30's policy: record it and - * continue, do not trap and do not invent generic FIFO/staged-data state. + * register/byte-count). Per TRM Table 6-112 the ID has no defined behaviour + * in that direction: record it and continue, do not trap and do not invent + * generic FIFO/staged-data state. */ _recordUnsupportedXfr(pruState, id) { if (!pruState.unsupported_xfr) pruState.unsupported_xfr = []; @@ -1361,7 +1362,7 @@ class Simulator { * An unresolved target is counted as a parse error rather than silently * stepping to the next instruction, again matching pru_core.js: a branch to * a label that is not in the map is a broken program, and staying quiet - * about it is what made the original defect invisible. + * about it would hide the broken branch. */ _executeConditionalBranch(pruState, opcode, operands, labelMap) { const label = (operands[0] || '').replace(/[,:]/g, '').trim(); diff --git a/tests/fixtures/firmware/linker.cmd b/tests/fixtures/firmware/linker.cmd new file mode 100644 index 0000000..4a9418a --- /dev/null +++ b/tests/fixtures/firmware/linker.cmd @@ -0,0 +1,54 @@ +/* + * AM243x_PRU0.cmd + * + * Example Linker command file for linking assembly programs built with the TI-PRU-CGT + * on AM243x PRU0 cores + */ + +/* Specify the System Memory Map */ +MEMORY +{ + PAGE 0: + /* 12 KB PRU Instruction RAM */ + PRU_IMEM : org = 0x00000000 len = 0x00003000 + + PAGE 1: + /* Data RAMs */ + /* 8 KB PRU Data RAM 0; use only the first 4 KB for PRU0 and reserve + * the second 4 KB for RTU0 and Tx_PRU0 */ + PRU0_DMEM_0 : org = 0x00000000 len = 0x00001000 + /* 8 KB PRU Data RAM 1; reserved completely for Slice1 cores - PRU1, + * RTU1 and Tx_PRU1; do not use for any Slice0 cores */ + PRU1_DMEM_1 : org = 0x00002000 len = 0x00001000 + /* NOTE: Custom split of the second 4 KB of ICSS Data RAMs 0 and 1 + * split equally between the corresponding RTU and Tx_PRU cores in + * each slice */ + RTU0_DMEM_0 : org = 0x00001000 len = 0x00000800 + TX_PRU0_DMEM_0 : org = 0x00001800 len = 0x00000800 + RTU1_DMEM_1 : org = 0x00003000 len = 0x00000800 + TX_PRU1_DMEM_1 : org = 0x00003800 len = 0x00000800 + + PAGE 2: + /* C28 needs to be programmed to point to SHAREDMEM, default is 0 */ + /* 64 KB PRU Shared RAM */ + PRU_SHAREDMEM : org = 0x00010000 len = 0x00010000 +} + +/* Specify the sections allocation into memory */ +SECTIONS { + .text:main > 0, PAGE 0 + .text > PRU_IMEM, PAGE 0 + .stack > PRU0_DMEM_0, PAGE 1 + .bss > PRU0_DMEM_0, PAGE 1 + .udmem > PRU0_DMEM_0, PAGE 1 + .initdmem > PRU0_DMEM_0, PAGE 1 + .cio > PRU0_DMEM_0, PAGE 1 + .data > PRU0_DMEM_0, PAGE 1 + .switch > PRU0_DMEM_0, PAGE 1 + .sysmem > PRU0_DMEM_0, PAGE 1 + .cinit > PRU0_DMEM_0, PAGE 1 + .rodata > PRU0_DMEM_0, PAGE 1 + .rofardata > PRU0_DMEM_0, PAGE 1 + .farbss > PRU0_DMEM_0, PAGE 1 + .fardata > PRU0_DMEM_0, PAGE 1 +} diff --git a/tests/fixtures/firmware/main.asm b/tests/fixtures/firmware/main.asm new file mode 100644 index 0000000..3c88341 --- /dev/null +++ b/tests/fixtures/firmware/main.asm @@ -0,0 +1,31 @@ +; SPDX-License-Identifier: BSD-3-Clause +; Copyright (C) 2024-2025 Texas Instruments Incorporated - http://www.ti.com/ + +;*************************************************************************************** +; File: main.asm +; +; Brief: Test fixture: jumps to the SysConfig-generated code and halts at +; sysconfig_generated_end, so a test can link one generated +; pru_syscfg.asm and run it to completion on the simulator. +; +;*************************************************************************************** + +; CCS/makefile specific settings + .retain ; Required for building .out with assembly file + .retainrefs ; Required for building .out with assembly file + + .global main + .ref sysconfig_generated_start + .sect ".text:main" + +;******** +;* MAIN * +;******** + +main: + ; halt the program after jumping to sysconfig generated start + JMP sysconfig_generated_start + + .global sysconfig_generated_end +sysconfig_generated_end: + halt diff --git a/tests/test_bissline.py b/tests/test_bissline.py index c694839..c21169d 100644 --- a/tests/test_bissline.py +++ b/tests/test_bissline.py @@ -27,7 +27,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from perif.bissline_device_model import ( +from pru_device_models.bissline_device_model import ( BissLineEncoder, crc6_bissc, hamming74_encode, @@ -45,8 +45,8 @@ def _independent_crc6(bits): Implemented by GF(2) polynomial long division, a structurally different method from the model's shift-register feedback loop in - perif/bissline_device_model.py:crc6_bissc (and from the third copy in - perif/buscosim.py:_bissline_crc6). Here the message bits are the + pru_device_models/bissline_device_model.py:crc6_bissc (and from the third copy in + pru_device_models/buscosim.py:_bissline_crc6). Here the message bits are the coefficients of M(x) (first bit = highest degree), the dividend is M(x) * x^6, and it is divided by G(x) = x^6 + x + 1 by repeatedly cancelling the leading term, exactly as paper long division. The @@ -512,11 +512,11 @@ def test_red_overclocked(self): class TestBuscosimWireShape: """SHAPE COVERAGE, not independent CRC verification. - The buscosim decoder's CRC (perif/buscosim.py:_bissline_crc6) is a + The buscosim decoder's CRC (pru_device_models/buscosim.py:_bissline_crc6) is a third copy of the model's shift-register algorithm, so this test is self-consistency for the CRC: it catches bit-level decode/trace bugs (wrong pin, wrong edge, wrong frame slicing), and it happens to catch - a mutation confined to perif/bissline_device_model.py:crc6_bissc, but + a mutation confined to pru_device_models/bissline_device_model.py:crc6_bissc, but a polynomial error written identically into both copies passes. Genuine CRC oracle-independence lives in TestCrc6Independent and TestIndependentCrcValidation against the GF(2) long-division helper. @@ -526,7 +526,7 @@ class TestBuscosimWireShape: """ def test_buscosim_decodes_raw_frame_shape(self): - from perif import buscosim + from pru_device_models import buscosim pos = 0xCAFEBABE m = BissLineEncoder( data_bits=32, position=pos, @@ -617,7 +617,7 @@ class TestWireLevelCodedFec: """Verify full 8b/10b and FEC decoding from raw wire bits.""" def test_wire_level_decode(self): - from perif.bissline_device_model import decode_8b10b_symbol, hamming74_decode + from pru_device_models.bissline_device_model import decode_8b10b_symbol, hamming74_decode pos = 0xC0DE m = BissLineEncoder(data_bits=16, position=pos, line_coding=True, fec_enable=True) trace = _clock_model(m, m.total_wire_bits, half_period=10) diff --git a/tests/test_fanout_register_alias.py b/tests/test_fanout_register_alias.py index 389f77a..3f4a00c 100644 --- a/tests/test_fanout_register_alias.py +++ b/tests/test_fanout_register_alias.py @@ -41,10 +41,11 @@ import elf_cosim # noqa: E402 -FW = os.path.join(REPO_ROOT, "examples", "ssi_encoder_sim", "firmware") +# Minimal firmware shell (jump to the generated code, halt at sysconfig_generated_end) and the AM243x PRU0 linker +# command file, both shipped as test fixtures. +FW = os.path.join(REPO_ROOT, "tests", "fixtures", "firmware") MAIN_ASM = os.path.join(FW, "main.asm") -LINKER_CMD = os.path.join(REPO_ROOT, "examples", "gray_encoder_4_bit", "firmware", "am243x-lp", - "icss_g0_pru0_fw", "ti-pru-cgt", "linker.cmd") +LINKER_CMD = os.path.join(FW, "linker.cmd") PRODUCT_JSON = os.path.join(REPO_ROOT, ".metadata", "product.json") SYSCFG_DIR = os.environ.get("SYSCFG_PATH", "C:/ti/sysconfig_1.28.0") @@ -59,12 +60,10 @@ HAVE_TOOLS = os.path.exists(SYSCFG_CLI) and os.path.exists(SYSCFG_NODE) except RuntimeError: HAVE_TOOLS = False -# The ELF arms link against the ssi_encoder_sim main.asm and the gray_encoder_4_bit linker command file. -HAVE_EXAMPLES = os.path.exists(MAIN_ASM) and os.path.exists(LINKER_CMD) -HAVE_TOOLS = HAVE_TOOLS and HAVE_EXAMPLES +assert os.path.exists(MAIN_ASM) and os.path.exists(LINKER_CMD), "tests/fixtures/firmware is part of the repository" needs_tools = pytest.mark.skipif( - not HAVE_TOOLS, reason="clpru, SysConfig 1.28 CLI, pru-simulator or the ssi_encoder_sim / gray_encoder_4_bit example sources missing") + not HAVE_TOOLS, reason="clpru, SysConfig 1.28 CLI or pru-simulator missing") MASK = 0xFFFFFFFF VALUE = 0x5A5A5A5A diff --git a/tests/test_gui_simulator_core.py b/tests/test_gui_simulator_core.py new file mode 100644 index 0000000..86788df --- /dev/null +++ b/tests/test_gui_simulator_core.py @@ -0,0 +1,72 @@ +"""Register and opcode semantics of the GUI simulator (common/simulation/pru_core.js). + +PRU word registers overlap byte-wise: Rn.w0 is bits 15:0, Rn.w1 bits 23:8 and Rn.w2 bits 31:16, so the shift of +Rn.wK is 8*K. A shift of 16*K puts .w1 at bits 31:16 (the .w2 field) and makes every .w1 read or write wrong. +A sub-register write must leave the register as an unsigned 32-bit value (JS bitwise operators return signed ones). + +Base instructions the simulator recognises but does not execute (MAC, MACU, WBC) must stop the run with an error +instead of being skipped, which would leave registers silently stale. + +Needs only node. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SIM_DIR = ROOT / ".metadata" / "sysconfig" / ".meta" / "pru_blocks" / "common" / "simulation" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node not on PATH") + +RUN = r""" +const fs = require('fs'), path = require('path'), vm = require('vm'); +const SIM_DIR = process.argv[1]; +const cache = {}; +function load(p) { + const sandbox = { console, Math, JSON, Number, Array, Object, Set, Error, Uint8Array, BigInt, parseInt, isNaN, + system: { getScript: (s) => { const b = path.basename(s); return cache[b] || (cache[b] = load(path.join(SIM_DIR, b))); } }, + exports: {} }; + vm.createContext(sandbox); + vm.runInContext(fs.readFileSync(p, 'utf8'), sandbox); + return sandbox.exports; +} +const core = load(path.join(SIM_DIR, 'pru_core.js')); +const prog = JSON.parse(process.argv[2]); +try { + const r = core.simulatePruInstructions(prog, {}, 50); + process.stdout.write(JSON.stringify({ regs: Array.from(r.pruState.registers) })); +} catch (e) { + process.stdout.write(JSON.stringify({ error: String(e.message || e) })); +} +""" + + +def run(program): + out = subprocess.run(["node", "-e", RUN, str(SIM_DIR), json.dumps(program)], + cwd=ROOT, text=True, capture_output=True, check=True) + return json.loads(out.stdout) + + +@pytest.mark.parametrize("dest,expected", [ + ("R1.w0", 0xAABB1234), + ("R1.w1", 0xAA1234DD), + ("R1.w2", 0x1234CCDD), +]) +def test_word_register_write_touches_only_its_16_bits(dest, expected): + regs = run(["LDI32 R1, 0xAABBCCDD", "LDI %s, 0x1234" % dest])["regs"] + assert regs[1] == expected, "%s write gave 0x%08X, want 0x%08X" % (dest, regs[1], expected) + + +@pytest.mark.parametrize("src,expected", [("R2.w0", 0x3344), ("R2.w1", 0x2233), ("R2.w2", 0x1122)]) +def test_word_register_read_returns_its_16_bits(src, expected): + regs = run(["LDI32 R2, 0x11223344", "MOV R3, %s" % src])["regs"] + assert regs[3] == expected, "MOV R3, %s gave 0x%08X, want 0x%04X" % (src, regs[3], expected) + + +@pytest.mark.parametrize("opcode", ["MAC", "MACU", "WBC"]) +def test_unimplemented_base_instruction_stops_the_run(opcode): + result = run(["LDI R1, 1", "%s R1, R2" % opcode]) + assert "not yet implemented" in result.get("error", ""), result diff --git a/tests/test_package_names.py b/tests/test_package_names.py new file mode 100644 index 0000000..7ea2c86 --- /dev/null +++ b/tests/test_package_names.py @@ -0,0 +1,57 @@ +"""This repository's Python packages must not shadow the pru-simulator checkout's. + +The simulation tests import this repository's device models and TI pru-simulator in the same process (pytest puts the +repository root on sys.path). A repository-root package named like one of the simulator's (core, perif, pru_io, ...) +is cached in sys.modules by the first import and then hides the simulator's package, so a later +`from core.pru_core import PRUCore` inside simulator.py fails with ModuleNotFoundError. + +The static check needs nothing installed. The import check runs in a fresh interpreter and needs a pru-simulator +checkout (PRU_SIMULATOR_PATH); it skips without one. +""" +import os +import subprocess +import sys +import textwrap + +import pytest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +SIMDIR = os.environ.get("PRU_SIMULATOR_PATH", "") +HAVE_SIM = os.path.isfile(os.path.join(SIMDIR, "simulator.py")) + +# Top-level packages of the pru-simulator checkout (simulator.py imports core.*, perif.*, pru_io.*, xfr.*). +SIMULATOR_TOPS = {"core", "perif", "pru_io", "xfr", "simulator", "tools", "mcp_server", "ui"} + + +def _repo_packages(): + return {d for d in os.listdir(ROOT) + if os.path.isfile(os.path.join(ROOT, d, "__init__.py")) or (d.endswith(".py") and d != "conftest.py")} + + +def _simulator_packages(): + tops = set(SIMULATOR_TOPS) + if HAVE_SIM: + tops |= {d for d in os.listdir(SIMDIR) if os.path.isfile(os.path.join(SIMDIR, d, "__init__.py"))} + return tops + + +def test_no_repository_package_has_a_simulator_package_name(): + clash = sorted({p[:-3] if p.endswith(".py") else p for p in _repo_packages()} & _simulator_packages()) + assert not clash, "repository-root package(s) shadow the pru-simulator's: " + ", ".join(clash) + + +@pytest.mark.skipif(not HAVE_SIM, reason="TI pru-simulator checkout (PRU_SIMULATOR_PATH) missing") +@pytest.mark.parametrize("model", ["pru_device_models.spi_adc", "pru_device_models.bissline_device_model", + "pru_device_models.waveform_player"]) +def test_a_model_import_does_not_hide_the_simulator(model): + code = textwrap.dedent(f""" + import sys + sys.path.insert(0, {ROOT!r}) + import {model} + sys.path.insert(0, {SIMDIR!r}) + from core.pru_core import PRUCore + from simulator import Simulator + print("ok") + """) + r = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, cwd=ROOT) + assert r.returncode == 0 and r.stdout.strip() == "ok", r.stdout + r.stderr diff --git a/tests/test_spi_read_zero_extend.py b/tests/test_spi_read_zero_extend.py index ce6c1cd..34a46b6 100644 --- a/tests/test_spi_read_zero_extend.py +++ b/tests/test_spi_read_zero_extend.py @@ -11,7 +11,7 @@ sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "scripts" / "sim")) import elf_cosim # noqa: E402 -from perif.spi_adc import SpiAdc # noqa: E402 +from pru_device_models.spi_adc import SpiAdc # noqa: E402 SCLK_PIN, SDI_PIN, CS_PIN = 0, 1, 3 @@ -101,8 +101,8 @@ def spi_read_spec(packet_size): "DELAY_COMPEN_1": "1", "DELAY_COMPEN_2": "1", "CS_PIN": str(CS_PIN), "CS_SETUP_TIME": "0", "CS_HOLD_TIME": "0", "DATA_SETUP_TIME": "0", }, - # Model the natural register reuse seen in the regression: the destination - # contains a prior word before the shorter SPI read starts. + # Model register reuse: the destination holds an older 32-bit word + # before the shorter SPI read starts. "prelude": ["set R30, R30, 3", "ldi32 R0, 0x%08X" % PRELOAD], "postlude": ["halt"], } diff --git a/tests/test_spi_transfer_zero_extend.py b/tests/test_spi_transfer_zero_extend.py index 19c628c..c494ee9 100644 --- a/tests/test_spi_transfer_zero_extend.py +++ b/tests/test_spi_transfer_zero_extend.py @@ -20,7 +20,7 @@ sys.path.insert(0, str(ROOT / "scripts" / "sim")) sys.path.insert(0, str(ROOT / "tests")) import elf_cosim # noqa: E402 -from perif.spi_adc import SpiAdc # noqa: E402 +from pru_device_models.spi_adc import SpiAdc # noqa: E402 import test_fanout_register_alias as T # noqa: E402 from test_spi_read_zero_extend import ( # noqa: E402 READ_VARIANTS, TRANSFER_VARIANTS, PRELOAD) diff --git a/tests/test_waveform_player.py b/tests/test_waveform_player.py index 30f3363..1222542 100644 --- a/tests/test_waveform_player.py +++ b/tests/test_waveform_player.py @@ -26,7 +26,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from pru_io.waveform_player import WaveformPlayer # noqa: E402 +from pru_device_models.waveform_player import WaveformPlayer # noqa: E402 MASK_20 = (1 << 20) - 1 ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) @@ -60,9 +60,9 @@ def _locate_simulator(): def _load_upstream_device_model(): """Load the real DeviceBus from the simulator checkout, by path. - By path because in this process the name ``pru_io`` is already this - repo's package; the upstream module has no intra-package imports, so - loading it standalone is safe. + By path, so the checkout's ``pru_io`` package is not bound in this + process; the upstream module has no intra-package imports, so loading it + standalone is safe. """ sim = _locate_simulator() if sim is None: diff --git a/tools/__init__.py b/tools/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tools/gpi_stimulus_runner.py b/tools/gpi_stimulus_runner.py index 15c8fbb..dac19c9 100644 --- a/tools/gpi_stimulus_runner.py +++ b/tools/gpi_stimulus_runner.py @@ -83,16 +83,14 @@ def locate_simulator(explicit=None): def load_waveform_player_module(): - """Load pru_io/waveform_player.py from THIS repo. + """Load pru_device_models/waveform_player.py from THIS repo. - Deliberately by path: inside the runner the name ``pru_io`` may already - be bound to the simulator checkout's package (the bus we need comes from - there), so a plain ``import pru_io.waveform_player`` could resolve to the - wrong tree. The module itself picks whichever DeviceModel base is + By path, so the module loads from this repository even though the + simulator checkout is first on sys.path (the bus comes from there). The module itself picks whichever DeviceModel base is importable; the bus never isinstance-checks, so the wire contract is what matters. """ - path = os.path.join(ROOT, "pru_io", "waveform_player.py") + path = os.path.join(ROOT, "pru_device_models", "waveform_player.py") spec = importlib.util.spec_from_file_location("waveform_player", path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) From a3f0b7acd4e814b62c4c2e8c1c6bf4cab1315d7c Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 16:40:24 +0530 Subject: [PATCH 22/44] Simulator lookup message and test docstrings describe current behaviour --- scripts/sim/elf_cosim.py | 11 ++++++----- tests/test_access_lut_index_scaling.py | 9 +++++---- tests/test_loop_carried_accumulator.py | 8 ++++---- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/scripts/sim/elf_cosim.py b/scripts/sim/elf_cosim.py index 617e34d..e40d7d3 100644 --- a/scripts/sim/elf_cosim.py +++ b/scripts/sim/elf_cosim.py @@ -74,9 +74,10 @@ class SimulatorNotFound(RuntimeError): def find_pru_simulator() -> str: - """Locate the TI pru-simulator checkout (tools/pru-simulator-upstream), - walking ancestors so worktrees at any depth resolve. Only a directory - that contains simulator.py counts.""" + """Locate the TI pru-simulator checkout: PRU_SIMULATOR_PATH, else a + tools/pru-simulator-upstream directory in this checkout or any parent + directory (so worktrees at any depth resolve). Only a directory that + contains simulator.py counts.""" tried = [] env = os.environ.get("PRU_SIMULATOR_PATH") if env: @@ -94,8 +95,8 @@ def find_pru_simulator() -> str: break d = parent raise SimulatorNotFound( - "pru-simulator-upstream not found (looked for simulator.py in each); " - "set PRU_SIMULATOR_PATH. Tried: " + " | ".join(tried)) + "no pru-simulator checkout found (set PRU_SIMULATOR_PATH); " + "looked for simulator.py in: " + " | ".join(tried)) def expand_block_asm(spec: Dict[str, Any], workdir: str) -> str: diff --git a/tests/test_access_lut_index_scaling.py b/tests/test_access_lut_index_scaling.py index 2a58c26..050efbd 100644 --- a/tests/test_access_lut_index_scaling.py +++ b/tests/test_access_lut_index_scaling.py @@ -2,14 +2,15 @@ The generated read of a ushort/uint table was `LBBO &res, TEMP_REG1, , 2|4`: the index register was used as a raw byte offset, so index 1 of a ushort table -returned bytes 1-2 of the table, not entry 1 (same defect -class as, which fixed crc only). Byte tables were correct. +returned bytes 1-2 of the table, not entry 1 (the crc block's table read had the +same issue). Byte tables were correct. The oracle is the table written in this test (distinct, non-repeating bytes) - independent of the block. The firmware under test is the block's own emitted macro (getMacro) assembled by clpru and run on the TI pru-simulator, with the -table placed in .data (PRU0 DMEM). Simulator tests fail (not skip) without -PRU_SIMULATOR_PATH / clpru: a skip would read as a pass. +table placed in .data (PRU0 DMEM). Without a pru-simulator checkout +(PRU_SIMULATOR_PATH) or clpru the simulator tests are reported as SKIPPED with +the reason (see conftest.py). """ import json diff --git a/tests/test_loop_carried_accumulator.py b/tests/test_loop_carried_accumulator.py index ac767f3..613380f 100644 --- a/tests/test_loop_carried_accumulator.py +++ b/tests/test_loop_carried_accumulator.py @@ -23,10 +23,10 @@ Oracle: the VALUE in out_buf after the real clpru ELF runs on the TI pru-simulator (never the asm text). Expected N*x mod 2^(8n). -Red at base: the design is rejected at validation (container -boundary error for acc -> wr, plus the unknown exportedBlocks / -accumulators fields), so the value assertion cannot pass. -Green after: out_buf == N*x for N=3 and N=10, x in (1, 7). +Without accumulators/exportedBlocks support the design is rejected at +validation (container boundary error for acc -> wr, plus the unknown +exportedBlocks / accumulators fields), so the value assertion cannot pass. +With it: out_buf == N*x for N=3 and N=10, x in (1, 7). """ import os From 7bde20cc6bba983ba7b23caa7f6a70a8e91ef6c2 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 16:45:44 +0530 Subject: [PATCH 23/44] elf_cosim: optional constant-table override and IEP0 mapping for firmware that programs the IEP through c26 --- scripts/sim/elf_cosim.py | 25 ++++++++++++++++ tests/test_elf_cosim_constants.py | 48 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 tests/test_elf_cosim_constants.py diff --git a/scripts/sim/elf_cosim.py b/scripts/sim/elf_cosim.py index e40d7d3..1625638 100644 --- a/scripts/sim/elf_cosim.py +++ b/scripts/sim/elf_cosim.py @@ -29,6 +29,12 @@ REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +#: PRU constant-table entry 26 is the ICSS IEP0 register block (0x0002E000) on +#: AM64x/AM243x. A pru-simulator checkout whose config/constants_am243x.cfg maps +#: c26 elsewhere still places its IEP model at 0x0002E000, so firmware that +#: programs the IEP through c26 passes this to run_elf_closed_loop(constants=...). +IEP_CONSTANTS = {26: 0x0002E000} + CLPRU_CANDIDATES = [ str(TI_TOOLS / "ti-cgt-pru_2.3.3" / "bin" / "clpru.exe"), str(TI_TOOLS / "ti-cgt-pru_2.3.3" / "bin" / "clpru"), @@ -69,6 +75,19 @@ def _is_simulator_checkout(path: str) -> bool: return os.path.isfile(os.path.join(path, "simulator.py")) +def simulator_iep_base() -> Optional[int]: + """Base address of the checkout's IEP0 register model, or None if it has none.""" + sim_dir = find_pru_simulator() + with _sim_namespace(sim_dir): + from simulator import Simulator # type: ignore + + sim = Simulator(os.path.join(sim_dir, "memory.cfg")) + for region in getattr(sim.memory, "regions", []): + if getattr(region, "name", "") == "ICSS_IEP": + return region.base_addr + return None + + class SimulatorNotFound(RuntimeError): """TI pru-simulator checkout not found. Tests treat it as a skip (see conftest.py).""" @@ -149,6 +168,7 @@ def run_elf_closed_loop( max_steps: int = 20000, core: str = "pru0", on_step=None, + constants: Optional[Dict[int, int]] = None, ) -> CosimResult: """Run a real ELF against a DeviceModel in a closed pin loop. @@ -156,6 +176,9 @@ def run_elf_closed_loop( tick(cycle, pins_out) -> pins_in (GPI word). ``on_step`` (optional) is called each step with (cycle, gpo, gpi, sim) so tests can inject extra stimuli (e.g. an EnDat mode command). + ``constants`` (optional) overrides constant-table entries {index: address} + before the run, for firmware that relies on an entry the simulator's + config maps differently from the device (see IEP_CONSTANTS). """ sim_dir = find_pru_simulator() # Run the whole loop with the simulator's packages first on sys.path, so its @@ -169,6 +192,8 @@ def run_elf_closed_loop( if errors: raise RuntimeError(f"ELF load errors: {errors}") pru = sim.cores[core] + for index, address in (constants or {}).items(): + sim.constant_table.set(index, address) result = CosimResult() result = _loop(sim, pru, core, model, max_steps, on_step) diff --git a/tests/test_elf_cosim_constants.py b/tests/test_elf_cosim_constants.py new file mode 100644 index 0000000..b9d6693 --- /dev/null +++ b/tests/test_elf_cosim_constants.py @@ -0,0 +1,48 @@ +"""run_elf_closed_loop(constants=...) overrides constant-table entries before the run. + +Firmware that programs the IEP through c26 relies on c26 being the ICSS IEP0 register block (0x0002E000), as on the +device. The check writes CMP0 (IEP offset 0x78) through c26 and reads it back by absolute address: with +elf_cosim.IEP_CONSTANTS the word must land in the simulator's IEP model. + +Needs clpru (CGT_TI_PRU_PATH) and a pru-simulator checkout (PRU_SIMULATOR_PATH); skips without them. +""" +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts" / "sim")) +import elf_cosim # noqa: E402 + +try: + elf_cosim.find_clpru() + HAVE_CLPRU = True +except RuntimeError: + HAVE_CLPRU = False + +ASM = """ + .global main + .sect ".text:main" +main: + ldi32 r1, 0x12345678 + sbco &r1, c26, 0x78, 4 + ldi32 r3, 0x0002E078 + lbbo &r4, r3, 0, 4 + halt +""" + + +class Idle: + def tick(self, _cycle, _pins): + return 0 + + +@pytest.mark.skipif(not HAVE_CLPRU, reason="clpru not found (CGT_TI_PRU_PATH)") +def test_iep_constants_route_c26_writes_to_the_iep_model(tmp_path): + if elf_cosim.simulator_iep_base() != 0x0002E000: + pytest.skip("pru-simulator checkout has no IEP0 model at 0x0002E000") + elf = elf_cosim.compile_asm(ASM, str(tmp_path), "c26_iep") + result = elf_cosim.run_elf_closed_loop(elf, Idle(), max_steps=50, constants=elf_cosim.IEP_CONSTANTS) + assert result.halted + assert result.registers[4] == 0x12345678, "IEP CMP0 read back 0x%08X" % result.registers[4] From 0c087baf368f9285ce30126cb88af117c3f1f129 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 16:47:49 +0530 Subject: [PATCH 24/44] elf_cosim: apply the constant-table override to the core being run --- scripts/sim/elf_cosim.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sim/elf_cosim.py b/scripts/sim/elf_cosim.py index 1625638..393b07f 100644 --- a/scripts/sim/elf_cosim.py +++ b/scripts/sim/elf_cosim.py @@ -193,7 +193,7 @@ def run_elf_closed_loop( raise RuntimeError(f"ELF load errors: {errors}") pru = sim.cores[core] for index, address in (constants or {}).items(): - sim.constant_table.set(index, address) + pru.constant_table.set(index, address) result = CosimResult() result = _loop(sim, pru, core, model, max_steps, on_step) From 3af6909c9b4555c261c1cfaf13c98cc73f68339d Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 16:49:53 +0530 Subject: [PATCH 25/44] elf_cosim: option to run the IEP at the core clock --- scripts/sim/elf_cosim.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/sim/elf_cosim.py b/scripts/sim/elf_cosim.py index 393b07f..63e4d03 100644 --- a/scripts/sim/elf_cosim.py +++ b/scripts/sim/elf_cosim.py @@ -169,6 +169,7 @@ def run_elf_closed_loop( core: str = "pru0", on_step=None, constants: Optional[Dict[int, int]] = None, + lock_iep_to_core: bool = False, ) -> CosimResult: """Run a real ELF against a DeviceModel in a closed pin loop. @@ -179,6 +180,10 @@ def run_elf_closed_loop( ``constants`` (optional) overrides constant-table entries {index: address} before the run, for firmware that relies on an entry the simulator's config maps differently from the device (see IEP_CONSTANTS). + ``lock_iep_to_core`` runs the IEP at the core's clock. A checkout that + clocks the IEP at its own rate (e.g. 200 MHz IEP, 250 MHz PRU) shifts + edges that wait on IEP compare events by the IEP-to-core phase, so tests + that compare two runs cycle for cycle pin the two clocks together. """ sim_dir = find_pru_simulator() # Run the whole loop with the simulator's packages first on sys.path, so its @@ -194,6 +199,8 @@ def run_elf_closed_loop( pru = sim.cores[core] for index, address in (constants or {}).items(): pru.constant_table.set(index, address) + if lock_iep_to_core: + sim.set_iep_clock_mhz(pru.clock_mhz) result = CosimResult() result = _loop(sim, pru, core, model, max_steps, on_step) From a19c375b79e022282d777abea1935094b547e12d Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 19:24:19 +0530 Subject: [PATCH 26/44] Static module: report device-binding errors against the module instance validate() passed a copy of the instance, with the companion pins defaulted from the bound block, to validateDeviceInput, which logged errors against that copy. SysConfig's report.logError throws "Unknown instance" for any object it does not own, so a device-mode GPI with a refused setting aborted validation, code generation and serialization. The effective pins are now passed separately and errors are logged against the real instance. --- .../common/pru_blocks_static_module.syscfg.js | 15 +++--- scripts/sim/test_device_binding_syscfg.js | 50 +++++++++++++++---- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js index 7bdddd5..cc96be1 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js @@ -165,14 +165,17 @@ function validatePatternInput(inst, report, pinNum) { * drop. Both produce a simulation that runs happily and proves nothing, which * is exactly the outcome attaching a real model is supposed to prevent. */ -function validateDeviceInput(inst, report, pinNum) { +function validateDeviceInput(inst, report, pinNum, pins) { const kindField = `gpi${pinNum}_deviceKind`; const clockField = `gpi${pinNum}_deviceClockPin`; const indexField = `gpi${pinNum}_deviceIndexPin`; const paramsField = `gpi${pinNum}_deviceParams`; - const clockPin = inst[clockField]; - const indexPin = inst[indexField]; + // `pins` carries the effective companion pins when they differ from the + // fields; errors are still logged against `inst`, because report.logError + // only accepts the instance SysConfig passed to validate(). + const clockPin = pins ? pins.clockPin : inst[clockField]; + const indexPin = pins ? pins.indexPin : inst[indexField]; for (const [field, value, label] of [[clockField, clockPin, "Clock/B"], [indexField, indexPin, "Index/Z"]]) { @@ -307,11 +310,7 @@ function validateR31SimulationInput(inst, report) { // for a field the design already answers. const kind = inst[`gpi${pinNum}_deviceKind`]; const defaults = devicePinDefaults(inst, pinNum, kind); - const effectiveInst = Object.assign({}, inst, { - [`gpi${pinNum}_deviceClockPin`]: defaults.clockPin, - [`gpi${pinNum}_deviceIndexPin`]: defaults.indexPin - }); - if (!validateDeviceInput(effectiveInst, report, pinNum)) { + if (!validateDeviceInput(inst, report, pinNum, defaults)) { return false; } } diff --git a/scripts/sim/test_device_binding_syscfg.js b/scripts/sim/test_device_binding_syscfg.js index 82468b9..23f02e6 100644 --- a/scripts/sim/test_device_binding_syscfg.js +++ b/scripts/sim/test_device_binding_syscfg.js @@ -25,24 +25,29 @@ const assert = require('assert'); const SRC = path.join(__dirname, '..', '..', '.metadata', 'sysconfig', '.meta', 'pru_blocks', 'common', 'pru_blocks_static_module.syscfg.js'); -function loadValidator() { - // Normalise line endings first: this repo's sources are CRLF, and matching - // on a bare "\n}\n" silently finds nothing. - const source = fs.readFileSync(SRC, 'utf8').replace(/\r\n/g, '\n'); - const start = source.indexOf('function validateDeviceInput'); - assert.notStrictEqual(start, -1, 'validateDeviceInput not found in the module'); +function extractFunction(source, name) { + const start = source.indexOf(`function ${name}(`); + assert.notStrictEqual(start, -1, `${name} not found in the module`); // Take from the function keyword to the line that closes it at column 0. const rest = source.slice(start); const end = rest.indexOf('\n}\n'); - assert.notStrictEqual(end, -1, 'could not find the end of validateDeviceInput'); - const body = rest.slice(0, end + 2); + assert.notStrictEqual(end, -1, `could not find the end of ${name}`); + return rest.slice(0, end + 2); +} +function loadFunctions(names) { + // Normalise line endings first: this repo's sources are CRLF, and matching + // on a bare "\n}\n" silently finds nothing. + const source = fs.readFileSync(SRC, 'utf8').replace(/\r\n/g, '\n'); + const bodies = names.map(name => extractFunction(source, name)).join('\n'); // eslint-disable-next-line no-new-func - return new Function(`${body}; return validateDeviceInput;`)(); + return new Function(`${bodies}; return { ${names.join(', ')} };`)(); } -const validateDeviceInput = loadValidator(); +const { validateDeviceInput, validateR31SimulationInput } = loadFunctions([ + 'validateDeviceInput', 'devicePinDefaults', 'validateR31SimulationInput' +]); function makeReport() { const errors = []; @@ -162,6 +167,31 @@ test('every refusal names the field it is about', () => { assert.ok(r.errors[0].field, 'error must name a config field'); }); +test('a refusal from the module validator is reported against the real instance', () => { + // SysConfig's report.logError only accepts an instance it owns; any other + // object throws "Unknown instance", which aborts validation and with it + // code generation and serialization of the whole design. + const real = inst(3, { gpi3_inputMode: 'device', gpi3_deviceKind: 'ssi' }); + real.signalsToDisplayR31 = ['3']; + const seen = []; + const report = { + logError(message, target, field) { + if (target !== real) throw new Error('Unknown instance'); + seen.push({ message, field }); + } + }; + assert.strictEqual(validateR31SimulationInput(real, report), false); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].field, 'gpi3_deviceClockPin'); +}); + +test('the module validator accepts a legal device binding', () => { + const real = inst(3, { gpi3_inputMode: 'device', gpi3_deviceKind: 'ssi', gpi3_deviceClockPin: 4 }); + real.signalsToDisplayR31 = ['3']; + const report = makeReport(); + assert.strictEqual(validateR31SimulationInput(real, report), true, JSON.stringify(report.errors)); +}); + let failed = 0; for (const [name, fn] of cases) { try { From 11950766638ee3ecdb35f270e87cf5c6bfbe52ff Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 19:24:20 +0530 Subject: [PATCH 27/44] Title-case Device Model and live-view display names SysConfig display names are Title Case, at most 36 characters. The Device Model block now takes each field's display name from displayTitle() in the device model table: an explicit title where the label is a sentence, else the label converted word by word. The label itself stays the noun used in messages. The live web view settings get Title Case names and the long tooltip moves to the long description. --- .../pru_blocks/common/device_model_table.js | 40 ++++++++-- .../common/pru_blocks_static_module.syscfg.js | 7 +- .../utils/device_model_block.syscfg.js | 14 ++-- scripts/sim/gui_run.py | 2 +- scripts/sim/test_device_model_titles.js | 76 +++++++++++++++++++ scripts/sim/test_ti_run_gui.js | 2 +- 6 files changed, 123 insertions(+), 18 deletions(-) create mode 100644 scripts/sim/test_device_model_titles.js diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js b/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js index 2a47855..40506a4 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/device_model_table.js @@ -38,6 +38,9 @@ const PIN_MAX = 19; // PRU R30/R31 pin range accepted by scripts/sim/device_bi // type: int | float | bool | bytes. `omit`: the value that means "not set" - // the key is then left out of the manifest and the model takes its own default // (the Python models take None there, which a typed int field cannot say). +// `label` is the noun used in messages; the field's display name is +// displayTitle(spec): `title` when given, else the label in Title Case. `hint` +// is extra tooltip text for a field whose title leaves something out. const KINDS = { quadrature: { displayName: "Quadrature encoder (A/B/Z)", @@ -63,7 +66,7 @@ const KINDS = { params: [ { key: "bits", label: "Frame width (bits)", type: "int", def: 13, min: 1, max: 64 }, { key: "position", label: "Position", type: "int", def: 0, min: 0, positionBitsKey: "bits" }, - { key: "cycles_per_count", label: "Cycles per count (0 = stationary)", type: "int", def: 0, min: 0, omit: 0 }, + { key: "cycles_per_count", label: "Cycles per count (0 = stationary)", title: "Cycles Per Count (0=Stationary)", type: "int", def: 0, min: 0, omit: 0 }, { key: "min_clk_period_cycles", label: "Minimum clock period (cycles)", type: "int", def: 20, min: 1 }, { key: "monoflop_cycles", label: "Monoflop time (cycles)", type: "int", def: 400, min: 1 }, { key: "gray", label: "Gray-coded position", type: "bool", def: true } @@ -108,7 +111,7 @@ const KINDS = { params: [ { key: "position_bits", label: "Position bits", type: "int", def: 24, min: 1, max: 64 }, { key: "position", label: "Position", type: "int", def: 0, min: 0, positionBitsKey: "position_bits" }, - { key: "cycles_per_count", label: "Cycles per count (0 = stationary)", type: "int", def: 0, min: 0, omit: 0 }, + { key: "cycles_per_count", label: "Cycles per count (0 = stationary)", title: "Cycles Per Count (0=Stationary)", type: "int", def: 0, min: 0, omit: 0 }, { key: "calc_cycles", label: "Calculation time (cycles)", type: "int", def: 50, min: 0 }, { key: "lead_in_clocks", label: "Lead-in clocks", type: "int", def: 2, min: 0 }, { key: "min_half_period_cycles", label: "Minimum half period (cycles)", type: "int", def: 0, min: 0 }, @@ -134,10 +137,11 @@ const KINDS = { displayName: "UART transmitter", sink: false, pins: [ - { key: "tx", label: "TX pin (device drives)", slot: "primaryPin", drives: true } + { key: "tx", label: "TX pin (device drives)", title: "TX Pin", slot: "primaryPin", drives: true } ], params: [ - { key: "payload", label: "Payload bytes (comma separated, 0-255; empty = model default)", type: "bytes", def: "", omit: "" }, + { key: "payload", label: "Payload bytes (comma separated, 0-255; empty = model default)", title: "Payload Bytes", + hint: "Comma-separated bytes 0-255. Empty uses the model default.", type: "bytes", def: "", omit: "" }, { key: "bit_period", label: "Bit period (cycles)", type: "int", def: 50, min: 1 }, { key: "start_cycle", label: "Start cycle", type: "int", def: 0, min: 0 }, { key: "idle_gap_bits", label: "Idle gap (bits)", type: "int", def: 2, min: 0 }, @@ -162,9 +166,11 @@ const KINDS = { displayName: "SPI ADC (SPI mode 0)", sink: false, pins: [ - { key: "sdi", label: "MISO pin (device drives; the model calls it sdi_pin)", slot: "primaryPin", drives: true }, + { key: "sdi", label: "MISO pin (device drives; the model calls it sdi_pin)", title: "MISO Pin", + hint: "The model calls it sdi_pin.", slot: "primaryPin", drives: true }, { key: "sclk", label: "SCLK pin", slot: "clockPin", drives: false }, - { key: "sdo", label: "MOSI pin (the model calls it sdo_pin)", slot: "indexPin", drives: false }, + { key: "sdo", label: "MOSI pin (the model calls it sdo_pin)", title: "MOSI Pin", + hint: "The model calls it sdo_pin.", slot: "indexPin", drives: false }, { key: "cs", label: "CS pin", slot: "param:cs_pin", drives: false } ], params: [ @@ -195,8 +201,8 @@ const KINDS = { { key: "data", label: "DATA pin", slot: "primaryPin", drives: false } ], params: [ - { key: "num_leds", label: "LEDs in the string", type: "int", def: 1, min: 1 }, - { key: "ns_per_cycle", label: "ns per PRU cycle", type: "float", def: 5, min: 0.001 }, + { key: "num_leds", label: "LEDs in the string", title: "Number of LEDs", type: "int", def: 1, min: 1 }, + { key: "ns_per_cycle", label: "ns per PRU cycle", title: "Nanoseconds Per PRU Cycle", type: "float", def: 5, min: 0.001 }, { key: "t0h_ns", label: "T0H (ns)", type: "float", def: 400, min: 0 }, { key: "t0l_ns", label: "T0L (ns)", type: "float", def: 850, min: 0 }, { key: "t1h_ns", label: "T1H (ns)", type: "float", def: 800, min: 0 }, @@ -230,6 +236,23 @@ function paramField(kind, param) { return kind + "_" + param.key.replace(/_([a-z0-9])/g, function (_m, c) { return c.toUpperCase(); }); } +// Words SysConfig's UI convention leaves in lower case inside a display name. +const TITLE_LOWER = ["to", "from", "for", "of", "by", "in"]; + +/** + * Display name of a pin or parameter field. SysConfig display names are Title + * Case with acronyms kept upper case and at most 36 characters, so the label is + * converted word by word: a word that already starts with a capital, a digit + * or a parenthesis is kept as it is. + */ +function displayTitle(spec) { + if (spec.title) return spec.title; + return spec.label.split(" ").map(function (w, i) { + if (i > 0 && TITLE_LOWER.indexOf(w) !== -1) return w; + return w.charAt(0).toUpperCase() + w.slice(1); + }).join(" "); +} + /** The (field, spec) pairs of a kind, for building and for reading back. */ function pinFields(kind) { return KINDS[kind].pins.map(function (p) { return { field: pinField(kind, p), spec: p }; }); @@ -622,6 +645,7 @@ exports = { paramField, pinFields, paramFields, + displayTitle, parseBytes, checkInstance, claimedPins, diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js index cc96be1..0a0ed60 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js @@ -2723,8 +2723,9 @@ If the frequencies don't match, timing-sensitive operations (SPI, ADC) may not w }, { name: "tiRunLiveView", - displayName: "Open live web view after the run", - description: "After the run, open TI pru-simulator's dashboard in your browser on the ELF the button just built, with the bound devices attached. It does not change the result, and it is not started when the run was refused or the build failed. The view shows the simulator, not hardware.", + displayName: "Open Live Web View After Run", + description: "After the run, open TI pru-simulator's dashboard in your browser on the ELF the button just built.", + longDescription: "After the run, open TI pru-simulator's dashboard in your browser on the ELF the button just built, with the bound devices attached. It does not change the result, and it is not started when the run was refused or the build failed. The view shows the simulator, not hardware.", default: false, onChange: (inst, ui) => { ui.tiRunLivePort.hidden = !inst.tiRunLiveView; @@ -2732,7 +2733,7 @@ If the frequencies don't match, timing-sensitive operations (SPI, ADC) may not w }, { name: "tiRunLivePort", - displayName: "Live web view port", + displayName: "Live Web View Port", description: "Local port for the live web view (127.0.0.1 only). A view from an earlier Run is replaced; a port held by anything else is reported, not taken.", default: 8765, hidden: true diff --git a/.metadata/sysconfig/.meta/pru_blocks/utils/device_model_block.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/utils/device_model_block.syscfg.js index 09ffc23..975fd95 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/utils/device_model_block.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/utils/device_model_block.syscfg.js @@ -39,10 +39,11 @@ function kindConfigs() { for (const pf of table.pinFields(kind)) { configs.push({ name: pf.field, - displayName: pf.spec.label, - description: pf.spec.drives + displayName: table.displayTitle(pf.spec), + description: (pf.spec.drives ? "Pin the DEVICE drives (the PRU reads it)." + (pf.spec.optional ? " -1 if unused." : "") - : "Pin the PRU drives (the device only listens)." + (pf.spec.optional ? " -1 if unused." : ""), + : "Pin the PRU drives (the device only listens)." + (pf.spec.optional ? " -1 if unused." : "")) + + (pf.spec.hint ? " " + pf.spec.hint : ""), default: defaultPin(pf.spec), hidden: hidden }); @@ -51,7 +52,7 @@ function kindConfigs() { const p = pf.spec; const cfg = { name: pf.field, - displayName: p.label, + displayName: table.displayTitle(p), default: p.def, hidden: hidden }; @@ -60,6 +61,9 @@ function kindConfigs() { const hi = p.max !== undefined ? p.max : "inf"; cfg.description = "Range " + lo + " .. " + hi + "."; } + if (p.hint) { + cfg.description = p.hint; + } configs.push(cfg); } } @@ -238,7 +242,7 @@ exports = { }, { name: "runHint", - displayName: "Where to run", + displayName: "Where to Run", description: "Where the Run on TI pru-simulator button for this design is.", default: "", getValue: runHint diff --git a/scripts/sim/gui_run.py b/scripts/sim/gui_run.py index 7b46cb0..8f4603f 100644 --- a/scripts/sim/gui_run.py +++ b/scripts/sim/gui_run.py @@ -514,7 +514,7 @@ def other_design_view(port, elf): who = f"design '{design}'" if design else "a design this run cannot identify" return {"status": "other design", "url": f"http://127.0.0.1:{port}", "pid": pid, "design": design, "reason": f"a live view of {who} is still serving on port {port}; this run did not ask for " - f"a live view, so it was left running (tick 'Open live web view' to replace it)", + f"a live view, so it was left running (tick 'Open Live Web View After Run' to replace it)", "log": None} diff --git a/scripts/sim/test_device_model_titles.js b/scripts/sim/test_device_model_titles.js new file mode 100644 index 0000000..1226530 --- /dev/null +++ b/scripts/sim/test_device_model_titles.js @@ -0,0 +1,76 @@ +/** + * Display names of the Device Model block's typed fields. + * + * The block builds one field per pin and parameter of every device kind from + * device_model_table.js, so their display names come from the table rather + * than from literal strings in the block. SysConfig's metadata sanity tests + * check every display name against the tool's UI convention: at most 36 + * characters, and every word capitalized - first letter upper case or a digit, + * the rest all lower case or all upper case (an acronym) - except "to", + * "from", "for", "of", "by" and "in". A word in parentheses, or one that + * contains "-" or "&", is accepted as it is. + * + * This test applies the same rule to every field the table produces, so a new + * kind or parameter whose label does not convert is caught here. + */ +'use strict'; + +const path = require('path'); +const assert = require('assert'); + +global.system = { getScript: () => ({}) }; +const table = require(path.join(__dirname, '..', '..', '.metadata', 'sysconfig', '.meta', + 'pru_blocks', 'common', 'device_model_table.js')); + +const LOWER_WORDS = ['to', 'from', 'for', 'of', 'by', 'in']; +const WORD = /(\b([A-Z0-9])([A-Z0-9&._-]+s?|[a-z0-9&._-]+)?)|\(.*\)|[&-]/; +const MAX_LENGTH = 36; + +function problems(name) { + const out = []; + if (name.length > MAX_LENGTH) out.push(`longer than ${MAX_LENGTH} characters`); + const bad = name.split(' ').filter(w => !(WORD.test(w) || LOWER_WORDS.includes(w))); + if (bad.length) out.push(`not capitalized: ${bad.join(', ')}`); + return out; +} + +const cases = []; +function test(name, fn) { cases.push([name, fn]); } + +test('the rule rejects sentence-case and over-long names', () => { + assert.deepStrictEqual(problems('DATA Pin'), []); + assert.deepStrictEqual(problems('Cycles Per Count (0=Stationary)'), []); + assert.ok(problems('Cycles per count').length); + assert.ok(problems('Payload Bytes (Comma Separated, 0-255; Empty Default)').length); +}); + +test('every pin and parameter field has a conforming display name', () => { + const failures = []; + for (const kind of Object.keys(table.KINDS)) { + for (const pf of table.pinFields(kind).concat(table.paramFields(kind))) { + const name = table.displayTitle(pf.spec); + const p = problems(name); + if (p.length) failures.push(`${pf.field} "${name}": ${p.join('; ')}`); + } + } + assert.deepStrictEqual(failures, []); +}); + +test('a title, when given, replaces the converted label', () => { + assert.strictEqual(table.displayTitle({ label: 'MISO pin (device drives)', title: 'MISO Pin' }), 'MISO Pin'); + assert.strictEqual(table.displayTitle({ label: 'Bit period (cycles)' }), 'Bit Period (cycles)'); + assert.strictEqual(table.displayTitle({ label: 'DIR level for positive steps' }), 'DIR Level for Positive Steps'); +}); + +let failed = 0; +for (const [name, fn] of cases) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (e) { + failed += 1; + console.log(`FAIL - ${name}\n ${e.message}`); + } +} +console.log(`\n${cases.length - failed} passed, ${failed} failed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/scripts/sim/test_ti_run_gui.js b/scripts/sim/test_ti_run_gui.js index f5589de..b96fec8 100644 --- a/scripts/sim/test_ti_run_gui.js +++ b/scripts/sim/test_ti_run_gui.js @@ -273,7 +273,7 @@ test('findPython: Windows fixed-directory fallback retains its backslashes', () 'a single-backslash JS literal would give C:Python313/python.exe'); }); -// ---- "Open live web view" checkbox --------------------------------- +// ---- "Open Live Web View After Run" checkbox --------------------------------- function liveArgs(settings) { return ti.launchSpec(sys, settings, 'AAAA').args; From 67d76c061e4b3a31f76d5976f7c2f81b748e7775 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 20:58:28 +0530 Subject: [PATCH 28/44] Test: SPI controller SCLK high and low widths equal the configured widths in every mode and bit order Each read, write and transfer macro variant is built with the block's own delay components, run on TI pru-simulator against a model peripheral, and every SCLK phase inside the packet is checked, along with the word read. --- tests/test_spi_sclk_duty.py | 148 ++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 tests/test_spi_sclk_duty.py diff --git a/tests/test_spi_sclk_duty.py b/tests/test_spi_sclk_duty.py new file mode 100644 index 0000000..1e66717 --- /dev/null +++ b/tests/test_spi_sclk_duty.py @@ -0,0 +1,148 @@ +"""Controller-mode SPI SCLK pulse widths equal the configured high / low widths, for every mode and bit order. + +Each pru_spi_{read,write,transfer} macro variant is expanded with the delay components the block itself computes +(delay_component1 / delay_component2) for an asymmetric 9-cycle high / 6-cycle low setting, compiled with clpru and run +on TI pru-simulator. Every SCLK high and low phase between the first and the last edge of the packet must last exactly +the configured number of cycles: an instruction that sits in the other half of the bit loop than the delay formula +assumes shows up as one half a cycle short and the other a cycle long, even though the bit period is right. + +The read variants also clock in a known word from a model peripheral, so moving an instruction between the two halves +cannot change which bit is stored where. +""" +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "sim")) +import elf_cosim # noqa: E402 + +try: + elf_cosim.find_clpru() + elf_cosim.find_pru_simulator() + _HAVE_SIM = True +except RuntimeError: + _HAVE_SIM = False +needs_sim = pytest.mark.skipif(not _HAVE_SIM, reason="needs clpru (CGT_TI_PRU_PATH) + TI pru-simulator (PRU_SIMULATOR_PATH)") + +SCLK_PIN, SDI_PIN, SDO_PIN, CS_PIN = 0, 1, 2, 3 +HIGH, LOW = 9, 6 +BITS = 16 +WORD = 0xA51C +MODES = ("MODE0", "MODE1", "MODE2", "MODE3") +ORDERS = ("msb", "lsb") +MACRO = {"read": "m_read_packet_spi_%s_%s_gpo_sclk", + "write": "m_send_packet_spi_%s_%s_gpo_sclk", + "transfer": "m_transfer_packet_spi_%s_%s_gpo_sclk"} +CASES = [(kind, mode, order) for kind in MACRO for mode in MODES for order in ORDERS] + + +def _delay_components(): + """{kind: {mode: [delay_component1, delay_component2]}} from the blocks' own getValue at HIGH / LOW, 0 ns data setup.""" + code = r''' +const {loadBlockModule}=require('./scripts/dual_oracle_harness'); +const [high, low] = process.argv.slice(1).map(Number); +const out = {}; +for (const kind of ['read', 'write', 'transfer']) { + const mod = loadBlockModule('pru_spi_' + kind, 'pru_io_blocks'); + const cfg = name => mod.config.find(c => c.name === name); + out[kind] = {}; + for (const mode of ['MODE0', 'MODE1', 'MODE2', 'MODE3']) { + const inst = {'Device Mode': 'controller', 'SPI Mode': mode, 'Data Setup Time': 0, + 'sclk high pulse width (in PRU cycles)': high, 'sclk low pulse width (in PRU cycles)': low}; + out[kind][mode] = [cfg('delay_component1').getValue(inst), cfg('delay_component2').getValue(inst)]; + } +} +process.stdout.write(JSON.stringify(out)); +''' + res = subprocess.run(["node", "-e", code, str(HIGH), str(LOW)], cwd=ROOT, text=True, capture_output=True, check=True) + return json.loads(res.stdout) + + +def _spec(kind, mode, order, d1, d2): + args = {"PACKETSIZE": str(BITS), "bitId": "R6.b0", "SCLK_PIN": str(SCLK_PIN), + "DELAY_COMPEN_1": str(d1), "DELAY_COMPEN_2": str(d2), "CS_PIN": str(CS_PIN), + "CS_SETUP_TIME": "0", "CS_HOLD_TIME": "0", "DATA_SETUP_TIME": "0"} + if kind == "read": + args.update(dataReg="R0.w0", SDI_PIN=str(SDI_PIN)) + elif kind == "write": + args.update(dataReg="R1.w0", SDO_PIN=str(SDO_PIN)) + else: + args.update(r_dataReg="R0.w0", s_dataReg="R1.w0", SDI_PIN=str(SDI_PIN), SDO_PIN=str(SDO_PIN)) + return {"block": "pru_spi_" + kind, "subdir": "pru_io_blocks", "macroCall": "", + "opCode": MACRO[kind] % (mode.lower(), order), "macroArgs": args, + "prelude": ["set R30, R30, %d" % CS_PIN] + (["set R30, R30, %d" % SCLK_PIN] if mode in ("MODE2", "MODE3") else []) + + ["ldi R1, 0x5AC3"], + "postlude": ["halt"]} + + +class Peripheral: + """Drives WORD on SDI, changing the bit on each shifting edge (CPHA 0: before the first edge and on every + second-of-pair edge; CPHA 1: on every first-of-pair edge), in the configured bit order.""" + + def __init__(self, mode, order): + self.cpha = mode in ("MODE1", "MODE3") + self.idle = 1 if mode in ("MODE2", "MODE3") else 0 + bits = [(WORD >> i) & 1 for i in range(BITS)] + self.bits = bits if order == "lsb" else bits[::-1] + self.prev, self.edges, self.out = self.idle, 0, None + + def tick(self, cycle, gpo): + cs = (gpo >> CS_PIN) & 1 + sclk = (gpo >> SCLK_PIN) & 1 + if cs: + self.prev, self.edges, self.out = self.idle, 0, None + return 0 + if sclk != self.prev: + self.edges += 1 + self.prev = sclk + if self.cpha and self.edges % 2 == 1: + self.out = self.bits[min(self.edges // 2, BITS - 1)] + elif not self.cpha and self.edges % 2 == 0: + self.out = self.bits[min(self.edges // 2, BITS - 1)] + if self.out is None and not self.cpha: + self.out = self.bits[0] + return (self.out or 0) << SDI_PIN + + +def _phases(trace): + """[(level, cycles)] for every SCLK phase between the first and the last SCLK edge while CS is asserted.""" + edges, prev = [], None + for cycle, gpo, _gpi in trace: + if (gpo >> CS_PIN) & 1: + prev = None + continue + bit = (gpo >> SCLK_PIN) & 1 + if prev is not None and bit != prev: + edges.append((cycle, bit)) + prev = bit + return [(lvl, nxt - cyc) for (cyc, lvl), (nxt, _) in zip(edges, edges[1:])] + + +@pytest.fixture(scope="module") +def delays(): + return _delay_components() + + +@needs_sim +@pytest.mark.parametrize("kind,mode,order", CASES) +def test_sclk_high_and_low_widths_match_the_configuration(delays, kind, mode, order): + d1, d2 = delays[kind][mode] + name = "duty_%s_%s_%s" % (kind, mode.lower(), order) + elf, _ = elf_cosim.build_block_firmware(_spec(kind, mode, order, d1, d2), name, str(ROOT / "build_out" / "spi_sclk_duty")) + run = elf_cosim.run_elf_closed_loop(elf, Peripheral(mode, order), max_steps=4000) + assert run.halted, "firmware did not halt" + phases = _phases(run.trace) + assert len(phases) == 2 * BITS - 1, "expected %d SCLK phases inside the packet, got %d" % (2 * BITS - 1, len(phases)) + want = {1: HIGH, 0: LOW} + wrong = [(i, lvl, n) for i, (lvl, n) in enumerate(phases) if n != want[lvl]] + assert not wrong, "%s %s %s: SCLK phases (index, level, cycles) off the configured %d high / %d low: %s" % ( + kind, mode, order, HIGH, LOW, wrong[:6]) + if kind != "write": + assert run.registers[0] & 0xFFFF == WORD, "%s %s %s read 0x%04x, peripheral sent 0x%04x" % ( + kind, mode, order, run.registers[0] & 0xFFFF, WORD) From 666a9e9a1852df87791f80216d9754bba0cc8a90 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Wed, 21 Oct 2026 21:20:46 +0530 Subject: [PATCH 29/44] Firmware test fixture linker file places .usmem in PRU_SHAREDMEM --- tests/fixtures/firmware/linker.cmd | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/fixtures/firmware/linker.cmd b/tests/fixtures/firmware/linker.cmd index 4a9418a..8d5810b 100644 --- a/tests/fixtures/firmware/linker.cmd +++ b/tests/fixtures/firmware/linker.cmd @@ -41,6 +41,7 @@ SECTIONS { .stack > PRU0_DMEM_0, PAGE 1 .bss > PRU0_DMEM_0, PAGE 1 .udmem > PRU0_DMEM_0, PAGE 1 + .usmem > PRU_SHAREDMEM, PAGE 2 .initdmem > PRU0_DMEM_0, PAGE 1 .cio > PRU0_DMEM_0, PAGE 1 .data > PRU0_DMEM_0, PAGE 1 From 498fcb07dcb677a536ade5abca1af268d956cba9 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Sat, 7 Nov 2026 02:44:46 +0530 Subject: [PATCH 30/44] Block validator runner reads the instance from --inst-file The option was not recognised in the argument loop, so its name was taken as the block path or the instance JSON and the file was never read. It is now parsed with its path, read and parsed with a clear error on failure, and listed in the usage text. --- scripts/block_validate_runner.js | 12 +++-- tests/test_block_validate_runner_inst_file.py | 53 +++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 tests/test_block_validate_runner_inst_file.py diff --git a/scripts/block_validate_runner.js b/scripts/block_validate_runner.js index afc4ce4..7e56b85 100644 --- a/scripts/block_validate_runner.js +++ b/scripts/block_validate_runner.js @@ -13,6 +13,7 @@ * Usage: * node scripts/block_validate_runner.js '' * node scripts/block_validate_runner.js --block --inst '' + * node scripts/block_validate_runner.js --inst-file * node scripts/block_validate_runner.js --sweep * node scripts/block_validate_runner.js --report * @@ -277,9 +278,11 @@ function main() { // Parse --block / --inst or positional let blockPath = null; let instJson = null; + let instFile = null; for (let i = 0; i < args.length; i++) { if (args[i] === '--block' && i + 1 < args.length) blockPath = args[++i]; else if (args[i] === '--inst' && i + 1 < args.length) instJson = args[++i]; + else if (args[i] === '--inst-file' && i + 1 < args.length) instFile = args[++i]; else if (!blockPath) blockPath = args[i]; else if (!instJson) instJson = args[i]; } @@ -294,10 +297,11 @@ function main() { console.error(`Invalid inst JSON: ${e.message}`); process.exit(2); } - } else if (args.includes('--inst-file')) { - const idx = args.indexOf('--inst-file'); - const fp = args[idx + 1]; - inst = JSON.parse(fs.readFileSync(fp, 'utf8')); + } else if (instFile) { + try { inst = JSON.parse(fs.readFileSync(instFile, 'utf8')); } catch (e) { + console.error(`Cannot read inst file ${instFile}: ${e.message}`); + process.exit(2); + } } const result = runValidate(blockPath, inst); // Emit structured errors and exit 0 so the caller can inspect errors vs a clean result diff --git a/tests/test_block_validate_runner_inst_file.py b/tests/test_block_validate_runner_inst_file.py new file mode 100644 index 0000000..0af5fa1 --- /dev/null +++ b/tests/test_block_validate_runner_inst_file.py @@ -0,0 +1,53 @@ +"""scripts/block_validate_runner.js reads the instance from --inst-file. + +The flag used to fall through to the positional arguments, so `--inst-file f.json` was taken as the +block path (or as the instance JSON) and the file was never read. +""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "block_validate_runner.js" +BLOCK = "program_control/conditional_block" +INST = {"$name": "If_Else_0", "numOfInputPorts": 2, + "input1": ["a"], "input2": ["b"], "T": ["x"], "F": []} + + +def _run(args): + return subprocess.run(["node", str(RUNNER)] + args, capture_output=True, text=True, + cwd=str(ROOT), timeout=30) + + +def _inst_file(tmp_path): + f = tmp_path / "inst.json" + f.write_text(json.dumps(INST), encoding="utf-8") + return str(f) + + +def test_inst_file_after_block_path_is_validated(tmp_path): + proc = _run([BLOCK, "--inst-file", _inst_file(tmp_path)]) + assert proc.returncode == 0, proc.stdout + proc.stderr + data = json.loads(proc.stdout.strip()) + assert [e for e in data["errors"] if "f_next" in e["message"]], data + + +def test_inst_file_before_block_path_is_validated(tmp_path): + proc = _run(["--inst-file", _inst_file(tmp_path), BLOCK]) + assert proc.returncode == 0, proc.stdout + proc.stderr + data = json.loads(proc.stdout.strip()) + assert [e for e in data["errors"] if "f_next" in e["message"]], data + + +def test_inst_file_matches_inline_json(tmp_path): + inline = _run([BLOCK, json.dumps(INST)]) + from_file = _run([BLOCK, "--inst-file", _inst_file(tmp_path)]) + assert inline.returncode == 0 and from_file.returncode == 0 + assert json.loads(from_file.stdout) == json.loads(inline.stdout) + + +def test_unreadable_inst_file_is_reported(tmp_path): + proc = _run([BLOCK, "--inst-file", str(tmp_path / "missing.json")]) + assert proc.returncode == 2 + assert "inst file" in proc.stderr From 84aaee1df4b098b950a66d126e4cae1ba6744b50 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Sat, 7 Nov 2026 02:44:48 +0530 Subject: [PATCH 31/44] Pad sharing check reports unavailable device pin data as unknown getDevicePadSharing returned an empty set when the device data had neither a usable runtime pin interface nor pin common info, which the validator read as "no pad is shared" and so emitted neither a collision warning nor the "could not be checked" warning. It now returns null in that case, keeping an empty set only when available data holds no shared pad. --- .../common/pru_blocks_static_module.syscfg.js | 4 +- tests/test_pad_sharing_unavailable.py | 72 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 tests/test_pad_sharing_unavailable.py diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js index fb2f0b6..1b37127 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js @@ -1266,7 +1266,9 @@ function getDevicePadSharing(deviceData, coreName) { // 2. Raw JSON device data structure (ICSSM devices, stubs and test harnesses). ICSSG pads carry a // PRGx_PRUy_GPOn + PRGx_PRUy_GPIn pair; ICSSM pads carry one two-way PRx_PRUy_GPIOn signal. - const infos = deviceData.pinCommonInfos || {}; + // No usable pin data from any source: report "unknown" (null), not "no pad is shared" (an empty set). + const infos = deviceData.pinCommonInfos; + if (!infos || typeof infos !== 'object' || Object.keys(infos).length === 0) return null; for (const info of Object.values(infos)) { if (!info || !info.pinModeInfo) continue; const sigs = new Set(info.pinModeInfo.map(m => m.signalName)); diff --git a/tests/test_pad_sharing_unavailable.py b/tests/test_pad_sharing_unavailable.py new file mode 100644 index 0000000..144ab07 --- /dev/null +++ b/tests/test_pad_sharing_unavailable.py @@ -0,0 +1,72 @@ +"""The GPO/GPI shared-pad check says so when the device carries no pad data. + +getDevicePadSharing returned an empty set for a device without usable pin data, which the validator +read as "no pad is shared": a design with a GPO and a GPI claim then got neither a collision warning nor +the "could not be checked" warning. Runs the real validateBidiPinCollisionsGlobal. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +STATIC = ROOT / ".metadata" / "sysconfig" / ".meta" / "pru_blocks" / "common" / "pru_blocks_static_module.syscfg.js" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node not installed") + +DRIVER = r""" +const fs = require("fs"), vm = require("vm"); +const [STATIC, DEVICE] = [process.argv[2], JSON.parse(process.argv[3])]; +function stub() { return new Proxy(function () { return stub(); }, { + get(_t, p) { if (p === Symbol.toPrimitive || p === "toString" || p === "valueOf") return () => ""; if (p === Symbol.iterator || p === "then") return undefined; return stub(); }, + apply() { return stub(); } }); } +const claimsOf = (dir) => ({ $instances: [{ $name: dir + "_0" }], + moduleStatic: { getPinUsage: () => [{ pin: 0, dir }] } }); +const system = { + deviceData: DEVICE, + modules: { "/pru_blocks/pru_io_blocks/pru_gpo_block": claimsOf("GPO"), "/pru_blocks/pru_io_blocks/pru_gpi_block": claimsOf("GPI") }, + getScript: (p) => (p === "/common" ? { getSelfSysCfgCoreName: () => "icss_g0_pru0" } + : new Proxy(stub(), { get: (t, k) => (k === "findDeviceInputConflicts" ? () => [] : t[k]) })), +}; +const sb = { system, exports: {}, module: { exports: {} }, console: { log() {}, error() {}, warn() {} }, require: () => stub() }; +sb.global = sb; +vm.createContext(sb); +vm.runInContext(fs.readFileSync(STATIC, "utf8"), sb, { filename: STATIC }); +const warnings = []; +sb.exports.validateBidiPinCollisionsGlobal({ $name: "design" }, { logWarning: (m) => warnings.push(m), logError: () => {}, logInfo: () => {} }); +console.log(JSON.stringify(warnings)); +""" + + +def _warnings(tmp_path, device): + drv = tmp_path / "driver.js" + drv.write_text(DRIVER, encoding="utf-8") + proc = subprocess.run(["node", str(drv), str(STATIC), json.dumps(device)], + capture_output=True, text=True, timeout=60) + assert proc.returncode == 0, proc.stdout + proc.stderr + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +def _pad(sig_list): + return {"pinModeInfo": [{"signalName": s} for s in sig_list]} + + +def test_device_without_pad_data_warns_that_sharing_was_not_checked(tmp_path): + for device in ({}, {"pinCommonInfos": {}}): + warns = _warnings(tmp_path, device) + assert [w for w in warns if "could not be checked" in w], (device, warns) + + +def test_shared_pad_still_reports_the_collision(tmp_path): + device = {"pinCommonInfos": {"A": _pad(["PRG0_PRU0_GPO0", "PRG0_PRU0_GPI0"])}} + warns = _warnings(tmp_path, device) + assert [w for w in warns if "will contend on this pad" in w], warns + assert not [w for w in warns if "could not be checked" in w], warns + + +def test_available_data_without_a_shared_pad_stays_silent(tmp_path): + device = {"pinCommonInfos": {"A": _pad(["PRG0_PRU0_GPO1"]), "B": _pad(["PRG0_PRU0_GPI2"])}} + assert _warnings(tmp_path, device) == [] From bb61cac4d63722f12603125fe06db16fd1238e7e Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Sat, 7 Nov 2026 02:45:28 +0530 Subject: [PATCH 32/44] Allocator passes lo/hi registers for outputs above 4 bytes on secondary ports A multi-output macro reserved the full width for output2 and up but emitted only the first register of the pair, and consumers wired to such a port read only that register, so a 64-bit second result lost its upper half. The macro destination and the consumer's source token now carry both registers, as output1 already did. --- .../pru_register_allocator.js | 8 ++- tests/test_register_allocator_wide_outputs.py | 58 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 tests/test_register_allocator_wide_outputs.py diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js b/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js index c0ae60e..bb7e6fa 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js @@ -470,7 +470,7 @@ function outputRegisterNotations(entry) { /** * Register token a consumer reads for input wired to the source port named by `conn`. - * Returns the source output port's own register; a missing/malformed port name or a port that owns no register is + * Returns the source output port's own register (lo and hi registers for a port wider than 4 bytes); a missing/malformed port name or a port that owns no register is * recorded in unroutedConnections (so validation names the block and port) and yields a token that cannot assemble, * because silently substituting output1's register would assemble into wrong code. */ @@ -488,6 +488,8 @@ function sourceRegisterToken(consumer, inputPort, conn) { unroutedConnections.push({ consumer: consumer.$name, inputPort: inputPort, source: source.$name, sourcePort: "output" + port }); return "UNROUTED_" + source.$name + "_output" + port; } + // a 64-bit port is two consecutive registers (lo, hi), as for output1 + if (alloc.numBytes > 4) return getPruRegister(alloc.byteOffset, 4) + " , " + getPruRegister(alloc.byteOffset + 4, 4); return getPruRegister(alloc.byteOffset, alloc.numBytes); } @@ -1402,7 +1404,9 @@ function pushInstruction(instance, parentInstance) { } moduleInstanceRegisterMap[instanceName]["byteOffsetOfOutput" + output] = extraOffset; moduleInstanceRegisterMap[instanceName]["numOfBytesReqByOutput" + output] = width; - macroOutputRegisters.push(getPruRegister(extraOffset, width)); + macroOutputRegisters.push(width > 4 + ? getPruRegister(extraOffset, 4) + ", " + getPruRegister(extraOffset + 4, 4) + : getPruRegister(extraOffset, width)); } } diff --git a/tests/test_register_allocator_wide_outputs.py b/tests/test_register_allocator_wide_outputs.py new file mode 100644 index 0000000..39abd4a --- /dev/null +++ b/tests/test_register_allocator_wide_outputs.py @@ -0,0 +1,58 @@ +"""Register allocator: wide secondary outputs. + + - A multi-output macro's output2 and up, and the consumers wired to them, get a lo/hi register pair when the port + is wider than 4 bytes (output1 already did). + +The node driver runs the shipped allocator on hand-built instances. +""" + +import os +import shutil +import subprocess + +import pytest + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ALLOC = os.path.join(ROOT, ".metadata", "sysconfig", ".meta", "pru_blocks", "common", + "register_allocation", "pru_register_allocator.js") + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node not installed") + +DRIVER = r""" +const fs = require("fs"), vm = require("vm"), assert = require("assert"); +const ALLOC = process.argv[2], CASE = process.argv[3]; +const modules = {}; +const sb = { system: { modules }, exports: {}, console }; +vm.createContext(sb); +vm.runInContext(fs.readFileSync(ALLOC, "utf8"), sb, { filename: "pru_register_allocator.js" }); +const alloc = sb.exports; +const run = (mods) => { Object.keys(modules).forEach(k => delete modules[k]); Object.assign(modules, mods); alloc.allocatePruRegisters(); + const instrs = alloc.getPruRegisterAllocationSummary().pruInstructions.filter(x => typeof x === "string"); + return { failed: alloc.getAllocationFailures().length, instrs: instrs, used: instrs.join(" ") }; }; + +if (CASE === "wide-extra-output") { + const W = { $name: "W", opCode: "WIDE", multiOutputMacroArgs: true, numOfOutputPorts: 2, numOfInputPorts: 0, numOfConstants: 0, + noOfCycles: 1, output1Size: "4", output2Size: "8" }; + const C = { $name: "C", opCode: "ADD", numOfInputPorts: 1, numOfConstants: 0, noOfCycles: 1, output1Size: "4", + input1: [{ inst: W, port: "output2" }] }; + W.output1 = []; W.output2 = [{ inst: C, port: "input1" }]; + const r = run({ "/f": { $instances: [W, C] } }); + assert.strictEqual(r.failed, 0); + // W: output1 R0 (no consumer, released), output2 = R1/R2; C reads that pair + assert.strictEqual(r.instrs[0], "WIDE R0, R1, R2", r.instrs[0]); + assert.strictEqual(r.instrs[1], "ADD R0, R1 , R2", r.instrs[1]); +} else { throw new Error("unknown case " + CASE); } +console.log("DRIVER OK " + CASE); +""" + + +def _case(tmp_path, case): + driver = tmp_path / ("driver_%s.js" % case) + driver.write_text(DRIVER, encoding="utf-8") + res = subprocess.run(["node", str(driver), ALLOC, case], capture_output=True, text=True, timeout=120) + assert res.returncode == 0 and ("DRIVER OK " + case) in res.stdout, \ + "node driver case %r failed:\n%s\n%s" % (case, res.stdout, res.stderr) + + +def test_wide_secondary_output_gets_a_register_pair(tmp_path): + _case(tmp_path, "wide-extra-output") From 3ea5fee8f901063b8184fe3e4737a85eb9352044 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Sat, 7 Nov 2026 02:45:51 +0530 Subject: [PATCH 33/44] Allocator holds back R26/R27 when block macros use TEMP_REG3/TEMP_REG4 TEMP_REG3 and TEMP_REG4 alias R26 and R27, which were still in the allocatable pool, so a macro writing them could overwrite a live value or its own result. Before allocation the pool now drops R26 and/or R27 when any block's macro text names TEMP_REG3 and/or TEMP_REG4; designs that use neither keep the full 28-register pool. The summary comments list the scratch registers in use and the out-of-registers error mentions the bytes held for them. --- .../common/pru_blocks_static_module.syscfg.js | 1 + .../pru_register_allocator.js | 41 ++++++++++- ...st_register_allocator_scratch_registers.py | 73 +++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 tests/test_register_allocator_scratch_registers.py diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js index 1b37127..5b4a321 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js @@ -975,6 +975,7 @@ function reportRegisterExhaustion(inst, report) { : "Out of registers: block '" + f.block + "' needs " + f.bytes + " byte(s) for " + f.what + " but only " + f.freeBytes + " of the " + f.budgetBytes + " allocatable bytes (R0-R" + (f.budgetRegisters - 1) + ", " + f.budgetRegisters + " registers; R28/R29 are reserved) are free, so its instruction cannot be generated. " + + (f.scratchBytes > 0 ? f.scratchBytes + " byte(s) are held for the macro scratch registers " + f.scratchRegisters.join("/") + " (R26/R27) that blocks in the design use. " : "") + (f.pinnedBytes > 0 ? f.pinnedBytes + " byte(s) of the pool are pinned by registers typed into block fields (" + f.pinnedBy.join(", ") + ") and are not available to the allocator. " : "") + "Reduce the number of values alive at once, or store intermediate results to memory."; diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js b/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js index bb7e6fa..1fd7b37 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/register_allocation/pru_register_allocator.js @@ -2,7 +2,8 @@ * PRU Register Allocator * * Manages allocation of PRU registers (R0-R29) for blocks. - * R28 and R29 are reserved as temporary registers per block. + * R28 and R29 are reserved as temporary registers per block. R26 and R27 (TEMP_REG3/TEMP_REG4) are also + * held back when a block macro in the design uses them. */ // Constants for register allocation @@ -52,6 +53,8 @@ let pinnedRegisters = []; // {block, field, token, firstByte, bytes} fo let pinnedRegisterConflicts = []; // two pins that claim the same byte, reported by validation as an ERROR let pinnedProblems = []; // {block, field, token, why} typed registers that are not a usable register window (ERROR in validation) let pinnedByteCount = 0; // bytes of the pool removed by pins +let scratchRegisterBytes = 0; // bytes of the pool held for macro scratch registers TEMP_REG3 (R26) / TEMP_REG4 (R27) +let scratchRegistersUsed = []; // the scratch registers ("TEMP_REG3", "TEMP_REG4") some block macro in the design uses let poolAfterPins = []; // pruByteArray right after the architectural windows and the pins were removed (free-register suggestion) /** @@ -78,6 +81,36 @@ function isIntendedHandOff(a, b) { return pair === "r+w" || pair === "r+r" || pair === "rm+w"; } +/** + * TEMP_REG3 / TEMP_REG4 are the macro scratch registers R26 / R27. They sit inside the allocatable range, so a design whose + * macros use them must not also keep values there: the macro would overwrite a live value, or its own result. Removes R26 + * and/or R27 from the pool when any block's macro text names TEMP_REG3 / TEMP_REG4. Designs that use neither keep the full pool. + * Must run after the pool is created and before anything is allocated. + */ +function reserveMacroScratchRegisters() { + scratchRegisterBytes = 0; + scratchRegistersUsed = []; + const scratch = [["TEMP_REG3", 26], ["TEMP_REG4", 27]]; + const found = {}; + for (const moduleName in systemModules) { + const module = systemModules[moduleName]; + if (!module || typeof module.getMacro !== "function") continue; + for (const instance of module.$instances || []) { + if (!instance || !instance.opCode) continue; + let body = ""; + try { body = String(module.getMacro("", instance.opCode) || ""); } catch (e) { body = ""; } + for (const [name] of scratch) if (new RegExp("\\b" + name + "\\b").test(body)) found[name] = true; + } + } + for (const [name, reg] of scratch) { + if (!found[name]) continue; + scratchRegistersUsed.push(name); + for (let b = reg * numOfBytesPerRegister; b < (reg + 1) * numOfBytesPerRegister && b < totalBytes; b++) { + if (pruByteArray[b] === 0) { pruByteArray[b] = 1; scratchRegisterBytes++; } + } + } +} + /** * Removes the user-pinned registers (all bytes they overlap) from the pool. Must run before anything is allocated. * Pins that fall outside R0-R27 (R28/R29 temporaries, R30/R31) take nothing from the pool. @@ -348,6 +381,8 @@ function recordAllocationFailure(instance, numOfBytes, what, contractFailure) { contractFailure: contractFailure === true, freeBytes: freeBytes, pinnedBytes: pinnedByteCount, + scratchBytes: scratchRegisterBytes, + scratchRegisters: scratchRegistersUsed.slice(), pinnedBy: pinnedRegisters.filter(p => p.reserved && p.firstByte < totalBytes).map(p => p.block + "." + p.field + "=" + p.token), budgetRegisters: numOfPruRegisters, budgetBytes: totalBytes @@ -1473,6 +1508,7 @@ function allocatePruRegisters() { pruByteArray = new Array(totalBytes).fill(0); // user-typed registers the blocks emit verbatim leave the pool before anything is allocated + reserveMacroScratchRegisters(); reservePinnedRegisters(); loopCounterRegisters = {}; // Reset loop counter tracking groupReturnAddrByteOffset = -1; // Reset group return address register @@ -1826,6 +1862,8 @@ function getRegisterAllocationComments() { } comments.push("; TEMP_REG1 (R28) -> Used by all macros"); comments.push("; TEMP_REG2 (R29) -> Used by all macros"); + if (scratchRegistersUsed.includes("TEMP_REG3")) comments.push("; TEMP_REG3 (R26) -> Used by some macros; not allocated to values"); + if (scratchRegistersUsed.includes("TEMP_REG4")) comments.push("; TEMP_REG4 (R27) -> Used by some macros; not allocated to values"); // Add overall peak cycles summary comments.push(";"); @@ -1891,6 +1929,7 @@ exports = { sourcePortNumber, getUnroutedConnections: () => unroutedConnections, getAllocationFailures: () => allocationFailures, + getScratchRegisters: () => scratchRegistersUsed, getPinnedRegisters: () => pinnedRegisters, getPinnedRegisterConflicts: getPinnedConflictsClassified, suggestFreeRegister, diff --git a/tests/test_register_allocator_scratch_registers.py b/tests/test_register_allocator_scratch_registers.py new file mode 100644 index 0000000..7edb3ec --- /dev/null +++ b/tests/test_register_allocator_scratch_registers.py @@ -0,0 +1,73 @@ +"""Register allocator: macro scratch registers TEMP_REG3 / TEMP_REG4. + + - TEMP_REG3 / TEMP_REG4 are R26 / R27. A design whose macros use them must not get those registers for + values (a macro would overwrite a live value, or its own result); a design that uses neither keeps them. + +The node driver runs the shipped allocator on hand-built instances. +""" + +import os +import shutil +import subprocess + +import pytest + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ALLOC = os.path.join(ROOT, ".metadata", "sysconfig", ".meta", "pru_blocks", "common", + "register_allocation", "pru_register_allocator.js") + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node not installed") + +DRIVER = r""" +const fs = require("fs"), vm = require("vm"), assert = require("assert"); +const ALLOC = process.argv[2], CASE = process.argv[3]; +const modules = {}; +const sb = { system: { modules }, exports: {}, console }; +vm.createContext(sb); +vm.runInContext(fs.readFileSync(ALLOC, "utf8"), sb, { filename: "pru_register_allocator.js" }); +const alloc = sb.exports; +const ldi = (n, i) => ({ $name: n + i, opCode: "LDI", numOfInputPorts: 0, numOfConstants: 1, constant1: i, noOfCycles: 1, output1Size: "4" }); +const fill = (k) => { const a = []; for (let i = 0; i < k; i++) a.push(ldi("K", i)); return a; }; +const scratchModule = (regs, insts) => ({ $instances: insts, getMacro: () => ["SCR .macro a", " ldi " + regs.join(", ") + ", 1", " .endm"].join("\n") }); +const scratchBlock = () => ({ $name: "S0", opCode: "SCR", numOfInputPorts: 0, numOfConstants: 0, noOfCycles: 1, output1Size: "4" }); +const run = (mods) => { Object.keys(modules).forEach(k => delete modules[k]); Object.assign(modules, mods); alloc.allocatePruRegisters(); + const instrs = alloc.getPruRegisterAllocationSummary().pruInstructions.filter(x => typeof x === "string"); + return { failed: alloc.getAllocationFailures().length, instrs: instrs, used: instrs.join(" ") }; }; +const uses = (text, reg) => new RegExp(String.raw`\b${reg}\b`).test(text); + +if (CASE === "pool-without-scratch") { + const r = run({ "/f": { $instances: fill(28) } }); + assert.strictEqual(r.failed, 0, "28 values fit the full pool when no macro uses R26/R27"); + assert(uses(r.used, "R26") && uses(r.used, "R27")); +} else if (CASE === "scratch-both") { + const r = run({ "/f": { $instances: fill(27) }, "/s": scratchModule(["TEMP_REG3", "TEMP_REG4"], [scratchBlock()]) }); + assert.strictEqual(r.failed, 2, "28 values do not fit once R26 and R27 are held back: " + JSON.stringify(r)); + assert(!uses(r.used, "R26") && !uses(r.used, "R27"), "R26/R27 handed out: " + r.used); + assert.deepStrictEqual(Array.from(alloc.getScratchRegisters()), ["TEMP_REG3", "TEMP_REG4"]); +} else if (CASE === "scratch-reg3-only") { + const r = run({ "/f": { $instances: fill(27) }, "/s": scratchModule(["TEMP_REG3"], [scratchBlock()]) }); + assert.strictEqual(r.failed, 1, "only R26 is held back: " + JSON.stringify(r)); + assert(!uses(r.used, "R26") && uses(r.used, "R27"), r.used); +} else { throw new Error("unknown case " + CASE); } +console.log("DRIVER OK " + CASE); +""" + + +def _case(tmp_path, case): + driver = tmp_path / ("driver_%s.js" % case) + driver.write_text(DRIVER, encoding="utf-8") + res = subprocess.run(["node", str(driver), ALLOC, case], capture_output=True, text=True, timeout=120) + assert res.returncode == 0 and ("DRIVER OK " + case) in res.stdout, \ + "node driver case %r failed:\n%s\n%s" % (case, res.stdout, res.stderr) + + +def test_pool_keeps_r26_r27_when_no_macro_uses_them(tmp_path): + _case(tmp_path, "pool-without-scratch") + + +def test_scratch_registers_are_not_allocated_to_values(tmp_path): + _case(tmp_path, "scratch-both") + + +def test_only_the_scratch_register_in_use_is_held_back(tmp_path): + _case(tmp_path, "scratch-reg3-only") From af0709966ae80ba9141664b0749a6c634778d8f6 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Sat, 7 Nov 2026 02:41:17 +0530 Subject: [PATCH 34/44] Make the quadrature model's forward order lead with A and tolerate a zero counts-per-turn setting The Python quadrature encoder and its independent decoder stepped B first on forward motion, the opposite of the in-tool model, so the same motion counted in opposite directions in the two simulators. Forward motion now rises A first in both. A counts-per-turn of zero is recorded as a fault as before, but the index check no longer divides by it, so the run is not aborted. --- pru_device_models/buscosim.py | 2 +- pru_device_models/quadrature_index_encoder.py | 7 +-- tests/test_quadrature_encoder.py | 53 +++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 tests/test_quadrature_encoder.py diff --git a/pru_device_models/buscosim.py b/pru_device_models/buscosim.py index 983d9a5..bf4e39e 100644 --- a/pru_device_models/buscosim.py +++ b/pru_device_models/buscosim.py @@ -16,7 +16,7 @@ # ---------------------------------------------------------------- quadrature -_FWD = {0: 1, 1: 3, 3: 2, 2: 0} +_FWD = {0: 2, 2: 3, 3: 1, 1: 0} # A leads B on forward motion _REV = {v: k for k, v in _FWD.items()} diff --git a/pru_device_models/quadrature_index_encoder.py b/pru_device_models/quadrature_index_encoder.py index fdea4f4..37dd873 100644 --- a/pru_device_models/quadrature_index_encoder.py +++ b/pru_device_models/quadrature_index_encoder.py @@ -21,8 +21,9 @@ from pru_device_models.device_model import DeviceModel -# x4 state table: state = 2*A + B. Forward (CW) order 0,1,3,2,0... -_FWD = {0: 1, 1: 3, 3: 2, 2: 0} +# x4 state table: state = 2*A + B. Forward (CW) order 0,2,3,1,0..., so A leads B +# on forward motion (A rises first), as in the in-tool model. +_FWD = {0: 2, 2: 3, 3: 1, 1: 0} _REV = {v: k for k, v in _FWD.items()} @@ -95,7 +96,7 @@ def tick(self, cycle: int, pins_out: int) -> int: "kind": "edge", "cycle": cycle, "state": self._state, "count": self.count, "dir": d, }) - if self.count % self.cpr == 0 and self.count != 0: + if self.cpr > 0 and self.count % self.cpr == 0 and self.count != 0: self._z_high_until = cycle + self.index_width_cycles self._events.append({"kind": "index", "cycle": cycle}) self._seg_counts_done += 1 diff --git a/tests/test_quadrature_encoder.py b/tests/test_quadrature_encoder.py new file mode 100644 index 0000000..fdc5d70 --- /dev/null +++ b/tests/test_quadrature_encoder.py @@ -0,0 +1,53 @@ +"""QuadratureIndexEncoder: forward motion has A lead B, and invalid parameters do not abort the run.""" +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from pru_device_models import buscosim # noqa: E402 +from pru_device_models.quadrature_index_encoder import QuadratureIndexEncoder # noqa: E402 + + +def _run(model, cycles): + return [(c, model.tick(c, 0)) for c in range(cycles)] + + +def test_forward_motion_a_rises_before_b(): + m = QuadratureIndexEncoder(cpr=1000) + m.set_motion([(10, 4, +1)]) + words = [w for _, w in _run(m, 60)] + ab = [] + for w in words: + s = (w & 1, (w >> 1) & 1) + if not ab or ab[-1] != s: + ab.append(s) + # (A, B): 00 -> 10 -> 11 -> 01 -> 00 + assert ab[:5] == [(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)] + + +def test_reverse_motion_b_rises_before_a(): + m = QuadratureIndexEncoder(cpr=1000) + m.set_motion([(10, 2, -1)]) + words = [w for _, w in _run(m, 40)] + ab = [] + for w in words: + s = (w & 1, (w >> 1) & 1) + if not ab or ab[-1] != s: + ab.append(s) + assert ab[:3] == [(0, 0), (0, 1), (1, 1)] + + +def test_independent_decoder_counts_forward_positive(): + m = QuadratureIndexEncoder(cpr=1000) + m.set_motion([(10, 6, +1)]) + dec = buscosim.decode_quadrature(_run(m, 100), 0, 1, 2) + edges = [e for e in dec if e["kind"] == "edge"] + assert [e["dir"] for e in edges] == [1] * 6 + + +def test_zero_cpr_reports_fault_instead_of_raising(): + m = QuadratureIndexEncoder(cpr=0) + m.set_motion([(10, 3, +1)]) + _run(m, 60) + assert any("cpr" in f for f in m.faults()) From 2d062754a37f62822e94f5339c7cab3d8204112c Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Sat, 7 Nov 2026 02:43:19 +0530 Subject: [PATCH 35/44] BiSS Line model: restart the per-frame state between frames and carry 8b/10b running disparity A second correctly timed frame in the same run was counted on top of the first frame's clocks, reported as overclocked and produced no frame event. The model now treats a clock idle for frame_gap_cycles as the end of a frame, reports it and starts the next one from clean state. Line coding always used the negative-disparity code of every byte, so repeated bytes with unbalanced codes drifted in disparity. The encoder now selects the RD- or RD+ code, including the alternate D.x.A7 symbol, and carries the running disparity across the symbols of a frame; the decoder accepts both columns. --- pru_device_models/bissline_device_model.py | 82 ++++++++++++++++++--- tests/test_bissline.py | 85 ++++++++++++++++++++++ 2 files changed, 157 insertions(+), 10 deletions(-) diff --git a/pru_device_models/bissline_device_model.py b/pru_device_models/bissline_device_model.py index 62dcd6f..1d7aa40 100644 --- a/pru_device_models/bissline_device_model.py +++ b/pru_device_models/bissline_device_model.py @@ -40,7 +40,10 @@ def crc6_bissc(bits: List[int]) -> int: # ------------------------------------------------------------ 8b/10b LUT -# Simplified 8b/10b: RD- column, sufficient for model encode/decode. +# 8b/10b data codes with running disparity. The RD- column below is the code +# used while the running disparity is negative; an unbalanced code is +# complemented for the RD+ column (the 5b/6b code for x=7 is the one balanced +# code that also has a complement). _5B6B_RD_NEG = { 0:0b100111, 1:0b011101, 2:0b101101, 3:0b110001, 4:0b110101, 5:0b101001, 6:0b011001, 7:0b111000, @@ -56,23 +59,62 @@ def crc6_bissc(bits: List[int]) -> int: 0:0b1011, 1:0b1001, 2:0b0101, 3:0b1100, 4:0b1101, 5:0b1010, 6:0b0110, 7:0b1110, } +_3B4B_ALT7_RD_NEG = 0b0111 # D.x.A7: avoids a run of five equal bits -_8B10B_ENC = {} _8B10B_DEC = {} + +def _ones(v: int) -> int: + return bin(v).count("1") + + +def _complement(code: int, width: int) -> int: + return code ^ ((1 << width) - 1) + + +def _code6(x: int, rd: int) -> int: + code = _5B6B_RD_NEG[x] + if rd > 0 and (_ones(code) != 3 or x == 7): + code = _complement(code, 6) + return code + + +def _code4(x: int, y: int, rd: int) -> int: + if y == 7 and ((rd < 0 and x in (17, 18, 20)) or (rd > 0 and x in (11, 13, 14))): + code = _3B4B_ALT7_RD_NEG + else: + code = _3B4B_RD_NEG[y] + if rd > 0 and (_ones(code) != 2 or y == 3): + code = _complement(code, 4) + return code + + +def encode_8b10b_with_rd(bv: int, rd: int = -1): + """Encode one data byte at running disparity `rd` (-1 or +1). + + Returns (10-bit symbol, running disparity after the symbol). + """ + x = bv & 0x1F + y = (bv >> 5) & 0x07 + c6 = _code6(x, rd) + if _ones(c6) != 3: + rd = -rd + c4 = _code4(x, y, rd) + if _ones(c4) != 2: + rd = -rd + return (c6 << 4) | c4, rd + + def _init_8b10b(): for bv in range(256): - c6 = _5B6B_RD_NEG[bv & 0x1F] - c4 = _3B4B_RD_NEG[(bv >> 5) & 0x07] - sym = (c6 << 4) | c4 - _8B10B_ENC[bv] = sym - _8B10B_DEC[sym] = bv + for rd in (-1, 1): + _8B10B_DEC[encode_8b10b_with_rd(bv, rd)[0]] = bv _init_8b10b() -def encode_8b10b_byte(bv: int) -> int: - return _8B10B_ENC[bv & 0xFF] +def encode_8b10b_byte(bv: int, rd: int = -1) -> int: + return encode_8b10b_with_rd(bv, rd)[0] def decode_8b10b_symbol(sym: int) -> Optional[int]: @@ -130,6 +172,8 @@ class BissLineEncoder(DeviceModel): min_bit_period_cycles: minimum cycles per full clock period line_coding: enable 8b/10b on the reply fec_enable: enable Hamming(7,4) on position nibbles + frame_gap_cycles: clock idle time after which the next rising edge starts + a new frame; the per-frame clock count and captured bits restart """ def __init__( @@ -145,6 +189,7 @@ def __init__( min_bit_period_cycles: int = 4, line_coding: bool = True, fec_enable: bool = True, + frame_gap_cycles: int = 64, ): self._faults: List[str] = [] self._events: List[Dict[str, Any]] = [] @@ -153,6 +198,9 @@ def __init__( self._faults.append(f"data_bits must be 16, 24, or 32, got {data_bits}") if min_bit_period_cycles < 1: self._faults.append("min_bit_period_cycles must be >= 1") + if frame_gap_cycles < 1: + self._faults.append("frame_gap_cycles must be >= 1") + self.frame_gap_cycles = frame_gap_cycles self.ma_pin = ma_pin self.mo_pin = mo_pin @@ -241,11 +289,12 @@ def _apply_8b10b(self, bits: List[int]) -> List[int]: padded.append(0) wire: List[int] = [] + rd = -1 # running disparity starts negative and carries across symbols for i in range(0, len(padded), 8): bv = 0 for j in range(8): bv = (bv << 1) | padded[i + j] - sym = encode_8b10b_byte(bv) + sym, rd = encode_8b10b_with_rd(bv, rd) for j in range(9, -1, -1): wire.append((sym >> j) & 1) return wire @@ -261,6 +310,9 @@ def tick(self, cycle: int, pins_out: int) -> int: if clk != self._last_clk: if clk == 1: # rising edge + if (self._clocks_in_burst > 0 and self._last_edge_cycle is not None + and cycle - self._last_edge_cycle > self.frame_gap_cycles): + self._start_new_frame(cycle) if (self._last_rising_edge_cycle is not None and cycle - self._last_rising_edge_cycle < self.min_bit_period_cycles): self._faults.append( @@ -290,6 +342,16 @@ def tick(self, cycle: int, pins_out: int) -> int: self._last_clk = clk return self._drive() + def _start_new_frame(self, cycle: int) -> None: + """The clock was idle long enough to end the previous frame: report it + and clear the per-frame state so the next burst is judged on its own.""" + self.flush(cycle) + self._clocks_in_burst = 0 + self._shift_index = 0 + self._overclock_flagged = False + self._frame_emitted = False + self._captured_bits = [] + def _drive(self) -> int: if self._shift_index == 0 or self._shift_index > len(self._wire_bits): return 1 << self.slo_pin # idle high diff --git a/tests/test_bissline.py b/tests/test_bissline.py index c21169d..51c06da 100644 --- a/tests/test_bissline.py +++ b/tests/test_bissline.py @@ -700,3 +700,88 @@ def _drive(self) -> int: frames = [e for e in m.events() if e["kind"] == "frame"] assert frames[0]["crc_ok"] is False, "Mutation was not caught! crc_ok is True" + + +# =================================================================== +# Consecutive frames and running disparity +# =================================================================== +def _drive_frames(model, n_frames, gap=100, half_period=10): + """Clock `n_frames` complete frames with an idle gap between them.""" + cyc = 0 + for _ in range(n_frames): + for _ in range(len(model._wire_bits)): + for _ in range(half_period): + model.tick(cyc, 0) + cyc += 1 + for _ in range(half_period): + model.tick(cyc, 1 << model.ma_pin) + cyc += 1 + for _ in range(gap): + model.tick(cyc, 0) + cyc += 1 + model.flush(cyc) + + +class TestConsecutiveFrames: + def test_second_frame_is_not_overclocked(self): + m = BissLineEncoder(data_bits=16, position=0xBEEF, line_coding=False, fec_enable=False) + _drive_frames(m, 2) + assert m.faults() == [] + frames = [e for e in m.events() if e["kind"] == "frame"] + assert len(frames) == 2 + assert all(f["crc_ok"] and f["position"] == 0xBEEF for f in frames) + assert all(f["clocks_received"] == len(m._wire_bits) for f in frames) + + def test_overclock_inside_one_frame_is_still_flagged(self): + m = BissLineEncoder(data_bits=16, position=0x1234, line_coding=False, fec_enable=False) + _clock_model(m, len(m._wire_bits) + 1) + assert any("overclocked" in f for f in m.faults()) + + def test_second_frame_with_line_coding_and_fec(self): + m = BissLineEncoder(data_bits=16, position=0xA55A) + _drive_frames(m, 3) + assert m.faults() == [] + frames = [e for e in m.events() if e["kind"] == "frame"] + assert [f["position"] for f in frames] == [0xA55A] * 3 + + +def _sym_str(sym): + return format(sym, "010b") + + +class TestRunningDisparity: + # Symbols written out from the 8b/10b data code table (abcdei fghj, RD- first). + def test_known_vectors(self): + assert _sym_str(encode_8b10b_byte(0x00, rd=-1)) == "1001110100" # D0.0 at RD- + assert _sym_str(encode_8b10b_byte(0x00, rd=+1)) == "0110001011" # D0.0 at RD+ + assert _sym_str(encode_8b10b_byte(0x20, rd=-1)) == "1001111001" # D0.1 at RD- + assert _sym_str(encode_8b10b_byte(0x20, rd=+1)) == "0110001001" # D0.1 at RD+ + assert _sym_str(encode_8b10b_byte(0x95, rd=-1)) == "1010101101" # D21.4 at RD- + assert _sym_str(encode_8b10b_byte(0xAA, rd=-1)) == "0101011010" # D10.5 at RD- + assert _sym_str(encode_8b10b_byte(0xF1, rd=-1)) == "1000110111" # D17.7 uses A7 at RD- + assert _sym_str(encode_8b10b_byte(0xEB, rd=+1)) == "1101001000" # D11.7 uses A7 at RD+ + + def test_repeated_byte_alternates_disparity(self): + m = BissLineEncoder(data_bits=16, position=0, line_coding=True, fec_enable=False) + wire = m._apply_8b10b([0, 0, 1, 0, 0, 0, 0, 0] * 2) # byte 0x20 twice + syms = [int("".join(map(str, wire[i:i + 10])), 2) for i in range(0, len(wire), 10)] + assert [_sym_str(s) for s in syms] == ["1001111001", "0110001001"] + + def test_disparity_stays_bounded_for_any_byte_sequence(self): + import random + rng = random.Random(7) + m = BissLineEncoder(data_bits=16, line_coding=True, fec_enable=False) + bits = [rng.getrandbits(1) for _ in range(8 * 400)] + wire = m._apply_8b10b(bits) + total = 0 + for i, b in enumerate(wire): + total += 1 if b else -1 + if i % 10 == 9: + assert total in (0, 2), f"disparity {total} after symbol {i // 10}" + # symbols decode back to the original bytes + out = [] + for i in range(0, len(wire), 10): + sym = int("".join(map(str, wire[i:i + 10])), 2) + bv = decode_8b10b_symbol(sym) + out.extend((bv >> j) & 1 for j in range(7, -1, -1)) + assert out == bits From 31e1883e0b6046f6dbd74497d2b254f87f1c1fb0 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Sat, 7 Nov 2026 02:43:54 +0530 Subject: [PATCH 36/44] Terminate the whole process group when a TI-run command times out on POSIX The wrapper started its commands in the parent's session and only killed the direct child on POSIX, so the compiler or simulator started by gmake kept running after a timeout while the log said the process tree was terminated. Commands now lead their own session, the group is signalled, and the child is reaped. --- scripts/sim/gui_run.py | 18 ++++++++++-- tests/test_gui_run_kill.py | 56 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 tests/test_gui_run_kill.py diff --git a/scripts/sim/gui_run.py b/scripts/sim/gui_run.py index 8f4603f..1987aed 100644 --- a/scripts/sim/gui_run.py +++ b/scripts/sim/gui_run.py @@ -30,6 +30,7 @@ import os import re import shlex +import signal import socket import subprocess import sys @@ -156,7 +157,9 @@ def run_logged(argv, cwd, env, log, timeout): try: flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0 proc = subprocess.Popen(argv, cwd=cwd, env=env, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, text=True, creationflags=flags) + stderr=subprocess.PIPE, text=True, creationflags=flags, + # own session on POSIX, so the whole group can be signalled + start_new_session=(os.name != "nt")) try: stdout, stderr = proc.communicate(timeout=timeout) except subprocess.TimeoutExpired: @@ -176,7 +179,18 @@ def kill_process_tree(proc): subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], capture_output=True, text=True, timeout=30) else: - proc.kill() + # the command leads its own session (see run_logged): signal the group so + # gmake's compiler / simulator children die with it + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except ProcessLookupError: + pass + except OSError: + proc.kill() + try: + proc.wait(timeout=30) + except subprocess.TimeoutExpired: + pass class ProjectLock: diff --git a/tests/test_gui_run_kill.py b/tests/test_gui_run_kill.py new file mode 100644 index 0000000..82b8e58 --- /dev/null +++ b/tests/test_gui_run_kill.py @@ -0,0 +1,56 @@ +"""A timed-out command in the TI-run wrapper is stopped together with its children on POSIX.""" +import os +import signal +import subprocess +import sys +import time +import types +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "sim")) +import gui_run # noqa: E402 + + +def test_posix_kill_signals_the_process_group(monkeypatch): + calls = [] + fake_os = types.SimpleNamespace( + name="posix", getpgid=lambda pid: pid + 1000, + killpg=lambda pgid, sig: calls.append((pgid, sig))) + monkeypatch.setattr(gui_run, "os", fake_os) + monkeypatch.setattr(signal, "SIGKILL", 9, raising=False) + proc = types.SimpleNamespace(pid=77, kill=lambda: calls.append("kill"), + wait=lambda timeout=None: 0) + gui_run.kill_process_tree(proc) + assert calls == [(1077, 9)] + + +def test_posix_kill_tolerates_an_already_gone_group(monkeypatch): + def gone(pgid, sig): + raise ProcessLookupError + fake_os = types.SimpleNamespace(name="posix", getpgid=lambda pid: pid, killpg=gone) + monkeypatch.setattr(gui_run, "os", fake_os) + monkeypatch.setattr(signal, "SIGKILL", 9, raising=False) + proc = types.SimpleNamespace(pid=5, kill=lambda: None, wait=lambda timeout=None: 0) + gui_run.kill_process_tree(proc) + + +@pytest.mark.skipif(os.name == "nt", reason="process groups are POSIX") +def test_timeout_kills_grandchildren(tmp_path): + pidfile = tmp_path / "child.pid" + script = f"sleep 60 & echo $! > {pidfile}; wait" + with pytest.raises(gui_run.Refused): + gui_run.run_logged(["sh", "-c", script], str(tmp_path), dict(os.environ), + str(tmp_path / "log.txt"), timeout=2) + child = int(pidfile.read_text()) + deadline = time.time() + 5 + while time.time() < deadline: + try: + os.kill(child, 0) + except ProcessLookupError: + return + time.sleep(0.1) + pytest.fail("the grandchild survived the timeout") From 2819fa3cb76d81cc3bf81697e21530f1842d81f4 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Sat, 7 Nov 2026 02:44:05 +0530 Subject: [PATCH 37/44] Report a missing TI pru-simulator checkout as a refusal in the bound-device runner main() called the simulator lookup outside its refusal handling, so a missing checkout ended in an uncaught traceback and exit code 1. It now prints a REFUSED diagnostic and returns the refusal exit code. --- scripts/sim/run_bound_devices.py | 8 ++++++-- tests/test_run_bound_devices_refusal.py | 26 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 tests/test_run_bound_devices_refusal.py diff --git a/scripts/sim/run_bound_devices.py b/scripts/sim/run_bound_devices.py index 0788045..b078a0a 100644 --- a/scripts/sim/run_bound_devices.py +++ b/scripts/sim/run_bound_devices.py @@ -31,7 +31,7 @@ sys.path.insert(0, ROOT) from scripts.sim import device_bindings -from scripts.sim.elf_cosim import find_pru_simulator, _sim_namespace +from scripts.sim.elf_cosim import SimulatorNotFound, find_pru_simulator, _sim_namespace #: Device kinds whose decoded value this runner can compare with the model. DECODERS = ("ssi", "bissc") @@ -358,7 +358,11 @@ def main(argv=None): print("REFUSED: manifest has no device bindings. An empty manifest is REFUSED.", file=sys.stderr) return EXIT_REFUSED - sim_dir = find_pru_simulator() + try: + sim_dir = find_pru_simulator() + except SimulatorNotFound as exc: + print(f"REFUSED: {exc}", file=sys.stderr) + return EXIT_REFUSED with _sim_namespace(sim_dir): from simulator import Simulator diff --git a/tests/test_run_bound_devices_refusal.py b/tests/test_run_bound_devices_refusal.py new file mode 100644 index 0000000..f9887e6 --- /dev/null +++ b/tests/test_run_bound_devices_refusal.py @@ -0,0 +1,26 @@ +"""run_bound_devices reports a missing TI pru-simulator as REFUSED (exit 2), not a traceback.""" +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from scripts.sim import run_bound_devices # noqa: E402 +from scripts.sim.elf_cosim import SimulatorNotFound # noqa: E402 + + +def test_missing_simulator_is_refused(monkeypatch, capsys): + monkeypatch.setattr(run_bound_devices.device_bindings, "load_manifest", + lambda path: {"bindings": [{"kind": "ssi"}]}) + + def missing(): + raise SimulatorNotFound("no pru-simulator checkout found (set PRU_SIMULATOR_PATH)") + + monkeypatch.setattr(run_bound_devices, "find_pru_simulator", missing) + try: + rc = run_bound_devices.main(["fw.out", "manifest.json"]) + except SimulatorNotFound: + # raised here (not skipped by the repository hook) because the runner must handle it itself + raise AssertionError("SimulatorNotFound escaped main()") from None + assert rc == run_bound_devices.EXIT_REFUSED + assert "REFUSED: no pru-simulator checkout found" in capsys.readouterr().err From 219017b59818270697d6b6fb8159d109e081574d Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Sat, 7 Nov 2026 02:45:01 +0530 Subject: [PATCH 38/44] Headless simulator: read ELF symbols from the symbol table section Each symbol was decoded at an offset counted from the start of the file, so names, values and sizes came from the ELF header and the bytes after it. The offset now starts at the symbol table section. --- scripts/simulator.js | 2 +- tests/test_simulator_elf_symbols.py | 53 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/test_simulator_elf_symbols.py diff --git a/scripts/simulator.js b/scripts/simulator.js index 94788a0..b4840c9 100644 --- a/scripts/simulator.js +++ b/scripts/simulator.js @@ -319,7 +319,7 @@ function loadElfFirmware(buffer) { } } for (let s = 0; s < numSyms; s++) { - const symOff = s * 16; + const symOff = sh_offset + s * 16; // symbols live in the .symtab section const st_name = readU32(symOff); const st_value = readU32(symOff + 4); const st_size = readU32(symOff + 8); diff --git a/tests/test_simulator_elf_symbols.py b/tests/test_simulator_elf_symbols.py new file mode 100644 index 0000000..827a0b6 --- /dev/null +++ b/tests/test_simulator_elf_symbols.py @@ -0,0 +1,53 @@ +"""The headless simulator's ELF loader reads symbols from the .symtab section, not the file start.""" +import json +import shutil +import struct +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node not on PATH") + + +def _elf_with_symbols(symbols): + text = struct.pack(" Date: Sat, 7 Nov 2026 02:46:10 +0530 Subject: [PATCH 39/44] Simulators: ZERO and FILL change exactly the addressed bytes The GUI core rounded the count up to whole registers and ignored the byte or word offset of the operand, so zero &R2.b2, 1 cleared all of R2. The headless simulator called a register-reference parser it does not define and threw a TypeError on either instruction. Both now start at the operand's byte, change exactly the requested number of bytes and stop after R31. --- .../pru_blocks/common/simulation/pru_core.js | 29 ++++++--------- scripts/simulator.js | 33 +++++++---------- tests/test_gui_simulator_core.py | 18 ++++++++++ tests/test_simulator_headless_zero_fill.py | 35 +++++++++++++++++++ 4 files changed, 75 insertions(+), 40 deletions(-) create mode 100644 tests/test_simulator_headless_zero_fill.py diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js index d66d188..f607d32 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js @@ -1678,30 +1678,21 @@ function executeInstruction(pruState, pruInstructions, labelMap, r30ValueHistory break; } - case 'ZERO': { - const op1Info = parseRegisterReference(operands[0].replace(/&|,/g, '')); - const byteCountStr = operands[1] ? operands[1].replace(/,/g, '') : "4"; - const byteCount = parseImmediate(byteCountStr); - if (op1Info) { - const startReg = (op1Info.regNum !== undefined) ? op1Info.regNum : op1Info.reg; - const numRegs = Math.ceil(byteCount / 4); - for (let i = 0; i < numRegs && (startReg + i) < 32; i++) { - pruState.registers[startReg + i] = 0; - } - } - pruState.pc++; - break; - } - + case 'ZERO': case 'FILL': { const op1Info = parseRegisterReference(operands[0].replace(/&|,/g, '')); const byteCountStr = operands[1] ? operands[1].replace(/,/g, '') : "4"; const byteCount = parseImmediate(byteCountStr); if (op1Info) { - const startReg = (op1Info.regNum !== undefined) ? op1Info.regNum : op1Info.reg; - const numRegs = Math.ceil(byteCount / 4); - for (let i = 0; i < numRegs && (startReg + i) < 32; i++) { - pruState.registers[startReg + i] = 0xFFFFFFFF; + // The operand names the first byte (Rn, Rn.bK or Rn.wK); the count covers + // exactly that many bytes of the register file, little-endian, and no more. + const startByte = op1Info.regNum * 4 + + (op1Info.byteMode ? op1Info.bytePos : op1Info.wordMode ? op1Info.wordPos : 0); + const fillByte = opcode === 'FILL' ? 0xFF : 0x00; + for (let b = startByte; b < startByte + byteCount && b < 32 * 4; b++) { + const shift = (b % 4) * 8; + const r = b >> 2; + pruState.registers[r] = ((pruState.registers[r] & ~(0xFF << shift)) | (fillByte << shift)) >>> 0; } } pruState.pc++; diff --git a/scripts/simulator.js b/scripts/simulator.js index b4840c9..c622f2b 100644 --- a/scripts/simulator.js +++ b/scripts/simulator.js @@ -1700,30 +1700,21 @@ class Simulator { case 'NOP': pruState.pc++; break; - case 'ZERO': { - const op1Info = this._parseRegisterReference(operands[0].replace(/&|,/g, '')); - const byteCountStr = operands[1] ? operands[1].replace(/,/g, '') : "4"; - const byteCount = this._parseImmediate(byteCountStr); - if (op1Info) { - const startReg = (op1Info.regNum !== undefined) ? op1Info.regNum : op1Info.reg; - const numRegs = Math.ceil(byteCount / 4); - for (let i = 0; i < numRegs && (startReg + i) < 32; i++) { - pruState.registers[startReg + i] = 0; - } - } - pruState.pc++; - break; - } + case 'ZERO': case 'FILL': { - const op1Info = this._parseRegisterReference(operands[0].replace(/&|,/g, '')); + const op1Info = this._parseRegister(operands[0].replace(/&|,/g, '')); + if (this._handleParsingError(pruState, op1Info)) { pruState.pc++; break; } const byteCountStr = operands[1] ? operands[1].replace(/,/g, '') : "4"; const byteCount = this._parseImmediate(byteCountStr); - if (op1Info) { - const startReg = (op1Info.regNum !== undefined) ? op1Info.regNum : op1Info.reg; - const numRegs = Math.ceil(byteCount / 4); - for (let i = 0; i < numRegs && (startReg + i) < 32; i++) { - pruState.registers[startReg + i] = 0xFFFFFFFF; - } + // The operand names the first byte (Rn, Rn.bK or Rn.wK); the count covers exactly + // that many bytes of the register file, little-endian, and no more. + const startByte = op1Info.reg * 4 + + (op1Info.type === 'byte' ? op1Info.byteNum : op1Info.type === 'word' ? op1Info.wordNum : 0); + const fillByte = opcode === 'FILL' ? 0xFF : 0x00; + for (let b = startByte; b < startByte + byteCount && b < 32 * 4; b++) { + const shift = (b % 4) * 8; + const r = b >> 2; + pruState.registers[r] = ((pruState.registers[r] & ~(0xFF << shift)) | (fillByte << shift)) >>> 0; } pruState.pc++; break; diff --git a/tests/test_gui_simulator_core.py b/tests/test_gui_simulator_core.py index 86788df..2987e9e 100644 --- a/tests/test_gui_simulator_core.py +++ b/tests/test_gui_simulator_core.py @@ -70,3 +70,21 @@ def test_word_register_read_returns_its_16_bits(src, expected): def test_unimplemented_base_instruction_stops_the_run(opcode): result = run(["LDI R1, 1", "%s R1, R2" % opcode]) assert "not yet implemented" in result.get("error", ""), result + + +@pytest.mark.parametrize("program,reg,expected", [ + (["LDI32 R0, 0xAABBCCDD", "ZERO &R0, 1"], 0, 0xAABBCC00), + (["LDI32 R0, 0xAABBCCDD", "ZERO &R0.b2, 1"], 0, 0xAA00CCDD), + (["LDI32 R0, 0xAABBCCDD", "ZERO &R0.w1, 2"], 0, 0xAA0000DD), + (["LDI32 R0, 0xAABBCCDD", "FILL &R0.b1, 1"], 0, 0xAABBFFDD), + (["LDI32 R0, 0xAABBCCDD", "ZERO &R0, 4"], 0, 0), +]) +def test_zero_and_fill_touch_only_the_named_bytes(program, reg, expected): + regs = run(program)["regs"] + assert regs[reg] == expected, "R%d is 0x%08X, want 0x%08X" % (reg, regs[reg], expected) + + +def test_zero_spans_into_the_next_register_by_bytes(): + regs = run(["LDI32 R0, 0xAABBCCDD", "LDI32 R1, 0x11223344", "ZERO &R0.b3, 3"])["regs"] + assert regs[0] == 0x00BBCCDD + assert regs[1] == 0x11220000 diff --git a/tests/test_simulator_headless_zero_fill.py b/tests/test_simulator_headless_zero_fill.py new file mode 100644 index 0000000..b5cdfb2 --- /dev/null +++ b/tests/test_simulator_headless_zero_fill.py @@ -0,0 +1,35 @@ +"""ZERO and FILL in the headless simulator change exactly the addressed bytes.""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node not on PATH") + +JS = r""" +const { Simulator } = require(process.argv[1]); +const sim = new Simulator(); +sim.loadInstructions(JSON.parse(process.argv[2]), []); +const st = sim.runUntil({ maxCycles: 20, watchpoints: [] }); +process.stdout.write(JSON.stringify(Array.from(st.state.registers))); +""" + + +def regs(program): + out = subprocess.run(["node", "-e", JS, str(ROOT / "scripts" / "simulator.js"), json.dumps(program)], + text=True, capture_output=True, check=True) + return json.loads(out.stdout) + + +@pytest.mark.parametrize("program,expected", [ + (["LDI32 R0, 0xAABBCCDD", "ZERO &R0, 1"], 0xAABBCC00), + (["LDI32 R0, 0xAABBCCDD", "ZERO &R0.b2, 1"], 0xAA00CCDD), + (["LDI32 R0, 0xAABBCCDD", "FILL &R0.b1, 1"], 0xAABBFFDD), + (["LDI32 R0, 0xAABBCCDD", "ZERO &R0, 4"], 0), +]) +def test_zero_fill_bytes(program, expected): + assert regs(program)[0] == expected From 8a27fcc638a0cda9072c87c3ca72fa7feddcb915 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Sat, 7 Nov 2026 02:47:35 +0530 Subject: [PATCH 40/44] Headless simulator: use the IEP register map and control bits of the GUI core The headless timer recognised count, status and compare at the offsets of an older timer layout (0x0C, 0x44, 0x48/0x4C), counted unconditionally and reset on any compare at or above CMP0, so firmware using the generated timer code (GLOBAL_CFG 0x00, COUNT 0x10/0x14, CMP_CFG 0x70, CMP_STATUS 0x74, CMPj at 0x78 + 8j) read and wrote different registers and saw compare hits it had not enabled. The map, the 64-bit count, CNT_ENABLE and DEFAULT_INC, CMP_EN, equality compare and CMP0_RST_CNT_EN now match the GUI core. LBCO and SBCO also read the register operand through fields the parser does not return, so register-sourced stores moved no data; they now use the parsed register number and byte offset. --- scripts/simulator.js | 78 ++++++++++++++++++++-------- tests/test_simulator_headless_iep.py | 63 ++++++++++++++++++++++ 2 files changed, 118 insertions(+), 23 deletions(-) create mode 100644 tests/test_simulator_headless_iep.py diff --git a/scripts/simulator.js b/scripts/simulator.js index c622f2b..a10d872 100644 --- a/scripts/simulator.js +++ b/scripts/simulator.js @@ -826,9 +826,15 @@ class Simulator { errorParsing: 0, r31HistoryMap: this._r31HistoryMap, mmioMap: this._mmioMap, + // IEP timer, same register map and semantics as the GUI core + // (pru_core.js): GLOBAL_CFG 0x00, COUNT_REG0/1 0x10/0x14, CMP_CFG 0x70, + // CMP_STATUS 0x74, CMPj_REG0/1 at 0x78 + 8j / 0x7C + 8j. + iepGlobalCfg: 0, iepCount: 0, - iepCmp0: 0, - iepCmp1: 0, + iepCountHi: 0, + iepCmpCfg: 0, + iepCmp: new Array(16).fill(0), + iepCmpHi: new Array(16).fill(0), iepCmpStatus: 0, // XFR2TR ring state (SPRUIM2J Table 6-112). queue holds the // submitted-but-not-yet-drained TR IDs (capacity 8, the FIFO depth). @@ -877,8 +883,9 @@ class Simulator { cycles: this._pruState.cycles, carry: this._pruState.carry, iepCount: this._pruState.iepCount, - iepCmp0: this._pruState.iepCmp0, - iepCmp1: this._pruState.iepCmp1, + iepCountHi: this._pruState.iepCountHi, + iepCmp0: this._pruState.iepCmp[0], + iepCmp1: this._pruState.iepCmp[1], iepCmpStatus: this._pruState.iepCmpStatus, xfr2tr: this._pruState.xfr2tr ? JSON.parse(JSON.stringify(this._pruState.xfr2tr)) : null, bsRam: this._pruState.bsRam ? JSON.parse(JSON.stringify(this._pruState.bsRam)) : null, @@ -1791,10 +1798,15 @@ class Simulator { // through to DMEM0 and returned wrong data. const iepOffset = memAddress - 0x0002E000; let regVal = 0; - if (iepOffset === 0x0C) regVal = pruState.iepCount; - else if (iepOffset === 0x44) regVal = pruState.iepCmpStatus; - else if (iepOffset === 0x48) regVal = pruState.iepCmp0; - else if (iepOffset === 0x4C) regVal = pruState.iepCmp1; + if (iepOffset === 0x00) regVal = pruState.iepGlobalCfg; + else if (iepOffset === 0x10) regVal = pruState.iepCount; + else if (iepOffset === 0x14) regVal = pruState.iepCountHi; + else if (iepOffset === 0x70) regVal = pruState.iepCmpCfg; + else if (iepOffset === 0x74) regVal = pruState.iepCmpStatus; + else if (iepOffset >= 0x78 && iepOffset < 0x78 + 16 * 8) { + const j = Math.floor((iepOffset - 0x78) / 8); + regVal = (((iepOffset - 0x78) % 8) === 0) ? pruState.iepCmp[j] : pruState.iepCmpHi[j]; + } data = new Uint8Array(byteCount); for (let i = 0; i < byteCount; i++) { data[i] = (regVal >>> (i * 8)) & 0xFF; @@ -1805,8 +1817,8 @@ class Simulator { // Write into destination register(s) if (data) { - let currentReg = destRegInfo.regNum; - let currentByte = destRegInfo.bytePos || destRegInfo.wordPos * 2 || 0; + let currentReg = destRegInfo.reg; + let currentByte = destRegInfo.byteNum || destRegInfo.wordNum || 0; let bytesRemaining = byteCount; let bufOffset = 0; while (bytesRemaining > 0) { @@ -1858,8 +1870,8 @@ class Simulator { const memAddress = baseValue + offsetValue; const dataBuffer = new Uint8Array(byteCount); - let currentReg = srcRegInfo.regNum; - let currentByte = srcRegInfo.bytePos || srcRegInfo.wordPos * 2 || 0; + let currentReg = srcRegInfo.reg; + let currentByte = srcRegInfo.byteNum || srcRegInfo.wordNum || 0; let bytesRemaining = byteCount; let bufOffset = 0; while (bytesRemaining > 0 && currentReg <= 30) { @@ -1880,10 +1892,16 @@ class Simulator { const iepOffset = memAddress - 0x0002E000; let regVal = 0; for (let i = 0; i < byteCount; i++) regVal |= dataBuffer[i] << (i * 8); - if (iepOffset === 0x0C) pruState.iepCount = regVal; - else if (iepOffset === 0x44) pruState.iepCmpStatus &= ~regVal; - else if (iepOffset === 0x48) pruState.iepCmp0 = regVal; - else if (iepOffset === 0x4C) pruState.iepCmp1 = regVal; + if (iepOffset === 0x00) pruState.iepGlobalCfg = regVal; + else if (iepOffset === 0x10) pruState.iepCount = regVal >>> 0; + else if (iepOffset === 0x14) pruState.iepCountHi = regVal >>> 0; + else if (iepOffset === 0x70) pruState.iepCmpCfg = regVal; + else if (iepOffset === 0x74) pruState.iepCmpStatus &= ~regVal; // write 1 to clear + else if (iepOffset >= 0x78 && iepOffset < 0x78 + 16 * 8) { + const j = Math.floor((iepOffset - 0x78) / 8); + if (((iepOffset - 0x78) % 8) === 0) pruState.iepCmp[j] = regVal >>> 0; + else pruState.iepCmpHi[j] = regVal >>> 0; + } } else { this._pruDMEM0.writeDMEM0(memAddress, dataBuffer); } @@ -2156,13 +2174,27 @@ class Simulator { if (pruState.r31HistoryMap && pruState.r31HistoryMap[pruState.cycles] !== undefined) { pruState.registers[31] = pruState.r31HistoryMap[pruState.cycles]; } - pruState.iepCount++; - if (pruState.iepCmp0 > 0 && pruState.iepCount >= pruState.iepCmp0) { - pruState.iepCmpStatus |= 1; - pruState.iepCount = 0; - } - if (pruState.iepCmp1 > 0 && pruState.iepCount >= pruState.iepCmp1) { - pruState.iepCmpStatus |= 2; + // Counter runs only with CNT_ENABLE; compares fire on equality of the + // 64-bit count and only for CMP_EN bits; CMP0 resets the counter only + // with CMP0_RST_CNT_EN (the GUI core's rules, pru_core.js). + if (pruState.iepGlobalCfg & 0x1) { + const inc = (pruState.iepGlobalCfg >> 4) & 0xF; + const sum = pruState.iepCount + inc; + pruState.iepCount = sum >>> 0; + if (sum > 0xFFFFFFFF) pruState.iepCountHi = (pruState.iepCountHi + 1) >>> 0; + let hit0 = false; + for (let j = 0; j < 16; j++) { + if ((pruState.iepCmpCfg & (1 << (j + 1))) + && pruState.iepCount === pruState.iepCmp[j] + && pruState.iepCountHi === pruState.iepCmpHi[j]) { + pruState.iepCmpStatus |= (1 << j); + if (j === 0) hit0 = true; + } + } + if (hit0 && (pruState.iepCmpCfg & 0x1)) { + pruState.iepCount = 0; + pruState.iepCountHi = 0; + } } r30History.push(pruState.registers[30]); r31History.push(pruState.registers[31]); diff --git a/tests/test_simulator_headless_iep.py b/tests/test_simulator_headless_iep.py new file mode 100644 index 0000000..13c2ad0 --- /dev/null +++ b/tests/test_simulator_headless_iep.py @@ -0,0 +1,63 @@ +"""The headless simulator's IEP timer uses the generated register map and the hardware control bits.""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node not on PATH") + +JS = r""" +const { Simulator } = require(process.argv[1]); +const sim = new Simulator(); +sim.loadInstructions(JSON.parse(process.argv[2]), []); +const st = sim.runUntil({ maxCycles: 200, watchpoints: [] }); +process.stdout.write(JSON.stringify(Array.from(st.state.registers))); +""" + +NOPS = ["NOP"] * 20 + + +def run(program): + out = subprocess.run(["node", "-e", JS, str(ROOT / "scripts" / "simulator.js"), json.dumps(program)], + text=True, capture_output=True, check=True) + return json.loads(out.stdout) + + +def configure(global_cfg, cmp0, cmp_cfg): + # offsets as the generated timer code uses them: GLOBAL_CFG 0x00, CMP_CFG 0x70, CMP0 0x78 + return [ + f"LDI32 R1, {cmp0}", "SBCO &R1, c26, 0x78, 4", + f"LDI32 R1, {cmp_cfg}", "SBCO &R1, c26, 0x70, 4", + f"LDI32 R1, {global_cfg}", "SBCO &R1, c26, 0x00, 4", + ] + + +def test_compare_at_generated_offsets_sets_status(): + prog = configure(0x11, 5, 0x3) + NOPS + ["LBCO &R3, c26, 0x74, 4"] + assert run(prog)[3] & 1 == 1 + + +def test_status_is_write_one_to_clear_at_generated_offset(): + prog = (configure(0x11, 5, 0x2) + NOPS + ["LDI32 R1, 1", "SBCO &R1, c26, 0x74, 4", + "LBCO &R3, c26, 0x74, 4"]) + assert run(prog)[3] & 1 == 0 + + +def test_counter_does_not_run_without_enable(): + prog = configure(0x10, 5, 0x3) + NOPS + ["LBCO &R3, c26, 0x74, 4", "LBCO &R4, c26, 0x10, 4"] + regs = run(prog) + assert regs[3] == 0 and regs[4] == 0 + + +def test_compare_without_cmp_enable_does_not_fire(): + prog = configure(0x11, 5, 0x0) + NOPS + ["LBCO &R3, c26, 0x74, 4"] + assert run(prog)[3] == 0 + + +def test_count_is_not_reset_without_cmp0_reset_enable(): + prog = configure(0x11, 5, 0x2) + NOPS * 3 + ["LBCO &R4, c26, 0x10, 4"] + assert run(prog)[4] > 5 From 7c14f0c99594d24417f9c326d054b095460d586c Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Sat, 7 Nov 2026 02:48:34 +0530 Subject: [PATCH 41/44] GUI core: settle the attached bus before the first instruction With devices attached, R30 started at the bus idle word but R31 stayed zero until the first bus settlement after an instruction, so firmware that read R31 in its first instruction saw zero instead of the level the devices and pull-ups give the idle outputs. The bus is now settled once at startup and its result seeds R31 and the R31 history. --- .../.meta/pru_blocks/common/simulation/pru_core.js | 5 ++++- scripts/sim/test_device_binding_runtime.js | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js index f607d32..4ee11ed 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/pru_core.js @@ -1978,10 +1978,13 @@ function simulatePruInstructions(pruInstructions, pruInstructionsLabels, cycleCo // generated. Open-loop runs (no bus) keep the historical zero start. if (deviceBus) { pruState.registers[30] = deviceBus.idlePruWord(); + // Resolve the bus once at startup, so the firmware's first R31 read sees the + // level the devices and pull-ups give the idle R30, not a cold zero. + pruState.registers[31] = deviceBus.settle(0, pruState.registers[30]); } const r30ValueHistory = [deviceBus ? deviceBus.idlePruWord() : 0]; - const r31ValueHistory = [0]; + const r31ValueHistory = [pruState.registers[31]]; // Create label map for jumps const labelMap = {}; diff --git a/scripts/sim/test_device_binding_runtime.js b/scripts/sim/test_device_binding_runtime.js index f3beef9..06c41c9 100644 --- a/scripts/sim/test_device_binding_runtime.js +++ b/scripts/sim/test_device_binding_runtime.js @@ -163,6 +163,17 @@ test('a master that clocks faster than f_max is refused by the device', () => { 'overclocked firmware must be refused: ' + JSON.stringify(enc.faults())); }); +test('the first R31 read sees the idle level of the attached bus, not zero', () => { + const enc = models.createDeviceModel('ssi', { primary: 1, clock: 0 }, { + bits: 4, position: 0b1011, gray: false, min_clk_period_cycles: 4, monoflop_cycles: 200 + }); + const bus = attachBus([enc]); + const { pruState, r31ValueHistory } = runSim(['mov R4, R31', 'halt'], [0, 0], 10, bus); + assert.ok(r31ValueHistory[0] !== 0, 'R31 history must start from the settled bus: ' + r31ValueHistory[0]); + assert.strictEqual(pruState.registers[4], r31ValueHistory[0]); + assert.ok((pruState.registers[4] >> 1) & 1, 'an idle SSI data line reads high'); +}); + test('without a device, R31 stays whatever the fixed history says (open loop unchanged)', () => { const { pruState } = runSim(SSI_MASTER, SSI_MASTER_LABELS, 300, null); // No device: DATA reads 0 throughout, so R4 shifts in zeros. From 19bff5c949d0a0330e526863b28ac31edecbb8df Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Sat, 7 Nov 2026 02:49:30 +0530 Subject: [PATCH 42/44] In-tool SSI encoder: build frames wider than 31 bits exactly The frame mask, Gray conversion and bit extraction used 32-bit JavaScript bitwise operators, so a 32-bit frame was masked to zero and wider frames (the table accepts up to 64 bits) had their shift counts wrapped. The position is now latched and shifted as a BigInt. --- .../common/simulation/device_models.js | 13 ++++++---- scripts/sim/test_device_models.js | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js index eac1501..7023e3d 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js @@ -186,6 +186,8 @@ class QuadratureEncoder { // samples on rising edges and shares nothing with this model. function toGray(value) { + // BigInt input is handled exactly: JS bitwise operators on numbers are 32-bit. + if (typeof value === "bigint") return value ^ (value >> BigInt(1)); return value ^ (value >> 1); } @@ -277,18 +279,19 @@ class SsiEncoder { // First edge of a frame: latch the position NOW. Everything after // this reads the snapshot, so the shaft turning mid-word cannot // tear the value - the whole point of a latched protocol. - const raw = this.position & ((1 << this.bits) - 1); - this._latched = (this.gray ? toGray(raw) : raw) >>> 0; + // BigInt: frames up to 64 bits wide, which 32-bit bitwise operators cannot mask or shift. + const raw = BigInt.asUintN(this.bits, BigInt(Math.trunc(this.position))); + this._latched = this.gray ? toGray(raw) : raw; this._bitIndex = 1; - this._data = (this._latched >> (this.bits - 1)) & 1; + this._data = Number((this._latched >> BigInt(this.bits - 1)) & BigInt(1)); } else if (this._bitIndex < this.bits) { this._bitIndex += 1; - this._data = (this._latched >> (this.bits - this._bitIndex)) & 1; + this._data = Number((this._latched >> BigInt(this.bits - this._bitIndex)) & BigInt(1)); } else if (this._bitIndex === this.bits) { this._bitIndex = this.bits + 1; this._data = 0; // trailing low for the monoflop this._events.push({ cycle: cycle, kind: "frame", - position: this.position, sent: this._latched, bits: this.bits }); + position: this.position, sent: Number(this._latched), bits: this.bits }); } else { this._faults.push( `cycle ${cycle}: clock continued past the end of the ${this.bits}-bit word ` + diff --git a/scripts/sim/test_device_models.js b/scripts/sim/test_device_models.js index 46dc15a..5571c12 100644 --- a/scripts/sim/test_device_models.js +++ b/scripts/sim/test_device_models.js @@ -218,6 +218,30 @@ test('ssi: clocking faster than f_max is a fault, not garbage', () => { 'overclocking must fault: ' + JSON.stringify(enc.faults())); }); +test('ssi: frames of 32 and 40 bits carry every bit of the position', () => { + for (const [bits, position, gray] of [[32, 0xDEADBEEF, false], [32, 0xDEADBEEF, true], + [40, 0xA5C3E1F0B2, false], [40, 0xA5C3E1F0B2, true]]) { + const enc = new models.SsiEncoder({ clkPin: 0, dataPin: 1, bits, position, + minClkPeriodCycles: 10, monoflopCycles: 100, gray }); + const script = ssiClockScript(0, bits + 1, 30, 10); + const { r31Trace } = runOnBus(enc, script, 10 + (bits + 1) * 30 + 50); + let prevClk = 1, word = BigInt(0), sampled = 0; + for (let c = 1; c < r31Trace.length; c++) { + const clk = r31Trace[c] & 1; + if (clk === 1 && prevClk === 0 && sampled < bits) { + word = (word << BigInt(1)) | BigInt((r31Trace[c] >> 1) & 1); + sampled++; + } + prevClk = clk; + } + const raw = BigInt(position); + const expected = gray ? raw ^ (raw >> BigInt(1)) : raw; + assert.strictEqual(sampled, bits, 'frame not clocked'); + assert.strictEqual(word, expected, `bits=${bits} gray=${gray}: got ${word.toString(16)}`); + assert.strictEqual(enc.faults().length, 0, JSON.stringify(enc.faults())); + } +}); + test('ssi: a mid-word stall aborts the transfer and faults', () => { const enc = new models.SsiEncoder({ clkPin: 0, dataPin: 1, bits: 13, position: 42, minClkPeriodCycles: 10, From e1c7aae5093a759e64b175a9890e73fdacd7fc02 Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Sat, 7 Nov 2026 02:50:39 +0530 Subject: [PATCH 43/44] Emit the effective companion pins in the device-binding manifest buildDeviceBindings wrote the raw clock and index fields, mapping the unset value to null, while validation, the pin-conflict check and the in-tool simulator use the pins defaulted from the bound block. A clocked device whose clock came from its block therefore reached the Python binding without a clock pin and was refused. The manifest now carries the same effective pins. --- .../common/pru_blocks_static_module.syscfg.js | 14 +++++++---- scripts/sim/test_device_binding_syscfg.js | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js index aab176f..6f17b79 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/pru_blocks_static_module.syscfg.js @@ -653,8 +653,13 @@ function buildDeviceBindings(inst) { params = null; } - const clockPin = inst[`gpi${pinNum}_deviceClockPin`]; - const indexPin = inst[`gpi${pinNum}_deviceIndexPin`]; + // The EFFECTIVE companion pins (user field, else the bound block's own pin), the + // same ones validation and the in-tool simulator use, so a design the GUI accepts + // reaches the Python binding with the clock pin it relies on. + const kind = inst[`gpi${pinNum}_deviceKind`]; + const effective = devicePinDefaults(inst, pinNum, kind); + const clockPin = effective.clockPin; + const indexPin = effective.indexPin; bindings.push({ primaryPin: pinNum, @@ -686,9 +691,8 @@ function buildDeviceBindings(inst) { * conflict check. Returns [{pinNum, kind, pins: [{pin, label}]}]. * Uses the EFFECTIVE companion pins (devicePinDefaults), the same pins the * device would really be wired to, not just the raw fields. - * BuildDeviceBindings emits the RAW fields (an unset companion pin is null in the - * manifest); this check claims the effective pin on purpose - the device is wired there either - * way, so a Device Model on that pin is a real short even though the manifest omits it. + * buildDeviceBindings emits the same effective pins, so the manifest and this check agree on + * where the device is wired. */ function collectGpiDeviceClaims(inst) { const claims = []; diff --git a/scripts/sim/test_device_binding_syscfg.js b/scripts/sim/test_device_binding_syscfg.js index 23f02e6..a26d948 100644 --- a/scripts/sim/test_device_binding_syscfg.js +++ b/scripts/sim/test_device_binding_syscfg.js @@ -192,6 +192,29 @@ test('the module validator accepts a legal device binding', () => { assert.strictEqual(validateR31SimulationInput(real, report), true, JSON.stringify(report.errors)); }); +test('the manifest carries the clock pin the bound block supplies when the field is unset', () => { + const src = fs.readFileSync(SRC, 'utf8').replace(/\r\n/g, '\n'); + const body = ['buildDeviceBindings', 'devicePinDefaults'].map(n => extractFunction(src, n)).join('\n'); + const system = { modules: { '/x/ssi_block': { $instances: [{ dataPin: 3, clkPin: 4 }] } } }; + const deviceModelTable = { COMPANION_PIN_BLOCKS: { ssi: [ + { module: '/x/ssi_block', matchField: 'dataPin', clockField: 'clkPin' }] } }; + // eslint-disable-next-line no-new-func + const { buildDeviceBindings } = new Function('system', 'deviceModelTable', + `${body}; return { buildDeviceBindings };`)(system, deviceModelTable); + + const real = inst(3, { gpi3_inputMode: 'device', gpi3_deviceKind: 'ssi' }); // clock field unset (-1) + real.signalsToDisplayR31 = ['3']; + let b = buildDeviceBindings(real).bindings[0]; + assert.strictEqual(b.clockPin, 4, 'clock pin must come from the block: ' + JSON.stringify(b)); + assert.strictEqual(b.indexPin, null); + + // an explicit field still wins over the block's pin + const explicit = inst(3, { gpi3_inputMode: 'device', gpi3_deviceKind: 'ssi', gpi3_deviceClockPin: 6 }); + explicit.signalsToDisplayR31 = ['3']; + b = buildDeviceBindings(explicit).bindings[0]; + assert.strictEqual(b.clockPin, 6); +}); + let failed = 0; for (const [name, fn] of cases) { try { From a29c4e8f10c2eb9bf76ffcaa6fb51d63635fa25b Mon Sep 17 00:00:00 2001 From: Pratheesh Gangadhar Date: Sat, 7 Nov 2026 05:17:37 +0530 Subject: [PATCH 44/44] Quadrature simulation: align model and phases to firmware direction The compiled PRU firmware decodes transition 0 -> 1 as forward motion (B rises before A). The Python encoder and decoder models, along with the in-tool JavaScript simulation phase table, now consistently follow this B-leading convention. Forward motion advances positive count across both simulation environments and agrees with firmware cosimulation. --- .../common/simulation/device_models.js | 3 +- pru_device_models/buscosim.py | 2 +- pru_device_models/quadrature_index_encoder.py | 6 +- scripts/sim/test_device_models.js | 6 +- tests/test_quadrature_encoder.py | 76 +++++++++++++++++-- 5 files changed, 79 insertions(+), 14 deletions(-) diff --git a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js index 7023e3d..85c6b3a 100644 --- a/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js +++ b/.metadata/sysconfig/.meta/pru_blocks/common/simulation/device_models.js @@ -48,7 +48,8 @@ const OPEN_DRAIN = deviceBus.OPEN_DRAIN; // alone. // Gray-coded (A, B) by position within the 4-state cycle. -const QUAD_PHASES = [[0, 0], [1, 0], [1, 1], [0, 1]]; +// Forward motion has B lead A, matching the PRU firmware decoder. +const QUAD_PHASES = [[0, 0], [0, 1], [1, 1], [1, 0]]; class QuadratureEncoder { constructor(opts) { diff --git a/pru_device_models/buscosim.py b/pru_device_models/buscosim.py index bf4e39e..203cdec 100644 --- a/pru_device_models/buscosim.py +++ b/pru_device_models/buscosim.py @@ -16,7 +16,7 @@ # ---------------------------------------------------------------- quadrature -_FWD = {0: 2, 2: 3, 3: 1, 1: 0} # A leads B on forward motion +_FWD = {0: 1, 1: 3, 3: 2, 2: 0} # B leads A on forward motion _REV = {v: k for k, v in _FWD.items()} diff --git a/pru_device_models/quadrature_index_encoder.py b/pru_device_models/quadrature_index_encoder.py index 37dd873..f48f014 100644 --- a/pru_device_models/quadrature_index_encoder.py +++ b/pru_device_models/quadrature_index_encoder.py @@ -21,9 +21,9 @@ from pru_device_models.device_model import DeviceModel -# x4 state table: state = 2*A + B. Forward (CW) order 0,2,3,1,0..., so A leads B -# on forward motion (A rises first), as in the in-tool model. -_FWD = {0: 2, 2: 3, 3: 1, 1: 0} +# x4 state table: state = 2*A + B. Forward (CW) order 0,1,3,2,0..., so B leads A +# on forward motion (B rises first), matching the PRU firmware decoder. +_FWD = {0: 1, 1: 3, 3: 2, 2: 0} _REV = {v: k for k, v in _FWD.items()} diff --git a/scripts/sim/test_device_models.js b/scripts/sim/test_device_models.js index 5571c12..0e09c36 100644 --- a/scripts/sim/test_device_models.js +++ b/scripts/sim/test_device_models.js @@ -127,8 +127,8 @@ test('quadrature: forward rotation walks the legal Gray sequence', () => { if (seen.length === 0 || seen[seen.length - 1] !== ab) seen.push(ab); } // ab packs A on bit 0, B on bit 1, so the forward table (A,B) = - // (0,0) -> (1,0) -> (1,1) -> (0,1) reads 0 -> 1 -> 3 -> 2. - const table = [0b00, 0b01, 0b11, 0b10]; + // (0,0) -> (0,1) -> (1,1) -> (1,0) reads 0 -> 2 -> 3 -> 1. + const table = [0b00, 0b10, 0b11, 0b01]; for (let i = 1; i < seen.length; i++) { const prevIdx = table.indexOf(seen[i - 1]); assert.strictEqual(seen[i], table[(prevIdx + 1) % 4], @@ -148,7 +148,7 @@ test('quadrature: reverse rotation walks the table backwards', () => { const ab = r31Trace[c] & 0x3; if (seen.length === 0 || seen[seen.length - 1] !== ab) seen.push(ab); } - const table = [0b00, 0b10, 0b11, 0b01]; // reverse order + const table = [0b00, 0b01, 0b11, 0b10]; // reverse order for (let i = 1; i < seen.length; i++) { const prevIdx = table.indexOf(seen[i - 1]); assert.strictEqual(seen[i], table[(prevIdx + 1) % 4], diff --git a/tests/test_quadrature_encoder.py b/tests/test_quadrature_encoder.py index fdc5d70..80a18ad 100644 --- a/tests/test_quadrature_encoder.py +++ b/tests/test_quadrature_encoder.py @@ -1,4 +1,6 @@ -"""QuadratureIndexEncoder: forward motion has A lead B, and invalid parameters do not abort the run.""" +"""QuadratureIndexEncoder: forward motion has B lead A, and invalid parameters do not abort the run.""" +import json +import subprocess import sys from pathlib import Path @@ -13,7 +15,7 @@ def _run(model, cycles): return [(c, model.tick(c, 0)) for c in range(cycles)] -def test_forward_motion_a_rises_before_b(): +def test_forward_motion_b_rises_before_a(): m = QuadratureIndexEncoder(cpr=1000) m.set_motion([(10, 4, +1)]) words = [w for _, w in _run(m, 60)] @@ -22,11 +24,11 @@ def test_forward_motion_a_rises_before_b(): s = (w & 1, (w >> 1) & 1) if not ab or ab[-1] != s: ab.append(s) - # (A, B): 00 -> 10 -> 11 -> 01 -> 00 - assert ab[:5] == [(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)] + # (A, B): 00 -> 01 -> 11 -> 10 -> 00 + assert ab[:5] == [(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)] -def test_reverse_motion_b_rises_before_a(): +def test_reverse_motion_a_rises_before_b(): m = QuadratureIndexEncoder(cpr=1000) m.set_motion([(10, 2, -1)]) words = [w for _, w in _run(m, 40)] @@ -35,7 +37,7 @@ def test_reverse_motion_b_rises_before_a(): s = (w & 1, (w >> 1) & 1) if not ab or ab[-1] != s: ab.append(s) - assert ab[:3] == [(0, 0), (0, 1), (1, 1)] + assert ab[:3] == [(0, 0), (1, 0), (1, 1)] def test_independent_decoder_counts_forward_positive(): @@ -51,3 +53,65 @@ def test_zero_cpr_reports_fault_instead_of_raising(): m.set_motion([(10, 3, +1)]) _run(m, 60) assert any("cpr" in f for f in m.faults()) + + +def _extract_transitions(words): + seq = [] + for w in words: + s = [w & 1, (w >> 1) & 1] + if not seq or seq[-1] != s: + seq.append(s) + return seq + + +def test_cross_language_quadrature_phase_agreement(): + """Verify in-tool simulation model and Python model agree on phase sequence.""" + js_code = f""" + const path = require('path'); + const fs = require('fs'); + const vm = require('vm'); + const simDir = path.join('{ROOT.as_posix()}', '.metadata', 'sysconfig', '.meta', 'pru_blocks', 'common', 'simulation'); + const scriptCache = {{}}; + function loadSim(p) {{ + const code = fs.readFileSync(p, 'utf8'); + const sandbox = {{ + console, Math, JSON, Number, Array, Object, Set, Error, + system: {{ getScript: (sp) => scriptCache[path.basename(sp)] || loadSim(path.join(simDir, path.basename(sp))) }}, + exports: {{}} + }}; + vm.createContext(sandbox); + vm.runInContext(code, sandbox); + return sandbox.exports; + }} + const models = loadSim(path.join(simDir, 'device_models.js')); + function runJs(fwd) {{ + const enc = new models.QuadratureEncoder({{ aPin: 0, bPin: 1, zPin: -1, countsPerRev: 100, cyclesPerCount: 10, forward: fwd }}); + const words = []; + for (let c = 0; c < 45; c++) {{ + const [, val] = enc.tick(c, 0); + words.push(val); + }} + return words; + }} + console.log(JSON.stringify({{ fwd: runJs(true), rev: runJs(false) }})); + """ + res = subprocess.run(["node", "-e", js_code], capture_output=True, text=True, check=True) + js_data = json.loads(res.stdout) + js_fwd_transitions = _extract_transitions(js_data["fwd"]) + js_rev_transitions = _extract_transitions(js_data["rev"]) + + # Python forward + m_fwd = QuadratureIndexEncoder(a_bit=0, b_bit=1, cpr=100) + m_fwd.set_motion([(10, 4, +1)]) + py_fwd_words = [m_fwd.tick(c, 0) for c in range(45)] + py_fwd_transitions = _extract_transitions(py_fwd_words) + + # Python reverse + m_rev = QuadratureIndexEncoder(a_bit=0, b_bit=1, cpr=100) + m_rev.set_motion([(10, 4, -1)]) + py_rev_words = [m_rev.tick(c, 0) for c in range(45)] + py_rev_transitions = _extract_transitions(py_rev_words) + + # Both environments must produce identical Gray code phase transitions + assert py_fwd_transitions == js_fwd_transitions == [[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]] + assert py_rev_transitions == js_rev_transitions == [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]