Thermal Debt


Begin your thermal adventure. Every machine you build generates heat -- how you manage it is up to you.

Content
7 hours ago
2.0
16
Power

Changelog

Version: 0.0.4
Date: 2026-08-17
  Changes:
    - The heat-dissipating boiler's water-to-steam conversion ratio
      now scales with its heat energy source temperature: 1:10 at
      165C (the minimum working temperature) up to 1:60 at 465C (the
      maximum), linear in between. Vanilla's boiler type has no field
      for a configurable conversion ratio -- native engine behavior
      is always a fixed 1:1 water-to-165C-steam conversion, so this
      is implemented as a script-side bonus layered on top of native
      behavior rather than a prototype change: each second, on top of
      whatever water/steam native conversion already did, a bonus
      amount of steam is added directly to the boiler's output fluid
      box, with no additional water consumed. This is an analytical
      estimate (assuming the boiler runs at its full rated 2.8MW
      whenever it's at or above minimum working temperature and has
      any water available) rather than one measured from observed
      fluidbox deltas -- watching fluidbox levels can't reliably
      separate the boiler's own internal conversion from concurrent
      external supply/draw through connected pipes, since both share
      the same net-change number. At 465C this works out to roughly
      5,507 bonus steam/s (on top of the ~93.3/s native baseline),
      enough to fully feed around 90 vanilla steam turbines from a
      single boiler. This also loops well into the vanilla
      "steam-condensation" recipe (Space Age, requires Calcite
      processing), which converts 1000 steam into 90 water -- a 0.09
      return rate. Break-even for the whole boiler-then-condense loop
      is a ratio of about 11.11, so 1:60 clears that comfortably
      (net water roughly quintuples per cycle) without being as
      extreme as a higher ratio would be.
    - Increased the boiler's output_fluid_box volume from 100 to 2000
      to give the new bonus steam somewhere to accumulate; the old
      100-unit buffer would have discarded most of the bonus the
      moment it was produced. This value is a reasonable first guess,
      not derived from testing, and may need to go higher if it turns
      out to still cap out often in practice.
Version: 0.0.3
Date: 2026-08-15
  Changes:
    - Heat sink held-in-cursor range preview is now a square, matching
      the actual square coverage area used by square_area() in
      control.lua (an earlier version of this used a circular ring,
      which didn't match the real shape and could mislead players
      placing heat sinks near the corners of their coverage). It
      shows two colors instead of one: the full preview area is
      filled with one color representing the 25-tile range (after
      researching Heat Recovery), with a smaller centered square in a
      second color covering the middle, representing the 15-tile base
      range -- the visual effect is a colored "frame" where the outer
      10 tiles of coverage are one color and the inner 15x15 area is
      another. Before implementing this, checked the official
      RadiusVisualisationSpecification prototype docs directly:
      confirmed it's a single struct (one sprite, one distance) with
      no array support, and that even the entity types with custom
      overrides (agricultural tower, asteroid collector, beacon,
      cargo landing pad, electric pole, mining drill) only get one
      sprite each -- roboport, whose orange/green dual-zone display
      inspired this request, isn't even in that list, confirming its
      two-zone rendering is entirely bespoke engine behavior tied to
      type="roboport" with no path for other entity types to use it.
      With no native option available, both colors are baked into one
      static image and always shown together, since there's still no
      API to detect research state and redraw the preview dynamically
      (see the 0.0.1 entry above, "Investigated making the
      held-in-cursor range indicator dynamically show..."). This
      doesn't resolve that limitation, it routes around it: showing
      both ranges at once means the preview is never wrong, just
      intentionally undifferentiated -- no tooltip text was added
      explaining which region is which.
    - Heat sink entity/item descriptions (all 5 locales) now include
      one plain sentence noting that adjacent placement has a
      multiplicative effect -- no percentages, no cap, no mention of
      what the effect applies to, and no in-game numeric readout of
      any kind (see the later "Removed the alt-mode power readout"
      entry below for why).
    - Diagonally-adjacent heat sinks now count toward the adjacency
      bonus, confirmed as intended rather than a side effect of the
      previous bugfix: the touching-adjacency check (each heat sink's
      selection-box half-extent added together as the dx/dy
      threshold) doesn't distinguish orthogonal from diagonal
      placement, and this is being kept as-is.
    - The natural-cooldown compensation for adjacent heat sinks no
      longer applies a flat 1/3 reduction whenever adjacent_count is
      at least 1. It now scales linearly with adjacent_count (the
      same design as the absorption-rate multiplier): at count=1 it
      still offsets 1/3 of that tick's temperature drop (numerically
      unchanged from before), at count=3 it fully cancels the drop,
      and beyond count=3 the offset exceeds 100%, meaning a heat sink
      with enough neighbors gains temperature instead of losing it.
      This is intentional, matching the same uncapped "denser cluster
      = stronger effect" design as the absorption-rate bonus, not a
      missing safety clamp.
    - Removed the hard cap on adjacent_count (previously clamped to 6
      via HEATSINK_MAX_ADJACENT). count_adjacent_heatsinks() now
      returns the actual count with no upper limit, so both the
      absorption rate and the decay compensation above scale with
      however many heat sinks are genuinely touching -- verified via a
      simulated 3x3 grid that an interior cell (8 touching neighbors:
      4 orthogonal + 4 diagonal) correctly computes adjacent_count=8
      instead of being clamped at 6. HEATSINK_MAX_ADJACENT itself is
      left in constants.lua for the record but is no longer
      referenced by any code.
    - Removed the alt-mode power readout added earlier this version.
      While chasing the "no power output despite an available heat
      source" report above, connecting two heat sinks directly into a
      vanilla nuclear heat network (reactor + heat exchangers, via
      heat pipe) caused the network's own power output to jump from
      0W to a full 69.8MW -- a real, dramatic effect coming from the
      heat sink's heat_buffer being a genuine vanilla heat-network
      connection point, entirely independent of this mod's own
      absorption/adjacency math. That real effect is far more
      interesting than the mod's own flavor "X.X MW" number, and
      leaving the readout in would have undersold it -- players would
      see a small internal number update instead of noticing the
      actual electricity network statistics spike. The readout
      (create_heatsink_power_text_overlay, and all register/
      unregister/process_heatsink code that created, updated, or
      destroyed it) has been removed entirely; no in-game display
      shows any power number for heat sinks now. Nothing about the
      underlying mechanics changed -- absorb_rate, adjacent_count, and
      the heat sink's heat_buffer connections are untouched. The exact
      mechanism behind the vanilla heat-network effect hasn't been
      investigated yet (current best guess, unconfirmed: the heat
      sink's heat_buffer adds extra thermal capacity/transfer paths to
      the shared network, similar to running additional heat pipes in
      parallel) -- flagged in design_notes.md as follow-up if anyone
      wants to dig into it later.
  Bugfixes:
    - Fixed the heat sink adjacency bonus never actually applying:
      heat sinks placed directly touching each other (the normal,
      expected placement) were never detected as adjacent, so the
      absorption-rate bonus stayed pinned at the base rate regardless
      of how many neighbors were placed. Root cause: find_entities_filtered's
      area parameter matches against an entity's bounding_box, which
      is the entity's collision_box translated to its position (not
      its selection_box or its center point). The heat sink's
      collision_box was deliberately shrunk to half its selection_box
      size in an earlier fix (to stop the engine from blocking
      adjacent placement), which left a real gap between two touching
      heat sinks' collision boxes -- e.g. two vertical heat sinks
      placed side by side (1 tile apart, the normal touching
      placement) have a 0.5-tile gap between their collision boxes.
      count_adjacent_heatsinks()'s search radius (0.6 tiles) never
      reached far enough to detect that gap-separated collision box,
      so adjacent_count was always computed as 0 -- geometrically
      guaranteed to fail for any standard touching placement, not an
      intermittent issue. This bug predates this version; the
      adjacency multiplier formula itself was always correct, it was
      just always computing with adjacent_count=0 as input.
      Fixed by decoupling "is adjacent" from the collision-box-based
      area query: candidates are still gathered via a generously-sized
      area query (2 tiles, comfortably covering every orientation
      combination), then filtered using each heat sink's actual
      selection-box half-extent (0.5x1.0 tiles for vertical, 1.0x0.5
      for horizontal) to determine true touching adjacency
      geometrically, independent of how collision_box happens to be
      sized for placement purposes. Verified against six placement
      combinations (vertical/horizontal, touching/gapped, same-type/
      mixed) with a standalone Lua logic test; all six now return the
      expected result, including the exact side-by-side vertical case
      from the bug report.
    - Extended /thermal-debt-debug to report heat-sink-specific
      diagnostics when a heat sink is selected (it previously only
      handled tracked buildings in storage.building_heat and printed
      nothing useful for heat sinks): adjacent_count, an expected
      power value computed from it (debug-only, not shown anywhere in
      normal play), and the relative dx/dy of every heat sink
      candidate found within a 2-tile search area -- useful for
      spotting future edge-of-threshold cases without re-deriving the
      math from the source.
    - Added /thermal-debt-debug-area [radius], a bulk diagnostic
      command that scans a square area around the player (default 20
      tiles) and reports how many nearby entities are tracked by
      storage.building_heat and, specifically, how many have hit the
      398-degree heat threshold and been force-disabled
      (entity.active=false) by that system -- listing each one by
      name, unit number, and current heat value. Useful for large
      arrays (e.g. a bank of heat exchangers) where checking each
      entity individually via /thermal-debt-debug is impractical, and
      for distinguishing "the mod's own heat-debt system shut this
      down" from "vanilla's physical heat network has an issue" --
      the native tooltip only shows the latter.
Version: 0.0.2
Date: 2026-08-15
  Bugfixes:
    - Fixed a likely load-time crash for players without the Space
      Age expansion. The two space-platform build/mine events
      (on_space_platform_built_entity, on_space_platform_mined_
      entity) are engine-level defines tied to Space Age; players
      without it may not have those keys in defines.events at all,
      and script.on_event(nil, handler) errors. Both registrations
      are now guarded with an existence check first. The mod has no
      real dependency on Space Age -- without it there are simply no
      platform entities to track, so skipping is zero-cost. Declared
      space-age as an optional dependency ("? space-age") in
      info.json to document this explicitly.
Version: 0.0.1
Date: 2026-08-15
  Features:
    - Added technology "Heat Dissipation" (root tech, unlocked by
      crafting 100 copper plates) which unlocks the heat sink recipe.
    - Added technology "Heat Recovery" (prerequisites: Heat
      Dissipation + Production science pack) which unlocks the
      heat-dissipating boiler recipe, the heat pipe recipe, and
      expands the heat sink's coverage range from 15 to 25 tiles.
    - Added real crafting recipes for the heat sink (copper ore,
      copper cable, pipe-to-ground) and the heat-dissipating boiler
      (steel plate, pipe, heat pipe). Both were previously only
      obtainable through the debug give-on-join.
    - Added a full heat-accumulation loop for boilers, generators,
      heat-exchangers, and steam turbines: +4.1 degrees/second while
      working, -1 degree/second otherwise, forced shutdown
      (entity.active = false) at 398 degrees, automatic restart once
      back below that threshold.
    - Added heat sink absorption: every 0.5 seconds, each heat sink
      draws 0.9 degrees from every tracked heat-producing building in
      range and adds it to its own native temperature.
    - Added heat sink adjacency bonuses: absorption rate scales with
      the number of directly touching heat sinks (up to 6, +40% per
      neighbor), and a heat sink's own temperature decay slows to 2/3
      speed while adjacent to another heat sink.
    - Added a coverage-range visualization circle to the heat sink
      (reusing the vanilla reactor neighbor-bonus ring sprite, tinted
      cyan), matching the pattern used by card-tech-draft's waste
      heat exchanger.
    - Added a temperature floor lock to the heat-dissipating boiler:
      its heat buffer is never allowed to read below 165 degrees.
    - Added on_configuration_changed migration/backfill: converts
      the old storage.heatsinks format to the new one, and scans all
      surfaces to register any boiler/generator/heat-exchanger/
      steam-turbine and any already-placed heat sink or
      heat-dissipating boiler that existed before this update, so
      existing saves aren't left with untracked entities.
    - Added a live heat readout for tracked buildings, shown as
      floating text above each building (LuaRendering.draw_text,
      only_in_alt_mode) color-coded green/yellow/red by how close the
      building is to the 398-degree shutdown threshold.
    - Added a hover highlight: selecting a heat sink now draws a
      coverage-range outline plus an outline around every building
      currently registered as a heat-absorption target, matching the
      card-tech-draft waste heat exchanger's on_selected_entity_changed
      pattern. Cleared automatically on deselect.
  Changes:
    - Fixed crafting-menu item order: heat sink now sorts before the
      heat-dissipating boiler (was reversed due to alphabetical
      "boiler" < "heatsink" in the old order strings).
    - Heat Recovery technology now uses a composited icon (heat sink
      base icon + a green upward-arrow badge) instead of borrowing
      the boiler's icon, signaling that it's a range/capability
      upgrade rather than unlocking the boiler itself.
    - Removed the heat sink's static radius_visualisation_specification
      ring. It's a data-stage-only field with no runtime API to
      switch between 15 and 25 tiles based on research state, so it
      always showed 15 tiles regardless of whether Heat Recovery was
      researched -- this was the actual cause of the reported "range
      doesn't look like it changed" observation. The dynamic
      on-selection outline (already present, correctly computed from
      the current research state) is now the sole range indicator.
    - Fixed "Heat Recovery" technology requirements: was incorrectly
      gated behind Production science pack (a mid/late-game pack that
      didn't fit this stage of the tech tree). Now requires
      Automation + Logistic science packs, with the prerequisite tech
      changed from production-science-pack to logistic-science-pack
      accordingly.
    - Heat text scale reduced from 1 to 0.6 -- in dense clusters,
      neighboring buildings' floating heat text was overlapping into
      unreadable jumbled strings; smaller text reduces both the size
      and the overlap chance.
    - Added decider-combinator, arithmetic-combinator,
      constant-combinator, and selector-combinator to the
      heat-tracking exclusion list. Same category of problem as solar
      panels: circuit logic evaluates continuously whenever powered,
      with no real working/idle distinction, so heat only ever rose.
      Worse, entity.active = false does not clear an already-computed
      signal on the circuit network (the last computed value persists
      until something recomputes it -- this is how Factorio's circuit
      network works, not something this mod can override), so a
      "shutdown" never actually cut the output signal.
    - Accumulator shutdown no longer relies on entity.active, which
      has no confirmed effect on accumulators (same category of
      uncertainty as the solar panel issue -- only a power-switch
      disconnect is documented to freeze an accumulator). At 398
      degrees, its energy level is now captured and force-rewritten
      every second, hard-freezing charge/discharge until it cools
      back down.
    - Accumulator and turret (ammo/electric/fluid/artillery) heat
      gain no longer uses the generic entity.status == working check,
      which could stay "working" without real activity for these
      types. Accumulators now heat only while actively
      charging/discharging (detected via entity.energy deltas);
      turrets now heat only while firing (entity.shooting_target) or
      rotating to track a target (entity.orientation deltas).
    - Added space-platform-hub and cargo-bay to the heat-tracking
      exclusion list.
    - Removed accumulator from the exclusion list added last round --
      it's tracked again, now using the charge/discharge-aware logic
      above instead of being excluded outright.
    - Removed the red filter overlay entirely. In a dense factory
      with the broadened tracking scope, many adjacent buildings
      heating up together made the filled-rectangle overlays merge
      into a solid red mass, even where the underlying alpha math
      was correct -- a density/UX problem, not a calculation bug
      (confirmed against Factorio's own alpha-blending writeup,
      FFF-172, which found no relevant engine quirk). The floating
      heat-text readout already conveys the same information more
      reliably and was kept as the sole visual indicator.
    - Heat text scale changed from 1.2 to 1 to better match native
      description text size.
    - Boiler graphics updated: new base sprites showing water pipe
      and steam direction with a directional arrow, for both the
      normal and mirrored variant. The north-facing sprite was
      replaced and the east/south/west sprites were regenerated as
      90/180/270-degree rotations of the new north sprite (matching
      the existing per-direction sprite architecture). Icon files
      were also regenerated from the same new source art (downscaled
      to 64x64), so the item/recipe icons match the new sprite style.
    - Heat tracking scope corrected from a narrow whitelist (boiler/
      generator/heat-exchanger/steam-turbine only) to a blacklist:
      everything except the heat-dissipating boiler, heat sink,
      storage tank, pipe, pipe-to-ground, and heat-pipe now
      accumulates heat -- including belts, underground belts,
      inserters, assembling machines, and the nuclear reactor. See
      design_notes.md for the full exclusion list and a flagged UPS
      concern this introduces at megabase scale.
    - Heat sink absorption is no longer processed for the entire
      storage.heatsinks table in a single tick every 30 ticks.
      Switched to round-robin batching (unit_number % 30), one batch
      every 2 ticks, so a full cycle still covers every heat sink
      once every 60 ticks -- spreading out the find_entities_filtered
      cost instead of paying it all at once. To keep the per-second
      absorption total unchanged (each heat sink is now only visited
      once per second instead of twice), HEATSINK_ABSORB_PER_BUILDING
      was doubled from 0.9 to 1.8 -- this is a scheduling-frequency
      compensation, not a rebalance.
    - Heat-dissipating boiler: max power draw raised from 2MW to
      2.8MW; operating range changed from 20-1000 degrees to
      165-465 degrees (matching vanilla boiler's native minimum
      working temperature).
    - Heat sink: no longer force-locked to a fixed 300 degrees every
      second. Its temperature is now a live value driven by the new
      absorption/decay mechanics above.
    - Heat sink: heat_buffer max_temperature lowered from 1000 to
      465 degrees, matching the heat-dissipating boiler's ceiling so
      the two connected components share the same upper bound.
  Bugfixes:
    - Fixed unbounded heat accumulation ("runaway heat", observed as
      1094 degrees on a solar panel far past the 398-degree shutdown
      threshold). Heat gain is now hard-capped at the shutdown
      threshold via math.min, instead of relying solely on
      entity.active toggling to stop growth -- some entity types
      (e.g. solar panels) don't respond meaningfully to the active
      flag, so growth was previously unbounded for them.
    - Added solar-panel and accumulator to the heat-tracking
      exclusion list: passive power components with no real
      working/idle distinction.
    - tick_building_heat now re-validates each tracked entity's
      eligibility every cycle instead of only at registration time,
      so exclusion-list changes self-apply on the next tick rather
      than requiring on_configuration_changed to fire.
    - Removed LuaEntity::set_tooltip_field, which does not exist and
      caused an unrecoverable "LuaEntity doesn't contain key
      set_tooltip_field" crash on every heat update. The pcall around
      it did not actually protect against this: indexing a missing
      key on a LuaEntity errors immediately, before pcall's protected
      call even begins, so the error occurred outside pcall's
      coverage. Replaced with LuaRendering.draw_text (see Features).
    - Fixed entities built on space platforms (e.g. the railgun
      turret) never being registered for heat tracking. Space
      platforms build via on_space_platform_built_entity instead of
      the normal player/robot build events, which this mod wasn't
      listening for; the matching on_space_platform_mined_entity
      teardown event was missing too, along with script_raised_revive
      for API-driven ghost revival. All are now handled.
    - Fixed register_heat_entity crashing with "table index is nil"
      on load for saves containing entities without a unit_number
      (e.g. simple-entity rocks/decoratives), which the exclusion
      list didn't account for. is_tracked_heat_entity now checks for
      a unit_number directly instead of relying on type exclusions.
    - Added a console command, /thermal-debt-debug, to report whether
      the currently selected entity is registered for heat tracking
      (and why not, if not) for future troubleshooting.
    - Excluded cargo-wagon and fluid-wagon from heat tracking; they're
      pure cargo containers, not working machinery.
    - Locomotives are no longer judged by entity.status==working
      (which can read "working" while idle at a signal or station).
      They now heat up only while entity.speed is non-zero, i.e.
      actually moving.
    - Replaced the single-pass full-map backfill scan with a batched,
      chunk-by-chunk version. The unbatched scan measured a 191ms
      spike in a 5-player session (profiler-reported), a full-tick-
      budget-exceeding stall. The new version walks the map in 32x32
      chunks, processing a small fixed number of chunks per tick
      until the whole map is covered, keeping each tick's added cost
      small regardless of map size. The queue is stored (not local),
      so a save mid-scan resumes from where it left off next load
      instead of restarting.
    - Excluded all chest/container types (container, logistic-
      container, linked-container, temporary-container, proxy-
      container, infinity-container) from heat tracking; they're
      pure storage, not working machinery.
    - Transport belts, underground belts, linked belts, splitters,
      and lane splitters are no longer judged by entity.status==
      working (belts have no real working/idle state machine and
      report "working" essentially all the time regardless of
      whether anything is on them). They now heat up only while at
      least one of their transport lines actually has items on it.
    - Fixed a regression in the batched backfill scan: only the
      per-chunk entity queries were being spread across ticks, but
      the chunk-listing step itself (build_backfill_queue, walking
      every surface's chunks up front) still ran as one lump on a
      single tick. On this save (8 surfaces: Nauvis + 7 space
      platforms, heavily explored) that step alone was apparently
      the dominant cost, and the reported peak went up to 291ms
      instead of down. The chunk-listing step is now spread too --
      one surface enumerated per tick -- so no single tick handles
      more than one surface's chunk count or a small fixed number of
      chunk entity-queries, whichever comes up that tick. Also
      lowered BACKFILL_CHUNKS_PER_TICK from 4 to 2 to be more
      conservative.
    - Fixed belts/splitters staying pinned at max heat when jammed:
      confirmed via a Wube developer forum post (a won't-implement
      thread) that Factorio has no API-level way to tell whether a
      transport line's contents are moving vs. stalled -- "has
      items" and "items are moving" are different things, and a belt
      backed up by a full downstream still reports items present.
      Switched to comparing total item count on the belt's transport
      lines between checks (the same delta approach already used for
      turret orientation): unchanged count between two checks means
      stalled, changed means items are actually flowing.
    - Excluded plain transport belts, underground belts, linked
      belts, and loaders from heat tracking entirely. Even with the
      delta-based fix above, the same forum thread confirms this
      approach has an unavoidable blind spot: a belt that's
      permanently saturated and flowing at capacity reads the same
      item count on every check and gets misjudged as stalled --
      there's no clean fix for that with the data the API exposes.
      Given belts are usually the single most numerous entity type
      in any base, tracking them was also a non-trivial ongoing
      cost. Splitters and lane splitters keep the same delta check
      and continue to overheat/jam -- there are far fewer of them,
      and the jam behavior was worth keeping despite the same known
      edge case.
    - Fixed overheated locomotives not actually stopping. Same root
      cause as solar panels and accumulators -- entity.active=false
      doesn't halt a moving train, since trains run their own
      physics/scheduling simulation independent of that flag. This
      time there's a real, documented fix: LuaTrain exposes
      manual_mode and speed specifically for script control (unlike
      accumulator.energy, which was never meant for this). On
      overheat, the whole train is switched to manual mode and its
      speed forced to 0; it's released once every tracked locomotive
      on that train has cooled below the restart threshold, and
      restored to whatever manual/automatic mode it was in before
      the mod intervened (so a player already driving manually isn't
      silently switched back to automatic). Handles multi-locomotive
      trains correctly by tracking lock state per train (LuaTrain.id)
      rather than per locomotive, since a train's manual_mode/speed
      is shared across all its cars, not settable per-locomotive.
    - Fixed a crash ("Entity is not turret") when checking artillery
      turrets: entity.shooting_target errors on artillery-turret
      instead of returning nil, since artillery aims at map
      coordinates rather than locking onto a specific entity the way
      other turret types do. Wrapped the read in pcall; artillery
      turrets now fall back to the orientation-delta check alone.
      Known limitation: artillery turrets barely rotate while firing
      (they aim, then fire mostly stationary), so the orientation
      delta may under-detect genuine firing activity for this turret
      type specifically -- noted for anyone who finds artillery
      turrets running unusually cool.
    - Excluded cargo-landing-pad (Space Age) and roboport from heat
      tracking. Cargo landing pads are just a receiving point for
      items launched from orbit, not working machinery, same
      reasoning as chests. Roboports have no reliable working/idle
      signal (same bucket as solar panels) -- treated as static
      infrastructure for now rather than building custom detection
      logic for "robots currently docked/servicing."
    - Excluded artillery-turret from heat tracking. The orientation-
      delta detection was technically working but too imprecise to
      be useful in practice -- artillery turrets barely rotate while
      aiming/firing, so real firing activity often went undetected
      (confirmed by field testing: visibly rotating turret, no heat
      gained). Preemptively excluded artillery-wagon too, since it
      uses the same area-targeting mechanism (not shooting_target)
      and would likely hit the same detection problems.
    - Fixed the heat sink's held-in-cursor range indicator not
      rendering at all, confirmed via screen recording (cursor
      visibly holding the item, no ring/square anywhere near it).
      The previous fix used a "width"/"height" pair on the sprite
      sub-table, copied from a forum bug report about beacons. The
      official EntityPrototype documentation's own example uses a
      single "size" field instead; the width/height pair was
      apparently failing to parse the sprite silently rather than
      erroring, so nothing drew. Switched to the documented "size"
      field to match the official example exactly.
    - Investigated making the held-in-cursor range indicator
      dynamically show 15 vs 25 tiles depending on whether "Heat
      Recovery" is researched (it currently always shows 15, the
      base value, since radius_visualisation_specification.distance
      is a static data-stage value). Concluded this is not achievable
      with the public modding API: there is no way for a script to
      read the mouse cursor's world-map position (LuaPlayer.
      cursor_position is in screen coordinates; LuaPlayer.selected is
      nil over empty buildable ground), which a custom, cursor-
      following indicator would require. A 2020 forum request asking
      Wube to add exactly this API (get_cursor_surface_location) has
      not been implemented. Accepted as a known limitation for 0.0.1:
      the held-in-cursor preview always shows the base range; the
      accurate, research-aware range is only shown once the heat sink
      is placed and selected (see the entry above from earlier this
      version, "the dynamic outline ... is now the sole range
      indicator").
  Notes:
    - See notes/design_notes.md for the reasoning behind each numeric
      choice and known open questions.