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.
= or return is needed; value-returning expressions
auto-return their value, and a bare function or method call shows
what it returned. (The legacy = prefix from the Evaluate panel
is still tolerated, just redundant here.)
printer.lua, pinfo, tvb, and tree are the parameters of
printer.dissector; printer, sessions, last_seen,
opcode_names, handshake, ip_src_F, and tcp_flags_F are
upvalues; and library names like string, tostring, and Field
fall through to globals automatically.
__pairs metamethod — are expandable in the Watch tree.
Children re-resolve against the current result on every pause;
they do not snapshot.
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:
Locals. / unqualified paths pick the
innermost frame where the name resolves; Upvalues. picks the
frame whose closure carries the upvalue; Globals. does not move
the stack.
For the formal grammar of what counts as a "plain variable path", see Section 14.6.2, “Path-watch syntax”.
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:
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.
Expansion state is kept only for the current session; watches open collapsed the next time you start Wireshark.
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:
t[0], t[-1], t[0x1F], t[-0X1f].
t[true], t[false]. True
and FALSE are parsed as identifiers, not booleans.
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.
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:
_G and _G. are rewritten to Globals and Globals..
. are collapsed outside bracket
literals, so Globals . foo . bar becomes Globals.foo.bar.
[ … ] around the key is tolerated.
["…"] / ['…'] 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.
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).
t[1.5]), booleans (t[true], including false), or a
long-literal [[...]] string.
_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.
{ f() })
instead of seeing only the first.
{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.
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.
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.
watch:1: prefix Lua adds to runtime errors is
stripped from the cell text but kept in the row’s tooltip for
diagnostics.
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.