You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@plc4x.apache.org by sr...@apache.org on 2026/09/07 10:55:35 UTC

(plc4x-extras) 02/02: feat: Analyse ten more protocols, against their own reference vectors

This is an automated email from the ASF dual-hosted git repository.

sruehl pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/plc4x-extras.git

commit acb7ceafdef5bd4315e5186eadf7d7f84db69a61
Author: Sebastian Rühl <sr...@apache.org>
AuthorDate: Mon Sep 7 12:27:14 2026 +0200

    feat: Analyse ten more protocols, against their own reference vectors
    
    The analyzer knew two protocols. It now knows twelve, and the browser's
    vocabulary is the same vocabulary: a user who connected to a device with
    "modbus-tcp" no longer has to learn a second spelling to analyse its
    traffic. bacnet-ip is accepted as an alias for that reason, and logix
    for eip, whose driver has no model of its own.
    
    internal/codec holds the ten new ones. Three things vary between them
    and all three are mechanical -- the parse function, the byte order its
    buffers use, and the BPF filter that selects its packets -- so adding a
    protocol is a data change rather than another branch in a switch. BACnet
    and C-Bus stay outside it: C-Bus tracks request context across a session
    and BACnet has an adapter that predates this, and bending either into
    this shape would be pretending they are simpler than they are.
    
    The support is verified rather than merely compiled, which is the whole
    point. internal/pcapfixture now carries the reference vectors from each
    protocol's own ParserSerializerTestsuite in the plc4x repository -- 94
    payloads across ten protocols -- which plc4x asserts round-trip. That is
    exactly the property this tool measures, so a capture built from them
    must come back completely clean, and a test requires that for every
    protocol. A failure means an adapter here is wrong or plc4x has
    regressed.
    
    The vectors also settled two questions I had guessed wrong. S7's
    top-level PDU is TPKTPacket, so S7 is the same trivial shape as the rest
    rather than needing a framing layer. And ADS wants AmsTCPPacket, the
    TCP-framed variant that is what actually appears on a wire.
    
    Byte order is in the codec because it is not decoration. EtherNet/IP is
    little-endian, and parsed through the default big-endian buffer not one
    of its twenty-seven vectors survives -- they fail at the first field. My
    first probe scored eip 0/27 and everything else full marks; wired up
    without that, this would have shipped a tool reporting that plc4x cannot
    parse its own protocol. ADS and SLMP declare little-endian too but
    round-trip either way, because their generated code sets the order per
    field. None of that is inferable from a protocol name.
    
    Each fixture packet's direction is the response argument its test case
    declares, not a guess from its name, because several of these protocols
    encode the two directions differently and the analyzer derives direction
    from the client address. A packet on the wrong side of the wire is read
    the wrong way round, and the analyzer now warns when a protocol needs a
    direction and no client address was given -- one line beats a capture's
    worth of parse failures.
    
    The interface dispatches through the same registry, so it and the
    command line cannot come to support different sets, and it now applies
    the protocol's default filter when the user gives none. The help lists
    the protocols from the registry rather than from a sentence that said
    "bacnetip or c-bus" and would have kept saying it.
    
    Still not analysable, with reasons rather than silence: OPC UA has no
    wire-level PDU in its suite, so an adapter could be written but not
    verified; UMAS needs stateful request-function correlation; IEC
    60870-5-104 has only a driver test suite, in a different shape.
    
    Four mutations checked: eip's byte order, an inverted direction, a wrong
    default filter, and Modbus RTU framed as TCP. Each fails the tests.
---
 plc4go/tools/README.md                             |  57 +++++-
 plc4go/tools/plc4xpcapanalyzer/cmd/analyze.go      |  11 +-
 plc4go/tools/plc4xpcapanalyzer/cmd/extract.go      |   6 +-
 plc4go/tools/plc4xpcapanalyzer/cmd/root.go         |   4 +-
 .../internal/analyzer/analyzer.go                  |  49 ++++-
 .../internal/analyzer/protocols_test.go            | 171 ++++++++++++++++
 .../plc4xpcapanalyzer/internal/codec/codec.go      | 180 +++++++++++++++++
 .../internal/pcapfixture/pcapfixture.go            |  62 ++++++
 .../internal/pcapfixture/vectors.go                | 221 +++++++++++++++++++++
 .../internal/protocol/protocol.go                  |  42 +++-
 .../internal/protocol/protocol_test.go             |  11 +-
 plc4go/tools/plc4xpcapanalyzer/ui/analysis.go      |  40 +++-
 12 files changed, 830 insertions(+), 24 deletions(-)

diff --git a/plc4go/tools/README.md b/plc4go/tools/README.md
index 79098e46..2a1633b4 100644
--- a/plc4go/tools/README.md
+++ b/plc4go/tools/README.md
@@ -58,9 +58,39 @@ plc4xpcapanalyzer extract <protocol> <capture>   # dump application payloads
 plc4xpcapanalyzer ui [capture]                   # the terminal interface
 ```
 
-The analyzer handles `bacnetip` and `c-bus`. `bacnet` and `cbus` are accepted as aliases, and
-every layer resolves names through one registry so the command line, the analyzer, the
-extractor and the interface cannot disagree about them.
+The analyzer handles twelve protocols, and `plc4xpcapanalyzer analyze --help` lists them with
+their aliases -- generated from the registry, so it cannot go stale the way a hand-written list
+does. Every layer resolves names through that one registry, so the command line, the analyzer,
+the extractor and the interface cannot disagree about them, and the names are the same driver
+codes the browser uses: a user who connected with `modbus-tcp` does not have to learn a second
+spelling to analyse its traffic.
+
+Ten of them are a codec plus a filter, held in `internal/codec`: the parse function, the byte
+order its buffers use, and the BPF expression that selects its packets. Adding one is a data
+change. BACnet and C-Bus stay outside it, because they need more -- C-Bus tracks request
+context across a session, and BACnet has an adapter that predates this.
+
+Three of the twelve are serial protocols -- Modbus RTU, Modbus ASCII, Firmata -- so they only
+reach a capture through a gateway, and analysing one means passing `--filter` for whichever port
+that gateway uses. They are supported because the framing is the same wherever it is carried,
+not because a serial line can be captured.
+
+Two protocols the browser drives are **not** analysable yet, and the reasons differ. **OPC UA**
+has no wire-level PDU in its reference suite, so an adapter could be written but not verified
+against anything; that one wants a real capture. **UMAS** needs a request-function key carried
+from a request to its response -- stateful correlation, like C-Bus -- rather than a codec.
+**IEC 60870-5-104** has only a driver test suite rather than a parser-serializer one, so its
+vectors are in a different shape and want separate work.
+
+### Why the byte order is in the codec
+
+Because it is not decoration. EtherNet/IP is little-endian, and parsed through the default
+big-endian buffer **not one** of Apache's twenty-seven EtherNet/IP reference vectors survives
+the round trip -- they fail at the first field. Wired up without it, the analyzer would have
+reported that plc4x cannot parse its own protocol. ADS and SLMP declare little-endian too but
+round-trip either way, because their generated code sets the order per field; EtherNet/IP's
+expects it on the buffer. Nothing here is inferable from the protocol name, which is why each
+codec states it and a test proves it.
 
 `--demo` needs no capture and no hardware: it generates a small C-Bus capture of ten packets,
 eight of which round-trip, analyses that, and removes it afterwards. It works on `analyze`,
@@ -304,6 +334,7 @@ tools/
       analyzer/     the parse, reserialize and compare loop
       extractor/    payload extraction
       pcaphandler/  libpcap access
+      codec/        the parse-and-reserialize pair for each protocol, with its byte order and filter
       pcapfixture/  small deterministic captures from real protocol bytes
       common/       shared packet types
     ui/           the terminal interface
@@ -375,10 +406,22 @@ injected clocks wherever time is observable. The terminal interfaces are driven
 typed messages to `Update` and asserting on the rendered output, which needs no terminal;
 `teatest` covers the end-to-end path.
 
-Protocol tests use real bytes. `internal/pcapfixture` builds small captures from sequences
-taken from plc4x's own protocol tests, each verified to parse and re-serialize
-byte-identically, plus sequences verified *not* to parse so the failure counters can be
-exercised. Captures are written with the pure-Go `pcapgo` writer, so building a fixture needs no
+Protocol tests use real bytes, and not bytes anyone here invented. `internal/pcapfixture`
+carries the reference vectors from each protocol's own `ParserSerializerTestsuite` in the Apache
+PLC4X repository -- ninety-four payloads across ten protocols -- which plc4x asserts round-trip:
+parse the raw bytes, serialize the result, get the same bytes back. That is precisely the
+property the analyzer measures, so a capture built from them is one the analyzer must report as
+entirely clean, and `TestEveryProtocolRoundTripsItsOwnReferenceVectors` requires exactly that.
+A failure there means either an adapter here is wrong or plc4x has regressed, which are the only
+two things worth knowing.
+
+The direction of each fixture packet is the `response` parser argument its test case declares,
+not a guess from its name: several protocols encode the two directions differently and the
+analyzer derives the direction from the client address, so a packet on the wrong side of the
+wire is read the wrong way round. The vectors were generated from those suites and committed,
+so the tests stay hermetic rather than depending on a plc4x checkout beside this one.
+
+There are also sequences verified *not* to parse, so the failure counters can be exercised. Captures are written with the pure-Go `pcapgo` writer, so building a fixture needs no
 cgo even though reading one back does, and with a fixed timestamp so they are byte-stable.
 
 ## Development
diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/analyze.go b/plc4go/tools/plc4xpcapanalyzer/cmd/analyze.go
index db698b92..bb61b860 100644
--- a/plc4go/tools/plc4xpcapanalyzer/cmd/analyze.go
+++ b/plc4go/tools/plc4xpcapanalyzer/cmd/analyze.go
@@ -48,10 +48,15 @@ A packet the protocol itself says is not a whole message -- a split transmission
 packet, an echo -- is skipped rather than failed. A skip is not a defect and is not counted as
 one.
 
-The protocol is bacnetip or c-bus; bacnet and cbus are accepted as aliases. The bacnet and c-bus
-subcommands do the same job with the protocol fixed and their own filter flags available.
+An interrupt stops the run and keeps the counts gathered so far.
 
-An interrupt stops the run and keeps the counts gathered so far.`,
+The protocols, with their aliases:
+
+` + protocol.Catalogue() + `
+The bacnet and c-bus subcommands do the same job with the protocol fixed and their own flags
+available. Three of these are serial protocols -- Modbus RTU, Modbus ASCII and Firmata -- so
+they only reach a capture through a gateway, and analysing one means passing --filter for
+whichever port that gateway uses.`,
 	Args: func(cmd *cobra.Command, args []string) error {
 		if demoRequested() {
 			// The demo supplies the capture, and for the generic commands the protocol too.
diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/extract.go b/plc4go/tools/plc4xpcapanalyzer/cmd/extract.go
index 5e09f5bf..f3c809b0 100644
--- a/plc4go/tools/plc4xpcapanalyzer/cmd/extract.go
+++ b/plc4go/tools/plc4xpcapanalyzer/cmd/extract.go
@@ -42,9 +42,11 @@ payloads or the codec are at fault.
 The payloads are only printed at verbosity two or above, so pass -vv. Without it the command
 walks the capture and prints nothing, which looks like a failure and is not one.
 
-The protocol is bacnetip or c-bus; bacnet and cbus are accepted as aliases.
+An interrupt stops the run.
 
-An interrupt stops the run.`,
+The protocols, with their aliases:
+
+` + protocol.Catalogue() + ``,
 	Args: func(cmd *cobra.Command, args []string) error {
 		if demoRequested() {
 			// The demo supplies the capture, and for the generic commands the protocol too.
diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/root.go b/plc4go/tools/plc4xpcapanalyzer/cmd/root.go
index 1d9dc1b5..1e96aa1d 100644
--- a/plc4go/tools/plc4xpcapanalyzer/cmd/root.go
+++ b/plc4go/tools/plc4xpcapanalyzer/cmd/root.go
@@ -52,8 +52,8 @@ for the codecs themselves.
   plc4xpcapanalyzer extract <protocol> <capture>   dump the application payloads
   plc4xpcapanalyzer ui [capture]                   the terminal interface
 
-The protocols are bacnetip and c-bus; bacnet and cbus are accepted as aliases. Reading a capture
-needs libpcap, because it goes through gopacket/pcap.
+Reading a capture needs libpcap, because it goes through gopacket/pcap. Run
+"plc4xpcapanalyzer analyze --help" for the protocols it understands.
 
 With no capture to hand, "ui --demo" generates one and analyses it.`,
 	// Uncomment the following line if your bare application
diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer.go b/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer.go
index 9c1fbec2..91d351a9 100644
--- a/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer.go
+++ b/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer.go
@@ -40,6 +40,7 @@ import (
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/bacnetanalyzer"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/cbusanalyzer"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/codec"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/common"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/finding"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcaphandler"
@@ -195,10 +196,38 @@ func AnalyzeWithOptions(ctx context.Context, pcapFile, protocolType string, opti
 			log.Info().Msg("Custom mapping disabled")
 		}
 	default:
-		// Unreachable: Resolve only returns protocols the registry knows. Kept so that adding a
-		// protocol to the registry without adding a branch here fails loudly instead of silently
-		// analysing nothing.
-		return errors.Errorf("protocol %s is registered but not implemented by the analyzer", proto.Name)
+		// Every other protocol is a codec plus a filter, which is what internal/codec holds.
+		// The two branches above are the exceptions rather than the pattern: C-Bus tracks
+		// request context across a session and BACnet has an adapter of its own.
+		protocolCodec, known := codec.For(proto.Name)
+		if !known {
+			// Reachable only by adding a protocol to the name registry and nowhere else, which
+			// has to fail loudly rather than silently analysing nothing.
+			return errors.Errorf("protocol %s is registered but not implemented by the analyzer", proto.Name)
+		}
+		if !config.AnalyzeConfigInstance.NoFilter {
+			if config.AnalyzeConfigInstance.Filter == "" && protocolCodec.DefaultFilter != "" {
+				filterExpression = protocolCodec.DefaultFilter
+				log.Debug().Str("filter", filterExpression).Str("protocol", proto.Name).
+					Msg("Using the protocol's default filter")
+			}
+		} else {
+			log.Info().Msg("All filtering disabled")
+		}
+		// A protocol whose two directions are encoded differently cannot be read at all without
+		// knowing which way a packet went, and the only thing that says so is the client
+		// address. Saying so once here is worth more than a capture's worth of parse failures.
+		client := net.ParseIP(config.AnalyzeConfigInstance.Client)
+		if protocolCodec.NeedsDirection && client == nil {
+			log.Warn().Str("protocol", proto.Name).
+				Msg("protocol encodes requests and responses differently: without -c <client ip> every packet is read as a request")
+		}
+		packageParse = func(info common.PacketInformation, payload []byte) (spi.Message, error) {
+			return protocolCodec.Parse(ctx, payload, isResponse(info, client))
+		}
+		serializePackage = func(message spi.Message) ([]byte, error) {
+			return protocolCodec.Serialize(ctx, message)
+		}
 	}
 
 	log.Info().
@@ -397,6 +426,18 @@ func AnalyzeWithOptions(ctx context.Context, pcapFile, protocolType string, opti
 	return nil
 }
 
+// isResponse reports whether a packet travelled from the device to the client.
+//
+// Unknown when there is no client address, and "request" is the honest default then: it is what
+// the protocols' own reference vectors mostly are, and guessing the other way would make a
+// capture look worse than it is.
+func isResponse(info common.PacketInformation, client net.IP) bool {
+	if client == nil || info.SrcIp == nil {
+		return false
+	}
+	return !info.SrcIp.Equal(client)
+}
+
 func createPacketInformation(pcapFile string, packet gopacket.Packet, timestampToIndexMap map[time.Time]int) common.PacketInformation {
 	packetTimestamp := packet.Metadata().Timestamp
 	realPacketNumber := timestampToIndexMap[packetTimestamp]
diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/protocols_test.go b/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/protocols_test.go
new file mode 100644
index 00000000..fb8f34c9
--- /dev/null
+++ b/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/protocols_test.go
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package analyzer_test
+
+import (
+	"context"
+	"io"
+	"path/filepath"
+	"slices"
+	"testing"
+
+	"github.com/rs/zerolog"
+	"github.com/rs/zerolog/log"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/analyzer"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/codec"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/finding"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol"
+)
+
+// Every protocol, against its own reference vectors.
+//
+// The payloads come from each protocol's ParserSerializerTestsuite in the Apache PLC4X repo,
+// which plc4x asserts round-trips. So the analyzer -- whose entire job is to measure that
+// property -- has to report a capture built from them as completely clean. Anything else means
+// the adapter here is wrong, or plc4x has regressed, and both are worth failing a build over.
+//
+// This is what makes the support real rather than merely compiled. It is also why the byte
+// order is in the codec: parsed through the default big-endian buffer, not one of EtherNet/IP's
+// twenty-seven vectors survives, which would have shipped as "the codec cannot parse its own
+// protocol".
+
+// protocolCase is one protocol's fixture.
+type protocolCase struct {
+	protocol string
+	wire     pcapfixture.Wire
+	packets  []pcapfixture.Packet
+}
+
+func protocolCases() []protocolCase {
+	return []protocolCase{
+		{protocol.AbEth.Name, pcapfixture.AbEthWire, pcapfixture.AbEthSession()},
+		{protocol.Ads.Name, pcapfixture.AdsWire, pcapfixture.AdsSession()},
+		{protocol.Eip.Name, pcapfixture.EipWire, pcapfixture.EipSession()},
+		{protocol.Firmata.Name, pcapfixture.FirmataWire, pcapfixture.FirmataSession()},
+		{protocol.KnxNetIp.Name, pcapfixture.KnxWire, pcapfixture.KnxSession()},
+		{protocol.ModbusTcp.Name, pcapfixture.ModbusWire, pcapfixture.ModbusSession()},
+		{protocol.ModbusAscii.Name, pcapfixture.ModbusAsciiWire, pcapfixture.ModbusAsciiSession()},
+		{protocol.ModbusRtu.Name, pcapfixture.ModbusRtuWire, pcapfixture.ModbusRtuSession()},
+		{protocol.S7.Name, pcapfixture.S7Wire, pcapfixture.S7Session()},
+		{protocol.Slmp.Name, pcapfixture.SlmpWire, pcapfixture.SlmpSession()},
+	}
+}
+
+// TestEveryProtocolRoundTripsItsOwnReferenceVectors is the test that makes the support real.
+func TestEveryProtocolRoundTripsItsOwnReferenceVectors(t *testing.T) {
+	for _, test := range protocolCases() {
+		t.Run(test.protocol, func(t *testing.T) {
+			require.NotEmpty(t, test.packets, "a protocol with no vectors proves nothing")
+
+			capture := filepath.Join(t.TempDir(), test.protocol+".pcap")
+			require.NoError(t, pcapfixture.Write(capture, test.wire, test.packets))
+
+			findings, counters := analyseFixture(t, capture, test.protocol)
+
+			require.Len(t, findings, len(test.packets),
+				"every packet in the capture has to be examined; the default filter may be dropping them")
+			assert.Equal(t, len(test.packets), counters.Walked)
+			assert.Zero(t, counters.Issues(),
+				"these payloads round-trip by construction, so any finding is a defect here or in plc4x")
+
+			for i, found := range findings {
+				assert.Equal(t, finding.VerdictOK, found.Verdict,
+					"%s packet %d (%s): %s", test.protocol, i+1, test.packets[i].Comment, found.Reason)
+				assert.NotEmpty(t, found.Summary, "a parsed message has to name its type")
+				assert.Equal(t, test.packets[i].Payload, found.Original)
+				assert.Equal(t, test.packets[i].Payload, found.Reserialized,
+					"the whole point: what the codec wrote back has to be what it read")
+			}
+		})
+	}
+}
+
+// TestEveryCodecIsRegisteredUnderAKnownName holds the two registries to each other. A codec
+// under a name the CLI does not accept is unreachable; a name with no codec is a promise the
+// analyzer cannot keep, and the switch's default says so at runtime rather than at build time.
+func TestEveryCodecIsRegisteredUnderAKnownName(t *testing.T) {
+	for _, name := range codec.Names() {
+		resolved, err := protocol.Resolve(name)
+		require.NoError(t, err, "codec %q is not a protocol the command line accepts", name)
+		assert.Equal(t, name, resolved.Name, "a codec has to be keyed by the canonical name")
+	}
+
+	// And the other direction: every name the CLI offers is analysable, by a codec or by one of
+	// the two adapters that predate this package.
+	adapters := []string{protocol.BacnetIP.Name, protocol.CBus.Name}
+	for _, known := range protocol.All() {
+		_, hasCodec := codec.For(known.Name)
+		assert.True(t, hasCodec || slices.Contains(adapters, known.Name),
+			"%s is offered by the command line but nothing can analyse it", known.Name)
+	}
+}
+
+// TestEveryProtocolCaseIsCovered guards the fixture list against drifting from the registry --
+// a protocol added to the codec registry without vectors would be untested support, which is
+// the thing this whole exercise exists to avoid.
+func TestEveryProtocolCaseIsCovered(t *testing.T) {
+	covered := make([]string, 0, len(protocolCases()))
+	for _, test := range protocolCases() {
+		covered = append(covered, test.protocol)
+	}
+	for _, name := range codec.Names() {
+		assert.Contains(t, covered, name,
+			"%s has a codec but no reference vectors, so nothing shows it works", name)
+	}
+}
+
+// analyseFixture runs the analyzer over a capture and returns what it found.
+func analyseFixture(t *testing.T, capture, protocolName string) ([]finding.Finding, finding.Counters) {
+	t.Helper()
+
+	savedRoot, savedPcap := config.RootConfigInstance, config.PcapConfigInstance
+	savedAnalyze := config.AnalyzeConfigInstance
+	savedLogger, savedLevel := log.Logger, zerolog.GlobalLevel()
+	t.Cleanup(func() {
+		config.RootConfigInstance, config.PcapConfigInstance = savedRoot, savedPcap
+		config.AnalyzeConfigInstance = savedAnalyze
+		log.Logger = savedLogger
+		zerolog.SetGlobalLevel(savedLevel)
+	})
+
+	config.RootConfigInstance.HideProgressBar = true
+	// The vectors' own client address, so the protocols that encode the two directions
+	// differently are read the right way round.
+	config.AnalyzeConfigInstance.Client = pcapfixture.ClientIP
+	// No flags are parsed here, so the limit is its zero value and the loop would stop after
+	// the first packet.
+	config.PcapConfigInstance.PackageNumberLimit = ^uint(0)
+	log.Logger = zerolog.New(io.Discard)
+	zerolog.SetGlobalLevel(zerolog.Disabled)
+
+	var findings []finding.Finding
+	var counters finding.Counters
+	err := analyzer.AnalyzeWithOptions(context.Background(), capture, protocolName, analyzer.Options{
+		OnFinding:  func(one finding.Finding) { findings = append(findings, one) },
+		OnCounters: func(all finding.Counters) { counters = all },
+	})
+	require.NoError(t, err)
+	return findings, counters
+}
diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/codec/codec.go b/plc4go/tools/plc4xpcapanalyzer/internal/codec/codec.go
new file mode 100644
index 00000000..2547adfa
--- /dev/null
+++ b/plc4go/tools/plc4xpcapanalyzer/internal/codec/codec.go
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Package codec is the parse-and-re-serialize pair for each protocol the analyzer understands.
+//
+// It exists so that supporting a protocol is a data change rather than another branch. The
+// analyzer used to name its two protocols in a switch, and the comment on that switch's default
+// said as much: adding one to the name registry without adding a branch had to fail loudly,
+// because there was nowhere else for the knowledge to live.
+//
+// Three things vary between protocols and all three are mechanical, which is why this works:
+// the parse function, the byte order its buffers use, and the BPF filter that selects its
+// packets. Two protocols need more than that -- C-Bus tracks request context across a session
+// and BACnet has an adapter of its own -- and those stay where they are rather than being bent
+// into this shape.
+package codec
+
+import (
+	"context"
+	"encoding/binary"
+
+	"github.com/pkg/errors"
+
+	"github.com/apache/plc4x/plc4go/spi"
+	"github.com/apache/plc4x/plc4go/spi/utils"
+
+	abethModel "github.com/apache/plc4x/plc4go/protocols/abeth/readwrite/model"
+	adsModel "github.com/apache/plc4x/plc4go/protocols/ads/readwrite/model"
+	eipModel "github.com/apache/plc4x/plc4go/protocols/eip/readwrite/model"
+	firmataModel "github.com/apache/plc4x/plc4go/protocols/firmata/readwrite/model"
+	knxModel "github.com/apache/plc4x/plc4go/protocols/knxnetip/readwrite/model"
+	modbusModel "github.com/apache/plc4x/plc4go/protocols/modbus/readwrite/model"
+	s7Model "github.com/apache/plc4x/plc4go/protocols/s7/readwrite/model"
+	slmpModel "github.com/apache/plc4x/plc4go/protocols/slmp/readwrite/model"
+)
+
+// parseFunc reads one message from a buffer. response says the payload travelled from the
+// device to the client, which several protocols need because they encode the two directions
+// differently -- and which is why the analyzer wants a client address.
+type parseFunc func(ctx context.Context, buffer utils.ReadBufferByteBased, response bool) (spi.Message, error)
+
+// Codec is what the analyzer needs in order to examine one protocol.
+type Codec struct {
+	// ByteOrder is the order this protocol's buffers read and write in.
+	//
+	// It is not decoration. EtherNet/IP is little-endian, and parsed through the default
+	// big-endian buffer not one of Apache's twenty-seven reference vectors survives the round
+	// trip -- they fail at the first field, which reads as a codec that cannot parse its own
+	// protocol rather than as a buffer configured wrongly.
+	ByteOrder binary.ByteOrder
+	// DefaultFilter selects this protocol's packets when the user gives no filter of their own.
+	DefaultFilter string
+	// NeedsDirection says the parse depends on which way the packet travelled, so a capture
+	// analysed without a client address will be read wrongly rather than not at all.
+	NeedsDirection bool
+
+	parse parseFunc
+}
+
+// Parse reads one message from a payload.
+func (c Codec) Parse(ctx context.Context, payload []byte, response bool) (spi.Message, error) {
+	buffer := utils.NewReadBufferByteBased(payload, utils.WithByteOrderForReadBufferByteBased(c.ByteOrder))
+	message, err := c.parse(ctx, buffer, response)
+	if err != nil {
+		return nil, err
+	}
+	return message, nil
+}
+
+// Serialize writes a message back out, in the same byte order it was read in.
+func (c Codec) Serialize(ctx context.Context, message spi.Message) ([]byte, error) {
+	buffer := utils.NewWriteBufferByteBased(utils.WithByteOrderForByteBasedBuffer(c.ByteOrder))
+	if err := message.SerializeWithWriteBuffer(ctx, buffer); err != nil {
+		return nil, errors.Wrap(err, "error serializing")
+	}
+	return buffer.GetBytes(), nil
+}
+
+// codecs are the protocols this package can examine, by canonical protocol name.
+//
+// The filters are the protocols' registered ports. A protocol carried somewhere else -- Modbus
+// RTU tunnelled over TCP, say -- needs the user's own --filter, which is why the serial ones
+// are here at all: their transport is not IP, so they only ever reach a capture through a
+// gateway, and only the user knows which port that gateway uses.
+var codecs = map[string]Codec{
+	"modbus-tcp": {
+		ByteOrder: binary.BigEndian, DefaultFilter: "tcp port 502", NeedsDirection: true,
+		parse: modbusParse(modbusModel.DriverType_MODBUS_TCP),
+	},
+	"modbus-rtu": {
+		ByteOrder: binary.BigEndian, DefaultFilter: "tcp port 502", NeedsDirection: true,
+		parse: modbusParse(modbusModel.DriverType_MODBUS_RTU),
+	},
+	"modbus-ascii": {
+		ByteOrder: binary.BigEndian, DefaultFilter: "tcp port 502", NeedsDirection: true,
+		parse: modbusParse(modbusModel.DriverType_MODBUS_ASCII),
+	},
+	"s7": {
+		ByteOrder: binary.BigEndian, DefaultFilter: "tcp port 102",
+		parse: func(ctx context.Context, buffer utils.ReadBufferByteBased, _ bool) (spi.Message, error) {
+			return s7Model.TPKTPacketParseWithBuffer(ctx, buffer)
+		},
+	},
+	"eip": {
+		ByteOrder: binary.LittleEndian, DefaultFilter: "tcp port 44818", NeedsDirection: true,
+		parse: func(ctx context.Context, buffer utils.ReadBufferByteBased, response bool) (spi.Message, error) {
+			return eipModel.EipPacketParseWithBuffer[eipModel.EipPacket](ctx, buffer, response)
+		},
+	},
+	"knxnet-ip": {
+		ByteOrder: binary.BigEndian, DefaultFilter: "udp port 3671",
+		parse: func(ctx context.Context, buffer utils.ReadBufferByteBased, _ bool) (spi.Message, error) {
+			return knxModel.KnxNetIpMessageParseWithBuffer[knxModel.KnxNetIpMessage](ctx, buffer)
+		},
+	},
+	"ads": {
+		ByteOrder: binary.LittleEndian, DefaultFilter: "tcp port 48898",
+		parse: func(ctx context.Context, buffer utils.ReadBufferByteBased, _ bool) (spi.Message, error) {
+			return adsModel.AmsTCPPacketParseWithBuffer(ctx, buffer)
+		},
+	},
+	"ab-eth": {
+		ByteOrder: binary.BigEndian, DefaultFilter: "tcp port 2222",
+		parse: func(ctx context.Context, buffer utils.ReadBufferByteBased, _ bool) (spi.Message, error) {
+			return abethModel.CIPEncapsulationPacketParseWithBuffer[abethModel.CIPEncapsulationPacket](ctx, buffer)
+		},
+	},
+	"slmp": {
+		ByteOrder: binary.LittleEndian, DefaultFilter: "tcp port 5007",
+		parse: func(ctx context.Context, buffer utils.ReadBufferByteBased, _ bool) (spi.Message, error) {
+			return slmpModel.SlmpMessageParseWithBuffer[slmpModel.SlmpMessage](ctx, buffer)
+		},
+	},
+	"firmata": {
+		ByteOrder: binary.BigEndian, DefaultFilter: "tcp port 3030", NeedsDirection: true,
+		parse: func(ctx context.Context, buffer utils.ReadBufferByteBased, response bool) (spi.Message, error) {
+			return firmataModel.FirmataMessageParseWithBuffer[firmataModel.FirmataMessage](ctx, buffer, response)
+		},
+	},
+}
+
+// modbusParse is the Modbus parse for one of its three framings. The three differ only in the
+// driver type they are told, which is what makes one entry serve TCP, RTU and ASCII.
+func modbusParse(driver modbusModel.DriverType) parseFunc {
+	return func(ctx context.Context, buffer utils.ReadBufferByteBased, response bool) (spi.Message, error) {
+		return modbusModel.ModbusADUParseWithBuffer[modbusModel.ModbusADU](ctx, buffer, driver, response)
+	}
+}
+
+// For returns the codec for a canonical protocol name.
+func For(name string) (Codec, bool) {
+	found, ok := codecs[name]
+	return found, ok
+}
+
+// Names lists the protocols this package can examine, for a test that wants to hold the
+// registry and the name registry to each other.
+func Names() []string {
+	names := make([]string, 0, len(codecs))
+	for name := range codecs {
+		names = append(names, name)
+	}
+	return names
+}
diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/pcapfixture.go b/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/pcapfixture.go
index d25664ba..767bfa69 100644
--- a/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/pcapfixture.go
+++ b/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/pcapfixture.go
@@ -60,8 +60,41 @@ const (
 	CBusPort = 10001
 	// BacnetPort is the UDP port BACnet/IP runs on.
 	BacnetPort = 47808
+	// The registered ports of the other protocols, which are also the ports their default
+	// filters select. A fixture has to be written on the port the analyzer will look for.
+	ModbusPort = 502
+	S7Port     = 102
+	EipPort    = 44818
+	KnxPort    = 3671
+	AdsPort    = 48898
+	AbEthPort  = 2222
+	SlmpPort   = 5007
+	// FirmataPort is not registered: Firmata is a serial protocol, so it only reaches a capture
+	// through a gateway. This is the port the fixtures use and the default filter looks for; a
+	// real tunnel needs the user's own --filter.
+	FirmataPort = 3030
+	Iec104Port  = 2404
+	OpcuaPort   = 4840
+	UmasPort    = 502
+	ClientPort  = 40000
 )
 
+// Transport is the transport a protocol runs over.
+type Transport int
+
+const (
+	// TCP is a stream protocol: the client connects from ClientPort to the server's port.
+	TCP Transport = iota
+	// UDP is a datagram protocol, where both ends use the protocol's own port.
+	UDP
+)
+
+// Wire is where a protocol lives, which is all a capture needs to know about it.
+type Wire struct {
+	Transport Transport
+	Port      int
+}
+
 // BaseTimestamp is the capture time of the first packet. It is fixed, not time.Now(), so that
 // a generated capture is byte-identical from run to run and golden files stay stable.
 var BaseTimestamp = time.Unix(1700000000, 0).UTC()
@@ -162,6 +195,35 @@ func WriteBacnet(path string, packets []Packet) error {
 	})
 }
 
+// Write writes packets to path as a capture of a protocol living on the given wire.
+//
+// One writer for every protocol, because the transport and the port are the only things that
+// differ between them -- and getting the port wrong makes a fixture the analyzer's own default
+// filter throws away, which reads as "the codec parsed nothing" rather than as a broken test.
+func Write(path string, wire Wire, packets []Packet) error {
+	return write(path, packets, func(p Packet, ip *layers.IPv4) (gopacket.SerializableLayer, error) {
+		if wire.Transport == UDP {
+			udp := &layers.UDP{
+				SrcPort: layers.UDPPort(wire.Port),
+				DstPort: layers.UDPPort(wire.Port),
+			}
+			if err := udp.SetNetworkLayerForChecksum(ip); err != nil {
+				return nil, errors.Wrap(err, "error preparing udp checksum")
+			}
+			return udp, nil
+		}
+		src, dst := layers.TCPPort(ClientPort), layers.TCPPort(wire.Port)
+		if p.Direction == FromServer {
+			src, dst = dst, src
+		}
+		tcp := &layers.TCP{SrcPort: src, DstPort: dst, Seq: 1, Window: 4096, PSH: true, ACK: true}
+		if err := tcp.SetNetworkLayerForChecksum(ip); err != nil {
+			return nil, errors.Wrap(err, "error preparing tcp checksum")
+		}
+		return tcp, nil
+	})
+}
+
 // write assembles each packet and writes the capture. transportFor supplies the transport
 // layer, which is the only part that differs between the protocols.
 func write(path string, packets []Packet, transportFor func(Packet, *layers.IPv4) (gopacket.SerializableLayer, error)) error {
diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/vectors.go b/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/vectors.go
new file mode 100644
index 00000000..b4c43418
--- /dev/null
+++ b/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/vectors.go
@@ -0,0 +1,221 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package pcapfixture
+
+// Reference vectors, one session per protocol.
+//
+// Every payload here is taken from the protocol's own ParserSerializerTestsuite in the Apache
+// PLC4X repository -- protocols/<protocol>/src/test/resources/protocols/<protocol>/ -- which
+// plc4x asserts round-trips: parse the raw bytes, serialize the result, get the same bytes.
+// That is exactly the property the analyzer measures, so a capture built from them is a capture
+// the analyzer must report as entirely clean. A failure here means either the analyzer's
+// adapter is wrong or plc4x has regressed, and those are the only two things worth knowing.
+//
+// The direction of each packet is the "response" parser argument its test case declares, not a
+// guess from its name: several of these protocols encode the two directions differently, and
+// the analyzer derives the direction from the client address, so a packet placed on the wrong
+// side would be read the wrong way round.
+//
+// Generated from those suites rather than hand-copied, then committed, so the tests stay
+// hermetic and do not depend on a plc4x checkout sitting beside this one.
+
+// AbEthWire is where ab-eth traffic is written for a fixture.
+var AbEthWire = Wire{Transport: TCP, Port: AbEthPort}
+
+// AbEthSession is the ab-eth reference vectors, 4 packets that all round-trip.
+func AbEthSession() []Packet {
+	return []Packet{
+		{Payload: mustHex("01010000000000000000000000040005000000000000000000000000"), Direction: FromClient, Comment: "Connection Request"},
+		{Payload: mustHex("02010000000003320000000000040005000000000000000000000000"), Direction: FromClient, Comment: "Connection Response"},
+		{Payload: mustHex("0107000e000003320000000040000000000000000000000000000000080500000f000401a21800640000"), Direction: FromClient, Comment: "Protected Typed Logical Read Request"},
+		{Payload: mustHex("02070020000003320000000040000000000000000000000000000000000508004f000401910101000900040405001f02010003000404050000024000"), Direction: FromClient, Comment: "Protected Typed Logical Read Response"},
+	}
+}
+
+// AdsWire is where ads traffic is written for a fixture.
+var AdsWire = Wire{Transport: TCP, Port: AdsPort}
+
+// AdsSession is the ads reference vectors, 8 packets that all round-trip.
+func AdsSession() []Packet {
+	return []Packet{
+		{Payload: mustHex("00002c000000c0a8171401015303c0a817c801015303020004000c000000000000000200000005f000000000801a01000000"), Direction: FromClient, Comment: "Ams-Single-Item-Read-Request"},
+		{Payload: mustHex("000029000000c0a817c801015303c0a817140101530302000500090000000000000002000000000000000100000001"), Direction: FromClient, Comment: "Ams-Single-Item-Read-Response"},
+		{Payload: mustHex("00004a000000c0a8171401015303c0a817cd0101feff090004002a000000000000000100000003f0000000000000040000001a0000006d61696e2e665f74726967446174656947656c6573656e2e4d00"), Direction: FromClient, Comment: "Ams-Resolve-Symbolic-Address-Request"},
+		{Payload: mustHex("00002c000000c0a817cd0101feffc0a8171401015303090005000c000000000000000100000000000000040000000100801b"), Direction: FromClient, Comment: "Ams-Resolve-Symbolic-Address-Response"},
+		{Payload: mustHex("00002c000000c0a8171401015303c0a817cd0101feff020004000c000000000000000100000005f000000100801b04000000"), Direction: FromClient, Comment: "Ams-Read-Symbolic-Address-Request"},
+		{Payload: mustHex("000029000000c0a817cd0101feffc0a817140101530302000500090000000000000001000000000000000100000001"), Direction: FromClient, Comment: "Ams-Read-Symbolic-Address-Response"},
+		{Payload: mustHex("000030000000c0a8171401015303c0a817cd0101feff0300040010000000000000000100000006f0000000000000040000000100801b"), Direction: FromClient, Comment: "Ams-Release-Symbolic-Address-Handle-Request"},
+		{Payload: mustHex("000024000000c0a817cd0101feffc0a81714010153030300050004000000000000000100000000000000"), Direction: FromClient, Comment: "Ams-Release-Symbolic-Address-Handle-Response"},
+	}
+}
+
+// EipWire is where eip traffic is written for a fixture.
+var EipWire = Wire{Transport: TCP, Port: EipPort}
+
+// EipSession is the eip reference vectors, 27 packets that all round-trip.
+func EipSession() []Packet {
+	return []Packet{
+		{Payload: mustHex("70003800c9070440000000005765277265000000000000000000000000000200a10004005942feffb10024000100520d91124b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b910370707000010000000000"), Direction: FromClient, Comment: "CIP Connected Read Request"},
+		{Payload: mustHex("6f0044001d5e074000000000000000000000000000000000000000000000020000000000b20034005b02200624010a0e02000020b4a500002bcb37132a0000000300000034122000a20f004201402000a20f0042a303010020022401"), Direction: FromClient, Comment: "CIP Connection Manager Open Large Forward Request"},
+		{Payload: mustHex("6f002e001d5e074000000000000000000000000000000000000000000000020000000000b2001e00db0000005b6bfeffb4a500002bcb37132a00000034122000014020000000"), Direction: FromServer, Comment: "CIP Connection Manager Open Large Forward Response"},
+		{Payload: mustHex("70002e001d5e0740000000005765277265000000000000000000000000000200a10004005b6bfeffb1001a0001005208910d5a5a5a5f5a5a5a5f415252415900010000000000"), Direction: FromClient, Comment: "CIP Connected Write Request"},
+		{Payload: mustHex("700020001d5e0740000000000000000000000000000000000000000000000200a1000400b4a50000b1000c000100d2000000c40000000000"), Direction: FromServer, Comment: "CIP Connected Write Response"},
+		{Payload: mustHex("700032001d5e0740000000006e6f000000000000000000000000000000000200a10004005b6bfeffb1001e0002004d09910d5a5a5a5f5a5a5a5f4152524159002801c400010001000000"), Direction: FromClient, Comment: "CIP Connected Write Request 4D"},
+		{Payload: mustHex("700032001d5e0740000000006e6f000000000000000000000000000000000200a10004005b6bfeffb1001e0002004d09910d5a5a5a5f5a5a5a5f4152524159002801c400010001000000"), Direction: FromServer, Comment: "CIP Connected Write Response 4D"},
+		{Payload: mustHex("6f0028001d5e074000000000000000000000000000000000000000000000020000000000b20018004e02200624010a0e2bcb37132a0000000300010020022401"), Direction: FromClient, Comment: "CIP Connection Manager Close Request"},
+		{Payload: mustHex("6f001e00c907044000000000000000000000000000000000000000000000020000000000b2000e00ce000000902137132a0000000000"), Direction: FromServer, Comment: "CIP Connection Manager Close Response"},
+		{Payload: mustHex("650004000000000000000000302e382e332020200000000001000000"), Direction: FromClient, Comment: "ENIP Register Session Request"},
+		{Payload: mustHex("650004001d5e074000000000302e382e332020200000000001000000"), Direction: FromServer, Comment: "ENIP Register Session Response"},
+		{Payload: mustHex("660000001d5e074000000000000000000000000000000000"), Direction: FromClient, Comment: "ENIP Un-Register Session Request"},
+		{Payload: mustHex("6f002c002a01004000000000000000000000000000000000000000000000020000000000b2001c00520220062401059d0e004c0591084d79537472696e67010001000100"), Direction: FromClient, Comment: "CIP Unconnected Send Request"},
+		{Payload: mustHex("6f0070002a01004000000000000000000000000000000000000000000000020000000000b2006000cc000000a002ce0f2b000000556d6d2c2049206e6f772073656520796f7520696e207468652077697265736861726b20636170747572650000000000000000000000000000000000000000000000000000000000000000000000000000000000"), Direction: FromServer, Comment: "CIP Unconnected Send Response"},
+		{Payload: mustHex("6500040081926a3500000000504c4334582020200000000001000000"), Direction: FromServer, Comment: "CIP Register Response - Simulator"},
+		{Payload: mustHex("700020005e5dccfd00000000504c433458202020000000000000000000000200a1000400c3a45cf2b1000c000100cc000000ca0000000000"), Direction: FromServer, Comment: "CIP Read Response 4C"},
+		{Payload: mustHex("04000000ec9a149000000000000000000000000000000000"), Direction: FromClient, Comment: "EIP List Services Request"},
+		{Payload: mustHex("04001900ec9a14900000000000000000000000000000000001000001130001002000436f6d6d756e69636174696f6e7300"), Direction: FromServer, Comment: "EIP List Services Response"},
+		{Payload: mustHex("040019000000000000000000504c4334582020200000000001000001130001002000436f6d6d756e69636174696f6e7300"), Direction: FromServer, Comment: "EIP List Services Response 2"},
+		{Payload: mustHex("6f00160045000040000000001400000030145b0300000000000000002000020000000000b2000600010220022401"), Direction: FromClient, Comment: "EIP Get Attribute List Request - Message Router"},
+		{Payload: mustHex("6f00b60045000040000000001400000030145b0300000000000000000000020000000000b200a600810000004e003a03770066004300f6003700f500ac035f005d005e000003ab033703a503040348004203a4038b002f031204b603b203b303b003b10330034f004e00aa03a803a703a6036e037003320331032d031703b20049033503710072007803ac00b0002b03b100730067006b0068007d038d008c006d006a0038031a0369004500f20074006e008e0070006c0002006a036400a100f4000100c100c000060000010500"), Direction: FromServer, Comment: "EIP Get Attribute List [...]
+		{Payload: mustHex("6f004c08b181852100000000504c43345820202000000000000000000000020000000000b2003c088100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 [...]
+		{Payload: mustHex("6f0014000100000000000000504c43345820202000000000000000000000020000000000b200040081000800"), Direction: FromServer, Comment: "EIP Get Attribute List Response - Message Router - Unknown Service Response Handling"},
+		{Payload: mustHex("6f0014000100000000000000504c43345820202000000000000000000000020000000000b2000400d2000800"), Direction: FromServer, Comment: "CIP Read Response - Unknown Service Response Handling"},
+		{Payload: mustHex("6f0018001d5e074000000000504c43345820202000000000000000000000020000000000b20008000e03200124013001"), Direction: FromClient, Comment: "CIP Get Attribute Single Request"},
+		{Payload: mustHex("6f0016001d5e074000000000504c43345820202000000000000000000000020000000000b20006008e0000000100"), Direction: FromServer, Comment: "CIP Get Attribute Single Response"},
+		{Payload: mustHex("6f0020001d5e074000000000504c43345820202000000000000000000000020000000000b2001000db0001010001341237132a0000000200"), Direction: FromServer, Comment: "CIP Connection Manager Forward Open rejected"},
+	}
+}
+
+// FirmataWire is where firmata traffic is written for a fixture.
+var FirmataWire = Wire{Transport: TCP, Port: FirmataPort}
+
+// FirmataSession is the firmata reference vectors, 13 packets that all round-trip.
+func FirmataSession() []Packet {
+	return []Packet{
+		{Payload: mustHex("ff"), Direction: FromClient, Comment: "Firmata Reset"},
+		{Payload: mustHex("f90205"), Direction: FromServer, Comment: "Firmata Report Version"},
+		{Payload: mustHex("f07902055300740061006e0064006100720064004600690072006d006100740061002e0069006e006f00f7"), Direction: FromServer, Comment: "Firmata Report Version And Name"},
+		{Payload: mustHex("f079f7"), Direction: FromClient, Comment: "FirmataMessageCommand->FirmataCommandSysex->SysexCommandReportFirmware (Request)"},
+		{Payload: mustHex("f07902055300740061006e0064006100720064004600690072006d006100740061002e0069006e006f00f7"), Direction: FromServer, Comment: "FirmataMessageCommand->FirmataCommandSysex->SysexCommandReportFirmware (Response)"},
+		{Payload: mustHex("f069f7"), Direction: FromClient, Comment: "FirmataMessageCommand->FirmataCommandSysex->SysexCommandAnalogMappingQuery (Request)"},
+		{Payload: mustHex("f06bf7"), Direction: FromClient, Comment: "FirmataMessageCommand->FirmataCommandSysex->SysexCommandCapabilityQuery (Request)"},
+		{Payload: mustHex("c001"), Direction: FromClient, Comment: "FirmataMessageSubscribeAnalogPinValue (Pin 0)"},
+		{Payload: mustHex("d001"), Direction: FromClient, Comment: "FirmataMessageSubscribeDigitalPinValue (Pin 0)"},
+		{Payload: mustHex("e05403"), Direction: FromServer, Comment: "FirmataMessageAnalogIO (Pin 0)"},
+		{Payload: mustHex("900000"), Direction: FromServer, Comment: "FirmataMessageDigitalIO (Pin 0)"},
+		{Payload: mustHex("f06d02f7"), Direction: FromClient, Comment: "FirmataMessageCommand->FirmataCommandSysex->SysexCommandPinStateQuery (Pin 2)"},
+		{Payload: mustHex("f06e020100f7"), Direction: FromServer, Comment: "FirmataMessageCommand->FirmataCommandSysex->SysexCommandPinStateResponse"},
+	}
+}
+
+// KnxWire is where knxnet-ip traffic is written for a fixture.
+var KnxWire = Wire{Transport: UDP, Port: KnxPort}
+
+// KnxSession is the knxnet-ip reference vectors, 16 packets that all round-trip.
+func KnxSession() []Packet {
+	return []Packet{
+		{Payload: mustHex("06100201000e0801c0a82ac8d6b4"), Direction: FromClient, Comment: "Search Request"},
+		{Payload: mustHex("06100202004c0801c0a82a0b0e5736010200ffff000000082d409852e000170c000ab327553647697261204b4e582f49502d5363686e6974747374656c6c6500000000000802020103010401"), Direction: FromClient, Comment: "Search Response"},
+		{Payload: mustHex("06100203000e0801000000000000"), Direction: FromClient, Comment: "Description Request"},
+		{Payload: mustHex("06100204004436010200ffff000000082d409852e000170c000ab327553647697261204b4e582f49502d5363686e6974747374656c6c6500000000000802020103010401"), Direction: FromClient, Comment: "Description Response"},
+		{Payload: mustHex("06100205001a0801c0a82ac8d6b40801c0a82ac8d6b404040200"), Direction: FromClient, Comment: "Connect Request"},
+		{Payload: mustHex("06100206001402000801c0a82a0b0e570404fffe"), Direction: FromClient, Comment: "Connect Response"},
+		{Payload: mustHex("06100207001002000801c0a82ac8d6b4"), Direction: FromClient, Comment: "Connection State Request"},
+		{Payload: mustHex("0610020800080200"), Direction: FromClient, Comment: "Connection State Response"},
+		{Payload: mustHex("06100310001104670000fc000001531001"), Direction: FromClient, Comment: "Device Configuration Request"},
+		{Payload: mustHex("06100311000a04670000"), Direction: FromClient, Comment: "Device Configuration Ack"},
+		{Payload: mustHex("06100209001001000801c0a82a0b0e57"), Direction: FromClient, Comment: "Disconnect Request"},
+		{Payload: mustHex("0610020a00086600"), Direction: FromClient, Comment: "Disconnect Response"},
+		{Payload: mustHex("061004200015040200002900bce0220a120c010081"), Direction: FromClient, Comment: "Tunneling Request"},
+		{Payload: mustHex("06100420001c046b00002b0703010504024502bc360a1e0ce100810d"), Direction: FromClient, Comment: "Tunneling Request (Busmon)"},
+		{Payload: mustHex("06100421000a046b0000"), Direction: FromClient, Comment: "Tunneling Response"},
+		{Payload: mustHex("0610020500180801c0a82a46c4090801c0a82a46c40a0203"), Direction: FromClient, Comment: "Default"},
+	}
+}
+
+// ModbusWire is where modbus-tcp traffic is written for a fixture.
+var ModbusWire = Wire{Transport: TCP, Port: ModbusPort}
+
+// ModbusSession is the modbus-tcp reference vectors, 6 packets that all round-trip.
+func ModbusSession() []Packet {
+	return []Packet{
+		{Payload: mustHex("000000000006ff0408d20002"), Direction: FromClient, Comment: "Read Input Registers Request"},
+		{Payload: mustHex("7cfe000000c9ff04c600000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000004000000000000000000000000000001db000001d600004a380000000000000000000000000000000000000000000000000000000000006461696d006e0000000000000000000000000000303100300000000000000000000000000000000000000000000000000000000000000000000000000000"), Direction: FromServer, Comment: "Read Input Registers [...]
+		{Payload: mustHex("000a0000001101140e060003270e000206000400000008"), Direction: FromClient, Comment: "Read Extended Registers Request Split File Record"},
+		{Payload: mustHex("000a0000001b011418050600000000110600000000000000000000000000000000"), Direction: FromServer, Comment: "Read Extended Registers Response Split File Record"},
+		{Payload: mustHex("000a0000000c011509060002000000010008"), Direction: FromClient, Comment: "Write Extended Registers Request File Record"},
+		{Payload: mustHex("000a00000015011512060001270f00010000060002000000010000"), Direction: FromClient, Comment: "Write Extended Registers Request Split File Record"},
+	}
+}
+
+// ModbusAsciiWire is where modbus-ascii traffic is written for a fixture.
+var ModbusAsciiWire = Wire{Transport: TCP, Port: ModbusPort}
+
+// ModbusAsciiSession is the modbus-ascii reference vectors, 2 packets that all round-trip.
+func ModbusAsciiSession() []Packet {
+	return []Packet{
+		{Payload: mustHex("01030000000af2"), Direction: FromClient, Comment: "Read Holding Registers Request"},
+		{Payload: mustHex("0103140000000000000000000000000000000000000000e8"), Direction: FromServer, Comment: "Read Holding Registers Response"},
+	}
+}
+
+// ModbusRtuWire is where modbus-rtu traffic is written for a fixture.
+var ModbusRtuWire = Wire{Transport: TCP, Port: ModbusPort}
+
+// ModbusRtuSession is the modbus-rtu reference vectors, 2 packets that all round-trip.
+func ModbusRtuSession() []Packet {
+	return []Packet{
+		{Payload: mustHex("01030000000ac5cd"), Direction: FromClient, Comment: "Read Holding Registers Request"},
+		{Payload: mustHex("0103140000000000000000000000000000000000000000a367"), Direction: FromServer, Comment: "Read Holding Registers Response"},
+	}
+}
+
+// S7Wire is where s7 traffic is written for a fixture.
+var S7Wire = Wire{Transport: TCP, Port: S7Port}
+
+// S7Session is the s7 reference vectors, 11 packets that all round-trip.
+func S7Session() []Packet {
+	return []Packet{
+		{Payload: mustHex("0300001611e00000000f00c2020100c1020311c0010a"), Direction: FromClient, Comment: "COTP Connection Request"},
+		{Payload: mustHex("0300001611d0000f000b00c0010ac1020311c2020100"), Direction: FromClient, Comment: "COTP Connection Response"},
+		{Payload: mustHex("0300001902f08132010000000000080000f0000008000803f0"), Direction: FromClient, Comment: "S7 Setup Communication Request"},
+		{Payload: mustHex("0300001b02f080320300000000000800000000f0000003000300f0"), Direction: FromClient, Comment: "S7 Setup Communication Response"},
+		{Payload: mustHex("0300002102f082320700000001000800080001120411440100ff09000400110000"), Direction: FromClient, Comment: "S7 Read PLC Type Request"},
+		{Payload: mustHex("0300007d02f080320700000001000c0060000112081284010100000000ff09005c00110000001c0003000136455337203231322d31424433302d3058423020202000012020000636455337203231322d31424433302d3058423020202000012020000736455337203231322d31424433302d3058423020202056020002"), Direction: FromClient, Comment: "S7 Read PLC Type Response"},
+		{Payload: mustHex("0300004302f08b32010000000b003200000404120a10010001000082000000120a10010001000082000000120a10010001000082000000120a10010001000082000000"), Direction: FromClient, Comment: "S7 Read Request"},
+		{Payload: mustHex("0300002c02f08032030000000b0002001700000404ff0300010100ff0300010100ff0300010100ff03000101"), Direction: FromClient, Comment: "S7 Read Response"},
+		{Payload: mustHex("0300001302f08032020000000a000000008500"), Direction: FromClient, Comment: "S7 Read Error Response"},
+		{Payload: mustHex("0300005a02f08e32010000000e003200170504120a10010001000082000000120a10010001000082000001120a10010001000082000002120a10010001000082000003ff0300010100ff0300010100ff0300010100ff03000101"), Direction: FromClient, Comment: "S7 Write Request"},
+		{Payload: mustHex("0300001902f08032030000000e0002000400000504ffffffff"), Direction: FromClient, Comment: "S7 Write Response"},
+	}
+}
+
+// SlmpWire is where slmp traffic is written for a fixture.
+var SlmpWire = Wire{Transport: TCP, Port: SlmpPort}
+
+// SlmpSession is the slmp reference vectors, 5 packets that all round-trip.
+func SlmpSession() []Packet {
+	return []Packet{
+		{Payload: mustHex("500000ffff03000c000000010400005e0100a80200"), Direction: FromClient, Comment: "3E Batch Read Request - D350, 2 words"},
+		{Payload: mustHex("d00000ffff030006000000ab560f17"), Direction: FromClient, Comment: "3E Batch Read Response - D350=0x56AB, D351=0x170F"},
+		{Payload: mustHex("500000ffff030024000000030400000403000000a8000000c2640000902000009cdc0500a86001009d57040090"), Direction: FromClient, Comment: "3E Random Read Request - D0,T0,M100,X20 word + D1500,Y160,M1111 dword"},
+		{Payload: mustHex("d00000ffff03001600000095190212302049484e4f544cafb9dec3b7bcddba"), Direction: FromClient, Comment: "3E Random Read Response - 4 word + 3 dword values"},
+		{Payload: mustHex("500000ffff030026000000060400000203000000a80400000100b40800000000900200800000900200000100a00300"), Direction: FromClient, Comment: "3E Multi-block Read Request - 2 word blocks (D0-D3, W100-W107) + 3 bit blocks (M0-M31, M128-M159, B100-B12F)"},
+	}
+}
diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol.go b/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol.go
index 29bb1833..aac3d742 100644
--- a/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol.go
+++ b/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol.go
@@ -65,7 +65,7 @@ func (p Protocol) Matches(name string) bool {
 var (
 	BacnetIP = Protocol{
 		Name:        "bacnetip",
-		Aliases:     []string{"bacnet"},
+		Aliases:     []string{"bacnet", "bacnet-ip"},
 		Description: "BACnet/IP",
 	}
 	CBus = Protocol{
@@ -73,10 +73,27 @@ var (
 		Aliases:     []string{"cbus"},
 		Description: "Clipsal C-Bus",
 	}
+
+	// The rest, examined through internal/codec. The names are the plc4x driver codes, which
+	// is deliberately the same vocabulary the browser uses: a user who connected to a device
+	// with "modbus-tcp" should not have to learn a second spelling to analyse its traffic.
+	ModbusTcp   = Protocol{Name: "modbus-tcp", Description: "Modbus TCP"}
+	ModbusRtu   = Protocol{Name: "modbus-rtu", Description: "Modbus RTU (needs --filter: serial unless tunnelled)"}
+	ModbusAscii = Protocol{Name: "modbus-ascii", Description: "Modbus ASCII (needs --filter: serial unless tunnelled)"}
+	S7          = Protocol{Name: "s7", Description: "Siemens S7"}
+	Eip         = Protocol{Name: "eip", Aliases: []string{"logix"}, Description: "EtherNet/IP"}
+	KnxNetIp    = Protocol{Name: "knxnet-ip", Aliases: []string{"knxnetip"}, Description: "KNXNet/IP"}
+	Ads         = Protocol{Name: "ads", Description: "Beckhoff TwinCAT ADS"}
+	AbEth       = Protocol{Name: "ab-eth", Aliases: []string{"abeth"}, Description: "Allen Bradley ETH"}
+	Slmp        = Protocol{Name: "slmp", Description: "SLMP (MELSEC)"}
+	Firmata     = Protocol{Name: "firmata", Description: "Firmata (needs --filter: serial unless tunnelled)"}
 )
 
 // all is the registry. Keep it ordered by canonical name so help output is stable.
-var all = []Protocol{BacnetIP, CBus}
+var all = []Protocol{
+	AbEth, Ads, BacnetIP, CBus, Eip, Firmata, KnxNetIp,
+	ModbusAscii, ModbusRtu, ModbusTcp, S7, Slmp,
+}
 
 // All returns every supported protocol.
 func All() []Protocol {
@@ -85,6 +102,27 @@ func All() []Protocol {
 	return out
 }
 
+// Catalogue is the protocols as help text: one per line, canonical name, aliases and
+// description.
+//
+// Generated rather than written, because a hand-written list is a list that goes stale. The
+// help said "bacnetip or c-bus" and would have kept saying it after ten more were added.
+func Catalogue() string {
+	var out strings.Builder
+	for _, p := range all {
+		name := p.Name
+		if len(p.Aliases) > 0 {
+			name += " (" + strings.Join(p.Aliases, ", ") + ")"
+		}
+		out.WriteString("  " + name)
+		if p.Description != "" {
+			out.WriteString(strings.Repeat(" ", max(1, 30-len(name))) + p.Description)
+		}
+		out.WriteString("\n")
+	}
+	return out.String()
+}
+
 // Names returns the canonical names, sorted, for help and error messages.
 func Names() []string {
 	names := make([]string, 0, len(all))
diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol_test.go b/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol_test.go
index ebde505f..a04e436c 100644
--- a/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol_test.go
+++ b/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol_test.go
@@ -82,8 +82,15 @@ func TestResolveRejectsEmptyName(t *testing.T) {
 }
 
 func TestNamesAreCanonicalAndSorted(t *testing.T) {
-	assert.Equal(t, []string{"bacnetip", "c-bus"}, Names(),
-		"help output depends on this being stable and canonical")
+	// Spelled out rather than derived, so that adding a protocol is a deliberate act with a
+	// visible diff: this list is what the help offers and what a user is allowed to type.
+	assert.Equal(t, []string{
+		"ab-eth", "ads", "bacnetip", "c-bus", "eip", "firmata", "knxnet-ip",
+		"modbus-ascii", "modbus-rtu", "modbus-tcp", "s7", "slmp",
+	}, Names(), "help output depends on this being stable and canonical")
+
+	// And sorted, whatever the list becomes.
+	assert.IsIncreasing(t, Names(), "the registry has to stay ordered by canonical name")
 }
 
 func TestAllReturnsACopy(t *testing.T) {
diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/analysis.go b/plc4go/tools/plc4xpcapanalyzer/ui/analysis.go
index 585ca0d2..23bb665e 100644
--- a/plc4go/tools/plc4xpcapanalyzer/ui/analysis.go
+++ b/plc4go/tools/plc4xpcapanalyzer/ui/analysis.go
@@ -38,6 +38,7 @@ import (
 	"github.com/apache/plc4x-extras/plc4go/tools/internal/progress"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/bacnetanalyzer"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/cbusanalyzer"
+	analyzercodec "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/codec"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/common"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/finding"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcaphandler"
@@ -163,7 +164,16 @@ func (r Request) filterExpression() string {
 	if r.NoFilter {
 		return ""
 	}
-	return r.Filter
+	if r.Filter != "" {
+		return r.Filter
+	}
+	// The protocol's own default, so that opening a capture in the interface selects the same
+	// packets the command line would. Without it every packet in the capture is handed to the
+	// codec, including the ones belonging to other protocols.
+	if protocolCodec, known := analyzercodec.For(r.Protocol.Name); known {
+		return protocolCodec.DefaultFilter
+	}
+	return ""
 }
 
 // Result is the outcome of one run.
@@ -223,8 +233,34 @@ func codecFor(ctx context.Context, request Request) (codec, context.CancelFunc,
 		}, cancel, nil
 
 	default:
-		return codec{}, func() {}, errors.Errorf("protocol %s is registered but not implemented by the analyzer", request.Protocol.Name)
+		// Every other protocol is a codec and a filter, from internal/codec -- the same
+		// registry the command line dispatches through, so the interface and the command line
+		// cannot come to support different sets.
+		protocolCodec, known := analyzercodec.For(request.Protocol.Name)
+		if !known {
+			return codec{}, func() {}, errors.Errorf("protocol %s is registered but not implemented by the analyzer", request.Protocol.Name)
+		}
+		client := net.ParseIP(request.Client)
+		return codec{
+			parse: func(info common.PacketInformation, payload []byte) (spi.Message, error) {
+				return protocolCodec.Parse(ctx, payload, isResponse(info, client))
+			},
+			serialize: func(message spi.Message) ([]byte, error) {
+				return protocolCodec.Serialize(ctx, message)
+			},
+			mapPackets: passthroughMapping,
+			wait:       func() {},
+		}, func() {}, nil
+	}
+}
+
+// isResponse reports whether a packet travelled from the device to the client, which the
+// protocols encoding the two directions differently need in order to be read at all.
+func isResponse(info common.PacketInformation, client net.IP) bool {
+	if client == nil || info.SrcIp == nil {
+		return false
 	}
+	return !info.SrcIp.Equal(client)
 }
 
 // passthroughMapping is the mapping for protocols that need no re-assembly.