# Changelog — Baileywiki Better Levels How this works: in-progress notes accumulate under **Unreleased**, grouped by Added / Changed / Fixed. At publish time, **Unreleased** is renamed to the version being released (with the date) and the version is bumped — so every *published* release carries ONE complete, user-facing summary. FoundryMods receives only the released version's `## ` section, never the whole file. **Release-channel workflow (as of 1.5.2 → testing).** Default publish target is the **testing** channel (`publish-foundrymods.py --module baileywiki-better-levels --testing`), left **private**. Every "publish it" cuts a new testing version; stable's `latest_version` stays put. Version numbers are **globally unique per module across both channels**, so a version can't be pushed twice — promotion re-points the stable channel at the artifact already sitting in testing (no rebuild, same bytes). The per-version sections below stay detailed for OUR tracking. When promoting to stable, do NOT send the raw pile of testing sections. Instead collate every `## ` newer than the `LAST-PUBLIC-STABLE` marker above, then hand the promotion **one rationalized, user-facing changelog**: keep only what matters to someone upgrading from the last public version, drop internal churn (harness/lint/verification fixes, "assert too strict", bugs introduced and fixed within the same testing run), and merge related items into single lines. After promoting, update the `LAST-PUBLIC-STABLE` marker to the promoted version. ## Unreleased ### Added - _new features_ ### Changed - _behaviour changes_ ### Fixed - _bug fixes_ ## 2.0.1 - 2026-08-25 ### Fixed - Helper panel remembers whether it was open, minimized, or closed, and no longer pops open just because a scene has levels. - Audit no longer errors when a roof is missing its surface region (that used to spam the console every time the helper panel redrew). ### Added - Set Defaults has a link that opens this module's Game Settings. ### Changed - Show visible tokens only (keeps overhead tiles intact when a token is seen on another floor) now defaults to on. Worlds that already saved it off stay off. ## 2.0.0 - 2026-08-24 **Any battlemap can now have levels, not just the ones designed for them.** Draw a region over artwork that is already painted into a map (a bridge, catwalk, awning, or cave mouth) and tokens traveling underneath vanish behind it, exactly as if an overhead tile covered them. No tile needed, and none is added. Anyone who can genuinely see under there still sees a ghost of the token; everyone else sees nothing. This turns a whole category of maps into levels maps with no art surgery. ### Edit region shapes point by point Foundry has no built-in way to move, add, or delete an individual vertex of a region polygon. Select a polygon region with the Regions layer Select tool and every vertex gets a handle: drag a point to move it, right-click a point to delete it, drag an edge midpoint to insert a point, hold Shift to bypass grid snapping. On by default for GMs; toggle it under "Edit region points on the canvas". ### Faux Overhead A region behavior that conceals tokens beneath painted-in overhead art. - Per-client display of concealed tokens: hidden, faded ghost, hitbox only, or the token as normal. Each player and the GM pick their own. - Per-region concealment override (solid stone vs slatted catwalk). Line of sight always wins: a token you can truly see ghosts rather than vanishing. - Partial occlusion: a token on the edge is clipped there, not popped in or out. - Concealed tokens hide their nameplate, bars, and elevation badge, and cannot be targeted by someone who cannot see them. - Keep this area lit through fog of war, so a walled interior under an overhead reads at the map brightness instead of a black hole (holes stay dark). - Make Surface gives an overhead a sight-blocking surface so tokens above and below cannot see through it. - Occlude light like a roof (mutually exclusive with Keep Lit). - Faux Overhead defaults: set draw-time options once; apply to selected for existing regions. - Fill a walled area from the Create menu (click inside a tent, hut, or alcove, or select the walls). Draw is for bridges and anything walls do not enclose. Escape or right-click cancels. ### Native roofs (Better Roofs is no longer required) - Fog-of-war reveal is native, with a Reveal fog of war checkbox on the tile Overhead tab. - Occlusion linking is native: tiles that share an Occlusion Link Id fade together. - Existing Better Roofs settings carry over. The old load warning is gone. ### Level Manager and audit - Per-level backgrounds, with a BG column and a modal for image or color. Deliberate shared artwork is respected by the audit. - Audit outlines on the canvas: red and amber outlines on the viewed floor when Build Mode is on or the helper panel is open. Click an outline to Fix or Ignore. GM only. ### Also in this release - The toolbar is one Better Levels group. Clicking the Better Levels icon opens the helper panel. - Lights dropped on a non-ground floor take that floor elevation (the Levels nav selection). - v13-to-v14 migration no longer loops on scenes that already have v14 levels. Leftover light and sound flags.levels crumbs can be stripped like any other legacy-flag finding. - Hardened the Placeables tile-thumbnail list against a Tiles-tab freeze. - A failure building our toolbar buttons can no longer remove other modules tools. - Concealment, targeting, and rendering refinements so hidden tokens stay hidden. ## 1.12.57 - 2026-08-13 ### Added - **Edit region points directly on the canvas.** Select a polygon region with the Regions layer's Select tool and every vertex gets a handle: drag a point to move it, right-click a point to delete it (its neighbors join with a straight line), and drag an edge midpoint to insert a new point. Hold Shift while dragging to bypass grid snapping. Foundry v14 has no built-in way to edit an individual region vertex, so the only way to reshape a drawn polygon used to be to delete it and draw it again. This makes it easy to tune a Faux Overhead, Define Surface, or stair region so its outline hugs the underlying map art. On by default (GM tool, hidden from players); turn it off under "Edit region points on the canvas" in settings. ## 1.12.56 - 2026-08-12 ### Reverted (Faux Overhead) - **Backed out the terrain-wall Keep-Lit experiment from 1.12.55.** It did not produce the intended result in practice, so Keep-Lit returns to its prior behavior for now. Reaching "see into the area at the scene's illumination level, like terrain walls" remains the open goal, to be revisited with a different approach. ## 1.12.54 - 2026-08-12 ### Changed (Faux Overhead) - **Keep-Lit and Occlude-Light are now mutually exclusive** (they can't both work on one region). In the Faux Defaults dialog they're a single radio group (Keep lit | Occlude light | Neither), and on the region behavior sheet checking one clears the other. Enforced on save so it holds from the sheet, the dialog, or the API - no more silent conflict. ## 1.12.53 - 2026-08-12 ### Documented (Faux Overhead) - **Light occlusion and "Keep lit through fog of war" cannot both work on one region in v14.** Source research confirmed the two are arbitrated by the SAME depth-mask channel: an occluder writes its elevation there, and the illumination gate reads that channel to zero out any brightness sourced below it - which is the keep-lit ground brightness. They only coexist while a token stands under the footprint. No occluder property changes this. The occluder itself is correct and works. Better Levels now warns once when a region has both enabled, and documents the mutual exclusivity in the hint. Use one or the other per region. ## 1.12.51 - 2026-08-12 ### Fixed (Faux Overhead) - **Light occluder no longer shadows the floor beneath it.** It was set to occlusionMode NONE, which makes the depth mask permanently block ALL light below the occluder plane - darkening the very under-bridge floor (and killing the region's Keep-Lit). Switched to occlusionMode FADE (verified against the v14 depth shader): when a token is under it, the occlusion state flips the depth write so the gate becomes "block light ABOVE the plane, pass it BELOW" - the under light lights the floor, but does not spill onto the deck. This is exactly how a roof tile avoids darkening the floor under it. ## 1.12.50 - 2026-08-12 ### Fixed (Faux Overhead) - **Light occluder is now invisible.** It occluded light correctly but rendered as a grey rectangle in the region. Set its display alpha to 0: the depth shader keys on the texture's per-pixel alpha, not the mesh's display alpha, so it still writes depth (still occludes) while drawing nothing to the colour buffer. ## 1.12.49 - 2026-08-12 ### Added (Faux Overhead) - **Light occlusion (per region), for real this time.** A Faux Overhead can now block light beneath it so a torch under a bridge does not spill onto its level, exactly like a roof tile. v14 research confirmed light occlusion is a DEPTH-MASK feature (regions/surfaces can't do it), and the depth pass is duck-typed over canvas.primary children - so Better Levels adds an invisible depth-writing occluder shaped to the region polygon: a PrimarySpriteMesh (restrictsLight, at the region's elevation) built from a polygon render texture, the exact mechanism the engine uses for level-background light occlusion. Opt-in via the region's Faux Overhead behavior and the Faux Defaults dialog; off by default. - Diagnostics: api.faux.lightOccluders() and api.faux.why() (occludesLight) report state. ## 1.12.47 - 2026-08-12 ### Fixed (Roofs / Fog of War) - **Roof fog reveal now holds through darkness.** Rebuilt on the same mechanism the faux-overhead region reveal uses (which already worked in the dark): a plain white fill of the tile footprint into the vision mask's brightness channel, which LIGHTS the footprint rather than merely marking it seen, so the roof stays visible at any scene-darkness level. Dropped the shader SpriteMesh (which wasn't rendering in that mask) and Better Roofs' token-below / line-of-sight gate (a same-elevation crypt lid never passed it). Reveal is now unconditional, matching the faux region reveal. - Trade-off: the lit footprint is the tile's rectangle, so a very irregular roof PNG also lights its transparent bbox margins. Shaping the fill to the texture silhouette is a future refinement. ## 1.12.46 - 2026-08-12 ### Fixed (Roofs / Fog of War) - **Roof fog reveal now survives scene darkness.** The revealed roof was painted into the vision mask ROOT (as Better Roofs does), which marks it seen but not lit - so raising scene darkness hid it again. It now paints into the mask's brightness channel (`fov`), the same channel the faux-overhead reveal uses, so the roof reads as lit and stays visible through fog at any darkness level. (Better than Better Roofs, whose own tooltip notes it does not work with lights.) ## 1.12.45 - 2026-08-12 ### Fixed (Roofs / Fog of War) - **Roof fog reveal now actually works.** The first native attempt used the wrong mechanism: it painted the roof footprint into the vision mask as a white-TINTED sprite, which multiplies the texture and so paints a dark roof's own dark colours - revealing almost nothing (the crypt-lid symptom). Rebuilt it from Better Roofs' actual approach: a custom sampler shader that outputs PURE WHITE shaped by the texture's alpha, drawing the roof through the fog in `canvas.masks.vision`. - Gating matches Better Roofs: a roof reveals only when the controlled token is BELOW it and its vision actually reaches the tile, so roofs across unexplored map aren't revealed. Driven on sight refresh. - Roof fog reveal moved to its own module (tile-fog-reveal.mjs); the faux-overhead painter is back to regions only (a painted-in bridge is lit through fog; a roof tile is re-drawn above fog - different problems, different mechanisms). ## 1.12.44 - 2026-08-12 ### Added (Roofs / Occlusion) - **Native occlusion linking** (Better Roofs' Occlusion Link Id / Source, rebuilt natively). Tiles that share an **Occlusion Link Id** fade together; a tile marked **Occlusion Link Source** broadcasts its occluded state to the rest, while non-sources only receive. Ideal for a multi-tile roof on a prefab that should fade as one piece. Done by wrapping core's `CanvasOcclusionMask#_identifyOccludedObjects` through libWrapper (never a raw patch), elevation-gated to the viewed level. - **Tile config (Overhead tab):** the "Reveal fog of war", "Occlusion Link Id" and "Occlusion Link Source" controls now appear just above "Restricts Light", where Better Roofs' controls used to be. Each writes its flag immediately. - **Migration** now also carries over Better Roofs' `occlusionLinkId` / `occlusionLinkSource` (not just the fog flag) onto the native flags on scene load. ## 1.12.43 - 2026-08-12 ### Added (Roofs / Fog of War) - **"Reveal fog of war" checkbox in the Tile config sheet**, bound to the native flag. It shows CHECKED on roofs (marking a tile as Roof sets it automatically) and can be toggled on any tile; the change takes effect immediately. This restores the UI Better Roofs used to provide, now native. - **Migration:** roofs configured before the dependency was dropped (carrying the old `flags.betterroofs.brMode`) adopt the native reveal-fog flag on scene load, so they keep revealing fog and show the checkbox checked. The old flag is left untouched for anyone still running Better Roofs. ## 1.12.42 - 2026-08-12 ### Changed (Roofs / Fog of War) - **Dropped the Better Roofs dependency for fog-of-war reveal - it is native now.** Roof tiles no longer rely on Better Roofs' `flags.betterroofs.brMode`; Better Levels reveals the fog itself by painting the roof's footprint into the sight mask (the same vision-mask painter the Faux Overhead fog reveal already used). The reveal follows the roof art: a white sprite of the tile's own texture, so an irregular roof reveals only what it covers (falls back to the tile rectangle if its texture isn't reachable). - A new native tile flag (`flags.baileywiki-better-levels.revealFog`) replaces brMode everywhere - tile configuration, the Audit "reveal fog" rule + fix, role inference, and Set Defaults. No external module is required or written to. Better Roofs can be uninstalled without losing roof fog reveal. ## 1.12.41 - 2026-08-12 ### Added (Faux Overhead) - **Faux Overhead defaults.** A new "Faux defaults" button in the Better Levels panel's Create menu opens a small dialog for the draw-time settings a Faux Overhead is born with: Keep lit through fog of war, Make Surface, and the token visibility override. Set them ONCE and every newly drawn (or toggled-on) faux region inherits them, instead of opening each region's Behaviors tab after the fact. - **"Apply to selected"** in the same dialog stamps the shown settings onto the faux regions you have selected - the quick on/off path for existing regions, since v14 has no region HUD to hang toggles on. - Light occlusion is intentionally not in this panel yet; it needs the depth-mask work tracked separately. ## 1.12.40 - 2026-08-12 ### Changed (Faux Overhead) - **Replaced the (non-working) light-occlusion toggle with "Make Surface".** v14 source research confirmed regions CANNOT occlude light in the rendered view - cross-elevation light blocking is a depth-mask feature written only by tiles, so the light-only Define Surface added in 1.12.37 never actually blocked any light. Removed it. - **New per-region "Make Surface" toggle** gives a Faux Overhead a real, FULL Define Surface so token occlusion works properly: without one, two tokens can see each other straight through the bridge because sight is not blocked between levels. It applies core's surface via the same machinery a hand-made Define Surface uses (all restrictions on), tagged to EVERY level mutually visible with this one (so tokens on a level that can't see the overhead don't suddenly reveal each other), with the region's own band as the level (not whatever floor you happen to be viewing). Off by default; leave off for a see-through awning or rope bridge. - Light occlusion for Faux Overheads is tracked separately - it needs a depth-mask occluder (emulating a tile), which regions cannot do natively. ## 1.12.39 - 2026-08-12 ### Fixed (Faux Overhead) - **Definitive white-screen fix.** Moving a token down a level triggers three full canvas redraws; during them PIXI could leave a partial-occlusion mask's `renderable` flag on while it sat in the primary group (which renders its children), so the mask's white fill painted the whole screen. All masks now live in a dedicated `renderable = false` holder: their transforms still update (so clipping still aligns and works) but they are skipped by the colour pass entirely and can never paint the screen. Combined with the bounded fill and per-mesh binding, the mask is now physically unable to white out the canvas. ## 1.12.38 - 2026-08-12 ### Fixed (Faux Overhead) - **The partial-occlusion mask can no longer white out the whole screen.** Its fill was scene-sized, so a single stray/orphaned mask painted white over the entire canvas (seen again when moving a token down a level). The fill is now bounded to the region's own bounding box plus a margin, so even if a mask ever leaks, the worst it can do is briefly flash the overhead's immediate footprint, never the whole scene. ## 1.12.37 - 2026-08-12 ### Added (Faux Overhead) - **Per-region "Occlude light (like a roof)" toggle.** A Faux Overhead can now block light at its own elevation, so a light beneath it does not spill onto the overhead's level (a torch under a stone bridge stops lighting the bridge deck). Off by default, for things you can see light through (a catwalk, a rope bridge). - Native, no tile and no module dependency: it uses core v14's own region light occlusion. Better Levels keeps a light-ONLY Define Surface on the region (move/sight/sound/occlusion all off), so only light is affected. The surface is flagged as ours - it is added and removed with the toggle and never touches a surface you built by hand. - Set it in the region's Faux Overhead behavior, per region, alongside the reveal override. ## 1.12.35 - 2026-08-12 ### Changed (Faux Overhead) - **Partial occlusion is now STANDARD in both concealing modes** - "Nothing at all" and "Hitbox only". A token straddling a Faux Overhead edge is clipped at that edge, so the part sticking out still shows; Hitbox additionally keeps its box. Ghost is unchanged. The separate opt-in setting has been removed. - **Reveal-mode copy clarified** - the setting now says plainly it changes only how a token beneath an overhead is drawn ON YOUR OWN SCREEN, not what it can do and not anyone else's view (the old wording could be read as controlling other tokens in view). ### Fixed (Faux Overhead) - **The scene no longer flashes white when dragging a token under an overhead.** A drag/refresh swaps in a new token mesh; the old partial-occlusion mask was left orphaned in the primary group, and PIXI can leave a spent mask's `renderable` flag on, so the orphan painted its white fill over the whole scene. The mask is now bound to the exact mesh it was built for (a mesh swap rebuilds it) and orphans are swept and destroyed every frame before they can render. ## 1.12.34 - 2026-08-12 ### Diagnostics (Faux Overhead) - **`api.faux.why()`** - reports the full reveal/partial decision for the controlled (or hovered) token: client setting, region found, region override, whether the token is seen by active vision, the final mode painted, whether partial occlusion would engage, and whether the region shape can be masked. Used to pin down why an edit appears to do nothing. ## 1.12.33 - 2026-08-12 ### Added (Faux Overhead) - **Partial occlusion (opt-in, client setting).** A hidden token standing HALF under a Faux Overhead now shows the half sticking out, clipped at the region's edge exactly as a real overhead tile would, instead of vanishing whole. Off by default (it changes the look of a feature people already rely on) and it only affects the fully-hidden reveal mode; ghost and hitbox-only are unchanged. - Done with a PIXI mask over the token's artwork, punched out by the region polygon. The mask geometry is static per region (rebuilt only when the region changes, not when the token moves) so it is cheap. - Name and health bars are still hidden when the token is under an overhead, since they aren't part of the artwork and can't be clipped by it. - Degrades safely: an unsupported region shape (ellipse/circle), a missing PIXI API, or any error falls back to the original whole-token hide, so it can never leave a token MORE visible than before. ## 1.12.32 - 2026-08-10 ### Removed - **Simple Elevation (the experimental elevation-drives-level mode) has been withdrawn.** It fought the v14 movement pipeline too hard (relative HUD field, elevation clamping, movement-id collisions, and a crash segmentizing through regions) and never became reliable. All of its code, settings, and the HUD readout are gone. Nothing shipped to stable. ### Fixed - **The roof-infinity suggestion no longer applies to lower-level roofs.** `roof-infinity-region` set a roof surface's top to infinite, which is only valid on the TOP level (open sky above). Applied to a roof on a lower level it created a surface spanning [bottom, +Infinity) that overlaps every level above AND crashes core's movement path-sampling on any elevation change through it (`Cannot read properties of undefined (reading 'testPoint')`). The suggestion is now gated to the top level. ### Added - **Audit rule: "Surface with infinite top below the top level"** (error) - finds surfaces already made infinite on a lower level and caps them at their own level's top, repairing scenes that hit the crash above. ## 1.12.31 - 2026-08-10 ### Changed (Simple Elevation) - **The elevation edit is now a single `TokenDocument#move()`** carrying the destination level and elevation together, replacing the two back-to-back updates that collided in core's movement tracking (`Cannot redefine property: `). This is the modern API the deprecated `teleport` option pointed to, and it is one clean movement with no second update to collide. Dropped the deprecated `teleport` option entirely. ### Known limitation - A scene with MALFORMED regions (e.g. duplicate surface regions, or surfaces with an infinite top) can still crash core's own elevation path-sampling (`Cannot read properties of undefined (reading 'testPoint')` inside segmentizeRegionMovementPath), independent of this feature. Run Better Levels' Audit & Fix / Repair on such a scene to clean the regions first. ## 1.12.30 — 2026-08-10 ### Fixed (Simple Elevation) - **Elevation edits no longer crash inside core's region movement code.** A non-teleport elevation change makes core SEGMENTIZE the vertical path through every region between the old and new position, and on a scene with a region whose polygon tree is unreadable that throws `Cannot read properties of undefined (reading 'testPoint')`. Both writes (level, then elevation) are now marked teleport, so core jumps straight to the destination and never samples the path. This is the same option the existing setElevationOnLevelChange handler uses, and removing it earlier was a mistake. ## 1.12.29 — 2026-08-10 ### Fixed (Simple Elevation) - **Rebuilt the elevation edit as two steps (Jerrod's design): change the level the normal way, then nudge the elevation once the token is there.** The single combined write changed the data but did not trigger the refresh a real level change does, so the canvas looked frozen ("totally unresponsive"). Changing the level first goes through the proven, self-refreshing path; by the time the elevation is set the token is already on the destination level, so the typed value is inside the new band and core has nothing to clamp. Moving within one level is still a single elevation write. ## 1.12.28 — 2026-08-10 ### Fixed (Simple Elevation) - **Removed the full-canvas redraw on every elevation edit.** Deriving a lower level now works (the atomic level+elevation write from 1.12.27 landed correctly), but the follow-up view switch to render the new floor forced Foundry to redraw every canvas layer and reload all scene assets on each edit, the log spam and the setTimeout violations. It is also not wanted: as Jerrod noted earlier, the GM follows the token to its level via the Levels nav, which is the light built-in way to change the shown floor. The mode now just moves the token to the right level and stops there. ## 1.12.27 — 2026-08-10 ### Fixed (Simple Elevation) - **Dropping a token to a lower level now works.** Confirmed live: core clamps a token's elevation to its CURRENT level's band during the update, so writing elevation 15 to a token on a base-30 level landed at 30 (the floor). The mode wrote elevation first and derived the level after, which deadlocks going down: the elevation can't fall below the current floor, so the level never changes, so the elevation can't fall. The absolute field now writes the derived level AND the elevation in ONE update, giving core a consistent pair with no moment to clamp, then switches the viewed level so the new floor renders. - The duplicate-event guard now dedupes by value, so two different elevations typed in quick succession both apply (the previous time-only guard could swallow the second). ## 1.12.26 — 2026-08-10 ### Fixed (Simple Elevation) - **The absolute-elevation field now writes ONCE per edit.** The stock field submits on Enter, on blur, and fires a `change`, and the override was listening to all three, so it wrote three times per edit (three preUpdate lines in the watcher). Everything now routes through a single `change` write, with Enter only committing-and-blurring and a short guard dropping stragglers. - The watcher now logs a line when the field override actually writes, so it is possible to confirm the override is winning over core's relative handler rather than inferring it. ## 1.12.25 — 2026-08-10 ### Fixed (Simple Elevation) - **Simple Elevation now actually delivers "one scene zero," and the new floor renders.** The v1 approach fought two core behaviors the watcher and the v14 source made plain: - **The HUD elevation field is relative to the VIEWED level** (reads `elevation - base`, writes `typed + base`) and never touches `level`. So typing "20" while viewing a base-20 level meant absolute 40, and once the level changed the base shifted and the same number meant something new, the chaotic wrong-floor drift. Simple Elevation now overrides that field in both directions so it shows and accepts ONE absolute number from the single scene zero, regardless of which level you are viewing. Only while the mode is on. - **Floors render from the viewed level, not the token's band.** A raw `level` write from a GM's own edit did not switch the view, so the new floor never drew. Deriving a level now also switches the viewed level to it (only for the controlled token, so it never yanks the view for a background update). - Removed the deprecated `teleport: true` update option from the level write (v13-deprecated; it only aliased the `displace` movement action and did not suppress the vision refresh anyway). ### Diagnostics - `api.simpleElevation.watch()` / `.unwatch()` retained for confirming behavior in a live scene. ## 1.12.24 — 2026-08-10 ### Added (diagnostics) - **`api.simpleElevation.watch()` / `.unwatch()`** — logs every token elevation/level update with the viewed-level frame, so the level-relative HUD anchoring can be observed directly. Writes nothing. ### Note Investigating why Simple Elevation misbehaves when driven from the HUD elevation field. Verified against the v14 client source: the HUD elevation field is level-RELATIVE (`typed + canvas.level.elevation.base`) and never sets `level`, and floors render from the VIEWED level, not the token's band. The current derive-level-from-elevation approach fights both. Fix pending real console data from the watcher. ## 1.12.23 — 2026-08-10 ### Added - **Simple Elevation** (world setting, off by default). Turn it on and elevation becomes the only number you touch: a token is assigned to whichever level its elevation falls in, measured from one scene zero, so there is no more "N feet above WHICH floor" arithmetic. A flyer is treated exactly like a walker at the same height, one point in space, one level. Levels stay fully alive underneath (vision, occlusion, per-level backgrounds); the mode only changes who assigns the level. - The level is derived on a token's every move AND the moment it is dropped, so a token is never left at `level: null` (the ambiguous state behind the earlier invisible-tokens scenes). - Out-of-range elevations clamp to the nearest real level: below the basement gives the basement, above the roof gives the roof. - Enabling it switches off "Set elevation on level change," which is its exact inverse and would otherwise form a feedback loop. - **Optional token-HUD readout** (client setting, off by default, only shown while Simple Elevation is on): a compact "10 : L2" chip showing a token's absolute elevation and the level it lands on. No separate always-on badge, the level dropdown already shows the level; this just adds the number that drives it for anyone who wants the reassurance. ### Internal - The stair `movementActions` write is unaffected; Simple Elevation derives `level` in `updateToken` after the move settles, teleport-flagged, with a re-entrancy guard, mirroring the existing level->elevation handler rather than fighting the v14 movement pipeline. Spec: `specs/better-levels-single-axis-elevation-spec.md`. ## 1.12.22 — 2026-08-09 ### Fixed - **Reverted 1.12.21's exclusion of `blink` from stairs.** That change rested on my own reasoning, not Foundry's: a teleporting move never passes through the intervening space, so I concluded it could not have climbed the stairs. Core disagrees. Its Change Level behavior offers `blink` in the field's own choices and hard-rejects only `displace` — before it consults the field at all. A player blinking onto a staircase changing floor is intended behavior, and 1.12.21 broke it. Stairs list `blink` again. - The audit rule added in 1.12.21 ("Stair triggers on teleporting movement") is removed for the same reason: it was flagging correct scenes. ### Note Stairs still get their movement actions written out explicitly rather than left empty, and that part was right for a reason worth recording: **an empty set means ALL actions are allowed**, per core's own hint — *"If no actions are selected, all movement actions are allowed."* A fresh Change Level behavior ships empty, so an untouched stair accepts everything. That said, writing the list is NOT protection against a module's custom movement action: core builds the choices as every registered action minus `displace`, so a new action lands in the list like any other. A vehicle stays off the stairs by moving with `displace`, which core hard-rejects. See `specs/mass-edit-vehicle-token-spec.md`. ## 1.12.21 — 2026-08-09 ### Fixed - **Stairs no longer trigger on teleporting movement.** Core's Change Level behavior has exactly one field, `movementActions`, so a stair fires only for tokens moving with a listed action. We were writing every action we could find into it, which is wrong twice over: a teleporting move "goes from the origin immediately to the destination grid space", so blinking across a staircase should no more change your floor than blinking across a doorway should open the door; and a vehicle carrying its own prefab staircase, driven by a token using a teleporting movement action, would set off its own stairs while merely sailing. New stairs now exclude any action whose config has `teleport: true`. ### Added - **Audit rule: "Stair triggers on teleporting movement"** — finds stairs created before the above and removes just the teleporting actions, leaving the walking ones alone. ## 1.12.20 — 2026-08-09 ### Removed - **"Lock Rotation Value" (added in 1.12.19) has been withdrawn.** It belongs in Mass Edit, not here: the problem it solves is a *linked-prefab* problem, and Mass Edit owns linking. Better Levels adding a token-config checkbox to fix Mass Edit's behaviour would have put the switch in one module and the thing it switches in another — exactly the kind of split that makes a bug impossible for a user to attribute. Specified for Aedif instead; nothing shipped to stable, so no user is affected. ## 1.12.19 — 2026-08-09 ### Added - **"Lock Rotation Value" — a new checkbox in the token config, directly beneath core's Lock Rotation.** Core's option is purely cosmetic; the v14 docs describe `lockRotation` as *"Prevent the Token image from visually rotating?"*, and the token's rotation VALUE still turns as it moves. That is fine for a character, whose art is all there is, but it breaks vehicles: a ship or wagon is a token plus a prefab of tiles pinned to it, so the art stays upright while everything attached to it swings. Ticking this behaves as though Automatic Token Rotation were switched off for that token alone, instead of turning it off world-wide and stopping every character from facing their movement. Deliberate rotation still works — the HUD, the rotate keys and the config sheet all turn the token as before. Only rotation applied automatically BY MOVEMENT is suppressed, so a ship stays steerable. Set it on a prototype token and every vehicle placed from that actor inherits it. ### Internal - Implemented on `preMoveToken`, whose `autoRotate` field the v14 docs mark as one of only two writable properties on the movement operation — a supported extension point rather than something prised open. Explicitly NOT via `preUpdateToken`: v14 rejects movement mutations there and silently aborts the whole update, a trap this codebase has already paid for once (see token-hud.mjs). The hook never returns false and swallows its own errors, because cancelling a move would leave the token undraggable — a worse failure than the one being fixed. ## 1.12.18 — 2026-08-09 ### Added - **Per-region token concealment on Faux Overhead.** The behavior gains a "Token concealment" field: *Use each player's setting* (default), *Nothing at all*, *Hitbox only*, *A faded ghost*, or *The token as normal*. The client setting answers "how do I like to see concealed tokens"; this answers "what does this piece of scenery do", which belongs to whoever built the scene — so a region wins when it expresses an opinion. A solid stone bridge can hide a token outright even for a player who prefers ghosts; a slatted catwalk can show hitboxes even for one who prefers them hidden. Inherit is the default and stays the default: a region that silently imposed a look on every client would take the choice away from players who never asked for scenery to have an opinion. Line of sight still beats concealment — a token a viewer can genuinely see ghosts rather than vanishing, whatever the region says. ### Fixed - Two things that the override would have quietly broken, found while wiring it: the per-token region test was skipped whenever a client had the effect switched off (which would have made "The token as normal" ignore every overriding region), and the targeting gate consulted the client setting rather than the outcome (which would have left a region-hidden token freely targetable). ### Internal - `test/import-graph.mjs` — the import smoke test now lives with the module instead of in a scratch file that kept evaporating between runs. `node --check` only parses; this module has shipped two bugs that only appear when the graph is actually loaded. ## 1.12.17 — 2026-08-04 ### Fixed - **Ghost tokens are no longer the wrong size for tokens with dynamic rings** (reported by RedB). With Foundry's Dynamic Token Rings **Adjustment mode** set to *Grid*, a ghost drew smaller than the token it stands for: two concentric rings while both were on screen, and an apparent shrink the moment the real token hid. We were computing the ghost's size as `token.width x grid size` — the token's FOOTPRINT, which equals the drawn artwork only for a plain token at texture scale 1. A dynamic ring scales its subject by the world's Adjustment mode, so Grid and Standard fit differ (the same ~1.2 factor that shows up when scaling a copied sprite in Sequencer). The ghost now MEASURES what core actually drew, so it inherits every correction core applies rather than us racing to reimplement each one. This also fixes a case nobody had reported: **`texture.scaleX/scaleY` was ignored entirely**, so art deliberately drawn larger or smaller than its square ghosted at the wrong size too, rings or no rings. The measurement is cached per token, because core destroys the mesh of an invisible token — which is precisely when a ghost is wanted. ## 1.12.16 — 2026-08-04 ### Added - **Per-level backgrounds in the Level Manager.** A new BG column shows, at a glance, which of three states each level is in: - a **solid thumbnail** — the level paints its own image; - a **faded thumbnail** — it has no image of its own and is showing another level's, named in the tooltip ("Using the background from Level 01"); - a **colour swatch** — no image anywhere visible below, so only the colour shows. Clicking any of them opens one modal offering both a file picker and a colour picker, because all three states are views of the same two fields. Edits are **staged like every other change in that window** and written on Commit, so Cancel genuinely undoes them; a pending edit is ringed in blue. A background-only change skips the backup prompt — it destroys nothing, and a prompt that cries wolf is one people learn to click through. - **The audit now defers to a background you chose.** Setting an image through the modal marks that level, and the duplicate-background rule skips it — so two levels can deliberately share artwork without being told off. Generated duplicates are still caught. Same contract as the visibility matrix. ### Note Foundry has no mechanism for one level to reference another's image: a level either has its own `background.src` or it has none, and when it has none you see the level below because it is in that level's Visible Levels. "Inherited" is therefore something we infer and display, never something we store — and emphatically not something we implement by copying the image, which is the bug fixed in 1.8.9 / 1.12.8. ## 1.12.15 — 2026-08-04 ### Fixed - **Hardened the Placeables tile-thumbnail decorator against locking up the interface.** A user reported Foundry crashing whenever they opened the Tile Controls or the Placeables → Tiles tab, only with this module enabled. A tab that dies with no exception is a freeze or an out-of-memory, and this code had three ways to cause one: - The MutationObserver watched the same rows it then modified. On its own that settles, but against another module that also rebuilds those rows the two wake each other forever. It now ignores its own writes, coalesces bursts into a single pass per frame, and **stands down permanently** if it is still being woken 25 times in two seconds — logging why, rather than taking the interface with it. - The row scan was `querySelectorAll("*")` combined with a per-tile substring search, so one pass cost roughly elements × attributes × tiles, and the observer re-ran it on every DOM change. It now scans row-shaped elements only, and the substring fallback is bounded. - Every animated tile created its own `