14.6. Watch
Prev | Chapter 14. Lua Debugger | Next

## 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.

- No leading `=` 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.)
- Bare identifiers resolve against the paused frame’s locals, then upvalues, then globals; see Section 14.5, “Variables”. In `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.
- Tables — and Wireshark userdata with attribute getters or a `__pairs` metamethod — are expandable in the Watch tree. Children re-resolve against the current result on every pause; they do not snapshot.
- Hovering a row shows its current type and a kind-of-watch hint — From: Locals/Upvalues/Globals for plain paths, Expression — re-evaluated on every pause. otherwise. Errors land in the row’s Value column with red error chrome and the unstripped Lua error in the tooltip.

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:

- Selection sync — clicking a path-watch row selects the matching Variables-tree row, and vice versa.
- Stack-frame nudge — `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.
- Sharper error chrome — a missing name produces Path not found: … pinpointing the missing segment instead of relaying a Lua runtime message.
- Cheaper re-resolution — the path is walked node-by-node against the live tables; it does not go through the Lua compiler.
- Section-aware tooltip — hovering reports which section the path resolved into (From: Locals, Upvalues, or Globals).

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.

Prev | Up | Next
14.5. Variables | Home | 14.7. Stack Trace