Table of Contents

NC Parsing Engine

SoftNcRunner is the NC interpreter. It reads a controller program — Fanuc, Siemens, Syntec, Mazak or Heidenhain G-code, an NX cutter-location file, or a CSV controller recording — and turns it into the machine actions the simulation executes.

It is a composed interpreter rather than a fixed one. The segmenter, the initializers, the syntax stages, the semantics and the dependency data are five ordered lists on the runner object, all of them serializable. Adding support for a G-code means adding one syntax unit and one line to a list; removing support means removing that line. Nothing about a brand is compiled into a central class.

Note

SoftNcRunner is the default NC pipeline as of 3.2 (EnableSoftNcRunner defaults to true). HardNcRunner remains reachable as the opt-out fallback for the shrinking set of features still bound to it.

The pipeline

A program becomes actions in five ordered stages. Each shipped preset expresses them as five BundleSyntax containers named Parsing, Evaluation, Logic, PostLogic and Inspection.

graph TD
    A[NC raw lines] --> SEG[ISegmenter]
    SEG --> SENT[Sentence stream]
    SENT --> INIT[INcInitializer]
    INIT --> P[Parsing<br/>text to structure]
    P --> E[Evaluation<br/>variables, expressions,<br/>calls, control flow]
    E --> L[Logic<br/>modal state, coordinates,<br/>compensation, motion]
    L --> PL[PostLogic<br/>modal carry]
    PL --> I[Inspection<br/>backfill, unconsumed check,<br/>snapshot]
    I --> SEM[INcSemantic]
    SEM --> OUT[Machine actions]

    DEP[INcDependency list<br/>brand tables, coordinate systems,<br/>tool offsets, kinematics, ...]
    DEP -. injected .-> P
    DEP -. injected .-> E
    DEP -. injected .-> L
    DEP -. injected .-> SEM
Stage Responsibility
ISegmenter cuts the raw text into blocks. Three implementations ship — one line per block, and the two multi-line forms Heidenhain and Siemens programs need
INcInitializer seeds the stream head with the machine's starting state, for example the configured home position
Parsing recognizes the text: words, statements, cycle bodies, comments. Writes structure, never meaning
Evaluation resolves anything the block computes for itself — variable reads and writes, arithmetic, conditional jumps, loops, and subprogram or macro calls, whose bodies are spliced into the stream so later stages walk them as if they had always been in the host file
Logic the modal machine model: units, positioning mode, plane, feed, spindle, coolant, tool change and compensation, work offsets, tilt and RTCP, and the program-to-machine coordinate chain
PostLogic carries the block's full modal context forward, so every block's data is self-contained
Inspection back-fills derived values, reports words nothing consumed, and optionally snapshots the block
INcSemantic turns the finished block into machine actions — linear and arc motion, teleports, tool change, spindle, dwell, stroke-limit checks

Over 150 syntax units ship across those five stages, plus the per-brand lists that select and order them.

Most syntax units implement ISituNcSyntax — they mutate the block in place. IExpandingNcSyntax exists for a unit that must turn one block into several. Note that the shipped call and repeat syntaxes are not expanders: they splice the callee's already-segmented blocks into the stream ahead of the current position, which keeps one block's identity intact through the rest of the pipeline.

Composition and presets

The runner is a container; its five lists are what make it a Fanuc runner or a Heidenhain one.

<SoftNcRunner>
  <PipelineNcDependencyList>...</PipelineNcDependencyList>
  <Segmenter>...</Segmenter>
  <NcInitializationList>...</NcInitializationList>
  <NcSyntaxList>...</NcSyntaxList>
  <NcSemanticList>...</NcSemanticList>
</SoftNcRunner>

Every unit implements IMakeXmlSource and registers itself with XFactory, so the whole pipeline round-trips through XML. Seven presets are built in:

Preset Reads
FanucNcRunner Fanuc G-code, including Custom Macro B
SiemensNcRunner Sinumerik .mpf / .spf
SyntecNcRunner Syntec G-code
MazakNcRunner Mazak EIA/ISO
HeidenhainNcRunner klartext and DIN/ISO, on one preset
GeneralCsvRunner a CSV controller recording
NxClRunner NX CLSF / APT-source cutter-location files

The five brand presets also ship as standalone files under Resource/Controller/ with the .Controller extension, written by ControllerPresetWriter, so the load browser starts populated. Those files are regenerable snapshots — the static properties above are the source of truth, and the files are rewritten whenever a brand pipeline changes.

Important

Reading a preset file back requires the pipeline types to be registered first (Reg through Reg at startup). The loader drops unregistered entries silently rather than failing the load, so an unregistered process reads a hollow pipeline that parses nothing.

A runner rehydrated from an older saved file keeps the syntax list it was saved with — nothing re-derives a brand's syntax list at read time. To pick up new brand syntaxes, take the current preset or a fresh NcRunnerSuit built from it. Missing system-wired dependencies are back-filled automatically on load; syntaxes are not.

The dataflow

Each block travels the pipeline as a SyntaxPiece carrying a JSON object. Every stage reads some keys, writes some keys, and removes the keys it has consumed. The convention is section plus term: the section key is a semantic name that is the same across brands (Unit, Feedrate, Motion, CoordinateOffset), while the controller's actual keyword lives in the section's Term field so the correspondence with the source text is never lost.

A Fanuc block N162 X-14.696 Y-6.42 Z45.638, after the pipeline (matrices elided):

{
  "IndexNote": {"Symbol":"N","Number":162},
  "Positioning": {"Term":"G90","Mode":"Absolute"},
  "Unit": {"Term":"G21","System":"Metric"},
  "PlaneSelect": {"Term":"G17","Plane":"XY"},
  "Feedrate": {"FeedrateValue":400,"Term":"G94","Unit":"mm/min"},
  "SpindleSpeed": {"SpindleSpeed_rpm":20000,"Direction":"CW"},
  "Coolant": {"IsOn":true,"Mode":"Flood"},
  "ToolChange": {"ToolId":4,"IsChange":false},
  "TiltTransform": {"Term":"G68.2"},
  "EndPointProgramToMcTransform": [
    {"Source":"TiltTransform",          "Mat4d":[ ... ]},
    {"Source":"ToolHeightCompensation", "Mat4d":[ ... ]},
    {"Source":"CoordinateOffset",       "Mat4d":[ ... ]},
    {"Source":"PivotTransform",         "Mat4d":[ ... ]}
  ],
  "ToolHeightCompensation": {"Offset_mm":16,"Term":"G43","OffsetId":4},
  "CoordinateOffset": {"CoordinateId":"G54","Offset_X":72.4,"Offset_Y":-72.4,"Offset_Z":-116.44},
  "ProgramXyz": {"X":-14.696,"Y":-6.42,"Z":45.638},
  "MachineCoordinate": {"X":140.5947,"Y":-78.8200,"Z":-124.4559},
  "MotionState": {"Term":"G01"},
  "MotionEvent": {"Form":"McLinear","IsRapid":false},
  "RadiusCompensation": {"Term":"G40","OffsetId":0,"Radius_mm":0}
}

Three things are worth reading off that block.

Program and machine coordinates are both present. The source states program coordinates; the pipeline keeps them and adds the solved machine coordinates, so a report or a UI can use either.

EndPointProgramToMcTransform flattens the cause chain. The program-to-machine mapping is not one opaque matrix but the ordered list of contributions that built it — tilt, tool height, work offset, pivot — each with its own matrix. When a machine coordinate is not what you expected, this array names which compensation is responsible without re-running anything.

Modal state is complete on every block, even where the source line states none of it, because PostLogic carries the previous block's sections forward. A section the pipeline synthesized rather than read from the source carries an AddedBy marker (ModalCarry or Backfill), so a reader can tell authored data from carried data — see SyntaxStageKeys.

Retention and the freeze

A session retains every executed block for its lifetime, and the live JSON graph costs about 12 KB per line against about 1.6 KB for its compact UTF-8 form — which is what made multi-million-line programs exhaust a client machine. Once a block leaves the executing window its piece is frozen: the graph is replaced by those bytes (Freeze, IsFrozen). On a 25,000-line play that takes session retention from 406 MB to 142 MB. The switch is FreezeExecutedPieces, on by default.

Downstream readers are unaffected — the JsonObject getter re-parses on demand and the encoding is byte-identical to the live form — but the object it returns is a fresh read-only snapshot per call, with no caching and no write-back. Two reads are not reference-equal, a mutation lands on a throwaway copy, and code that reads the same piece repeatedly should hold the snapshot in a local. To inspect the dataflow, snapshot it in the pipeline with the SnapshotSyntax entry each bundle carries (disabled by default) rather than holding pieces and poking them afterwards.

Type discrimination is slightly looser after a round trip, because JSON has fewer types than the live graph: NaN and ±Infinity serialize as quoted strings and thaw as string nodes, and 5.0 freezes as 5, so an integer read of it succeeds where it previously would not. GetDouble maps the quoted non-finite spellings back to their double constants, so read numbers through it rather than through a raw node cast.

Dependencies

Machine and case data reach the syntaxes as a list of INcDependency objects rather than as fields on a shared configuration object. A syntax declares what it needs by interface and pulls it:

// A syntax that needs the machine's home position asks for the interface, not for a class.
var homeConfig = ncDependencyList.OfType<IHomeMcConfig>().FirstOrDefault();

Adding a brand means adding a table that implements the interfaces its syntaxes ask for — the brand parameter tables derive from ControllerParameterTableBase.

Per-case data travels as a proxy. Tool offsets, work-coordinate offsets, Siemens frames, Heidenhain datums and retained macro variables belong to a job, not to a controller configuration. Those entries sit in the pipeline list as placeholders that resolve, per session, against the owning project's per-case list — which is what lets one controller configuration be shared across projects. PipelineNcDependencyList is the raw list; consumers read the resolved view through GetEffectiveNcDependencyList.

NcRunnerSuit bundles a runner with its per-case data as one file-loadable unit, so a whole parser configuration — pipeline and job data together — moves as a single file.

Machine wiring

ConfigureByMachiningChain takes the machining chain and settles what the pipeline needs to know about the physical machine: axis order, which axes are rotary and which linear, and the kinematics the coordinate syntaxes solve against. A five-axis machine, a four-axis machine and a twin-table machine all run the same program path — the difference is the chain, not the parser.

Extending it

Three kinds of customization need no rebuild of the libraries:

Switch brand.

var runner = SoftNcRunner.HeidenhainNcRunner;
runner.ConfigureByMachiningChain(machine.Chain);

Add a syntax for one machine's own vocabulary. A machine whose PLC uses a non-standard M168 for clamping needs a class implementing ISituNcSyntax and one entry in that project's pipeline list. No HiAPIs source changes.

Declare OEM M-codes without writing code at all. IMCodeDeclarationConfig and MCodeEffects let a machine state what its own M-codes do — a composite spindle-and-coolant code, a tool-change trigger, turret T-word semantics — and MCodeExpansionSyntax expands them into the canonical ISO flags the shared consumers already understand. A code declared with no modeled effects is voiced once as DeclaredMCode--UnmodeledEffects instead of raising an unknown-code warning on every occurrence.

Important

Composing the pipeline is a licensed capability. Registering a unit that is not built in — into the syntax list, the dependency list, the semantics, the initializers or the segmenter — and executing an NC-embedded C# script both require the NcComposition licence feature. The check runs once per session at the run entry, so it covers project-XML load, whole-object replacement and direct list mutation alike.

Degradation is silent and functional, not an error: external units are skipped for that session and named in one Composition--NotLicensed diagnostic, an external segmenter falls back to the single-line segmenter, and scripts are skipped with Script--NotLicensed. The runner's persisted lists are never mutated, so the project still saves correctly — but the simulation that ran is a different one. If you build against this surface, check for that diagnostic rather than assuming your unit ran.

Built-in units are unrestricted in order, count, duplication and constructor configuration, and calling the public API from your own application or session script needs no extra licence.

Brand support

Coverage is stated in three states. Recognized but not simulated is a deliberate state, not a gap: the construct is consumed safely and reported with its own diagnostic id, so it can never be misread as something else — a PLANE AXIAL B+45 will not be mistaken for a rotary axis command.

Brand Supported Recognized, not simulated Not supported
Fanuc ISO core, canned cycles G73–G89, G41/G42, G43.4 RTCP, G53/G53.1, G68/G68.2/G69, G12.1/G13.1 polar with compensation, Custom Macro B (# variables, IF/GOTO, WHILE/DO), M98/M99 subprograms, G65/G66/G67 macro calls G10 programmable data setting, G50 spindle limit, G31 skip
Siemens modal vocabulary, SUPA/G153, T="name" with D offsets, $TC_DP tool tables, R-parameters and DEF variables with a full expression evaluator, $P_UIFR, TRANS/ATRANS/ROT/AROT frames, TRAORI/TRAFOOF, CYCLE800, MSG/STOPRE, CR=/TURN= arcs, L/named subprograms, MCALL, REPEAT, PROC/labels, GOTOF/GOTOB, IF/ELSE/ENDIF, WHILE/FOR/REPEAT-UNTIL/LOOP, AC()/IC()/DC()/ACP()/ACN(), the coded-position family, G74/G75 ROTS/AROTS, SCALE/ASCALE, MIRROR/AMIRROR (SiemensFrame--Unsupported) SETAL
Heidenhain klartext motion and FMAX, M91, TOOL CALL with DL/DR, CYCL DEF 247 presets and CYCL DEF 7 additive shifts, CC/C arcs, RL/RR/R0, M126/M127, M140, CYCL DEF 32, Q/QR parameters with the FN grammar and FN 9–12 jumps, PLANE SPATIAL, FUNCTION TCPM, M128/M129, machining cycles 200/232/251/252/253, CYCL CALL/CYCL CALL POS, CALL LBL with REP, CALL PGM, tilde continuation, BLK FORM, STOP, mirror image (G28 and CYCL DEF 8), and the DIN/ISO dialect with absolute I/J/K centres, the ISO label family, G247, G54 datum words and G70/G71 PLANE VECTOR (captured), PLANE EULER / POINTS / RELATIV / AXIAL / PROJECTED (HeidenhainPlane--Unsupported), unimplemented FN opcodes such as FN 18 SYSREAD, unrecognized CYCL DEF bodies (HeidenhainCycl--Unsupported) TOOL DEF, FK free contour, SL cycles, PATTERN DEF, TCH PROBE
Syntec ISO core plus the Fanuc-family macro and subprogram vocabulary, polar interpolation custom G macros, Pr parameter mapping, twin-head / twin-turret syntax
Mazak EIA/ISO with the Fanuc-family macro and subprogram vocabulary, polar interpolation Mazatrol conversational sections, MAZATROL ↔ EIA/ISO switching
Note

On the Heidenhain preset, DIN/ISO G28 is MIRROR IMAGE, not a Fanuc reference-point return, and ReferenceReturnSyntax is not in that preset's Logic list. HardNcRunner keeps the Fanuc reading, so the two engines are deliberately divergent on Heidenhain G28 files.

Loading a HardNc-era project

Projects written for the legacy interpreter still load.

Mechanism Purpose
FromLegacyNcEnvXml builds a SoftNcRunner from a legacy configuration element
the NcEnv XML alias a project saved under the old element name still deserializes
the legacy version patches a project saved by an older build gains the syntaxes and semantics added since, according to the project API version it carries
the system-wired back-fill a saved pipeline gains the runtime-wired dependencies it predates
EnableSoftNcRunner set false to run the legacy interpreter for comparison

The version patches cover projects back to the 3.1.163 era; the back-fill is unconditional, because the dependencies it adds are stateless runtime-wired singletons for which presence is the only question worth asking.