---
title: "Rebuilding a Tooltip Template Language Without Documentation, One Spell at a Time"
description: "Rebuilding, without a spec, the template language that renders an MMO's tooltips: one token at a time, one bug at a time, with guardrails against generalizing too soon."
date: "2026-06-23T09:00:00.000Z"
updated: "2026-08-24T15:05:22.376Z"
locale: "en"
canonical: "https://ninhache.fr/en/blog/reverse-dsl-tooltips"
author: "Néo Almeida"
tags: "reverse-engineering, dsl, parsing"
categories: "dev"
---

# Rebuilding a Tooltip Template Language Without Documentation, One Spell at a Time

The data dump of a live MMO I follow stores, for every spell effect, a title string like `Deals [#1] damage, {[+2]?and [$1$1#1] additional damage:no bonus}`. No spec describes this format, and the client that knows how to interpret it is closed-source.

The only way forward was to observe tooltips as rendered in-game, guess the rule that explains each one, and verify that rule against the exact case that produced it before generalizing it.

## One Real Case, Then Generalize

That produces a long tail of commits that all look alike. The loop is always the same:

**Spot**

I stumble on a spell that renders wrong.

**Isolate**

I isolate the token responsible.

**Handle narrowly**

I write the narrowest possible handler.

**Verify**

I check the render.

**Widen**

I widen the rule only once a second case confirms it.

Writing a generic template engine before understanding the grammar would have meant guessing a spec blind. The vocabulary that emerges covers four families:

**Parameter**

[#N]

 reads the Nth parameter of the current effect (base + increment × level).

**Navigation**

[$C$D#P]

 navigates to a child at a given path, then reads its parameter P.

**Ternary**

{[COND]?true:false}

 a ternary, with several condition operators.

**Math**

|expr|

 an arithmetic formula evaluated inline.

**The condition operators**

`+N` positive, `-N` negative, `~N` tuple present, `!N` null or absent, `V>N` / `V<N` literal comparison, and an implicit threshold of `1` when the comparison has no value on the left: `[>2]` means "parameter 2 is greater than 1."

Three bugs illustrate the kind of trap you only see by stepping in it.

## The One-Level Fallback

A token [$A$B#P] describes a two-hop path: enter child A, then its own child B, and read parameter P off that grandchild. That works for the vast majority of spells. The problem comes from a subset of effects wrapped in a "group" node, a transparent container that exists for internal engine reasons, with no effect visible to the player. When a title references this kind of structure, the path targets a leaf with no grandchild: the group wraps it directly as an immediate child.

Navigation therefore fails silently, and the token is left displayed as-is. The fix isn't to rewrite path resolution top to bottom, but to add a localized fallback exactly where the token is consumed: if the full navigation fails and the starting pool contains only a single wrapping node, retry the same path one level down.

```ts title="Targeted fallback, at the consumption site"
// Fallback: the path targeted a grandchild, but the starting pool
// has only a single wrapping node (transparent group) that already
// contains the target as a direct child. Retry one level down.
if (!target && pool.length === 1 && pool[0].children?.length) {
  target = navigateEffectPath(pool[0].children, indices); // [!code highlight]
}
```

**Pitfall**

Fixing the general path-resolution function to absorb every transparent group at once would have broken paths that genuinely target a grandchild elsewhere in the tree. The targeted fallback, triggered as a last resort at each call site that needs it, avoids regressing on cases that already worked.

At the cost of a small debt: the same fallback exists duplicated in two places in the code that build state descriptions, rather than once in the general resolution. Accepted for now, rather than a premature factoring that would have to guess what the general version should look like.

## Math Leaking Into the Displayed Text

The second bug was dumber and more visible: |expr| tokens, meant to be evaluated as an arithmetic formula, simply weren't. The text shown to the player literally contained fragments like "Stacks up to |20*2|" instead of "Stacks up to 40," and nobody noticed, since the token is rare and the broken output was vaguely readable.

The fix comes down to two points. Order first: substitute parameter tokens before evaluating |expr| tokens, otherwise you evaluate an expression that still contains brackets. Safety second: rather than `eval` or `new Function`, validate that the string contains only safe arithmetic characters, and only then evaluate it with a small hand-written recursive-descent parser.

```ts title="Safe evaluation of |expr|"
result = result.replace(/\|([^|]+)\|/g, (match, expr: string) => {
  if (!/^[\d.\s+\-*/()]+$/.test(expr)) return match; // not a formula, leave as-is
  const value = evalArithmeticExpr(expr);
  return value === null ? match : String(Math.floor(Math.abs(value)));
});
```

Detail discovered after the fact (This pass also has to run before converting other tokens that likewise use the vertical bar as a separator, otherwise a marker's bar and a formula's bar mistakenly pair up and swallow everything in between.): none of this is written down anywhere. You learn it by watching a tooltip display a raw marker fragment in place of a word.

## The Third Bug Didn't Crash

The first two bugs shared a reassuring trait: they were visible. An unresolved token or an unevaluated formula shows up at a glance on the tooltip, can be found with a full-text search, and the fix can be verified by rereading the same screen. The third case gave none of these signals: the tooltip rendered fine, with plausible numbers, just wrong ones.

The dump stores effects, spells, and states in a single shared numeric id space. An effect references its parent by a plain id, without explicitly stating what kind of container that parent is. The first version of the indexing grouped "every effect that points at this id" as if they necessarily had to be children of the same parent effect. Except an id can denote an effect, a spell, or a state, each with its own list of legitimate children: nothing prevents an effect from carrying the same number as an unrelated spell or state. The result: the indexing grafted the top-level effects of an unrelated container onto an effect that merely happened to share its number. The tooltip then displayed a damage line borrowed from a completely different entity: a believable number, at the right scale, just borrowed from the wrong place.

The fix restricts grouping to the only structurally valid case: an effect is the child of another effect only if its declared parent type is explicitly a "group" in the engine's sense. Everything else, even when the id matches, is an entry point of a different container, not a child.

```ts title="Restrict grouping to real children"
// Only effects attached to a GROUP are true children of another EFFECT.
// Any other parent type (SPELL, STATE, ITEM_EQUIP, AREA, ...) means
// the effect is a top-level entry point of that container, NOT a child.
// The parent id is shared across the effect/spell/state spaces.
if ((e.parentType ?? '').trim() !== 'GROUP') continue; // [!code highlight]
```

This bug changes the nature of the risk. The previous two broke down because of a rule that was too narrow, or a missing pass; this one comes from a rule that looked general ("group by parent id") and silently got it wrong the moment a second case contradicted it, never crashing and never showing a raw token. It's the clearest version of this article's thesis: a rule that looks like it generalizes correctly on the first case can produce, on the next one, a result that looks correct without being correct.

## The T/F/U Ternary Evaluator

The piece I'm happiest with: a static three-valued evaluator for the criteria DSL that decides whether an effect triggers. Some spells have subtrees gated by a criterion that can never be true outside combat (an enemy count, a state applied by a caster that doesn't exist outside combat). Proving that statically lets the stats tool skip those branches cleanly.

The criterion is a small boolean expression (`and`, `or`, `not`, comparisons, function calls). Most of the identifiers found in it are only resolvable at runtime, so a binary evaluator would be forced to guess. Instead, each sub-expression reduces to True, False, or Unknown, with a conservative algebra for mixed cases:

An `and` with one side False is False regardless of the other side; an `or` with one side True is True regardless of the other side; everything else falls back to Unknown rather than risk a false certainty.

This evaluator exists in duplicate: the dump build pipeline and the client that renders tooltips are two separate projects, with no shared package, and each keeps its own copy of the same three-valued algebra, kept in hand-maintained parity. A handful of functions have a known value outside combat, each justified in a comment rather than treated as self-evident:

```ts title="Functions with a known value outside combat"
const KNOWN_FUNCTIONS = {
  // Outside combat, no enemies on the field.
  GetEnnemyCountInRange: () => ({ kind: 'num', n: 0 }),
  // "Applied by such-and-such caster" assumes a runtime caster. An effect
  // granted by equipment has no caster: structurally F.
  HasStateFromUser: () => F,
};
```

**A Bad Entry Prunes Silently**

A bad entry in this registry silently prunes real effects: it's not an error you can see, it's a regression waiting for a player to notice a stat has vanished. Each addition is therefore weighed one at a time, and each fix ships with a test that pins down the real case that motivated it.

## Two Guardrails: Named Cases and No-Loss Diffs

Rebuilding a format through targeted fixes only holds up if each fix stays fixed. Two disciplines take care of that, at two different scales.

**Named Cases (Tests)**

A Vitest suite on the client side: one `it()` per real tooltip case encountered in production, named after that specific case rather than after the mechanism under test. Every bug in this article has its own test, which would fail if the fix regressed.

**No-Loss Diff**

Before any change that touches text: render every known spell, once before and once after, and diff line by line. The proof being sought isn't "this looks better," it's zero lines gone missing, zero lines appearing outside the intended target.

Only the client carries the test suite, and that's a deliberate choice: that's where regressions are the most costly, since they're visible to a player. The build pipeline, on the other hand, is a script you rerun and whose output you reread. The two disciplines complement each other: the named test pins a case against being forgotten, the no-loss diff proves that a change targeting one case hasn't silently broken fifty others you didn't think to list.

## Pruning Early Breaks Things, Filtering Late Fixes Them

The first version of the pipeline pruned at build time: an effect whose criterion was literally the string "False" was removed from the dump, subtree included. That broke a real production tooltip, because its criterion wasn't just "False" but a conjunction ending in "... and False": the effect never triggers in-game, correctly, but its text description was still supposed to show up in the spell's effects tab. By removing it from the dump, I'd removed the text along with it.

The second accident comes from another pruning rule, the one that removes effects marked "don't show in description." Reasonable on the surface, except some parent titles specifically reference the parameters of these hidden effects via a path token. Removing the hidden child left an orphaned token in the parent's title, and the whole line's display collapsed.

Both regressions pointed in the same direction: deciding at build time what's safe to remove requires anticipating every future use of a node, including ones you can't see yet because another node references it from a distance. The decision was to never prune inside the dump again: the full tree ships as-is, criteria and hidden effects included. Filtering happens at runtime, on the consumer side, and each consumer keeps its own philosophy. The stats calculation skips branches using the same T/F/U evaluator, because for numbers, staying silent on uncertainty is the safe choice. Text rendering, on the other hand, shows everything it can, because for a readable description, too much text beats a missing line.

Two more examples follow the same logic on the text side. Some effects have a generic per-action template and a more specific per-effect override that takes precedence: when that override, written for a different context, carries a state-name token that has nothing to resolve here, appending the generic template to the end of the string reverses the word order. The fix substitutes the template in place of the failing token, not at the end, so the words that follow keep their position. And when a per-effect title already summarizes the applied state, the raw effect lines become redundant in the inline display: rather than deleting them (the same mistake again), they're moved to the hover card, and only if they're shown to be present there word-for-word once normalized.

**The Right Question**

Instead of choosing between keeping and deleting a piece of information, the right question is figuring out which consumer should receive it.

## What I Take Away From This

**Lesson**

Rebuilding a proprietary format without a spec looks like debugging in reverse: instead of starting from a known rule and finding where it breaks, you start from a broken render and work your way back up to an unknown rule. Generalizing too early, before seeing at least two real cases, produces a rule that looks clean and gets the third case wrong. And the only thing more dangerous than a mis-parsed token is a pruning decision made too early in the pipeline: it doesn't just display bad text, it deletes the information that would have let you fix it later.
