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.