14.6. Watch

The Watch section pins values that are re-evaluated on every pause. Type any Lua expression into the Watch column — tostring(pinfo.src), tvb:len(), pinfo.dst_port == 80, assert(tvb:len() >= 20, "short IP header") — and the row updates the next time the debugger pauses.

For a goal-organized cheat sheet of expression shapes — formats, predicates, bit fields, ad-hoc tables, fallbacks, type inspection — see Section 14.6.3, “Expression patterns”.

Plain variable paths get extras. When the spec is a plain name optionally chained with .field or [key] segments — for example pinfo.src, Upvalues.sessions[1].src, opcode_names[0x10], or just pinfo — the debugger recognizes the shape and routes the row through a path watch fast path: a constrained but tree-aware specialization of the general expression watcher. Anything that isn’t a plain path (operators, calls, comparisons, table constructors, …) falls through to the expression watcher automatically; you don’t choose between the two, the panel routes each row.

The path-watch fast path adds:

For the formal grammar of what counts as a "plain variable path", see Section 14.6.2, “Path-watch syntax”.

14.6.1. Controls and behavior

The section header carries three small controls to the right of the title rule:

  • Add Watch: insert a new top-level row and open its inline editor (same as Ctrl+Shift+W).
  • Remove Watch: remove the selected top-level watches. Disabled when no top-level watch row is selected.
  • Remove All Watches (Ctrl+Shift+K). Disabled when the Watch list is empty.

Rows are added by Add Watch (header , Ctrl+Shift+W, the Watch-tree context menu, the editor context menu, or double-clicking the empty area below the last row), edited inline via double-click or F2, removed via Delete / Backspace or the button, and reordered by drag. The tree supports Ctrl / Shift-click to extend the selection so a bulk delete removes every selected top-level row in one shot. The right-click context menu offers Add Watch; on a watch root also Duplicate Watch (Ctrl+Shift+D), Edit Watch (F2), Copy Value (Ctrl+Shift+C), Remove (Delete), and Remove All Watches (Ctrl+Shift+K); on a sub-row only Add Watch and Copy Value. Committing an empty spec removes the row. The Value column is always read-only.

The Wireshark-specific behaviors:

  • Error chrome. A row with an invalid path or a lookup failure is drawn with a red background and a tooltip that reports the error message.
  • Not paused. When the debugger is not paused, the Value column shows a muted em dash. Hovering the em dash explains that watches are only evaluated while the debugger is paused.
  • Changed-value cue. Identical treatment to Variables; see Section 14.5.1, “Changed-value cue”.
  • Selection sync. Selecting a watch row selects the matching Variables row (and vice versa). Selecting a path-style watch root also moves the Call Stack selection to a frame where that root resolves — Locals.… / unqualified paths pick the innermost matching frame; Upvalues.… picks the frame whose closure carries that upvalue; Globals.… does not move the stack. Expression watches have no Variables-tree counterpart, so they do not participate in selection sync or stack-frame nudging.
  • Depth cap. Subtrees stop at the same depth limit as the Variables tree; see Section 14.6.2, “Path-watch syntax”. The sentinel child at the cap has the tooltip Maximum watch depth reached. This applies to expression-watch sub-elements as well: the subpath walked under the expression result counts toward the cap.

Expansion state is kept only for the current session; watches open collapsed the next time you start Wireshark.

14.6.2. Path-watch syntax

The chapter intro called out that a Watch row whose contents look like a plain variable path gets the path-watch fast path — selection sync with the Variables tree, sharper error chrome, cheaper re-resolution, and so on. This subsection nails down what "plain variable path" means.

A path watch is one identifier followed by any number of .name or [key] segments, optionally qualified by a section prefix. Anything else — method calls, arithmetic, function calls, comparisons, table constructors — is routed to the expression watcher (see Section 14.6.3, “Expression patterns”) automatically; there is no error and no UI toggle.

Examples.

-- A local in the current frame (a parameter of printer.dissector):
Locals.pinfo

-- A field on that local userdata:
pinfo.src

-- An upvalue table indexed by an integer key (printer.lua's
-- per-frame cache):
Upvalues.sessions[1]

-- A nested field reachable through the same path:
Upvalues.sessions[1].src

-- Hex and boolean keys (printer.lua's lookup tables):
Upvalues.opcode_names[0x10]
Upvalues.handshake[true]

-- An explicit Globals path (a Lua-side global function):
Globals.get_version

-- Unqualified — tried in locals, then upvalues, then globals:
sessions                  -- finds the upvalue
get_version               -- finds the global

-- _G aliases:
_G.get_version            -- same as Globals.get_version
_G                        -- same as Globals (whole section)

Prefixes. A Watch spec may start with one of the three debugger section prefixes:

  • Locals.name — a local in the current stack frame.
  • Upvalues.name — an upvalue of the current stack frame’s function.
  • Globals.name — a global, resolved through _G first and then through the paused frame’s _ENV upvalue (see the note in Section 14.5, “Variables”).

The exact tokens Locals, Upvalues, and Globals (with no trailing .) are also accepted and select the whole section’s contents. _G is aliased to Globals and _G. to Globals.. A bare path with no prefix (including a lone identifier) is resolved in Locals, then Upvalues, then Globals, in that order, and the UI rewrites the tooltip with the section that actually matched.

A common confusion: a file-scope local like printer.lua’s `local sessions = {} is an upvalue of any function defined in the same chunk, not a global. So the path is Upvalues.sessions, not Globals.sessions. Globals are names that were assigned without local (or that come from the standard library, such as print, tostring, Field, or get_version).

What’s allowed inside [ ]. Bracket subscripts mirror Lua’s own short-literal syntax and accept:

  • Integer keys — decimal or hex, optionally negative: t[0], t[-1], t[0x1F], t[-0X1f].
  • Boolean keys — Lua-case only: t[true], t[false]. True and FALSE are parsed as identifiers, not booleans.
  • String keys — double- or single-quoted, with the standard Lua 5.x short-string escape set.

The decoded key is looked up exactly the way Lua itself indexes a table, so any key type that a Lua table can carry is reachable. For userdata (Wireshark class instances, e.g. Proto, Field), a bracket key must be a string and is looked up as an attribute name, the same as ud.name.

Anything that doesn’t match the grammar above is routed to the expression watcher. See When the path-watch fast path doesn’t apply in Section 14.6.3, “Expression patterns” for the full list of shapes that go that way.

14.6.2.1. Reference and edge cases

Grammar. After canonicalization the Watch grammar is:

watch   := section "." body
         | section
         | body
section := "Locals" | "Upvalues" | "Globals"
body    := ident ( "." ident | "[" key "]" )*
ident   := [A-Za-z_] [A-Za-z0-9_]*
key     := integer | boolean | string
integer := "-"? ( decimal-digits | ("0x" | "0X") hex-digits )
boolean := "true" | "false"
string  := '"' ( char-except-"\"\\ | escape )* '"'
         | "'" ( char-except-'\\ | escape )* "'"

String escapes. String keys accept the Lua 5.x short-string escape set: \a \b \f \n \r \t \v \\ \" \' \?, decimal bytes \NNN (1–3 digits, value ≤ 255), hex bytes \xHH, Unicode code points \u{H…} (1–8 hex digits, value ≤ 0x7FFFFFFF, UTF-8 encoded), and \z which skips the following whitespace. Raw newlines inside a string key are rejected; use \n.

Canonicalization. Before the path is validated:

  • Leading and trailing whitespace is trimmed.
  • _G and _G. are rewritten to Globals and Globals..
  • Spaces and tabs around . are collapsed outside bracket literals, so Globals . foo . bar becomes Globals.foo.bar.
  • Whitespace inside [ …​ ] around the key is tolerated.
  • String escapes inside ["…"] / ['…'] are decoded before lookup, so t["a\tb"] indexes the key a<TAB>b.

Depth cap. Path depth is capped at 32 — the count of . and [ in the canonical path. Paths that reach that limit are rejected. The same cap applies to Variables and Watch sub-trees, with the sentinel child carrying the tooltip Maximum watch depth reached.

14.6.3. Expression patterns

The mini-blocks below are the cheat sheet for what kinds of Lua a Watch row accepts when the spec isn’t a plain path. Each is paste-and-run — drop a line into the Watch column and the row updates on every pause. Names like pinfo, tvb, and tree refer to whatever locals the current dissector exposes; substitute as needed.

Format a value the way columns do.

-- Address as the canonical column string:
tostring(pinfo.src)

-- Endpoints in one row:
tostring(pinfo.src) .. " -> " .. tostring(pinfo.dst)

-- TCP/UDP port pair:
string.format("%d -> %d", pinfo.src_port, pinfo.dst_port)

-- A flag byte rendered like Wireshark does:
string.format("0x%02x", tvb:range(13,1):uint())

Use tostring() and string.format() exactly as you would in a dissector callback. Wireshark’s userdata classes (Address, Pinfo, Tvb, …) expose conversion via the standard __tostring metamethod, so tostring(pinfo.src) is the canonical form — they do not offer a :tostring() method. The :method syntax still works on string locals (name:upper()).

Compute a derived number or boolean.

-- Bytes left to consume:
tvb:len() - offset

-- "Beyond frame N":
pinfo.number > 100

-- "Targets HTTP":
pinfo.dst_port == 80

-- First-pass dissection?
not pinfo.visited

-- Seconds since the previous captured frame, negated:
-pinfo.delta_ts

Inspect a derived value without expanding the underlying userdata or table. Standard arithmetic, comparison, and unary operators all work. Note that there is no __len metamethod on Tvb / TvbRange, so use tvb:len() rather than #tvb.

Pick out a bit field.

-- (Offset 13 here is illustrative; substitute whatever byte
--  position is meaningful for the protocol you're inspecting.)

-- High bit of that byte:
tvb:range(13, 1):uint() & 0x80

-- Upper nibble of the same byte:
(tvb:range(13, 1):uint() >> 4) & 0x0F

Lua 5.3+ bitwise operators on integers (Wireshark’s floor). The bundled bit library is also available if you prefer the named-function form (bit.band(tvb:range(13, 1):uint(), 0x80)).

Read a previously-constructed Field.

-- printer.lua defines `ip_src_F` and `tcp_flags_F` at file scope:
ip_src_F()
(tcp_flags_F()).value

Calling a Field returns the most recent FieldInfo for the current packet — handy when the local you want isn’t in scope but the protocol field is. If the field is not present in the current packet (e.g. tcp_flags_F() on a non-TCP frame) the call returns nil, so (tcp_flags_F()).value errors with attempt to index a nil value; guard with tcp_flags_F() and (tcp_flags_F()).value when the protocol may be missing. Field.new(…​) itself cannot be called from a watch: extractors can only be constructed at script load, before any dissector or tap callback runs (the watch evaluates inside that callback). Define the Field once at file scope, the way printer.lua does, and reference it by name in the watch.

Index a table by a non-path key.

-- Hyphenated key in _G (no such global by default; the watcher
-- just returns nil):
_G["my-shared-state"]

-- A subtable indexed by the current frame number:
sessions[pinfo.number]

-- A userdata stringified for use as a stable table key:
last_seen[tostring(pinfo.src)]

-- Non-integer / boolean keys:
sessions[1.5]
handshake[true]

Path watches accept identifiers and short integer / boolean / string keys; expressions accept anything Lua tables accept, including hyphenated names, non-integer numbers, booleans (including false), or a long-literal [[...]] string (e.g. state[ [[multi-line key]] ]).

Userdata as a table key. Lua hashes table keys by raw identity for userdata, ignoring __eq. Wireshark attribute getters like pinfo.src and pinfo.dst return a fresh userdata wrapper on each access, so t[pinfo.src] = … followed by t[pinfo.src] always misses on the second read. Key by a stable representation instead — tostring(pinfo.src) for an Address, pinfo.number for per-frame uniqueness — which is why last_seen above keys by tostring(pinfo.src).

Build an ad-hoc table.

-- A tuple of locals, expandable in the Watch tree:
{pinfo.src, pinfo.dst, pinfo.dst_port}

-- All return values of a multi-valued function:
{ string.find(tostring(pinfo.src), "(%d+)%.(%d+)") }

Expandable in the Watch tree without polluting the dissector with a temporary local. The second form captures every return value of a multi-valued function — see Capture all return values below.

Inspect type or metatable.

-- "userdata", "table", "string", ...:
type(pinfo.src)

-- Class-ish name on a Wireshark userdata
-- (Address, Pinfo, Tvb, ...):
getmetatable(pinfo.src).__name

-- Full metatable contents when something behaves unexpectedly:
getmetatable(tree)

Useful before assuming what a value is, especially when a row reports an unexpected attempt to index a nil value. Note that Wireshark userdata raise an error when indexed with an unknown attribute — pinfo.src.__name does not fall back to nil, so go through getmetatable(…​) for the class name.

Predicates and "tell me when X changes".

-- Is this the first dissection of this frame?
not pinfo.visited

-- "Seen" or "first time" — flips colour as soon as it changes:
pinfo.visited and "seen" or "first time"

-- Fail loudly the moment an invariant breaks:
assert(tvb:len() >= 20, "short IP header")

-- "Frame is more than 30 s after the previous one":
pinfo.delta_ts > 30

A failing assert (or a bare error("…​")) turns the row red with the user-supplied message in the Value column — a lightweight alternative to a conditional breakpoint for "tell me when X changes". The row stays normal as long as the predicate holds. The synthetic watch:1: prefix Lua adds to the message is stripped from the cell text, so a failing assert(..., "short IP header") reads simply short IP header; the full prefixed message is preserved in the row’s tooltip.

Conditional / fallback.

-- "Frame number where this source was last seen, or 'never'":
last_seen[tostring(pinfo.src)] or "never"

-- A two-state badge:
pinfo.visited and "seen" or "first time"

"Show this if available, otherwise that." The first branch evaluates Lua-truthy, so a boolean like pinfo.visited works as expected.

Capture all return values.

-- string.find returns (start, end, capture1, capture2, ...);
-- a bare watch only shows `start`, so wrap to capture them all:
{ string.find(tostring(pinfo.src), "(%d+)%.(%d+)") }

A bare string.find(…​) watch shows only the first return value. Wrap with { …​ } to get them as a sequence you can expand.

When the path-watch fast path doesn’t apply. The shapes below cannot be expressed as a plain variable path, so a row that uses any of them is automatically routed to the expression watcher and gives up the path-watch extras (selection sync, stack-frame nudge, sharper error chrome, cheaper re-resolution).

  • Indexing a table by a non-path key — non-integer numbers (t[1.5]), booleans (t[true], including false), or a long-literal [[...]] string.
  • Indexing _G by a key that isn’t a Lua identifier — _G["weird-key-with-dashes"]. Path syntax does not alias _G[…​] to Globals.[…​]; the expression watcher resolves it against the live _G table on every pause.
  • Any computation, transformation, or format applied to the value — operators, function or method calls, string formatting, type or metatable lookups.
  • Capturing multiple return values as a sequence ({ f() }) instead of seeing only the first.
  • An ad-hoc tuple ({a, b, c}) you can expand in the Watch tree without adding a temporary local to the dissector.

In short: keep specs as plain paths whenever you can, and reach for the expression watcher only for the cases above (or when one of the patterns earlier in this subsection is what you actually want).

What to watch out for.

  • Locals are read-only. foo = 42 writes to _G.foo, not to a local foo. Use the Evaluate panel and debug.setlocal() if you really need to mutate a local.
  • Side effects persist. The expression runs in the live dissector Lua state. Mutating a global, a userdata field, or a shared table changes what subsequent dissection sees. Keep watch expressions read-only when you can.
  • Don’t put statements in a watch. flag = true and similar assignments do compile, but the implicit return makes them illegal as a value expression, so the chunk is wrapped as a block and the row reports nil. The side effect still happens — a bare flag = true writes to _G.flag. Use the Evaluate panel for assignments, blocks, loops, or anything else with side effects you actually care about.
  • Errors are scoped to one row. A failing expression marks its row with the usual error chrome and the Lua error message in the Value column; the rest of the Watch list keeps refreshing. The synthetic watch:1: prefix Lua adds to runtime errors is stripped from the cell text but kept in the row’s tooltip for diagnostics.
  • Long-running expressions are aborted (Lua 5.4+). The same instruction-count and call-depth caps that protect the Evaluate panel apply to expression watches; a runaway while true do end is killed with an error rather than freezing the GUI. On builds linked against Lua 5.3 the caps are inactive (Lua’s debug-hook gate cannot be reached safely from outside the engine’s private headers in that release), so a runaway watch can freeze Wireshark; a one-shot warning is logged the first time a watch or Evaluate expression is run.
  • Sub-element copy. Copy Value on a sub-element of an expression-watch root re-evaluates the expression and walks the same subpath, mirroring how path watches re-resolve on copy.