DramaticShapeVoxelMod archival mirror · read-only
tests/arena_editor.lua

arena_editor.lua

tests/arena_editor.lua63.1 KB100644b540b117d7
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617
-- Driver: the interactive battle-arena editor.
--
-- data/battle_arenas.lua holds one authored spot per area -- where that map's
-- 3D battles are staged. tests/arena_pick.lua AUTHORS that file in a batch:
-- it takes the search's answer for every map, photographs each one, and
-- leaves a pile of PNGs and PICK lines to be read afterwards. This is the
-- other way round. It is one window you sit in front of: pick a map, slide
-- the arena around on a plan of it, watch the fit and the sightlines answer
-- as you move, stage a REAL battle on the spot to see what the shot actually
-- looks like, and commit. Committing rewrites the config.
--
-- The plan is the MAP'S OWN ART -- the tiles extracted from the cartridge,
-- drawn from the game's own atlas through the game's own palette -- with the
-- walk grid washed over it, because "where may an arena stand" and "what is
-- it standing in" are two different questions and a spot has to answer both.
--
--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_editor.lua \
--     "/c/Program Files/LOVE/lovec.exe" .
--
-- from the PROJECT ROOT, with this mod enabled. lovec rather than love, so
-- the console is attached: every commit prints its line there too, and the
-- console transcript alone is enough to redo a session by hand.
--
-- ------- the keys
--
--   arrows      move the arena's north-west corner (hold shift for 5 cells)
--   [  ]        previous / next map
--   s           cycle the SHAPE -- wide (3x6, with the apron) or narrow (1x4)
--   r           TURN the whole staging a quarter (shift: the other way) --
--               footprint, both mons and the camera together, so the picture
--               is unchanged and only the ground under it moves. What it is
--               for: a long arena down a corridor that runs east-west, and
--               swapping what stands behind the pair
--   c           cycle the LENS -- the long default, or cam = "wide"
--   h           cycle the HOST map: stage this map's fight on a sibling floor
--   w           water counts as ground for the search (the surf routes)
--   g           what the plan shows: the map's own tiles, the walk grid over
--               them, or the walk grid alone
--   f           scan this map for every arena that fits, clear ones first
--   n  p        step through that scan
--   a           jump to the automatic pick (what arena_pick would have said)
--   u           back to whatever the config file holds for this map
--   t           TEST: stage a real battle here and look at it
--   y           screenshot
--   enter       COMMIT this map (shift+enter: commit and go to the next map)
--   backspace   commit a REFUSAL: nowhere on this map a fight can be seen
--   delete      commit a REMOVAL: no entry, so battle time falls back to the
--               nearest-clear search
--   e           export now (a commit exports on its own; this is for after a
--               revert, or just to be sure)
--   F1          the key legend on screen
--   escape      quit (in TEST, back to the editor)
--
-- In TEST every key except t / escape / y goes to the GAME, so the fight is
-- driven with the game's own buttons and the camera steered with the mod's
-- own Q/E and mouse -- what you are judging is the shot a player gets.
--
-- ------- what it writes
--
-- The export is data/battle_arenas.lua REWRITTEN LINE BY LINE rather than
-- regenerated: an entry that changed has its lines replaced in place, a new
-- one is appended, and every other line of that file -- which is mostly the
-- prose explaining why each spot is the spot -- comes through untouched. A
-- comment sitting directly above a replaced entry describes the OLD spot, so
-- each of those is reported by id at export time and is yours to re-word.
--
-- It lands in .scratchpad/arena_editor/battle_arenas.lua by default, so a
-- session is a file to diff and copy over rather than an edit to the mod
-- under a running game. ARENA_WRITE=1 writes to data/battle_arenas.lua
-- itself, keeping a .bak of what was there. Either way the written text is
-- parsed back and checked against what was committed BEFORE it replaces
-- anything -- a tool that writes a config file must never be the reason one
-- stops loading.
--
-- Re-run and the session resumes: the export is a complete config, so it is
-- read back as the base when it exists (ARENA_FRESH=1 starts from the mod's
-- own file instead).
--
-- ------- environment
--
--   ARENA_MAPS=ID,ID   work this list instead of every map a fight happens on
--   ARENA_OUT=path     where the config is written
--   ARENA_WRITE=1      write to data/battle_arenas.lua (keeps a .bak)
--   ARENA_FRESH=1      ignore an existing export and start from the mod's file
--   ARENA_MON=SPECIES  what to fight in TEST (default NIDORINO)
--   ARENA_LEVEL=N      its level (default 20)
--   ARENA_RUNG=N       the voxel angle rung to sit on (default 3)
--   ARENA_MARGIN=N     keep the scan this far off the map's edge (default: 2
--                      outdoors, where the outermost cells are the connection
--                      border, and 0 indoors)
--   SHOT_DIR=path      screenshots and the default export directory
return function(game)
  local U = dofile("tests/drivers/util.lua")
  -- the config's line surgery, kept in its own file so it can be tested
  -- without a game (see the header there)
  local Config = dofile("mods/DramaticShapeVoxelMod/tests/arena_config.lua")

  local function truthy(v) return v ~= nil and v ~= "" and v ~= "0" end

  local DIR = os.getenv("SHOT_DIR")
  if not DIR or DIR == "" then DIR = ".scratchpad/arena_editor" end
  local DATA = "mods/DramaticShapeVoxelMod/data/battle_arenas.lua"
  local IN_PLACE = truthy(os.getenv("ARENA_WRITE"))
  local OUT = os.getenv("ARENA_OUT")
  if not OUT or OUT == "" then
    OUT = IN_PLACE and DATA or (DIR .. "/battle_arenas.lua")
  end
  local FRESH = truthy(os.getenv("ARENA_FRESH"))
  local SELFTEST = truthy(os.getenv("ARENA_SELFTEST"))
  if SELFTEST then
    -- never the mod's own file, whatever the environment says: the check runs
    -- unattended and its whole point is that it can be thrown away
    IN_PLACE = false
    OUT = DIR .. "/selftest_battle_arenas.lua"
    FRESH = true          -- and always from the mod's own file, so a rerun
  end                     -- checks the same surgery on the same text
  local SPECIES = os.getenv("ARENA_MON")
  if not SPECIES or SPECIES == "" then SPECIES = "NIDORINO" end
  local LEVEL = tonumber(os.getenv("ARENA_LEVEL") or "") or 20
  local RUNG = tonumber(os.getenv("ARENA_RUNG") or "") or 3
  local MARGIN = tonumber(os.getenv("ARENA_MARGIN") or "")

  -- SHOT_DIR is created with both spellings: U.shot's own mkdir is
  -- Unix-flavoured and silently does nothing on Windows, which is where this
  -- tool is driven from, and a screenshot that never reaches disk is the one
  -- failure a screenshot tool must not have.
  -- U.shot's own mkdir is Unix-flavoured, and this tool is driven from
  -- Windows, where `2>/dev/null` is cmd.exe being asked to redirect into a
  -- directory that does not exist -- which prints "the system cannot find the
  -- path specified" for every call and creates nothing. So the shell is asked
  -- in its own language.
  local WINDOWS = love.system and love.system.getOS() == "Windows"

  local function mkdirp(dir)
    if not dir or dir == "" then return end
    if WINDOWS then
      os.execute('if not exist "' .. dir:gsub("/", "\\") .. '" mkdir "'
                 .. dir:gsub("/", "\\") .. '"')
    else
      os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
    end
  end
  mkdirp(DIR)

  local function readFile(path)
    local f = io.open(path, "rb")
    if not f then return nil end
    local text = f:read("*a")
    f:close()
    return text
  end

  local function writeFile(path, text)
    mkdirp(path:match("^(.*)[/\\][^/\\]+$"))
    local f = io.open(path, "wb")
    if not f then return false end
    f:write(text)
    f:close()
    return true
  end

  -- ------- the mod, and the modes this tool needs it in

  local exports = game.mods and game.mods.exports
  local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
  if not lib then
    U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
    return
  end
  local Arena = lib.require("BattleArena")
  local Battles = lib.require("OverworldBattle")
  local BattleCam = lib.require("BattleCam")
  local DayNight = lib.require("DayNight")
  local ChunkMesher = lib.require("ChunkMesher")
  local Voxel3D = lib.require("Voxel3D")
  local Pipelines = require("src.render.Pipelines")
  local PaletteFX = require("src.render.PaletteFX")
  local BattleState = require("src.battle.BattleState")
  local Pokemon = require("src.pokemon.Pokemon")
  local MapLoader = require("src.world.MapLoader")
  local Map = require("src.world.Map")

  game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
  game.save.player.name = "RED"

  -- Every mod setting is set WITHOUT a game argument, deliberately: passing
  -- one persists the value into the player's own options file, and a tool
  -- that is borrowing their install for an hour should not come back out
  -- having changed what they play with. ModSetting caches the index it is
  -- given, which is what every reader asks, so the modes are live for this
  -- process and stored nowhere.
  Battles.setting:setValue(true)             -- 2D-3D A: the fight on the map
  Battles.backSetting:setValue(false)        -- both mons out there on it
  DayNight.setting:setValue("day")           -- one light to judge them all in
  -- the rung IS persisted by the engine's own registry, so it is noted here
  -- and put back on the way out
  local wasRung = Pipelines.level("voxel")
  Pipelines.setLevel("voxel", RUNG)
  if not Voxel3D.available() then
    U.log("no 3D pass available here -- the editor still picks and exports, "
          .. "but TEST will draw the flat battle screen")
  end

  -- ------- which maps get a spot
  --
  -- The same filter arena_pick uses, and for the same reason: authoring a
  -- spot for a shop floor is authoring a spot for a fight that never happens
  -- there. The encounter tables are their own registry keyed by map id, NOT a
  -- field on the map record -- reading def.encounters answers nil for every
  -- map in the game and silently narrows this to trainer maps alone.
  local function fights(id, def)
    local enc = game.data.encounters and game.data.encounters[id]
    if enc then
      for _, kind in ipairs({ "grass", "water" }) do
        local t = enc[kind]
        if t and (t.rate or 0) > 0 and t.slots and t.slots[1] then
          return true
        end
      end
    end
    for _, obj in ipairs(def.objects or {}) do
      if obj.trainer or obj.trainerClass or obj.species then return true end
    end
    return false
  end

  local ids = {}
  local explicit = os.getenv("ARENA_MAPS")
  if explicit and explicit ~= "" then
    for id in explicit:gmatch("[^,%s]+") do ids[#ids + 1] = id end
  else
    for id, def in pairs(game.data.maps) do
      if type(id) == "string" and type(def) == "table" and fights(id, def) then
        ids[#ids + 1] = id
      end
    end
    table.sort(ids)
  end
  if #ids == 0 then
    U.log("no maps to work on")
    return
  end

  -- ------- the config this session starts from
  --
  -- The export is a complete config file, so when one is already there it is
  -- what we read back: a session that ran out of evening resumes exactly
  -- where it stopped, and the file being rewritten is the same one being
  -- read, which keeps the prose in it accumulating rather than reverting.
  local baseFrom = OUT
  local baseText = (not FRESH) and readFile(OUT) or nil
  if not baseText then
    baseFrom = DATA
    baseText = readFile(DATA)
  end
  local base = {}
  if baseText then
    local chunk = load(baseText, "@" .. baseFrom)
    local ok, list = pcall(chunk)
    if ok and type(list) == "table" then
      base = list
    else
      U.log(("could not read %s (%s) -- starting from an empty config")
            :format(baseFrom, tostring(list)))
      baseText = "return {\n}\n"
      baseFrom = "(empty)"
    end
  else
    U.log("no config file found -- starting from an empty one")
    baseText = "return {\n}\n"
    baseFrom = "(empty)"
  end
  U.log(("base config: %s (%d maps)"):format(baseFrom, (function()
    local n = 0
    for _ in pairs(base) do n = n + 1 end
    return n
  end)()))

  -- What this session has decided, per map id: an entry table, `false` for a
  -- refusal, or REMOVE for "take the entry out and let the search answer".
  -- Kept apart from `base` so the export knows which lines it may touch.
  local REMOVE = Config.REMOVE
  local edits = {}
  local sameEntry = Config.same

  local backedUp = false

  -- Apply every committed edit to the base text and write it out. Returns
  -- ok, message.
  local function export()
    local n = 0
    for _ in pairs(edits) do n = n + 1 end
    local text, report = Config.apply(baseText, edits)

    -- Parsed back and checked against what was committed BEFORE it replaces
    -- anything. A config file this tool has made unloadable would take the
    -- authored spot away from every map in the game, and the failure would
    -- turn up as battles quietly happening somewhere else.
    local list, why = Config.verify(text, edits, OUT)
    if not list then return false, why end

    if IN_PLACE and not backedUp then
      if writeFile(DATA .. ".bak", baseText) then
        U.log("kept the original at " .. DATA .. ".bak")
      end
      backedUp = true
    end
    if not writeFile(OUT, text) then
      return false, "could not open " .. OUT .. " for writing"
    end

    -- the written file becomes the base, so the next export edits the text it
    -- just produced and every edit is applied exactly once
    baseText, base, edits = text, list, {}

    if #report.added > 0 then
      U.log("ADDED  " .. table.concat(report.added, ", "))
    end
    if #report.removed > 0 then
      U.log("REMOVED  " .. table.concat(report.removed, ", "))
    end
    if #report.stale > 0 then
      U.log("NOTE   these entries had comments above them that describe the "
            .. "spot they USED to be: " .. table.concat(report.stale, ", "))
    end
    return true, ("wrote %s (%d %s)"):format(OUT, n, n == 1 and "map" or "maps")
  end

  -- ------- the editor's state

  local S = {
    i = 1,                  -- which map, into ids
    map = nil,              -- the live Map for it
    pick = nil,             -- the working spot: a table, false, or nil
    fits = false,           -- does its footprint lie on open ground
    clear = false,          -- would both mons be seen from the battle camera
    arena = nil,            -- the arena record the two above were judged on
    host = nil,             -- the map that arena stands on (a sibling floor)
    cands = nil,            -- the last scan
    ci = 0,
    surf = false,           -- water counts as ground for the search
    mode = "edit",
    msg = "",
    legend = true,
    planMode = 1,           -- the map's own tiles, with the walk grid over
    tiles = nil,            -- that map, baked from its own tileset
    shooting = false,       -- the overlay stands aside for a screenshot
    quit = false,
    done = {},              -- ids committed at some point this session
    doneN = 0,
    overrideOn = nil,       -- the map whose entry we have forced, if any
  }

  local queue = {}
  local function post(cmd, arg) queue[#queue + 1] = { cmd = cmd, arg = arg } end
  local function say(fmt, ...)
    S.msg = select("#", ...) > 0 and fmt:format(...) or fmt
  end

  local function mapId() return ids[S.i] end

  local hosts = {}
  local function hostMap(id)
    if id == nil or (S.map and id == S.map.id) then return S.map end
    if hosts[id] ~= nil then return hosts[id] or nil end
    local ok, m = pcall(MapLoader.load, game.data, id)
    hosts[id] = (ok and m) or false
    return hosts[id] or nil
  end

  local function shapeFor(id)
    for _, s in ipairs(Arena.SHAPES) do
      if s.id == (id or "wide") then return s end
    end
    return Arena.SHAPES[1]
  end

  -- Does this spot's whole footprint lie on ground an arena may be laid on?
  --
  -- Water counts, whatever the player is doing, because that is what
  -- BattleArena.find does for an AUTHORED entry: the surfing test exists to
  -- stop the automatic search staging a walker's fight out at sea, and an
  -- entry chosen by a person who is looking at it is not that.
  -- the footprint a shape covers at this turn: a quarter turn swaps how far
  -- it reaches each way, which is the whole reason a corridor takes one
  -- orientation and refuses the other
  local function extentOf(shapeId, turn)
    return Arena.extent(shapeFor(shapeId), turn)
  end

  local function footprintFits(host, x, y, shapeId, turn)
    local w, h = extentOf(shapeId, turn)
    for cy = y, y + h - 1 do
      for cx = x, x + w - 1 do
        if not Arena.openCell(host, cx, cy, true) then return false end
      end
    end
    return true
  end

  -- Re-judge the working pick and hand the same answer to the mod, so a TEST
  -- stages exactly what is on screen rather than whatever the search would
  -- have found from the player's cell.
  local function evaluate()
    S.fits, S.clear, S.arena, S.host = false, false, nil, nil
    -- The working spot is handed to the mod as this map's authored entry, so
    -- a TEST stages what is on screen. Any map we forced earlier is let go
    -- first: an override outlives the map it was set on, and one left behind
    -- would quietly stage some other area's fight on a spot that was never
    -- committed.
    if S.overrideOn and S.overrideOn ~= mapId() then
      Arena.setOverride(S.overrideOn, nil)
    end
    S.overrideOn = mapId()
    Arena.setOverride(mapId(), S.pick)
    local p = S.pick
    if type(p) ~= "table" or not S.map then return end
    local host = hostMap(p.map)
    if not host then
      say("host map %s could not be loaded", tostring(p.map))
      return
    end
    S.host = host
    if not footprintFits(host, p.x, p.y, p.shape, p.turn) then return end
    S.fits = true
    local arena = Arena.at(p.x, p.y, p.shape, p.turn)
    if not arena then return end
    arena.map, arena.cam = host, p.cam
    S.arena = arena
    local ok, clear = pcall(Arena.clearance, host, arena)
    S.clear = (ok and clear) and true or false
  end

  local function setPick(entry, why)
    S.pick = entry
    evaluate()
    if why then say(why) end
  end

  -- Put the player on the near mon's cell, so the free-roam camera is looking
  -- at the spot being edited and the meshes around it are the ones built.
  -- Written the way OverworldController's own placement writes it -- cell,
  -- pixel and the half-finished step alike -- because setting the cell alone
  -- leaves the body mid-stride toward wherever it was walking.
  local function standOnPick()
    local p, ow = S.pick, game.overworld
    if type(p) ~= "table" or not (ow and ow.player) then return end
    if S.host and S.map and S.host.id ~= S.map.id then return end
    -- through the placed record rather than the shape's own offsets, so the
    -- near mon's cell is the turned one
    local arena = Arena.at(p.x, p.y, p.shape, p.turn)
    if not arena then return end
    local cx, cy = arena.playerCell[1], arena.playerCell[2]
    if not S.map:inBounds(cx, cy) then return end
    local player = ow.player
    player.cellX, player.cellY = cx, cy
    player.px, player.py = cx * 16, cy * 16
    player.moving = false
    player.targetX, player.targetY = nil, nil
    player.facing = "up"
  end

  -- ------- what the map looks like from above
  --
  -- THE MAP'S OWN ART, first: the plan is the surface a spot is chosen on,
  -- and a grid of coloured squares is a map of the walk grid rather than of
  -- the place. What tells you the fight will be staged in the clearing north
  -- of the ledge, or on the jetty rather than the sand, is the tiles -- the
  -- game's own, extracted from the cartridge, drawn from the same atlas and
  -- through the same shade-remap shader the flat 2D world uses.
  --
  -- Baked ONCE per map into a canvas rather than drawn per frame: the tile
  -- layer is a static SpriteBatch over the whole body, and asking the engine
  -- for it sixty times a second would refill that batch against a window the
  -- game is also using. Baked during UPDATE, never inside a draw, because it
  -- binds a canvas of its own -- the same reason the battle's scene pass
  -- lives on the update hook.
  local PLAN_MAX = 2048     -- the largest bake, in pixels on the long side

  -- The four colours this map's ground is drawn in: its own SGB palette,
  -- looked up exactly as the overworld looks it up, so a cave is a cave's
  -- grey and Viridian Forest is its own green. Falls back to the boot ROM's
  -- background palette, which is what the engine falls back to.
  local function paletteFor(m)
    local ow = game.overworld
    if ow and ow.paletteNameFor then
      local okName, name = pcall(ow.paletteNameFor, ow, m)
      if okName and name then
        local okPal, colours = pcall(PaletteFX.pal, game.data, name)
        if okPal and colours then return colours end
      end
    end
    local okOg, og = pcall(PaletteFX.ogBg)
    return okOg and og or nil
  end

  local function bakeTiles()
    if S.tiles then pcall(S.tiles.release, S.tiles) end
    S.tiles = nil
    local m = S.map
    if not (m and m.renderer and love.graphics and love.graphics.newCanvas) then
      return
    end
    local w, h = m.widthCells * 16, m.heightCells * 16
    local scale = math.min(1, PLAN_MAX / math.max(w, h))
    local okNew, canvas = pcall(love.graphics.newCanvas,
                                math.max(1, math.floor(w * scale)),
                                math.max(1, math.floor(h * scale)))
    if not (okNew and canvas) then return end
    -- linear, deliberately, and the one place in this mod where that is
    -- right: the plan is this art MINIFIED to a panel, where nearest
    -- sampling throws away most of the tiles and keeps a moire of whichever
    -- texels the ratio happens to land on
    pcall(canvas.setFilter, canvas, "linear", "linear")

    local g = love.graphics
    local prev = g.getCanvas()
    -- the true-colour marks a RED++ atlas registers are for the frame the
    -- engine is composing, not for a canvas baked outside one
    local wasTrue = m.renderer.trueColor
    m.renderer.trueColor = false
    local ok, err = pcall(function()
      g.push("all")
      g.origin()
      g.setCanvas(canvas)
      g.clear(0, 0, 0, 0)
      g.setBlendMode("alpha")
      g.setColor(1, 1, 1, 1)
      g.scale(scale, scale)
      -- the atlas is the Game Boy's four greys; this is the pass that turns
      -- them into the map's colours, and it is the engine's own
      local shader = PaletteFX.shader()
      local colours = paletteFor(m)
      if shader and colours then
        g.setShader(shader)
        PaletteFX.sendColors(shader, colours)
      end
      m.renderer:drawMapOnly(0, 0, w, h)
      g.pop()
    end)
    m.renderer.trueColor = wasTrue
    if prev then pcall(g.setCanvas, prev) else pcall(g.setCanvas) end
    if not ok then
      pcall(canvas.release, canvas)
      U.log("could not bake the map's tiles: " .. tostring(err))
      return
    end
    S.tiles = canvas
  end

  -- And the walk grid over it, one class per cell, built once with it. The
  -- art says what the place looks like; this says where an arena may legally
  -- go, which is not visible in the art -- a ledge lip and the path beside it
  -- are the same three pixels of grass.
  --
  -- How much of it is laid over the art is the `g` key. Declared up here
  -- rather than beside the drawing that reads it: a local declared further
  -- down the chunk would leave the key handler above reading a GLOBAL of the
  -- same name, which is nil forever and takes the key silently with it.
  local PLAN_MODES = { "both", "tiles", "grid" }

  local CELL_COLOUR = {
    open  = { 0.78, 0.74, 0.63 },
    grass = { 0.33, 0.60, 0.30 },
    water = { 0.24, 0.44, 0.78 },
    warp  = { 0.85, 0.68, 0.20 },
    solid = { 0.17, 0.17, 0.20 },
  }

  local function buildPlan()
    local m = S.map
    if not m then S.plan = nil return end
    local w, h = m.widthCells, m.heightCells
    local plan = { w = w, h = h }
    for cy = 0, h - 1 do
      for cx = 0, w - 1 do
        local kind
        if not m:isWalkableCell(cx, cy) then
          kind = m:isWaterCell(cx, cy) and "water" or "solid"
        elseif m:warpAtCell(cx, cy) or m:isWarpTileCell(cx, cy) then
          kind = "warp"
        elseif m.isGrassCell and m:isGrassCell(cx, cy) then
          kind = "grass"
        else
          kind = "open"
        end
        plan[cy * w + cx] = kind
      end
    end
    S.plan = plan
  end

  -- ------- moving between maps

  local function autoPick(quiet)
    if not S.map then return nil end
    local def = game.data.maps[mapId()]
    -- def.width/height are in BLOCKS and a block is two cells, so the map's
    -- middle in cells is the width itself
    local ox, oy = math.floor(def.width), math.floor(def.height)
    if S.surf then
      -- A surf route's land is a rim of beach around the edge of the map, so
      -- a search from the middle always lands on the rim and the fight is
      -- staged on sand at the corner of a sea. Starting from the middle of
      -- the BIGGEST body of water puts it out in the open instead -- biggest,
      -- not all of it at once, because a map with a lake and an ocean has a
      -- centroid between them that is on neither.
      local m = S.map
      local w, h = m.widthCells, m.heightCells
      local seen, best = {}, nil
      for y0 = 0, h - 1 do
        for x0 = 0, w - 1 do
          if not seen[y0 * w + x0] and m:isWaterCell(x0, y0) then
            local stack, n, sx, sy = { { x0, y0 } }, 0, 0, 0
            seen[y0 * w + x0] = true
            while #stack > 0 do
              local cell = table.remove(stack)
              local cx, cy = cell[1], cell[2]
              n, sx, sy = n + 1, sx + cx, sy + cy
              for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do
                local nx, ny = cx + d[1], cy + d[2]
                local key = ny * w + nx
                if nx >= 0 and ny >= 0 and nx < w and ny < h and not seen[key]
                   and m:isWaterCell(nx, ny) then
                  seen[key] = true
                  stack[#stack + 1] = { nx, ny }
                end
              end
            end
            if not best or n > best.n then
              best = { n = n, x = sx / n, y = sy / n }
            end
          end
        end
      end
      if best then ox, oy = best.x, best.y end
    end
    local found = Arena.search(S.map, ox, oy, S.surf, true)
    local why = "clear"
    if not found then
      found = Arena.search(S.map, ox, oy, S.surf)
      -- The shipped search asks about the DEFAULT lens only, which is what
      -- makes it say this about rooms it cannot stand back from. `f` asks
      -- about both, so it is the thing to reach for next rather than a
      -- conclusion that the map has nowhere to fight.
      why = "OBSTRUCTED on the long lens -- try f, which also tries the wide one"
    end
    if not found then
      if not quiet then say("no arena of either shape fits on this map") end
      return nil
    end
    if not quiet then
      say("automatic pick, %s", why)
    end
    return { x = found.x, y = found.y, shape = found.shape }
  end

  local function enterMap(index)
    S.i = ((index - 1) % #ids) + 1
    local id = mapId()
    S.cands, S.ci = nil, 0
    local ok, err = pcall(function() U.teleport(game, id, 1, 1, "down") end)
    if not ok then
      S.map, S.plan = nil, nil
      say("could not enter %s (%s)", id, tostring(err))
      U.log(("SKIP   %s -- could not enter (%s)"):format(id, tostring(err)))
      return
    end
    S.map = game.overworld.map
    buildPlan()
    bakeTiles()
    -- the entry this map already has wins; a map with none gets the search's
    -- answer as a starting point, uncommitted
    local entry = edits[id]
    if entry == REMOVE then entry = nil end
    if entry == nil then entry = base[id] end
    if entry == nil then
      setPick(autoPick(true), nil)
      say("%s: no entry yet -- this is the automatic pick", id)
    else
      if entry == false then
        setPick(false, nil)
        say("%s: the config refuses this map", id)
      else
        setPick({ x = entry.x, y = entry.y, shape = entry.shape or "wide",
                  turn = entry.turn, cam = entry.cam, map = entry.map }, nil)
        say("%s: from the config", id)
      end
    end
    standOnPick()
  end

  -- ------- the frame
  --
  -- The driver harness turns the frame cap off (main.lua: a scripted run is
  -- not paced), so each step is held to a 60th of a second by hand. Without
  -- it the game's logic runs at the refresh rate of whatever monitor this is
  -- on, and a battle intro on a 165Hz panel is over before it can be read.
  local lastT = love.timer.getTime()
  local function step(n)
    for _ = 1, (n or 1) do
      local rest = (1 / 60) - (love.timer.getTime() - lastT)
      if rest > 0 then love.timer.sleep(rest) end
      lastT = love.timer.getTime()
      U.wait(1)
    end
  end

  -- `withHud` keeps the overlay in the shot, which is how a spot is filed
  -- with its own plan and verdict beside it; the plain shot is the picture a
  -- player would get and is the one to judge by.
  local function shoot(name, withHud)
    local path = ("%s/%s.png"):format(DIR, name)
    S.shooting = not withHud
    game.capturePath = path
    for _ = 1, 120 do
      if not game.capturePath then break end
      step(1)
    end
    step(2)
    S.shooting = false
    local f = io.open(path, "rb")
    if f then
      f:close()
      -- the panel gets the file name and the console the whole path: one is
      -- read at a glance and the other is pasted into something
      say("shot %s.png", name)
      U.log("SHOT   " .. path)
    else
      say("screenshot did not reach disk: %s", path)
    end
  end

  -- The spot IS the filename, so a directory of these is a contact sheet of
  -- decisions rather than a pile of numbered frames -- and the mode is in it
  -- too, or the shot of the staged fight lands on top of the shot of the
  -- ground it was staged on.
  local function shotName(kind)
    local p = S.pick
    local where = mapId():lower()
    if type(p) == "table" then
      where = ("%s_x%d_y%d_%s"):format(where, p.x, p.y, p.shape or "wide")
      -- the turn is part of the spot's identity, or the same corner shot two
      -- ways round writes one file twice and the comparison is impossible
      if (p.turn or 0) ~= 0 then where = ("%s_t%d"):format(where, p.turn) end
    end
    return where .. "_" .. (kind or (S.mode == "test" and "battle" or "plan"))
  end

  -- ------- TEST: a real fight on the spot
  --
  -- Staged through the mod's own path -- the override is already installed by
  -- evaluate(), the overworld's own pushBattle is what starts it -- so what
  -- appears is a battle, not a mock of one. Judging a spot against anything
  -- less is how a spot that reads on a plan and hides a Pokemon in the game
  -- gets committed.
  local function beginTest()
    if type(S.pick) ~= "table" then
      say("nothing to stage here")
      return
    end
    if not S.fits then
      say("that footprint is not on open ground -- nothing would stage")
      return
    end
    standOnPick()
    say("staging...")
    -- let the terrain around the arena finish building before the shot needs
    -- it, so the first battle frame is not the flat fallback
    for _ = 1, 120 do
      if (ChunkMesher.pending and ChunkMesher.pending() or 0) == 0 then break end
      step(1)
    end
    step(20)
    local battle = BattleState.newWild(game, SPECIES, LEVEL)
    -- the fight is a fixture, so it must not run the real one's aftermath
    battle.onFinish = function() end
    game.overworld:pushBattle(battle)
    S.mode = "test"
    -- Drive it to the MENU. Anything earlier is the intro, where the player's
    -- side legitimately shows the trainer's back and there is no Pokemon on
    -- the field at all -- which looks exactly like a broken arena and is not.
    for _ = 1, 200 do
      if battle.phase == "menu" then break end
      U.tap(game, "a")
      step(6)
    end
    local staged = Battles.arena()
    if staged then
      say("TEST at %d,%d %s -- t or escape to come back", staged.x, staged.y,
          staged.shape)
    else
      say("TEST: nothing staged, this is the flat battle screen")
    end
    U.log(("TEST   %s phase=%s staged=%s"):format(mapId(),
          tostring(battle.phase), staged and "yes" or "no"))
  end

  local function endTest()
    U.log("TEST   left")
    while game.stack:top() and game.stack:top() ~= game.overworld do
      game.stack:pop()
    end
    step(8)
    S.mode = "edit"
    standOnPick()
    say("back in the editor")
  end

  -- ------- the scan
  --
  -- Every arena that fits anywhere on this map, clear ones first. The
  -- clearance test is what makes the list worth stepping through: an
  -- obstructed spot is not a candidate however good the ground under it
  -- looks, and half the ground on a route is obstructed from a camera this
  -- low.
  local function scan()
    local m = S.map
    if not m then return end
    local margin = MARGIN
    if not margin then
      -- outdoors the outermost cells are the CONNECTION BORDER, the strip the
      -- neighbouring map is drawn into: ground there passes every test and is
      -- still the wrong answer, because a fight staged on it happens at the
      -- edge of the world. Indoors there is no such strip and the walls are
      -- already off the table, so nothing is kept back.
      margin = Map.isOutdoor(m.def) and 2 or 0
    end
    local list = {}
    local mx = (m.widthCells - 1) / 2
    local my = (m.heightCells - 1) / 2
    -- Both footprints, because a quarter turn is what lets the 3x6 shape into
    -- an east-west corridor at all: turns 0 and 2 cover the same ground and
    -- so do 1 and 3, so the GROUND is walked twice and the four ways round
    -- are asked about each patch that takes one.
    for _, shape in ipairs(Arena.SHAPES) do
      for _, lie in ipairs({ 0, 90 }) do
        local fw, fh = Arena.extent(shape, lie)
        -- the square footprint the narrow shape never has, and the wide one
        -- never has either -- so nothing is scanned twice
        local twice = (fw == shape.w and fh == shape.h and lie ~= 0)
        for y = margin, m.heightCells - fh - margin do
          for x = margin, m.widthCells - fw - margin do
            local fits = not twice
            if fits then
              for cy = y, y + fh - 1 do
                for cx = x, x + fw - 1 do
                  if not Arena.openCell(m, cx, cy, S.surf) then
                    fits = false
                    break
                  end
                end
                if not fits then break end
              end
            end
            if fits then
              -- Judged on both TURNS that share this footprint and on both
              -- LENSES, because which way round a fight stands and what it is
              -- shot on are part of the answer, not separate questions asked
              -- afterwards.
              --
              -- The lens matters most indoors: the long default cannot stand
              -- back from a small room -- its eye lands outside the walls, and
              -- on an indoor map that is inside the border ring this mode
              -- extrudes into a cliff -- so a gym where nothing is clear on
              -- the tele lens can be entirely clear on the 44-degree one. That
              -- is what `cam = "wide"` is for on the three gyms in the shipped
              -- file, and an editor that only asked about the default lens
              -- would report those maps as having nowhere to fight.
              --
              -- The turn matters most against a wall: the two ends of the same
              -- axis put the camera on opposite sides of the pair, and one of
              -- them is often looking into the rock the other has behind it.
              --
              -- First combination that reads wins, in preference order, so the
              -- common case -- clear, as it lies, on the default lens -- costs
              -- exactly one clearance test.
              local clear, cam, turn = false, nil, lie
              for _, t in ipairs({ lie, lie + 180 }) do
                for _, lens in ipairs({ false, "wide" }) do
                  local a = Arena.at(x, y, shape.id, t)
                  a.cam = lens or nil
                  local ok, got = pcall(Arena.clearance, m, a)
                  if ok and got then
                    clear, cam, turn = true, lens or nil, t
                    break
                  end
                end
                if clear then break end
              end
              local dx = x + (fw - 1) / 2 - mx
              local dy = y + (fh - 1) / 2 - my
              list[#list + 1] = { x = x, y = y, shape = shape.id, turn = turn,
                                  clear = clear, cam = cam,
                                  d = dx * dx + dy * dy }
            end
          end
          say("scanning %s%s... %d found", shape.id,
              lie ~= 0 and " (turned)" or "", #list)
          step(1)
        end
      end
    end
    -- Clear before obstructed; among the clear, the default lens before the
    -- one that had to be widened to get there; then the wide arena shape
    -- before the narrow one, since that is the shot this mode is framed for;
    -- and within all that, nearest to the middle of the map -- which is the
    -- order arena_pick's own answer comes first in, so `f` then `n` walks
    -- outward from the spot the batch tool would have chosen.
    table.sort(list, function(a, b)
      if a.clear ~= b.clear then return a.clear end
      if (a.cam ~= nil) ~= (b.cam ~= nil) then return a.cam == nil end
      if a.shape ~= b.shape then return a.shape == "wide" end
      -- and the way round the mode was drawn for before the three that had
      -- to be turned to get there
      if (a.turn or 0) ~= (b.turn or 0) then
        return (a.turn or 0) < (b.turn or 0)
      end
      return a.d < b.d
    end)
    local clearN, wideN = 0, 0
    for _, c in ipairs(list) do
      if c.clear then
        clearN = clearN + 1
        if c.cam then wideN = wideN + 1 end
      end
    end
    S.cands, S.ci = list, 0
    S.candClear, S.candWide = clearN, wideN
    if #list == 0 then
      say("nothing fits anywhere on this map")
    else
      say("%d spots fit, %d of them clear%s -- n and p step through them",
          #list, clearN,
          wideN > 0 and (" (%d only on the wide lens)"):format(wideN) or "")
    end
  end

  local function stepCandidate(dir)
    if not S.cands or #S.cands == 0 then
      say("scan the map first (f)")
      return
    end
    S.ci = ((S.ci + dir - 1) % #S.cands + #S.cands) % #S.cands + 1
    local c = S.cands[S.ci]
    local keep = type(S.pick) == "table" and S.pick or {}
    -- the turn and the lens come WITH the candidate: the scan reached its
    -- verdict about one particular way round on one particular lens, and
    -- landing on the spot with either of the others selected would show a
    -- verdict that was never about this shot
    setPick({ x = c.x, y = c.y, shape = c.shape, turn = c.turn,
              cam = c.clear and c.cam or keep.cam, map = keep.map })
    standOnPick()
    say("candidate %d/%d  %s%s%s", S.ci, #S.cands,
        c.clear and "clear" or "OBSTRUCTED",
        (c.turn or 0) ~= 0 and (" turned %d"):format(c.turn) or "",
        c.cam and " on the wide lens" or "")
  end

  -- ------- committing

  local function commit(entry, advance)
    local id = mapId()
    local was = base[id]
    if entry == nil then
      -- there is no spot to commit. Saying so is the honest answer; a map
      -- with nowhere a fight can be seen is committed with backspace, which
      -- writes a refusal the mod reads rather than silence it has to guess at
      say("no spot here to commit -- backspace commits a refusal instead")
      return
    end
    if not S.done[id] then
      S.done[id] = true
      S.doneN = S.doneN + 1
    end
    if entry == REMOVE then
      if was == nil then
        edits[id] = nil
        say("%s has no entry to remove", id)
        return
      end
      edits[id] = REMOVE
      U.log(("REMOVE %s -- battle time falls back to the search"):format(id))
    elseif entry == false then
      edits[id] = false
      U.log(("PICK   [%q] = false,"):format(id))
    else
      if sameEntry(entry, was) then
        edits[id] = nil
        say("%s already says exactly that", id)
        if advance then post("map", 1) end
        return
      end
      edits[id] = { x = entry.x, y = entry.y, shape = entry.shape or "wide",
                    turn = ((entry.turn or 0) ~= 0) and entry.turn or nil,
                    cam = entry.cam, map = entry.map }
      U.log(("PICK   [%q] = { %sx = %d, y = %d, shape = %q%s%s },%s")
            :format(id, entry.map and ("map = %q, "):format(entry.map) or "",
                    entry.x, entry.y, entry.shape or "wide",
                    ((entry.turn or 0) ~= 0)
                      and (", turn = %d"):format(entry.turn) or "",
                    entry.cam and (", cam = %q"):format(entry.cam) or "",
                    S.clear and "" or "   -- OBSTRUCTED"))
    end
    local ok, msg = export()
    if ok then
      say("committed %s -- %s", id, msg)
    else
      say("COMMITTED BUT NOT WRITTEN: %s", msg)
      U.log("EXPORT FAILED: " .. msg)
    end
    if advance then post("map", 1) end
  end

  -- ------- the keys
  --
  -- Wrapped around love.keypressed rather than polled, so a press is taken
  -- INSTEAD of reaching the game rather than as well as -- an arrow key that
  -- moved the arena and walked the player would fight itself. Only the keys
  -- below are claimed; everything else falls through, which is what leaves
  -- the mod's own hotkeys (the rung, the water, the wireframe) working while
  -- the editor is up, and the whole keyboard working inside a TEST.
  local function shifted()
    return love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift")
  end

  -- Keep the corner on the map, measured against the footprint as it
  -- currently lies -- a shape that has just been turned or swapped reaches a
  -- different distance each way from the same corner.
  local function clampPick()
    local p = S.pick
    if type(p) ~= "table" then return end
    local fw, fh = extentOf(p.shape, p.turn)
    local w = S.host and S.host.widthCells or (S.map and S.map.widthCells) or 0
    local h = S.host and S.host.heightCells or (S.map and S.map.heightCells) or 0
    p.x = math.max(0, math.min(w - fw, p.x))
    p.y = math.max(0, math.min(h - fh, p.y))
  end

  local function nudge(dx, dy)
    local p = S.pick
    if type(p) ~= "table" then
      setPick(autoPick(true), "no spot here yet -- took the automatic one")
      return
    end
    local n = shifted() and 5 or 1
    p.x, p.y = p.x + dx * n, p.y + dy * n
    clampPick()
    evaluate()
    standOnPick()
    say("%d,%d %s", p.x, p.y, p.shape or "wide")
  end

  -- A quarter turn of the whole staging: the footprint, both mons and the
  -- camera together (see BattleArena's `turn`). The picture does not change --
  -- the pair land on the same screen anchors at the same size -- so what this
  -- is for is the GROUND: fitting a long arena down a corridor that runs the
  -- other way, and swapping what is standing behind the pair.
  local function rotate(dir)
    local p = S.pick
    if type(p) ~= "table" then return end
    p.turn = (((p.turn or 0) + dir * 90) % 360 + 360) % 360
    clampPick()
    evaluate()
    standOnPick()
    local fw, fh = extentOf(p.shape, p.turn)
    say("turned %d degrees -- %dx%d cells, foe to the %s", p.turn, fw, fh,
        ({ [0] = "north", [90] = "east", [180] = "south",
           [270] = "west" })[p.turn])
  end

  local function cycleShape()
    local p = S.pick
    if type(p) ~= "table" then return end
    p.shape = (p.shape == "narrow") and "wide" or "narrow"
    -- the wide shape is 3x6 and the narrow one 1x4, so a corner that was
    -- legal for the smaller one can hang off the map with the bigger
    clampPick()
    evaluate()
    standOnPick()
    say("shape %s", p.shape)
  end

  local function cycleCam()
    local p = S.pick
    if type(p) ~= "table" then return end
    p.cam = (p.cam == nil) and "wide" or nil
    evaluate()
    say("lens %s", p.cam or "tele (the long default)")
  end

  -- The floors of the same cave or building, for an entry that stages its
  -- fight on one of them. Family is the id with its floor suffix taken off,
  -- which is how these are actually named: POKEMON_TOWER_4F borrows
  -- POKEMON_TOWER_3F, CERULEAN_CAVE_2F borrows CERULEAN_CAVE_B1F.
  local function family(id)
    return (id:gsub("_B?%d+F", ""))
  end

  local function cycleHost()
    local p = S.pick
    if type(p) ~= "table" then return end
    local id = mapId()
    local kin = { id }
    for other in pairs(game.data.maps) do
      if type(other) == "string" and other ~= id
         and family(other) == family(id) then
        kin[#kin + 1] = other
      end
    end
    table.sort(kin)
    if #kin == 1 then
      say("%s has no sibling floors to stage on", id)
      return
    end
    local at = 1
    for k, other in ipairs(kin) do
      if other == (p.map or id) then at = k end
    end
    local nextId = kin[(at % #kin) + 1]
    p.map = (nextId ~= id) and nextId or nil
    -- the corner is a coordinate on the HOST, so a floor change is a fresh
    -- question about where on it -- clamp it in, then let the fit test speak
    evaluate()
    if S.host then
      clampPick()
      evaluate()
    end
    standOnPick()
    say("staging on %s", p.map or "this map")
  end

  local function handle(key)
    if S.mode == "test" then
      if key == "t" or key == "escape" then post("endtest") return true end
      if key == "y" then post("shot", shifted() and "hud" or nil) return true end
      if key == "f1" then S.legend = not S.legend return true end
      return false
    end

    if key == "up" then nudge(0, -1) return true end
    if key == "down" then nudge(0, 1) return true end
    if key == "left" then nudge(-1, 0) return true end
    if key == "right" then nudge(1, 0) return true end
    if key == "[" then post("map", -1) return true end
    if key == "]" then post("map", 1) return true end
    if key == "s" then cycleShape() return true end
    if key == "r" then rotate(shifted() and -1 or 1) return true end
    if key == "c" then cycleCam() return true end
    if key == "h" then cycleHost() return true end
    if key == "w" then
      S.surf = not S.surf
      say("the search %s water as ground",
          S.surf and "counts" or "does not count")
      return true
    end
    if key == "g" then
      S.planMode = (S.planMode % #PLAN_MODES) + 1
      say("plan: %s", PLAN_MODES[S.planMode])
      return true
    end
    if key == "f" then post("scan") return true end
    if key == "n" then stepCandidate(1) return true end
    if key == "p" then stepCandidate(-1) return true end
    if key == "a" then
      local found = autoPick()
      if found then
        setPick(found)
        standOnPick()
      end
      return true
    end
    -- `u` for undo, because `r` is rotate: the strongest convention on each
    -- wins, and this one is only ever wanted after a change you regret
    if key == "u" then
      local id = mapId()
      local entry = base[id]
      edits[id] = nil
      if entry == nil then
        setPick(autoPick(true), "no entry in the config -- automatic pick")
      elseif entry == false then
        setPick(false, "the config refuses this map")
      else
        setPick({ x = entry.x, y = entry.y, shape = entry.shape or "wide",
                  turn = entry.turn, cam = entry.cam, map = entry.map },
                "back to the config")
      end
      standOnPick()
      return true
    end
    if key == "t" then post("test") return true end
    if key == "y" then post("shot", shifted() and "hud" or nil) return true end
    if key == "return" or key == "kpenter" then
      post("commit", shifted() and "advance" or "stay")
      return true
    end
    if key == "backspace" then post("refuse") return true end
    if key == "delete" then post("drop") return true end
    if key == "e" then post("export") return true end
    if key == "f1" then S.legend = not S.legend return true end
    if key == "escape" then S.quit = true return true end
    return false
  end

  local innerKey = love.keypressed
  function love.keypressed(key, scancode, isrepeat)
    -- the unattended check drives handle() directly, so anything arriving
    -- HERE during one is a real key from the window and worth naming
    if SELFTEST then U.log("KEY    " .. tostring(key)) end
    local ok, claimed = pcall(handle, key)
    if ok and claimed then return end
    if not ok then U.log("key error: " .. tostring(claimed)) end
    if innerKey then return innerKey(key, scancode, isrepeat) end
  end

  -- ------- the overlay
  --
  -- Drawn after the game's own frame, over it. The plan on the right is what
  -- the spot is CHOSEN on -- open ground, grass, water, the walls -- and the
  -- two lines drawn across it from the camera's eye are why a spot that looks
  -- open gets rejected: they are the sightlines the clearance test walks, and
  -- seeing them cross a building is the whole explanation.
  local font = love.graphics.newFont(13)
  local smallFont = love.graphics.newFont(11)

  local function drawPlan(x, y, w, h)
    local plan = S.plan
    if not plan then return end
    local g = love.graphics
    local cell = math.min(w / plan.w, h / plan.h)
    if cell < 1 then cell = 1 end
    local pw, ph = plan.w * cell, plan.h * cell
    local ox, oy = x + (w - pw) / 2, y + (h - ph) / 2
    g.setColor(0, 0, 0, 0.55)
    g.rectangle("fill", ox - 4, oy - 4, pw + 8, ph + 8)

    local mode = PLAN_MODES[S.planMode or 1]
    -- the map itself, from its own tiles
    if S.tiles and mode ~= "grid" then
      g.setColor(1, 1, 1, 1)
      g.draw(S.tiles, ox, oy, 0, pw / S.tiles:getWidth(),
             ph / S.tiles:getHeight())
    end
    -- and the walk grid over it: opaque with no art under it, and a wash
    -- when there is -- enough to read where an arena may go without hiding
    -- what it would be standing on. Open ground is never washed: it is what
    -- the art is being read for.
    if mode ~= "tiles" then
      local alpha = (mode == "grid" or not S.tiles) and 1 or 0.22
      for cy = 0, plan.h - 1 do
        for cx = 0, plan.w - 1 do
          local kind = plan[cy * plan.w + cx]
          local c = CELL_COLOUR[kind]
          if c and not (alpha < 1 and kind == "open") then
            g.setColor(c[1], c[2], c[3], alpha)
            g.rectangle("fill", ox + cx * cell, oy + cy * cell, cell, cell)
          end
        end
      end
    end

    -- The candidates, so a scan is a picture of where the map will take a
    -- fight at all rather than a number to press n against.
    --
    -- One mark at each one's middle rather than its footprint filled in: on a
    -- route most open ground is a candidate, and painting all of them lays a
    -- sheet of colour over the very art the spot is being chosen from. A dot
    -- says "here, and here" without hiding what is there.
    if S.cands then
      local r = math.max(1.5, cell * 0.35)
      for _, c in ipairs(S.cands) do
        local fw, fh = extentOf(c.shape, c.turn)
        if c.clear then
          -- green for the default lens, amber for one that only reads on the
          -- wide one -- the difference is a decision, not a detail
          if c.cam then g.setColor(1, 0.8, 0.2, 0.85)
          else g.setColor(0.3, 1, 0.4, 0.85) end
        else
          g.setColor(1, 0.35, 0.2, 0.35)
        end
        g.circle("fill", ox + (c.x + fw / 2) * cell,
                 oy + (c.y + fh / 2) * cell, r)
      end
    end

    local ow = game.overworld
    if ow and ow.player then
      g.setColor(1, 1, 1, 0.8)
      g.circle("fill", ox + (ow.player.cellX + 0.5) * cell,
               oy + (ow.player.cellY + 0.5) * cell, math.max(2, cell * 0.4))
    end

    -- the spot itself, on whichever map it is measured against
    local p = S.pick
    local onHost = not (S.host and S.map and S.host.id ~= S.map.id)
    -- laid out through the placed record rather than from the shape's own
    -- offsets, so a turned arena is drawn the way it will actually stand.
    -- Built even when it does not fit -- a footprint half off the map is
    -- exactly the thing you need to see to move it back on.
    local shot = type(p) == "table" and Arena.at(p.x, p.y, p.shape, p.turn)
    if shot and onHost then
      g.setColor(S.fits and (S.clear and 0.2 or 1) or 1,
                 S.fits and 1 or 0.3, S.fits and (S.clear and 1 or 0.2) or 0.3,
                 0.9)
      g.setLineWidth(2)
      g.rectangle("line", ox + shot.x * cell, oy + shot.y * cell,
                  shot.w * cell, shot.h * cell)
      g.setLineWidth(1)
      local ex, ey = shot.enemyCell[1], shot.enemyCell[2]
      local px, py = shot.playerCell[1], shot.playerCell[2]
      g.setColor(0.95, 0.35, 0.35, 1)
      g.rectangle("fill", ox + ex * cell, oy + ey * cell, cell, cell)
      g.setColor(0.4, 0.65, 1, 1)
      g.rectangle("fill", ox + px * cell, oy + py * cell, cell, cell)

      -- the camera, and what it is looking through
      if S.arena then
        local ok, rig = pcall(BattleCam.rig, S.arena, 0, true)
        if ok and rig and rig.eye then
          local eyeX = ox + (rig.eye[1] / 16) * cell
          local eyeY = oy + (rig.eye[3] / 16) * cell
          if S.clear then g.setColor(0.3, 1, 0.5, 0.85)
          else g.setColor(1, 0.35, 0.3, 0.85) end
          g.line(eyeX, eyeY, ox + (ex + 0.5) * cell, oy + (ey + 0.5) * cell)
          g.line(eyeX, eyeY, ox + (px + 0.5) * cell, oy + (py + 0.5) * cell)
          g.circle("fill", eyeX, eyeY, 4)
        end
      end
    end
    g.setColor(1, 1, 1, 1)
  end

  local LEGEND = {
    "arrows  move the corner (shift x5)",
    "[  ]    previous / next map",
    "s       shape: wide / narrow",
    "r       turn it 90 (shift: the other way)",
    "c       lens: tele / wide",
    "h       stage on a sibling floor",
    "w       search over water",
    "g       plan: tiles / walk grid / both",
    "f       scan the map     n p  step",
    "a       automatic pick   u    revert",
    "t       TEST a real battle here",
    "y       screenshot (shift: with this panel)",
    "enter   commit   (shift: and next map)",
    "bksp    commit a refusal",
    "del     commit a removal",
    "e       export      F1  this list",
    "escape  quit",
  }

  local function drawOverlay()
    local g = love.graphics
    local W, H = g.getDimensions()

    if S.mode == "test" then
      g.setFont(font)
      g.setColor(0, 0, 0, 0.65)
      g.rectangle("fill", 0, 0, W, 26)
      g.setColor(1, 0.85, 0.3, 1)
      g.print(("TEST  %s  %s   [t] back   [y] shot")
              :format(mapId(), S.msg), 8, 5)
      g.setColor(1, 1, 1, 1)
      return
    end

    local panel = math.min(340, W * 0.34)
    g.setFont(font)
    g.setColor(0, 0, 0, 0.68)
    g.rectangle("fill", 0, 0, panel, H)

    local p = S.pick
    local id = mapId()
    local mark = S.done[id] and " *" or ""
    local lines = {}
    local function add(fmt, ...)
      lines[#lines + 1] = select("#", ...) > 0 and fmt:format(...) or fmt
    end
    add("ARENA EDITOR   %d/%d%s", S.i, #ids, mark)
    add("%s", id)
    if S.map then
      add("%d x %d cells", S.map.widthCells, S.map.heightCells)
    end
    add("")
    if p == false then
      add("REFUSAL -- no fight is staged here")
    elseif type(p) ~= "table" then
      add("no spot")
    else
      local fw, fh = extentOf(p.shape, p.turn)
      add("x %d   y %d   %s  (%dx%d)", p.x, p.y, p.shape or "wide", fw, fh)
      add("turn %d   foe to the %s", p.turn or 0,
          ({ [0] = "north", [90] = "east", [180] = "south",
             [270] = "west" })[p.turn or 0] or "?")
      add("lens %s", p.cam or "tele")
      add("host %s", p.map or "this map")
      add("")
      add("fits    %s", S.fits and "YES" or "NO -- not open ground")
      add("clear   %s", S.clear and "YES"
                        or (S.fits and "NO -- a mon is hidden" or "-"))
    end
    add("")
    -- what the CONFIG says right now, which after a commit is what was just
    -- written to it -- an export rebases on its own output, so "pending" is
    -- never a state this can be left in
    local held = edits[id]
    if held == nil then held = base[id] end
    if held == REMOVE or held == nil then
      add("config  no entry (the search answers)")
    elseif held == false then
      add("config  refuses this map")
    else
      add("config  %d,%d %s%s%s%s", held.x, held.y, held.shape or "wide",
          ((held.turn or 0) ~= 0) and (" turn=" .. held.turn) or "",
          held.cam and (" cam=" .. held.cam) or "",
          held.map and (" on " .. held.map) or "")
    end
    if not sameEntry(p, held) then
      add("NOT COMMITTED -- enter writes it")
    elseif S.done[id] then
      add("committed this session")
    else
      add("unchanged")
    end
    if S.cands then
      add("scan  %d fit, %d clear (%d need the wide lens)%s",
          #S.cands, S.candClear or 0, S.candWide or 0,
          S.ci > 0 and ("   at %d"):format(S.ci) or "")
    end
    add("water %s", S.surf and "counts as ground" or "is not searched")
    add("done  %d of %d maps this session", S.doneN, #ids)
    add("out   %s", OUT)

    g.setColor(1, 1, 1, 1)
    local y = 10
    for _, l in ipairs(lines) do
      g.print(l, 10, y)
      y = y + 16
    end

    y = y + 6
    g.setColor(1, 0.85, 0.35, 1)
    g.setFont(smallFont)
    local msg = S.msg or ""
    local _, wrapped = smallFont:getWrap(msg, panel - 20)
    g.printf(msg, 10, y, panel - 20)
    y = y + 13 * math.max(1, #wrapped)

    -- what the marks on the plan mean, in the marks themselves
    if S.cands then
      y = y + 8
      local key = { { { 0.3, 1, 0.4 }, "clear" },
                    { { 1, 0.8, 0.2 }, "wide lens only" },
                    { { 1, 0.35, 0.2 }, "obstructed" } }
      local kx = 14
      for _, entry in ipairs(key) do
        g.setColor(entry[1][1], entry[1][2], entry[1][3], 0.9)
        g.circle("fill", kx, y + 5, 3.5)
        g.setColor(0.8, 0.8, 0.85, 1)
        g.print(entry[2], kx + 9, y - 1)
        kx = kx + 12 + smallFont:getWidth(entry[2]) + 10
      end
      y = y + 16
    end

    if S.legend then
      y = y + 10
      g.setColor(0.75, 0.8, 0.9, 1)
      for _, l in ipairs(LEGEND) do
        g.print(l, 10, y)
        y = y + 14
      end
    end

    -- the plan, in whatever is left of the window
    local px = panel + 12
    drawPlan(px, 12, W - px - 12, H - 24)
    g.setColor(1, 1, 1, 1)
  end

  local innerDraw = love.draw
  function love.draw()
    if innerDraw then innerDraw() end
    if S.shooting then return end
    local g = love.graphics
    g.push("all")
    g.origin()
    local ok, err = pcall(drawOverlay)
    g.pop()
    if not ok and not S.drawWarned then
      S.drawWarned = true
      U.log("overlay draw failed: " .. tostring(err))
    end
  end

  -- ------- the session

  U.log("arena editor -- " .. #ids .. " maps")
  for _, l in ipairs(LEGEND) do U.log("  " .. l) end
  U.log("exports to " .. OUT)

  enterMap(1)

  local function dispatch(job)
    if job.cmd == "map" then
      enterMap(S.i + job.arg)
    elseif job.cmd == "scan" then
      scan()
    elseif job.cmd == "test" then
      beginTest()
    elseif job.cmd == "endtest" then
      endTest()
    elseif job.cmd == "shot" then
      local hud = job.arg == "hud"
      shoot(shotName(hud and "sheet" or nil), hud)
    elseif job.cmd == "commit" then
      commit(S.pick, job.arg == "advance")
    elseif job.cmd == "refuse" then
      commit(false, false)
    elseif job.cmd == "drop" then
      commit(REMOVE, false)
    elseif job.cmd == "export" then
      local ok, msg = export()
      say("%s", msg)
      U.log((ok and "EXPORT " or "EXPORT FAILED ") .. msg)
    end
  end

  -- One frame of the session: the next thing a key asked for, or a frame of
  -- standing still while the game runs underneath.
  local function pump(frames)
    for _ = 1, (frames or 1) do
      local job = table.remove(queue, 1)
      if job then dispatch(job) else step(1) end
      if S.quit then return end
    end
  end

  -- ------- ARENA_SELFTEST=1: the same session, with the keys pressed for you
  --
  -- Every command this tool has, driven through the SAME entry point the
  -- keyboard drives -- so it exercises the real handler, the real staging and
  -- the real export rather than a parallel script that can drift from them.
  -- It is how the tool is checked after a change without a person sitting in
  -- front of it, and it writes to its own file, never to the mod's.
  if SELFTEST then
    local function press(key, frames)
      local ok, err = pcall(handle, key)
      if not ok then U.log("SELFTEST key failed: " .. tostring(err)) end
      pump(frames or 20)
      U.log(("SELFTEST %-9s %s"):format(key, S.msg or ""))
    end
    press("f", 900)         -- scan this map
    press("n")              -- and step through what it found
    press("n")
    press("s")              -- both shapes
    press("s")
    press("r")              -- all four ways round, and back to where it was
    press("r")
    press("r")
    press("r")
    press("right")          -- nudge, with and without shift
    press("down")
    press("c")              -- both lenses
    press("c")
    press("h")              -- a sibling floor, and back off it
    press("h", 40)
    press("a", 60)          -- the automatic pick
    press("n", 60)          -- back onto the scan's best answer
    -- and the rest of the run happens TURNED, so what gets photographed and
    -- committed is a turned staging: the projection maths is proved in the
    -- suite, but whether the whole pipeline behind it -- the shadow cast, the
    -- billboards, the pinned pics -- still composes at a turn is the kind of
    -- thing only a picture settles.
    --
    -- HALF a turn, twice, rather than a quarter: the candidate under us fits
    -- the ground it was found on, and 180 keeps that footprint exactly while
    -- moving the camera to the other end of it. A quarter turn swaps the
    -- footprint's reach, so on a lane that is six cells one way and three the
    -- other it lands on a wall -- which the tool rightly refuses to stage, and
    -- which would leave this check photographing nothing.
    press("r")
    press("r", 60)
    post("shot", "hud")     -- and one with the panel in it, which is the
    pump(240)               -- only thing that proves the overlay draws
    press("y", 240)         -- a screenshot of the free-roam view
    press("g")              -- each way of drawing the plan
    press("g")
    press("g")
    press("t", 1200)        -- a real battle on the spot
    press("y", 240)         -- and one of that
    press("t", 90)          -- out of the test, by the key that is not also
                            -- the quit key -- escape is checked at the end
    press("return", 120)    -- commit it, which exports
    press("]", 200)         -- the next map
    press("backspace", 120) -- commit a refusal there
    press("delete", 120)    -- and take the entry out again
    press("e", 60)          -- an explicit export
    press("escape", 5)      -- and the way out
    U.log("SELFTEST done -- " .. tostring(S.msg))
    S.quit = true
  end

  while not S.quit do pump(1) end

  -- Hand the install back the way it was found: the forced entry let go, the
  -- baked plan released, the two wraps unwound and the camera rung put back.
  -- The mod settings this tool moved were never persisted (see the top), so
  -- there is nothing to undo there.
  if S.overrideOn then Arena.setOverride(S.overrideOn, nil) end
  if S.tiles then pcall(S.tiles.release, S.tiles) end
  love.draw = innerDraw
  love.keypressed = innerKey
  pcall(Pipelines.setLevel, "voxel", wasRung)
  U.log("done -- " .. OUT)
end