01 — System Overview#
Who this is for. Anyone about to work on Ring for the first time, and anyone returning to it after a while and needing the map again.
What you'll learn. What Ring is and what plant it drives; what the solution actually contains; where each kind of code lives; the exact technology stack; and which controller address means what — because confusing the bench twin with the live plant controller is the single cheapest way to do real damage here.
1.0 Vocabulary — read this if you come from only one side#
Ring sits between two worlds that rarely share a vocabulary. This section defines every term this book uses without further explanation. Skip whichever column you already know.
Controls / PLC terms, for a software engineer#
| Term | What it means here |
|---|---|
| PLC | Programmable Logic Controller — the industrial computer that actually operates the plant. Ring never controls anything directly; it asks the PLC to, and the PLC decides |
| CompactLogix 1769-L36ERM | The specific Allen-Bradley PLC model this plant runs. "L36ERM" and "the controller" mean the same box throughout this book |
| EtherNet/IP | The industrial Ethernet protocol Ring speaks to the controller. Not the same thing as "an ethernet IP address" |
| CIP | Common Industrial Protocol — the request/response layer carried over EtherNet/IP. A "lost CIP reply" is a request that reached the controller whose answer never came back |
| EIP session | A connection the controller holds open for one client. A CompactLogix supports only about four to eight per source IP, which is why Ring staggers its pollers and why running several field scripts at once starves them |
| Tag | A named variable inside the controller (current_step, Tanks[3].Level). Ring reads and writes tags; it has no other way to see or change anything |
| UDT | User-Defined Type — a struct on the controller. Tanks[3] is one UDT element; .Level is a member of it |
| DINT / INT / SINT / REAL / BOOL | Controller data types: 32-bit int, 16-bit int, 8-bit int, 32-bit float, single bit. Reading an INT with a 32-bit read fails; this bites regularly |
| Ladder / ST routine | The program running on the controller. "Ladder logic" is the graphical form; "ST" is Structured Text. Ring does not run it, cannot change it, and must not fight it |
| Scan | One pass of the controller's program. "Cleared within four scans" means the ladder overwrites your value almost immediately |
| Rung | One line of ladder logic. XIC / XIO / OTE / OTU / OTL / ONS / MOV are instructions on a rung. ONS is a one-shot: it fires on a rising edge, so a bit stuck TRUE means it can never fire again |
| Sealed coil | A rung whose output holds itself on until something specific breaks the seal. PC_Write_Integer[30].1 (Hold) drives one — which is why a stuck Hold bit cannot be resumed |
| Momentary pulse | Set a bit TRUE, wait, set it FALSE. The controller acts on the transition. Both halves matter; leaving the bit TRUE is a fault |
| L5K / L5X | Text exports of the controller program. scripts/RS_3000_2024_APR_22.L5K is plaintext and is the math authority; the 2026 running export is source-protected |
| Setpoint | A target value an operator sets (a temperature, a level). Distinct from a command bit |
| HMI | Human-Machine Interface — the operator screen. Ring is an HMI |
| LCP | Local Control Panel — the plant PC that runs Ring |
| Commissioning | Proving on real hardware that a change does what it is supposed to. "Held for commissioning" means the code exists and is deliberately inert |
| TVC | The temperature-control unit family in this plant (TVC[], TVC_Ctrl[], TVC_IO[] tags). One TVC unit can serve more than one tank |
| Doser / use tank | The tanks that dose adhesive to the corrugator, as opposed to storage tanks and the single mix (Make-Ready) tank |
WPF / .NET terms, for a controls engineer#
| Term | What it means here |
|---|---|
| WPF | Windows Presentation Foundation — the Windows UI framework Ring's screens are built with |
| XAML | The markup language that describes a WPF screen. Each .xaml file has a matching .xaml.cs "code-behind" |
| Code-behind | The C# file paired with a XAML file. Most Ring screens do their work here |
| MVVM / ViewModel | A pattern where screen state lives in a plain class (the ViewModel) and the XAML binds to it. Ring uses this for its process screens and plain code-behind elsewhere — see chapter 06 |
Binding / DynamicResource |
Ways XAML pulls a value from somewhere else. A DynamicResource re-reads when the source changes, which is how a language switch repaints live |
| Dispatcher / UI thread | The single thread allowed to touch controls. Blocking it freezes the screen |
DispatcherTimer vs System.Threading.Timer |
The first ticks on the UI thread; the second on a background thread. Ring's PLC pollers deliberately use the second |
| Resource dictionary | A XAML file of shared values — colours, sizes, and every translated string |
| DI container | The object that constructs services and hands them to whoever needs them |
xUnit / [Fact] |
The test framework and the attribute that marks one test |
| csproj (non-SDK) | Ring's project files list every source file by hand. Adding a file without adding its row means it silently does not exist |
| SQLite / WAL | The single-file database Ring keeps its history in, and its write-ahead log |
| libplctag | The open-source library Ring uses to speak EtherNet/IP. Ring adds no protocol code of its own |
1.1 What Ring is#
Ring is a Windows desktop HMI for a corrugator's starch-adhesive glue
kitchen — the plant that cooks and doses the starch adhesive a corrugator
uses to glue board. It replaces ~RS360, a Borland C++ application that ran the
plant for years (README.md); the marketed product name for that lineage is
RS-3000, which is why both names appear in the tree (docs/manual/sales/README.md).
Three properties shape everything else in the codebase:
- It is a plant application, not a records application. A bad write does not corrupt a row, it moves a valve. This is the origin of the whole gate stack in chapter 04.
- It ships read-only.
PlcSettings.ReadOnlyModedefaults totruein C# (Ring/Infrastructure/Configuration/AppSettings.cs:326) and istruein the shippedRing/Config/appsettings.json. The resolverPlcWriteGuard.ResolveConfiguredReadOnlyissettings?.PlcSettings?.ReadOnlyMode ?? true(Ring/Services/PLC/PlcWriteGuard.cs:75-76) — a missing or garbled settings object fails closed. - It is a single process with no server. One EXE, one local SQLite file, one controller. There is no service tier, no message bus, no cloud dependency.
Cutover. The repository is pointed at the 2026-09-08 write-enabled cutover — the day
ReadOnlyModeis intended to be flipped tofalseand Ring begins driving the plant. Wherever this book says "writes are suppressed", that is a statement about the shipped configuration, not a permanent property. The go/no-go gate isdocs/production-readiness/WRITE_ENABLE_READINESS_2026-07-26.md; the procedure isCUTOVER_RUNBOOK.md.
1.2 The plant#
Ring's first site runs it in English, but the plant replaced a Dutch-language
legacy system (README.md) — the controller's alarm text and the imported
recipe/tank vocabulary are Dutch. That heritage matters in code in at least
two places worth knowing about up front:
- Operator-facing text is localized into thirteen languages
(
Ring/Resources/Strings/, thirteenStrings.<code>.xamlfiles), and Dutch is a first-class target, not an afterthought. LocalizationServicedeliberately never touchesCurrentCulture/CurrentUICulture(Ring/Services/Display/LocalizationService.cs:23), so a Dutch UI never changes numeric parsing. That separation is whyPlcTagWriter.TryParseRealInvariant(Ring/Services/PLC/PlcTagWriter.cs:393) exists: undernl-NL, a culture-sensitive parse turns"1.5"into15.0— a tenfold setpoint write.

Tanks — read this carefully, the numbers are role-dependent#
The tank population is configurable at runtime, not compiled in. It lives in
Ring/Services/Roster/TankRoster.cs, persisted as
%LocalAppData%\Ring\tank_roster.json by
Ring/Services/Roster/TankRosterState.cs.
| Fact | Value | Source |
|---|---|---|
| Roles in the roster | Storage, Doser |
Ring/Services/Roster/TankRole.cs |
| Max slots per role | 6 | TankRoster.MaxSlotsPerRole (TankRoster.cs:20) |
| Hard cap on total roster slots | 12 | TankRoster.MaxTotalSlots (TankRoster.cs:23) |
| Shipped fallback roster | 4 storage (PLC 1–4) + 4 dosers (discovery-filled) | TankRoster.Default() (TankRoster.cs:102-115) |
| The 13-tank target preset | 1 mix (not in the roster) + 6 storage + 6 doser | TankRoster.IndianaPreset() (TankRoster.cs:118-139) |
So the widely-quoted "13 tanks (1 mix + 6 storage + 6 doser)" is the target
preset, and the mix tank is deliberately outside the roster — the roster
holds at most twelve slots and the mix / Make-Ready tank is modelled separately
(Ring/Views/MainScreen/MakeReadyTank.xaml,
Ring/ViewModels/MainScreen/MakeReadyTankViewModel.cs). A site that has never
touched Setup → Tank Roster is running the legacy four-storage default. Do not
assume six of anything from a screenshot.
A roster slot (Ring/Services/Roster/TankRosterSlot.cs) carries:
| Member | Meaning |
|---|---|
SlotNumber |
Position within its role's list |
Role |
Storage or Doser |
PlcIndex |
The 1-based controller array index this slot reads and writes; -1 means unmapped (dosers use -1 to mean "fill me from role-byte discovery") |
Enabled |
Disabled slots issue no reads and are never written |
LegacyWindowKey |
Which legacy detail window this slot routes to — "StorageTank1", "StorageTank2", "StorageTank4", "Lowmidtank" |
LegacyWindowKey is under a rename freeze. Its literal string values are
compared StringComparison.Ordinal in
RosterWriteIndex.StoragePlcIndexForLegacyWindow
(Ring/Services/PLC/RosterWriteIndex.cs:235-261), whose return value is the
PLC array index a storage-tank write targets. See
ARCHITECTURE.md for the full
freeze list; chapter 04 explains what happens
when the lookup cannot answer honestly.
1.3 Controllers: which address is which#
| Address | Device | Ring's relationship |
|---|---|---|
172.22.103.10 |
CompactLogix 1769-L36ERM v20.12 | The plant controller Ring reads and (post-cutover) writes |
172.22.103.11 |
PanelView | none |
172.22.103.12 |
MicroLogix | none |
172.22.103.14 |
Legacy RS-360 HMI PC | source of legacy data captures only |
192.168.202.15 |
Bench L36ERM twin | where write behaviour is proven first |
(Table reproduced from docs/PLC_FACTS.md, which cites
the 2026-06-29 field survey.)
Two consequences that catch people:
- Bench evidence does not automatically transfer. Tag parity between the
two controllers is an open question, and it has to be checked per tank —
enabl_agt/enabl_tvcare true for tanks 1, 2, 8 and 9 only, so a tag can exist on both boxes and be inert on one (docs/PLC_FACTS.md). - Nothing in Ring's configuration distinguishes bench from plant. The IP is
one string.
PlcWriteEndpointGuard(Ring/Services/PLC/PlcWriteEndpointGuard.cs) can tell you the endpoint is loopback or unconfigured, but it cannot tell you that you aimed a write at the wrong real controller. That is a procedural control, not a coded one.
The shipped Ring/Config/appsettings.json ships DefaultIpAddress: "" — an
empty IP, not a loopback one. That matters: PlcConnectionConfig.GetPlcIp()
falls back to 127.0.0.1 with a once-per-process warning rather than throwing
(Ring/Infrastructure/Configuration/PlcConnectionConfig.cs:93-121), so the
fall-through is the shipped state. Chapter 04 covers the endpoint gate that
exists precisely because of this.
1.4 Solution layout#
Ring.sln contains exactly two projects — there is no third assembly, no
shared library, no separate test-utility project:
| Project | Kind | Target | Output |
|---|---|---|---|
Ring/Ring.csproj |
WPF app, classic (non-SDK) csproj | v4.7.2, LangVersion 8.0 |
WinExe, AssemblyName Ring |
Ring.Tests/Ring.Tests.csproj |
xUnit test library, classic csproj | v4.7.2 |
Library, AssemblyName Ring.Tests |
(Verified in Ring.sln, Ring/Ring.csproj:8-12, Ring.Tests/Ring.Tests.csproj:8-11.)
Nothing is globbed. A new .cs, .xaml, or image is invisible to the
compiler until a row is hand-added to the csproj — and
Ring.Tests/ProjectMembershipGuardTests.cs fails the build if you forget. See
chapter 08.
1.5 Directory map#
Ring/ the WPF application
Views/ XAML screens + code-behind
App.xaml / App.xaml.cs <ApplicationDefinition>; the real entry point
MainWindow.xaml(.cs) the shell: chrome, content area, nav history,
PLC watchdog tick, alarm pump
MainScreen/ process screens (Make Ready, tank groups, TVC card…)
BatchStart/ the four batch-arming screens + their pure policy types
TVCcontrol/ the four TVC per-tank control windows + bridge
UseTanks/ MF1 + UseTank2 + UseTank3 windows + bridge
Process/ Alarm screen
Reports/ report screens + ReportHostView (the hosted-screen shell)
Setup/ supervisor/admin configuration screens and dialogs
Shifts/ shift control, handover, handoff, production log
Help/ manuals library, alarm legend, contact
Wizard/ first-run setup wizard and its steps
UserControls/ NavBar, FitHost, ScreenHeader, toasts, badges, charts
Dashboard/ DashboardView
ViewModels/ the VM-driven subset (MainScreen/, UseTanks/, chrome VMs)
Services/ everything non-UI (see chapter 07)
PLC/ pollers, snapshots, readers, writers, gates
Alarms/ Audit/ Batch/ Configuration/ Display/ Documentation/ Email/
Export/ GroupSetup/ Notifications/ Operators/ Predictive/ Reports/ Roster/
Database/ DatabaseInitializer + Interfaces/ + Repositories/
Models/ plain data records
Infrastructure/ Configuration/, DependencyInjection/, Exceptions/, Security/
Controls/ NumericKeypad, TankLevelControl, KeypadBehavior
Converters/ value converters
Validation/ attribute + helper validators
Resources/ Colors/Tokens/Components/RingTheme + Strings/ (13 locales) + data/
Images/ screen art and icons (every file hand-registered)
Assets/Manuals/ the operator manual PDF that ships inside the app
Config/appsettings.json the config that ships; copied into the build output
Ring.Tests/ the xUnit suite — the only test project
docs/ see docs/INDEX.md for what is authoritative
manual/engineering/ this book
manual/sales/ the commercial book
reference/plc/ durable tag maps + APPSETTINGS_REFERENCE
production-readiness/ runbooks, controls records, PLC layouts, analysis
archive/ historical evidence — never a current-state authority
scripts/ PowerShell + the two L5K controller exports
tools/ab_server/ bundled libplctag AB simulator (a fake PLC for dev)
remote-view/ MeshCentral-based remote-viewing companion package
plc-discovery/ EtherNet/IP discovery + legacy-extract helpers
.github/workflows/ production-gate.yml + plc-tag-audit.yml
Root-level documents are the ones an on-site team reads:
CUTOVER_RUNBOOK.md,
OPERATOR_TRAINING_RUNBOOK.md,
HYPERCARE_PLAN.md.
1.6 Technology stack#
| Concern | Choice | Notes |
|---|---|---|
| UI | WPF on .NET Framework 4.7.2, C# 8.0 | Ring/Ring.csproj:11-12 |
| Look and feel | MahApps.Metro base styles + Ring's own token dictionaries | merge order pinned in Ring/Views/App.xaml |
| DI | Microsoft.Extensions.DependencyInjection |
composition root is ServiceCollectionExtensions.AddRingServices (Ring/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs) |
| Config | Microsoft.Extensions.Configuration JSON provider |
Ring/Infrastructure/Configuration/ConfigurationService.cs |
| Controller I/O | libplctag 1.5.2 + libplctag.NativeImport |
no custom EtherNet/IP stack; see chapter 03 |
| Persistence | System.Data.SQLite, hand-written ADO |
no ORM, no Dapper; see chapter 05 |
| JSON | Newtonsoft.Json | used by the journals, roster/state files, profile export |
| Tests | xUnit, run by xunit.console.exe |
dotnet test cannot run this suite |
| Packages | packages.config + packages/ with pinned relative HintPaths |
a package upgrade is a csproj edit |
Third-party components and their notices are enumerated in
THIRD-PARTY-NOTICES.md.
Licensing is an open owner decision. There is no
LICENSEfile in this repository and no statement of ownership or redistribution terms (CONTRIBUTING.md). That gap is deliberate — the terms are the owner's call — and this book does not invent one.
1.7 Running it without a plant#
Three distinct mechanisms, often confused:
| Mode | How | What it gives you |
|---|---|---|
| Demo Mode | DemoMode.Enabled = true in <exe dir>\Config\appsettings.json (or the .local.json overlay) + restart. Config only — there is no CLI flag (docs/CONFIG_AND_STARTUP.md §6) |
Synthetic animated plant data from Ring/Services/PLC/DemoModeData.cs; every write short-circuits to a logged no-op |
| Simulated batch loop | scripts/Run-SimDemo.ps1 |
Copies the build output to an isolated directory, writes its own loopback overlay, starts tools/ab_server plus a heartbeat pump, launches Ring with --isolated-sim-harness, drives a mock batch and asserts it persisted. scripts/Stop-SimDemo.ps1 tears it down |
| Synthetic history | scripts/Seed-SmokeTestData.ps1 |
Deterministic batches, usage and alarms in SQLite so every report screen has content (scripts/SMOKE_TEST_README.md) |
--isolated-sim-harness is not a general "quiet mode": it only takes effect
when ReadOnlyMode is true and the configured PLC IP is a loopback
address, so it cannot be used to silence warnings against a real controller
(docs/CONFIG_AND_STARTUP.md §6; the check is IsIsolatedSimulationHarness in
Ring/Views/App.xaml.cs).
Ring is single-instance: a second launch focuses the first and exits
(Ring/Views/App.xaml.cs:21, 113). Chapter 02 explains why that mutex covers
less than it looks like it does.
1.8 Two standing constraints you inherit on day one#
Both are stated in ARCHITECTURE.md, and both will
catch you before you have read that far.
The rename freeze (ARCHITECTURE.md §11)#
Three identifiers are load-bearing as strings. Renaming any of them is a behaviour change wearing a refactor's clothes, and none of them will fail to compile.
| Identifier | Why the string matters | Verified at |
|---|---|---|
TankRosterSlot.LegacyWindowKey values — "StorageTank1", "StorageTank2", "StorageTank4", "Lowmidtank" |
Compared with StringComparison.Ordinal in RosterWriteIndex.StoragePlcIndexForLegacyWindow, whose return value is the PLC array index a storage-tank write targets. Rename one and a write goes to a different physical tank; make two collide and the lookup returns NoWrite. The same strings are hardcoded in the four TVC screens and switched on in NavBar |
Ring/Services/PLC/RosterWriteIndex.cs:250; Ring/Services/Roster/TankRoster.cs:104-113 |
The Ring.Views.BatchStart namespace |
MainWindow.NavigateBack() branches on t.Namespace.StartsWith("Ring.Views.BatchStart", StringComparison.Ordinal) to decide whether Back re-creates a screen or reuses the cached instance. The four Batch Start screens assume a first-time Load; rename the namespace and Back silently switches them to instance reuse |
Ring/Views/MainWindow.xaml.cs:640 |
| Screen class names | UiScreenResolver writes GetType().Name into the tamper-evident UI audit journal, and that value is part of the hashed record payload. Renaming a screen class splits its identity across the rename boundary: old records still verify, but they no longer match live labels |
Ring/Services/Audit/UiScreenResolver.cs |
If you need one of these renames, it is a change with a test and a decision behind it, not a cleanup commit.
Architectural debt that is deferred on purpose (ARCHITECTURE.md §10)#
None of it is on fire, and all of it is parked past the 2026-09-08 cutover by an explicit decision: deep refactors of the PLC layer weeks before a write-enabled cutover trade a real risk for a cosmetic gain. Knowing the list stops you "fixing" something that is a known, scheduled item:
- PLC layer — no
PlcTagFactory/IPlcSessionabstraction (tag construction is duplicated across many sites); write paths returnbool, tuples and several bespoke enums instead of onePlcWriteResult; there is noPlcPollerBase, so the seven pollers repeat timer/backoff/publish logic and the timers outside the coordinator stay outside it; there is no single tag-name builder and noPlcConnectionConfigcache;WriteTimeoutis wired butReadTimeoutis what most readers resolve. - UI — per-tank screen family consolidation (UseTanks → TVC → BatchStart),
with three ordered prerequisites;
StorageTankGroupViewModel's half-finished migration; a Dashboard hardcoded around the legacy tank count. - Cross-cutting —
ServiceLocatorretirement and ambient-state → DI conversion; alarm operator text is English-only in SQLite (a schema change, not a dictionary edit); C#-composed text does not live-repaint on a language change; and a git history rewrite that is post-cutover only because it would force a rebase of every worktree days before the plant goes live.
The forward-looking list is
docs/production-readiness/POST_CUTOVER_FOLLOWUPS.md.
Next: 02 — Runtime Architecture.
Verified against#
Every claim in this chapter was read out of these files on 2026-09-01:
README.md · ARCHITECTURE.md · CLAUDE.md · CONTRIBUTING.md ·
Ring.sln · Ring/Ring.csproj · Ring.Tests/Ring.Tests.csproj ·
Ring/Infrastructure/Configuration/AppSettings.cs ·
Ring/Infrastructure/Configuration/PlcConnectionConfig.cs ·
Ring/Config/appsettings.json ·
Ring/Services/PLC/PlcWriteGuard.cs · Ring/Services/PLC/PlcTagWriter.cs ·
Ring/Services/PLC/RosterWriteIndex.cs ·
Ring/Services/Roster/TankRoster.cs · Ring/Services/Roster/TankRosterSlot.cs ·
Ring/Services/Roster/TankRole.cs ·
Ring/Services/Display/LocalizationService.cs ·
Ring/Views/MainWindow.xaml.cs · Ring/Views/App.xaml.cs ·
Ring/Resources/Strings/ (directory listing) · docs/PLC_FACTS.md ·
docs/CONFIG_AND_STARTUP.md · THIRD-PARTY-NOTICES.md · scripts/ (listing)