<?xml version="1.0" encoding="iso-8859-1"?>
<!DOCTYPE muclient>
<muclient>

<plugin
   name="VoidMUD"
   author="Void"
   id="b8e8c4cdbb9a4f7f8d4a6e22"
   language="Lua"
   purpose="VoidMUD UI + GMCP handler for MUSHclient"
   save_state="y"
   date_written="2026-05-09"
   requires="4.90"
   version="0.1"
>
<description trim="y">
  VoidMUD client UI for MUSHclient.

  Features:
  - GMCP handshake + receive
  - map/chat/status auxiliary windows
  - no auto-mapping (display-only map pane)

  Alias:
  - voidgmcpdebug 0|1|2
</description>
</plugin>

<aliases>
<alias
   name="voidgmcpdebug"
   script="VoidGMCPDebugAlias"
   match="^voidgmcpdebug\s+([012])$"
   enabled="y"
   regexp="y"
   sequence="100"
   ignore_case="y"
></alias>
<alias
   name="voidfontreset"
   script="VoidFontResetAlias"
   match="^voidfontreset$"
   enabled="y"
   regexp="y"
   sequence="100"
   ignore_case="y"
></alias>
<alias
   name="voidfontall"
   script="VoidFontAllAlias"
   match="^voidfontall +(.+)$"
   enabled="y"
   regexp="y"
   sequence="100"
   ignore_case="y"
></alias>
<alias
   name="voidfontdiag"
   script="VoidFontDiagAlias"
   match="^voidfontdiag$"
   enabled="y"
   regexp="y"
   sequence="100"
   ignore_case="y"
></alias>
</aliases>

<script>
<![CDATA[

local IAC, SB, SE = 0xFF, 0xFA, 0xF0
local GMCP = 201

local state = {
  gmcp_debug = tonumber(GetVariable("gmcp_debug")) or 0,

  -- Geometry defaults.
  map_w = tonumber(GetVariable("map_w")) or 430,
  map_h = tonumber(GetVariable("map_h")) or 360,
  map_x = tonumber(GetVariable("map_x")) or 690,
  map_y = tonumber(GetVariable("map_y")) or 8,

  chat_w = tonumber(GetVariable("chat_w")) or 430,
  chat_h = tonumber(GetVariable("chat_h")) or 250,
  chat_x = tonumber(GetVariable("chat_x")) or 690,
  chat_y = tonumber(GetVariable("chat_y")) or 380,

  status_w = tonumber(GetVariable("status_w")) or 760,
  status_h = tonumber(GetVariable("status_h")) or 170,
  status_x = tonumber(GetVariable("status_x")) or 8,
  status_y = tonumber(GetVariable("status_y")) or 560,

  map_text = "",
  status_text = "",
  chat_lines = {},
  chat_max_lines = tonumber(GetVariable("chat_max_lines")) or 500,

  map_font_size = tonumber(GetVariable("map_font_size")) or 11,
  chat_font_size = tonumber(GetVariable("chat_font_size")) or 11,
  status_font_size = tonumber(GetVariable("status_font_size")) or 11,
}

local json_ok, json = pcall(require, "json")

local win = {
  map = "",
  chat = "",
  status = "",
}

local colour = {
  bg = ColourNameToRGB("black"),
  title_bg = ColourNameToRGB("midnightblue"),
  title_fg = ColourNameToRGB("white"),
  map_fg = ColourNameToRGB("white"),
  chat_fg = ColourNameToRGB("lightgreen"),
  status_fg = ColourNameToRGB("cyan"),
  border = ColourNameToRGB("slategray"),
  border_inner = ColourNameToRGB("darkslategray"),
}

local UI = {
  title_h = 20,
  pad = 6,
  resize_grab = 20,
  min_w = 220,
  min_h = 120,
}

local panels = {
  map = { title = "Map", fg = "map_fg" },
  chat = { title = "Chat", fg = "chat_fg" },
  status = { title = "Status", fg = "status_fg" },
}

local drag = { active = nil, mode = nil, start_mouse_x = 0, start_mouse_y = 0, start_x = 0, start_y = 0, start_w = 0, start_h = 0, hotspot_offset_x = 0, hotspot_offset_y = 0 }
local runtime = {
  rebuilding = false,
  rebuild_queued = false,
  initialized = false,
  ui_ready = false,
  last_rebuild_at = 0,
  last_textrect = nil,
}

local ansi_palette = {
  normal = {
    [30] = ColourNameToRGB("black"),
    [31] = ColourNameToRGB("red"),
    [32] = ColourNameToRGB("lime"),
    [33] = ColourNameToRGB("yellow"),
    [34] = ColourNameToRGB("blue"),
    [35] = ColourNameToRGB("magenta"),
    [36] = ColourNameToRGB("cyan"),
    [37] = ColourNameToRGB("white"),
  },
  bright = {
    [30] = ColourNameToRGB("gray"),
    [31] = ColourNameToRGB("orangered"),
    [32] = ColourNameToRGB("lightgreen"),
    [33] = ColourNameToRGB("lightyellow"),
    [34] = ColourNameToRGB("lightskyblue"),
    [35] = ColourNameToRGB("violet"),
    [36] = ColourNameToRGB("paleturquoise"),
    [37] = ColourNameToRGB("white"),
  }
}

local function dlog(level, msg)
  if state.gmcp_debug >= level then
    ColourNote("darkorange", "", "[VoidMUD] " .. msg)
  end
end

local function choose_body_font()
  local fonts = utils.getfontfamilies()
  -- QMUD miniwindows look much better with TrueType fonts; avoid bitmap Dina here.
  if fonts["Consolas"] then return "Consolas" end
  if fonts["Cascadia Mono"] then return "Cascadia Mono" end
  if fonts["Cascadia Code"] then return "Cascadia Code" end
  if fonts["JetBrains Mono"] then return "JetBrains Mono" end
  if fonts["Fira Code"] then return "Fira Code" end
  if fonts["DejaVu Sans Mono"] then return "DejaVu Sans Mono" end
  if fonts["Liberation Mono"] then return "Liberation Mono" end
  if fonts["Courier New"] then return "Courier New" end
  if fonts["Lucida Console"] then return "Lucida Console" end
  return "Monospace"
end

local function recommended_font_size(font_name)
  if font_name == "Consolas" or font_name == "Cascadia Mono" or font_name == "Cascadia Code" or font_name == "JetBrains Mono" or font_name == "Fira Code" then
    return 11
  end
  return 10
end

local function get_world_output_font()
  -- Prefer documented output font info in QMUD.
  local out_name = GetInfo(20)
  if not out_name or out_name == "" then
    out_name = GetAlphaOption("output_font_name")
  end
  local out_size = tonumber(GetOption("output_font_height"))
  if out_name and out_name ~= "" and out_size and out_size > 0 then
    return out_name, out_size
  end
  local f = choose_body_font()
  return f, recommended_font_size(f)
end

local function apply_main_terminal_font(font_name, font_size)
  if not font_name or font_name == "" then
    return
  end
  local sz = tonumber(font_size) or 14
  SetAlphaOption("output_font_name", font_name)
  SetOption("output_font_height", sz)
end

local function mc_rgb(r, g, b)
  r = math.max(0, math.min(255, tonumber(r) or 0))
  g = math.max(0, math.min(255, tonumber(g) or 0))
  b = math.max(0, math.min(255, tonumber(b) or 0))
  -- MUSHclient stores RGB as 0x00BBGGRR (red in low byte).
  return r + (g * 256) + (b * 65536)
end

local function strip_ansi(s)
  if type(s) ~= "string" then
    return ""
  end
  if StripANSI then
    return StripANSI(s)
  end
  return s
end

local function xterm_256_to_rgb(n)
  n = tonumber(n) or 7
  if n < 0 then n = 0 end
  if n > 255 then n = 255 end

  local basic = {
    [0] = ColourNameToRGB("black"),
    [1] = ColourNameToRGB("maroon"),
    [2] = ColourNameToRGB("green"),
    [3] = ColourNameToRGB("olive"),
    [4] = ColourNameToRGB("navy"),
    [5] = ColourNameToRGB("purple"),
    [6] = ColourNameToRGB("teal"),
    [7] = ColourNameToRGB("silver"),
    [8] = ColourNameToRGB("gray"),
    [9] = ColourNameToRGB("red"),
    [10] = ColourNameToRGB("lime"),
    [11] = ColourNameToRGB("yellow"),
    [12] = ColourNameToRGB("blue"),
    [13] = ColourNameToRGB("fuchsia"),
    [14] = ColourNameToRGB("aqua"),
    [15] = ColourNameToRGB("white"),
  }
  if basic[n] then
    return basic[n]
  end

  if n >= 16 and n <= 231 then
    local v = n - 16
    local r = math.floor(v / 36)
    local g = math.floor((v % 36) / 6)
    local b = v % 6
    local function c(x)
      if x == 0 then return 0 end
      return 55 + (x * 40)
    end
    return mc_rgb(c(r), c(g), c(b))
  end

  local gray = 8 + ((n - 232) * 10)
  return mc_rgb(gray, gray, gray)
end

local function ansi_segments(line, default_fg, default_bg)
  local segments = {}
  local fg = default_fg
  local bg = default_bg
  local bold = false
  local inverse = false
  local concealed = false
  local i = 1

  while i <= #line do
    local s, e, codes = line:find("\27%[([0-9;:]*)m", i)
    if not s then
      local tail = line:sub(i)
      if #tail > 0 then
        table.insert(segments, { text = tail, fg = fg, bg = bg })
      end
      break
    end

    if s > i then
      table.insert(segments, { text = line:sub(i, s - 1), fg = fg, bg = bg })
    end

    if codes == "" then
      codes = "0"
    end

    local code_list = {}
    for code in tostring(codes):gmatch("([0-9]+)") do
      table.insert(code_list, tonumber(code))
    end

    local ci = 1
    while ci <= #code_list do
      local c = code_list[ci]
      if c == 0 then
        fg = default_fg
        bg = default_bg
        bold = false
        inverse = false
        concealed = false
      elseif c == 1 then
        bold = true
      elseif c == 22 then
        bold = false
      elseif c == 7 then
        inverse = true
      elseif c == 27 then
        inverse = false
      elseif c == 8 then
        concealed = true
      elseif c == 28 then
        concealed = false
      elseif c == 39 then
        fg = default_fg
      elseif c == 49 then
        bg = default_bg
      elseif c >= 30 and c <= 37 then
        fg = (bold and ansi_palette.bright[c]) or ansi_palette.normal[c] or fg
      elseif c >= 40 and c <= 47 then
        bg = ansi_palette.normal[c - 10] or bg
      elseif c >= 90 and c <= 97 then
        fg = ansi_palette.bright[c - 60] or fg
      elseif c >= 100 and c <= 107 then
        bg = ansi_palette.bright[c - 70] or bg
      elseif c == 38 and code_list[ci + 1] == 5 and code_list[ci + 2] ~= nil then
        fg = xterm_256_to_rgb(code_list[ci + 2])
        ci = ci + 2
      elseif c == 38 and code_list[ci + 1] == 2 and code_list[ci + 2] and code_list[ci + 3] and code_list[ci + 4] then
        local r = math.max(0, math.min(255, tonumber(code_list[ci + 2]) or 255))
        local g = math.max(0, math.min(255, tonumber(code_list[ci + 3]) or 255))
        local b = math.max(0, math.min(255, tonumber(code_list[ci + 4]) or 255))
        fg = mc_rgb(r, g, b)
        ci = ci + 4
      elseif c == 48 and code_list[ci + 1] == 5 and code_list[ci + 2] ~= nil then
        bg = xterm_256_to_rgb(code_list[ci + 2])
        ci = ci + 2
      elseif c == 48 and code_list[ci + 1] == 2 and code_list[ci + 2] and code_list[ci + 3] and code_list[ci + 4] then
        local r = math.max(0, math.min(255, tonumber(code_list[ci + 2]) or 0))
        local g = math.max(0, math.min(255, tonumber(code_list[ci + 3]) or 0))
        local b = math.max(0, math.min(255, tonumber(code_list[ci + 4]) or 0))
        bg = mc_rgb(r, g, b)
        ci = ci + 4
      end
      ci = ci + 1
    end

    if inverse then
      fg, bg = bg, fg
    end
    if concealed then
      fg = bg
    end

    i = e + 1
  end

  return segments
end

local function split_lines(s)
  local out = {}
  if not s or s == "" then
    return out
  end
  s = s:gsub("\r\n", "\n"):gsub("\r", "\n")
  for line in (s .. "\n"):gmatch("(.-)\n") do
    table.insert(out, line)
  end
  return out
end

local function extract_data(payload)
  if payload == nil then
    return nil
  end
  if type(payload) == "string" then
    return payload
  end
  if type(payload) ~= "table" then
    return nil
  end
  if payload.data ~= nil then
    return payload.data
  end
  if payload[1] ~= nil then
    if type(payload[1]) == "table" and payload[1].data ~= nil then
      return payload[1].data
    end
    return payload[1]
  end
  return nil
end

local function extract_textish(payload, preferred_keys)
  local function stringify_value(v, depth)
    depth = depth or 0
    if depth > 4 then
      return nil
    end
    if type(v) == "string" then
      return v
    end
    if type(v) == "number" or type(v) == "boolean" then
      return tostring(v)
    end
    if type(v) ~= "table" then
      return nil
    end

    local lines = {}
    local had_kv = false
    for k, subv in pairs(v) do
      local sv = stringify_value(subv, depth + 1)
      if sv and sv ~= "" then
        if type(k) == "number" then
          table.insert(lines, sv)
        else
          had_kv = true
          table.insert(lines, tostring(k) .. ": " .. sv)
        end
      end
    end
    if #lines == 0 then
      return nil
    end
    if had_kv then
      table.sort(lines)
    end
    return table.concat(lines, "\n")
  end

  local data = extract_data(payload)
  if type(data) == "string" then
    return data
  end
  if type(data) == "table" then
    if preferred_keys then
      for _, k in ipairs(preferred_keys) do
        if type(data[k]) == "string" and data[k] ~= "" then
          return data[k]
        end
      end
    end
    for _, k in ipairs({ "data", "text", "map", "status", "extra", "msg", "message", "value", "body", "line" }) do
      if type(data[k]) == "string" and data[k] ~= "" then
        return data[k]
      end
    end
    local sv = stringify_value(data, 0)
    if sv and sv ~= "" then
      return sv
    end
  end
  if type(payload) == "table" then
    if preferred_keys then
      for _, k in ipairs(preferred_keys) do
        if type(payload[k]) == "string" and payload[k] ~= "" then
          return payload[k]
        end
      end
    end
    for _, k in ipairs({ "data", "text", "map", "status", "extra", "msg", "message", "value", "body", "line" }) do
      if type(payload[k]) == "string" and payload[k] ~= "" then
        return payload[k]
      end
    end
    local sv = stringify_value(payload, 0)
    if sv and sv ~= "" then
      return sv
    end
  end
  return nil
end

local function apply_voidmud_text_styles(s)
  if type(s) ~= "string" then
    return s
  end
  -- Special-case requested styling for death state text.
  -- We render it as magenta ANSI so miniwindow color parsing can draw it.
  s = s:gsub("Death Sickness", "\27[95mDeath Sickness\27[0m")
  return s
end

local function send_gmcp_packet(payload)
  if not payload or payload == "" then
    return
  end

  SendPkt(
    string.char(IAC, SB, GMCP) ..
    payload:gsub("\255", "\255\255") ..
    string.char(IAC, SE)
  )
end

local function make_window_names()
  local id = GetPluginID()
  win.map = "void_map_" .. id
  win.chat = "void_chat_" .. id
  win.status = "void_status_" .. id
end

local function panel_name_from_window(window_name)
  if window_name == win.map then return "map" end
  if window_name == win.chat then return "chat" end
  if window_name == win.status then return "status" end
  return nil
end

local function panel_name_from_hotspot(hotspot_id)
  if not hotspot_id then return nil end
  return hotspot_id:match("^([a-z]+):")
end

local function create_one_window(panel_name)
  local w = state[panel_name .. "_w"]
  local h = state[panel_name .. "_h"]
  local x = state[panel_name .. "_x"]
  local y = state[panel_name .. "_y"]
  local name = win[panel_name]
  local flags = miniwin.create_absolute_location + miniwin.create_keep_hotspots
  local pos = miniwin.pos_top_left or 4
  WindowCreate(name, x, y, w, h, pos, flags, colour.bg)
  WindowShow(name, true)
end

local function draw_window_chrome(name, panel_title)
  local width = tonumber(WindowInfo(name, 3)) or 300
  local height = tonumber(WindowInfo(name, 4)) or 200

  WindowRectOp(name, miniwin.rect_fill, 0, 0, width, height, colour.bg)
  WindowRectOp(name, miniwin.rect_frame, 0, 0, width - 1, height - 1, colour.border)
  WindowRectOp(name, miniwin.rect_frame, 1, 1, width - 2, height - 2, colour.border_inner)

  WindowRectOp(name, miniwin.rect_fill, 2, 2, width - 3, 2 + UI.title_h, colour.title_bg)
  WindowText(name, "title", " " .. panel_title, 6, 4, width - 8, UI.title_h + 2, colour.title_fg, true)

  -- resize handle in lower-right corner
  local rx = width - UI.resize_grab
  local ry = height - UI.resize_grab
  WindowRectOp(name, miniwin.rect_fill, rx, ry, width - 3, height - 3, colour.border_inner)
  WindowRectOp(name, miniwin.rect_frame, rx, ry, width - 3, height - 3, colour.border)

  -- Draw a simple diagonal grip with lines so it stays crisp across fonts/themes.
  local gx = width - 5
  local gy = height - 5
  WindowLine(name, gx - 4, gy, gx, gy - 4, colour.title_fg, 0, 1)
  WindowLine(name, gx - 8, gy, gx, gy - 8, colour.title_fg, 0, 1)
  WindowLine(name, gx - 12, gy, gx, gy - 12, colour.title_fg, 0, 1)
end

local function install_hotspots(panel_name)
  local name = win[panel_name]
  local width = tonumber(WindowInfo(name, 3)) or 300
  local height = tonumber(WindowInfo(name, 4)) or 200

  WindowDeleteAllHotspots(name)

  local title_id = panel_name .. ":title"
  local resize_id = panel_name .. ":resize"

  WindowAddHotspot(name, title_id, 2, 2, width - 3, 2 + UI.title_h, "", "", "VoidWindowMouseDown", "", "", panel_name .. " window", miniwin.cursor_hand, 0)
  WindowDragHandler(name, title_id, "VoidWindowDragMove", "VoidWindowDragRelease", 0)
  WindowScrollwheelHandler(name, title_id, "VoidWindowWheel")

  WindowAddHotspot(name, resize_id, width - UI.resize_grab, height - UI.resize_grab, width - 2, height - 2, "", "", "VoidWindowMouseDown", "", "", "Resize " .. panel_name, miniwin.cursor_both_arrow, 0)
  WindowDragHandler(name, resize_id, "VoidWindowDragMove", "VoidWindowDragRelease", 0)
  WindowScrollwheelHandler(name, resize_id, "VoidWindowWheel")

  local body_id = panel_name .. ":body"
  -- Keep body hotspot clear of the resize corner so resize capture is reliable.
  WindowAddHotspot(name, body_id, 2, 2 + UI.title_h, width - UI.resize_grab - 1, height - UI.resize_grab - 1, "", "", "", "", "", panel_name, miniwin.cursor_ibeam, 0)
  WindowScrollwheelHandler(name, body_id, "VoidWindowWheel")
end

local function apply_panel_geometry(panel_name, refresh_hotspots)
  local name = win[panel_name]
  local x = state[panel_name .. "_x"]
  local y = state[panel_name .. "_y"]
  local w = state[panel_name .. "_w"]
  local h = state[panel_name .. "_h"]
  local pos = miniwin.pos_top_left or 4
  WindowPosition(name, x, y, pos, miniwin.create_absolute_location + miniwin.create_keep_hotspots)
  WindowResize(name, w, h, colour.bg)
  if refresh_hotspots then
    install_hotspots(panel_name)
  end
end

local function load_window_fonts()
  local world_font, world_size = get_world_output_font()
  local body_font = GetVariable("body_font_name") or world_font or choose_body_font()
  local title_font = "Segoe UI"
  local charset = miniwin.font_charset_default
  local pitch = miniwin.font_pitch_default + miniwin.font_family_modern + miniwin.font_truetype
  local base = world_size or recommended_font_size(body_font)
  local map_sz = tonumber(state.map_font_size) or base
  local chat_sz = tonumber(state.chat_font_size) or base
  local status_sz = tonumber(state.status_font_size) or base

  local function try_load_body_font(target_win, size)
    local candidates = {
      body_font,
      world_font,
      "Consolas",
      "Cascadia Mono",
      "Cascadia Code",
      "JetBrains Mono",
      "Fira Code",
      "DejaVu Sans Mono",
      "Liberation Mono",
      "Courier New",
      "Lucida Console",
    }
    local seen = {}
    for _, fname in ipairs(candidates) do
      if fname and fname ~= "" and not seen[fname] then
        seen[fname] = true
        local rc = WindowFont(target_win, "body", fname, size, false, false, false, false, charset, pitch)
        if tonumber(rc) == 0 then
          return fname
        end
      end
    end
    return body_font
  end

  WindowFont(win.map, "title", title_font, 9, true, false, false, false)
  WindowFont(win.chat, "title", title_font, 9, true, false, false, false)
  WindowFont(win.status, "title", title_font, 9, true, false, false, false)

  local chosen_map_font = try_load_body_font(win.map, map_sz)
  local chosen_chat_font = try_load_body_font(win.chat, chat_sz)
  local chosen_status_font = try_load_body_font(win.status, status_sz)

  -- Keep persisted preference in sync with what actually loaded successfully.
  if chosen_map_font and chosen_map_font ~= "" then
    SetVariable("body_font_name", chosen_map_font)
  elseif chosen_chat_font and chosen_chat_font ~= "" then
    SetVariable("body_font_name", chosen_chat_font)
  elseif chosen_status_font and chosen_status_font ~= "" then
    SetVariable("body_font_name", chosen_status_font)
  end
end

local function apply_main_text_rectangle()
  local ww = GetInfo(281) or 1200
  local wh = GetInfo(280) or 800
  local left = 8
  local top = 8

  local right = math.min((state.map_x or ww) - 10, (state.chat_x or ww) - 10, ww - 8)
  local bottom = math.min((state.status_y or wh) - 10, wh - 8)

  if right - left < 260 then
    right = left + 260
  end
  if bottom - top < 220 then
    bottom = top + 220
  end
  if right > ww - 8 then right = ww - 8 end
  if bottom > wh - 8 then bottom = wh - 8 end

  local rect = string.format("%d,%d,%d,%d", left, top, right, bottom)
  if runtime.last_textrect == rect then
    return
  end
  runtime.last_textrect = rect

  TextRectangle(
    left, top, right, bottom,
    4,
    colour.border,
    1,
    colour.bg,
    0
  )
end

local function render_text_block(name, text, fg)
  local width = tonumber(WindowInfo(name, 3)) or 300
  local height = tonumber(WindowInfo(name, 4)) or 200

  local panel_name = panel_name_from_window(name) or "map"
  draw_window_chrome(name, panels[panel_name].title)

  local line_h = tonumber(WindowFontInfo(name, "body", 1)) or 14
  local y = UI.title_h + UI.pad

  local lines = split_lines(text)
  local usable_h = height - UI.title_h - (UI.pad * 2)
  local max_lines = math.max(1, math.floor(usable_h / line_h))
  local start = 1
  if #lines > max_lines then
    start = #lines - max_lines + 1
  end

  for i = start, #lines do
    if y + line_h > height - UI.pad then
      break
    end
    local x = UI.pad
    for _, seg in ipairs(ansi_segments(lines[i], fg, colour.bg)) do
      if seg.text ~= "" then
        local seg_w = tonumber(WindowTextWidth(name, "body", seg.text, false)) or 0
        if seg_w <= 0 then
          seg_w = tonumber(WindowTextWidth(name, "body", " ", false)) or 8
        end
        if seg.bg and seg.bg ~= colour.bg and seg_w > 0 then
          WindowRectOp(name, miniwin.rect_fill, x, y, x + seg_w, y + line_h, seg.bg)
        end
        local draw_fg = seg.fg or fg
        -- Keep map visible if malformed/odd color sequences collapse fg/bg.
        if panel_name == "map" and seg.bg and draw_fg == seg.bg then
          draw_fg = fg
        end
        local drawn_w = WindowText(name, "body", seg.text, x, y, 0, 0, draw_fg, false)
        if tonumber(drawn_w) and drawn_w > 0 then
          x = x + drawn_w
        else
          x = x + seg_w
        end
      end
    end
    y = y + line_h
  end

  WindowShow(name, true)
end

local function redraw_all()
  render_text_block(win.map, state.map_text, colour.map_fg)
  render_text_block(win.chat, table.concat(state.chat_lines, "\n"), colour.chat_fg)
  render_text_block(win.status, state.status_text, colour.status_fg)
end

local function rebuild_windows()
  if runtime.rebuilding then
    return
  end
  runtime.rebuilding = true
  create_one_window("map")
  create_one_window("chat")
  create_one_window("status")
  load_window_fonts()
  install_hotspots("map")
  install_hotspots("chat")
  install_hotspots("status")
  apply_main_text_rectangle()
  redraw_all()
  runtime.last_rebuild_at = (utils and utils.timer and utils.timer()) or 0
  runtime.rebuilding = false
end

local function queue_rebuild(delay_s)
  if not runtime.ui_ready then
    return
  end
  if runtime.rebuilding then
    return
  end
  if runtime.rebuild_queued then
    return
  end
  local delay = tonumber(delay_s) or 0.10
  if delay < 0.10 then
    delay = 0.10
  end
  runtime.rebuild_queued = true
  DoAfterSpecial(delay, "VoidDeferredRebuild()", sendto.script)
end

function VoidDeferredRebuild()
  runtime.rebuild_queued = false
  rebuild_windows()
end

local function init_ui_if_possible()
  -- GetInfo(106): true when disconnected.
  local disconnected = GetInfo(106)
  if disconnected == false or disconnected == 0 then
    runtime.ui_ready = true
    rebuild_windows()
    queue_rebuild(0.20)
  end
end

local function append_chat_line(line)
  if line == "" then
    return
  end
  table.insert(state.chat_lines, line)
  while #state.chat_lines > state.chat_max_lines do
    table.remove(state.chat_lines, 1)
  end
end

local function handle_gmcp_message(message, payload)
  local lower = string.lower(message or "")

  if lower == "extra" or lower == "gmcp.extra" then
    local data = extract_textish(payload, { "extra", "status", "data", "text" })
    if type(data) == "string" and data ~= "" then
      data = apply_voidmud_text_styles(data)
      state.status_text = data
      render_text_block(win.status, state.status_text, colour.status_fg)
    end
    return
  end

  if lower == "chat" or lower == "gmcp.chat" then
    local data = extract_textish(payload, { "chat", "msg", "message", "data", "text" })
    if type(data) == "string" and data ~= "" then
      data = apply_voidmud_text_styles(data)
      append_chat_line(data)
      render_text_block(win.chat, table.concat(state.chat_lines, "\n"), colour.chat_fg)
    end
    return
  end

  if lower == "map" or lower == "gmcp.map" then
    local data = extract_textish(payload, { "map", "data", "text", "body" })
    if type(data) == "string" and data ~= "" then
      data = apply_voidmud_text_styles(data)
      state.map_text = data
      render_text_block(win.map, state.map_text, colour.map_fg)
    end
    return
  end

  -- Compatibility fallback: some servers/packages use different GMCP names.
  if lower:find("map", 1, true) then
    local data = extract_textish(payload, { "map", "data", "text", "body", "room", "ascii" })
    if type(data) == "string" and data ~= "" then
      data = apply_voidmud_text_styles(data)
      state.map_text = data
      render_text_block(win.map, state.map_text, colour.map_fg)
      return
    end
  end

  -- Intentionally do NOT auto-map arbitrary status/vitals-like packages into the
  -- status pane. Those payloads are often structured tables (e.g. Char.Vitals),
  -- and stringifying them causes noisy "hp: ..., mana: ..." clobber output.
  -- Status pane is driven by explicit Extra channel handling above.

  if lower == "room" or lower == "gmcp.room" then
    -- Intentionally no auto-mapping. Do not clobber status/vitals content.
    if state.gmcp_debug >= 1 then
      dlog(1, "Room update seen (" .. tostring(message) .. ").")
    end
    return
  end

  if lower == "sound" or lower == "gmcp.sound" then
    if type(payload) == "table" and payload.data then
      dlog(1, "Sound request: " .. tostring(payload.data))
    end
    return
  end

  if lower == "music" or lower == "gmcp.music" then
    if type(payload) == "table" and payload.data then
      dlog(1, "Music request: " .. tostring(payload.data))
    end
    return
  end

  if lower == "stopmusic" or lower == "gmcp.stopmusic" then
    dlog(1, "Stop music request")
    return
  end
end

local function decode_gmcp(data)
  local message, params = string.match(data or "", "^([%w%._]+)%s*(.*)$")
  if not message then
    return nil, nil
  end

  if not params or params == "" then
    return message, {}
  end

  -- Some servers send raw text (not JSON) for channels like map/chat/extra.
  -- If payload clearly isn't JSON, pass it through as a string.
  local starts_jsonish = string.match(params, "^%s*[%[{\"%-0-9tfn]") ~= nil
  if not starts_jsonish then
    return message, params
  end

  local payload = params
  if not string.match(payload, "^[%[{\"]") then
    payload = "[" .. payload .. "]"
  end

  if not json_ok or not json or not json.decode then
    dlog(1, "json module not available; skipping decode for: " .. message)
    return message, params
  end

  local function sanitize_json_controls(s)
    -- Replace raw control bytes (including ESC) with JSON-safe \u00XX escapes.
    return (s:gsub("[%z\1-\8\11\12\14-\31]", function(c)
      return string.format("\\u%04x", string.byte(c))
    end))
  end

  local ok, decoded = pcall(json.decode, payload)
  if (not ok) and type(payload) == "string" then
    local sanitized = sanitize_json_controls(payload)
    ok, decoded = pcall(json.decode, sanitized)
  end
  if not ok then
    -- Fall back to raw payload text instead of dropping the message.
    dlog(1, "Failed to decode GMCP for " .. message .. ": " .. tostring(decoded))
    return message, params
  end

  return message, decoded
end

function VoidWindowMouseDown(flags, hotspot_id)
  local panel = panel_name_from_hotspot(hotspot_id)
  if not panel then
    return
  end
  drag.active = panel
  drag.mode = hotspot_id:match(":([a-z]+)$")
  drag.start_mouse_x = WindowInfo(win[panel], 17) or 0
  drag.start_mouse_y = WindowInfo(win[panel], 18) or 0
  drag.hotspot_offset_x = WindowInfo(win[panel], 14) or 0
  drag.hotspot_offset_y = WindowInfo(win[panel], 15) or 0
  drag.start_x = state[panel .. "_x"] or 0
  drag.start_y = state[panel .. "_y"] or 0
  drag.start_w = state[panel .. "_w"] or 300
  drag.start_h = state[panel .. "_h"] or 200
end

function VoidWindowDragMove(flags, hotspot_id)
  local panel = panel_name_from_hotspot(hotspot_id)
  if not panel or panel ~= drag.active then
    return
  end

  local mx = WindowInfo(win[panel], 17) or drag.start_mouse_x
  local my = WindowInfo(win[panel], 18) or drag.start_mouse_y
  if drag.mode == "title" then
    state[panel .. "_x"] = math.max(0, mx - drag.hotspot_offset_x)
    state[panel .. "_y"] = math.max(0, my - drag.hotspot_offset_y)
  elseif drag.mode == "resize" then
    local left = state[panel .. "_x"] or drag.start_x
    local top = state[panel .. "_y"] or drag.start_y
    state[panel .. "_w"] = math.max(UI.min_w, mx - left)
    state[panel .. "_h"] = math.max(UI.min_h, my - top)
  end

  -- Do not rebuild hotspots while dragging; that can break mouse capture.
  apply_panel_geometry(panel, false)
  render_text_block(win[panel], panel == "map" and state.map_text or (panel == "chat" and table.concat(state.chat_lines, "\n") or state.status_text), colour[panels[panel].fg])
  apply_main_text_rectangle()
end

function VoidWindowDragRelease(flags, hotspot_id)
  local panel = panel_name_from_hotspot(hotspot_id)
  if not panel then
    return
  end
  apply_panel_geometry(panel, true)
  render_text_block(win[panel], panel == "map" and state.map_text or (panel == "chat" and table.concat(state.chat_lines, "\n") or state.status_text), colour[panels[panel].fg])
  drag.active = nil
  drag.mode = nil
  OnPluginSaveState()
end

function VoidWindowWheel(flags, hotspot_id)
  local panel = panel_name_from_hotspot(hotspot_id)
  if not panel then
    return 0
  end

  local direction
  -- As documented: 0x100 set means wheel scrolled down/towards user.
  if bit.band(flags, 0x100) ~= 0 then
    direction = -1
  else
    direction = 1
  end

  -- QMUD wheel flags can produce very large high-word deltas; keep zoom predictable.
  local step = 1
  if bit.band(flags, miniwin.hotspot_got_shift or 1) ~= 0 then
    step = 2
  end
  local key = panel .. "_font_size"
  local current = tonumber(state[key]) or 10
  state[key] = math.max(6, math.min(28, current + (direction * step)))

  load_window_fonts()
  render_text_block(win[panel], panel == "map" and state.map_text or (panel == "chat" and table.concat(state.chat_lines, "\n") or state.status_text), colour[panels[panel].fg])
  OnPluginSaveState()
  return 0
end

local function install_numpad_accelerators()
  AcceleratorTo("Shift+End", "sw", sendto.world)
  AcceleratorTo("Shift+Down", "s", sendto.world)
  AcceleratorTo("Shift+PgDn", "se", sendto.world)
  AcceleratorTo("Shift+Left", "w", sendto.world)
  AcceleratorTo("Shift+Right", "e", sendto.world)
  AcceleratorTo("Shift+Home", "nw", sendto.world)
  AcceleratorTo("Shift+Up", "n", sendto.world)
  AcceleratorTo("Shift+PgUp", "ne", sendto.world)
  AcceleratorTo("Shift+Numpad1", "sw", sendto.world)
  AcceleratorTo("Shift+Numpad2", "s", sendto.world)
  AcceleratorTo("Shift+Numpad3", "se", sendto.world)
  AcceleratorTo("Shift+Numpad4", "w", sendto.world)
  AcceleratorTo("Shift+Numpad6", "e", sendto.world)
  AcceleratorTo("Shift+Numpad7", "nw", sendto.world)
  AcceleratorTo("Shift+Numpad8", "n", sendto.world)
  AcceleratorTo("Shift+Numpad9", "ne", sendto.world)
  AcceleratorTo("Shift+Subtract", "u", sendto.world)
  AcceleratorTo("Shift+Add", "d", sendto.world)
end

function OnPluginInstall()
  if GetOption("utf_8") == 0 then
    SetOption("utf_8", 1)
  end
  local default_font = "Bitstream Vera Sans Mono"
  local default_size = 14
  apply_main_terminal_font(default_font, default_size)
  if GetVariable("installed_font_default_done") ~= "1" then
    SetVariable("body_font_name", default_font)
    state.map_font_size = default_size
    state.chat_font_size = default_size
    state.status_font_size = default_size
    SetVariable("installed_font_default_done", "1")
  end
  install_numpad_accelerators()
  make_window_names()
  runtime.initialized = true
  runtime.ui_ready = false
  init_ui_if_possible()
  dlog(1, "Plugin installed. UI will initialize after connect.")
end

function OnPluginEnable()
  if GetOption("utf_8") == 0 then
    SetOption("utf_8", 1)
  end
  local desired_font = GetVariable("body_font_name") or "Bitstream Vera Sans Mono"
  local desired_size = tonumber(state.map_font_size) or tonumber(GetVariable("map_font_size")) or 14
  apply_main_terminal_font(desired_font, desired_size)
  install_numpad_accelerators()
  make_window_names()
  runtime.initialized = true
  runtime.ui_ready = false
  init_ui_if_possible()
end

function OnPluginDisable()
  if win.map ~= "" then WindowShow(win.map, false) end
  if win.chat ~= "" then WindowShow(win.chat, false) end
  if win.status ~= "" then WindowShow(win.status, false) end
  TextRectangle(0, 0, 0, 0, 0, colour.border, 0, colour.bg, 0)
  runtime.last_textrect = nil
end

function OnPluginWorldOutputResized()
  if not runtime.initialized then
    return
  end
  if not runtime.ui_ready then
    return
  end
  if runtime.rebuilding then
    return
  end
  local now = (utils and utils.timer and utils.timer()) or 0
  if (now > 0) and (runtime.last_rebuild_at > 0) and ((now - runtime.last_rebuild_at) < 0.30) then
    return
  end
  queue_rebuild(0.10)
end

function OnPluginConnect()
  dlog(1, "Connected.")
  runtime.ui_ready = true
  -- Build immediately so panes appear even if timer callbacks are unavailable/disabled.
  rebuild_windows()
  queue_rebuild(0.20)
end

function OnPluginDisconnect()
  dlog(1, "Disconnected.")
end

function OnPluginSaveState()
  SetVariable("gmcp_debug", tostring(state.gmcp_debug))
  SetVariable("map_w", tostring(state.map_w))
  SetVariable("map_h", tostring(state.map_h))
  SetVariable("map_x", tostring(state.map_x))
  SetVariable("map_y", tostring(state.map_y))
  SetVariable("chat_w", tostring(state.chat_w))
  SetVariable("chat_h", tostring(state.chat_h))
  SetVariable("chat_x", tostring(state.chat_x))
  SetVariable("chat_y", tostring(state.chat_y))
  SetVariable("status_w", tostring(state.status_w))
  SetVariable("status_h", tostring(state.status_h))
  SetVariable("status_x", tostring(state.status_x))
  SetVariable("status_y", tostring(state.status_y))
  SetVariable("map_font_size", tostring(state.map_font_size))
  SetVariable("chat_font_size", tostring(state.chat_font_size))
  SetVariable("status_font_size", tostring(state.status_font_size))
  SetVariable("chat_max_lines", tostring(state.chat_max_lines))
end

function OnPluginTelnetRequest(msg_type, data)
  if msg_type ~= GMCP then
    return false
  end

  if data == "WILL" then
    return true
  end

  if data == "SENT_DO" then
    dlog(1, "Enabling GMCP handshake.")
    send_gmcp_packet(string.format('Core.Hello {"client":"MUSHclient","version":"%s"}', Version()))
    send_gmcp_packet('Core.Supports.Set ["Core 1", "Char 1", "Comm 1", "Room 1"]')
    return true
  end

  return false
end

function OnPluginTelnetSubnegotiation(msg_type, data)
  if msg_type ~= GMCP then
    return
  end

  if state.gmcp_debug >= 2 then
    dlog(2, "RAW GMCP: " .. tostring(data))
  end

  local message, payload = decode_gmcp(data)
  if not message then
    return
  end

  if state.gmcp_debug >= 1 then
    dlog(state.gmcp_debug >= 2 and 2 or 1, "GMCP: " .. tostring(message))
  end

  handle_gmcp_message(message, payload)
end

function VoidGMCPDebugAlias(name, line, wildcards)
  local v = tonumber(wildcards[1]) or 1
  state.gmcp_debug = math.max(0, math.min(2, v))
  ColourNote("darkorange", "", "[VoidMUD] gmcp_debug set to " .. tostring(state.gmcp_debug))
end

function VoidFontResetAlias()
  local font_name, font_size = get_world_output_font()
  state.map_font_size = font_size
  state.chat_font_size = font_size
  state.status_font_size = font_size
  SetVariable("body_font_name", font_name)
  OnPluginSaveState()
  rebuild_windows()
  ColourNote("darkorange", "", string.format("[VoidMUD] Font reset to %s %d", tostring(font_name), tonumber(font_size)))
end

function VoidFontAllAlias(name, line, wildcards)
  local args = tostring(wildcards[1] or ""):gsub("^%s+", ""):gsub("%s+$", "")
  local fname, fsize_s = args:match("^(.-)%s+(%d+)$")
  fname = tostring(fname or ""):gsub("^%s+", ""):gsub("%s+$", "")
  local fsize = tonumber(fsize_s or "")
  if fname == "" or not fsize or fsize < 6 or fsize > 32 then
    ColourNote("white", "red", "[VoidMUD] Usage: voidfontall <font name> <size>")
    return
  end

  SetAlphaOption("output_font_name", fname)
  SetOption("output_font_height", fsize)
  SetVariable("body_font_name", fname)
  state.map_font_size = fsize
  state.chat_font_size = fsize
  state.status_font_size = fsize
  OnPluginSaveState()
  rebuild_windows()
  ColourNote("darkorange", "", string.format("[VoidMUD] Main + pane fonts set to %s %d", fname, fsize))
end

function VoidFontDiagAlias()
  local function dump_one(panel)
    local wname = win[panel]
    local fname = tostring(WindowFontInfo(wname, "body", 21) or "?")
    local fheight = tonumber(WindowFontInfo(wname, "body", 1)) or -1
    local favg = tonumber(WindowFontInfo(wname, "body", 6)) or -1
    local fpf = tonumber(WindowFontInfo(wname, "body", 19)) or -1
    ColourNote("darkorange", "", string.format("[VoidMUD] %s font=%s h=%d avgw=%d pf=%d", panel, fname, fheight, favg, fpf))
  end

  local world_name = tostring(GetInfo(20) or "?")
  local world_h = tonumber(GetInfo(212)) or -1
  local dpi_x = tonumber(GetDeviceCaps(88)) or -1
  local dpi_y = tonumber(GetDeviceCaps(90)) or -1
  ColourNote("darkorange", "", string.format("[VoidMUD] world_font=%s world_h=%d dpi=%dx%d", world_name, world_h, dpi_x, dpi_y))
  dump_one("map")
  dump_one("chat")
  dump_one("status")
end

]]>
</script>

</muclient>
