'OHRRPGCE - CUSTOM editor helper code
'(C) Copyright 1997-2020 James Paige, Ralph Versteegen, and the OHRRPGCE Developers
'Dual licensed under the GNU GPL v2+ and MIT Licenses. Read LICENSE.txt for terms and disclaimer of liability.

' This file is for utility code (generally for editing) used by CUSTOM but not by GAME.
' Some editors are also here, but ought to be split into separate files.

#include "config.bi"
#include "string.bi" 'for format
#include "vbcompat.bi" 'for NOW
#include "allmodex.bi"
#include "common.bi"
#include "loading.bi"
#include "const.bi"
#include "scrconst.bi"
#include "cglobals.bi"
#include "reload.bi"
#include "slices.bi"
#include "custom.bi"
#include "customsubs.bi"
#include "thingbrowser.bi"
#include "sliceedit.bi"

'Subs and functions only used here
DECLARE SUB cond_editor (cond as Condition, default as bool = NO, outer_state as MenuState)

DECLARE SUB check_used_onetime_npcs_npcdata(bits() as integer, npcdata() as NPCType, mapnum as integer)


'Module-local variables
DIM SHARED comp_strings() as string
REDIM comp_strings(7) as string 
comp_strings(0) = ""
comp_strings(1) = "="
comp_strings(2) = "<>"
comp_strings(3) = "<"
comp_strings(4) = "<="
comp_strings(5) = ">"
comp_strings(6) = ">="
comp_strings(7) = "tag"   'debugging use only


FUNCTION safe_tag_name(byval tagnum as integer) as string 
 IF tagnum >= 1 AND tagnum <= gen(genMaxTagName) THEN
  RETURN load_tag_name(tagnum)
 ELSE
  RETURN ""
 END IF
END FUNCTION

'Return YES if the tag has changed
'allowspecial:  Whether to allow picking autoset tags (eg hero is alive)
'               If you want to change this, use tag_set_grabber instead if possible.
'always_choice: 'Always' is an option
'allowneg:      Allow set tag=OFF.
'               If you want to change this, use tag_id_grabber instead if possible.
FUNCTION tag_grabber (byref n as integer, state as MenuState, allowspecial as bool=YES, always_choice as bool=NO, allowneg as bool=YES) as bool
 DIM min as integer = 0
 IF allowneg THEN min = -max_tag()
 IF intgrabber(n, min, max_tag()) THEN RETURN YES
 IF enter_space_click(state) THEN
  DIM browse_tag as integer
  browse_tag = tags_menu(n, YES, allowspecial, allowneg, always_choice)
  IF browse_tag <> n THEN
   n = browse_tag
   RETURN YES
  END IF
 END IF
 RETURN NO
END FUNCTION

'A tag_grabber wrapper for set tag ON/OFF actions.
'Return YES if the tag has changed
FUNCTION tag_set_grabber (byref n as integer, state as MenuState) as bool
 RETURN tag_grabber(n, state, NO)
END FUNCTION

'A tag_grabber wrapper for tag ids to define as autoset tags: no on/off
'selection, and you usually shouldn't have multiple autoset settings for the same tag
'Return YES if the tag has changed
FUNCTION tag_id_grabber (byref n as integer, state as MenuState) as bool
 RETURN tag_grabber(n, state, NO, , NO)  'allowneg=NO
END FUNCTION

LOCAL SUB tag_autoset_warning(byval tag_id as integer)
 notification !"This tag is automatically set or unset on the following conditions:\n" + describe_tag_autoset_places(tag_id) + !"\nThis means that you should not attempt to set or unset the tag in any other way, because your changes will be erased -- unpredictably!"
END SUB

'If picktag is true, then can be used to pick a tag. In that case, allowspecial indicates whether to allow
'picking 'special' tags: those automatically set, eg. based on inventory conditions
'If showsign is true, picking a tag condition (tag=ON/OFF), and the ON/OFF condition can be selected.
'Returns a signed tag number (+ve, tag ON, -ve tag OFF).
FUNCTION tags_menu (byval starttag as integer=0, byval picktag as bool=NO, byval allowspecial as bool=YES, byval showsign as bool=NO, byval always_choice as bool=NO) as integer
 STATIC searchstring as string
 
 'If this method for guessing checktag mode ever fails, we can change it to be an argument
 DIM checktag as bool = picktag ANDALSO allowspecial

 DIM thisname as string
 DIM ret as integer = starttag
 IF gen(genMaxTagname) < 1 THEN gen(genMaxTagname) = 1
 
 DIM tagid as integer vector
 DIM menu as BasicMenuItem vector

 DIM menu_size as integer = gen(genMaxTagname) + 1
 IF picktag THEN menu_size += 1
 IF always_choice THEN menu_size += 1

 v_new menu, menu_size
 v_new tagid, menu_size

 IF picktag THEN
  menu[0].text = "Cancel"
 ELSE
  menu[0].text = "Previous Menu"
 END IF
 tagid[0] = -1

 DIM menu_i as integer = 1
 
 IF picktag THEN
  'When picktag is true, it should be possible to clear the tag selection
  tagid[menu_i] = 0
  IF checktag THEN
   menu[menu_i].text = "No Tag Check"
  ELSE
   menu[menu_i].text = "No Tag Set"
  END IF
  menu_i += 1
 END IF

 IF always_choice THEN
  tagid[menu_i] = 1 'Magic value to indicate we want the "Always" tag
  menu[menu_i].text = "ALWAYS"
  menu_i += 1
 END IF

 FOR i as integer = 2 TO gen(genMaxTagname) + 1
  'Load all tag names plus the first blank name
  menu[menu_i].text = "Tag " & i & ":" & load_tag_name(i)
  tagid[menu_i] = i
  IF tag_is_autoset(i) THEN
   IF allowspecial = NO AND i <> ABS(starttag) THEN
    menu[menu_i].disabled = YES
   END IF
  END IF
  menu_i += 1
 NEXT i

 DIM tagsign as integer
 tagsign = SGN(starttag)
 IF tagsign = 0 THEN tagsign = 1

 DIM menuopts as MenuOptions
 menuopts.fullscreen_scrollbar = YES
 menuopts.highlight_selection = YES

 DIM state as MenuState
 state.autosize = YES
 state.autosize_ignore_lines = 1
 IF showsign THEN
  state.autosize_ignore_lines = 2
 END IF
 state.last = v_len(menu) - 1
 init_menu_state state, menu

 state.pt = 0
 'If ABS(starttag) >= 2 (valid tag) or 0 (do nothing), sets initial selection
 FOR i as integer = 0 to v_len(tagid) - 1
  IF tagid[i] = ABS(starttag) THEN
   state.pt = i
   EXIT FOR
  END IF
 NEXT i

 DIM int_browsing as bool = NO
 DIM uninterrupted_alt_press as bool = NO
 DIM alt_pt as integer 
 
 DIM do_search as bool = NO
 DIM search_cur_str as string
 DIM search_found as bool = NO

 setkeys YES
 DO
  setwait 55
  setkeys YES
  IF keyval(ccCancel) > 1 THEN EXIT DO
  IF keyval(scF1) > 1 THEN show_help "tagnames"
  IF keyval(scCTRL) > 0 ANDALSO keyval(scF) > 1 THEN
   IF prompt_for_string(searchstring, "Search") THEN
    do_search = YES
   END IF
  END IF
  IF keyval(scF3) > 1 THEN
   do_search = YES
  END IF
  IF do_search THEN
   do_search = NO
   IF searchstring <> "" THEN
    FOR i as integer = large(state.pt + 1, 1) TO v_len(tagid) - 1
     IF tagid[i] < 2 THEN CONTINUE FOR
     search_cur_str = load_tag_name(tagid[i])
     IF INSTR(LCASE(search_cur_str), LCASE(searchstring)) THEN
      state.pt = i
      correct_menu_state state
      search_found = YES
      EXIT FOR
     END IF
    NEXT i
    IF NOT search_found THEN
     '--wrap the search
     FOR i as integer = 1 TO state.pt - 1
      IF tagid[i] < 2 THEN CONTINUE FOR
      search_cur_str = load_tag_name(tagid[i])
      IF INSTR(LCASE(search_cur_str), LCASE(searchstring)) THEN
       state.pt = i
       correct_menu_state state
       search_found = YES
       EXIT FOR
      END IF
     NEXT i
    END IF
    search_found = NO
   END IF
  END IF
  IF usemenu(state) THEN
   IF tagid[state.pt] >= 2 THEN
    alt_pt = tagid[state.pt]
   ELSE
    alt_pt = 0
   END IF
  END IF
  IF keyval(scAlt) AND 4 THEN uninterrupted_alt_press = YES
  IF keyval(scAlt) = 0 AND uninterrupted_alt_press = YES THEN
   uninterrupted_alt_press = NO
   int_browsing XOR= YES
   IF tagid[state.pt] >= 2 THEN
    alt_pt = tagid[state.pt]
   ELSE
    alt_pt = 0
   END IF
  END IF
  IF int_browsing THEN
   IF intgrabber(alt_pt, 0, gen(genMaxTagName) + 1) THEN
    FOR i as integer = 0 TO v_len(tagid) - 1
     IF alt_pt = tagid[i] THEN
      state.pt = i
      correct_menu_state state
      EXIT FOR
     END IF
    NEXT i
   END IF
  ELSEIF showsign THEN
   IF keyval(ccLeft) > 1 ORELSE keyval(ccRight) > 1 THEN
    tagsign = tagsign * -1
   END IF
  END IF
  IF tagid[state.pt] = -1 AND enter_space_click(state) THEN
   'We want to cancel out with no changes
   ret = starttag
   EXIT DO
  END IF
  IF tagid[state.pt] = 0 AND enter_space_click(state) THEN
   'We want to return 0, clearing the tag set/check
   ret = 0
   EXIT DO
  END IF
  IF tagid[state.pt] = 1 AND enter_space_click(state) THEN
   'We want to return -1, indicating a tag-check of "ALWAYS"
   IF NOT checktag THEN debug "tags_menu() returned -1 ALWAYS when not in checktag mode."
   ret = -1
   EXIT DO
  END IF
  IF tagid[state.pt] >= 2 THEN
   IF keyval(scTab) > 1 ANDALSO tag_is_autoset(tagid[state.pt]) THEN
    tag_autoset_warning tagid[state.pt]
   END IF
   IF enter_click_but_not_space(state) THEN ' Can't call enter_space_click() because we can type spaces when editing tag names
    IF menu[state.pt].disabled THEN
     tag_autoset_warning tagid[state.pt]
    ELSEIF picktag THEN
     ret = tagid[state.pt] * tagsign
     EXIT DO
    END IF
   END IF
   thisname = safe_tag_name(tagid[state.pt])
   IF int_browsing = NO ANDALSO strgrabber(thisname, 20) THEN
    uninterrupted_alt_press = NO
    save_tag_name thisname, tagid[state.pt]
    menu[state.pt].text = "Tag " & tagid[state.pt] & ":" & thisname
    IF tagid[state.pt] = gen(genMaxTagName) + 1 THEN
     IF gen(genMaxTagName) < max_tag() THEN
      gen(genMaxTagName) += 1
      v_resize menu, v_len(menu) + 1
      v_resize tagid, v_len(tagid) + 1
      tagid[state.pt + 1] = tagid[state.pt] + 1
      menu[state.pt + 1].text = "Tag " & tagid[state.pt + 1] & ":"
      state.last += 1
     END IF
    END IF
   END IF
  END IF

  clearpage dpage
  standardmenu menu, state, , , dpage, menuopts
  DIM tmpstr as string
  IF int_browsing THEN
   textcolor uilook(uiText), uilook(uiHighlight)
   tmpstr = "Tag " & alt_pt
  ELSE
   textcolor uilook(uiDisabledItem), 0
   tmpstr = "Alt:Tag #"
  END IF
  printstr tmpstr, pInfoRight, pMenuY, dpage

  IF NOT int_browsing THEN
   tmpstr = "CTRL+F Search"
   printstr tmpstr, pInfoRight, pMenuY + 10, dpage
   IF LEN(searchstring) > 0 THEN
    tmpstr = "F3 Again"
    printstr tmpstr, pInfoRight, pMenuY + 20, dpage
   END IF
  END IF

  IF showsign THEN
   ' Show whether we are picking a tag that can be ON or OFF
   DIM signstr as string
   IF checktag THEN
    signstr = "Check if tag is"
   ELSE
    signstr = "Set tag ="
   END IF
   signstr = signstr & " " & IIF(tagsign = 1, "ON", "OFF")
   textcolor uilook(uiText), 0
   DIM signrect as RectType
   signrect = str_rect(signstr, 0 , 0)
   signrect.x = vpages(dpage)->w - 4 - signrect.size.x
   signrect.y = vpages(dpage)->h - 16
   IF rect_collide_point(signrect, readmouse.pos) THEN
    textcolor uilook(uiSelectedItem + state.tog), uilook(uiHighlight)
    IF readmouse.release AND mouseLeft THEN tagsign *= -1
   END IF
   printstr signstr, pRight - 4, pBottom - 8, dpage
  END IF
  
  IF tag_is_autoset(tagid[state.pt]) THEN
   'Showing tag autoset status is not important when using picktag for a tag check
   'so we only show it when in set-tag more or non-tag-picking mode
   textcolor uilook(uiDisabledItem), 0
   printstr "An auto-set tag. Press TAB for details", pInfoX, pInfoY, dpage
  END IF

  SWAP vpage, dpage
  setvispage vpage
  dowait
 LOOP
 setkeys

 v_free menu
 RETURN ret
END FUNCTION

'default: meaning of the null condition (true: ALWAYS, false: NEVER)
'alwaysedit: experimental parameter, changes behaviour of enter/space
'Return value is currently very unreliable.
FUNCTION cond_grabber (cond as Condition, default as bool = NO, alwaysedit as bool, st as MenuState) as bool

 DIM intxt as string = getinputtext
 DIM entered_operator as bool = NO
 DIM temp as integer

 WITH cond

  'debug "cond_grabber: .comp = " & comp_strings(.comp) & " tag/var = " & .tag & " value = " & .value & " editst = " & .editstate & " lastchar = " & CHR(.lastinput) & "  default = " & default

  IF keyval(scDelete) > 1 THEN
   .comp = 0
   RETURN YES
  END IF

  'Simplify
  IF .comp = compTag AND .tag = 0 THEN .comp = 0

  'enter_or_space
  IF .comp = compTag AND alwaysedit = NO THEN
   IF enter_or_space() THEN
    DIM browse_tag as integer
    browse_tag = tags_menu(.tag, YES, YES)
    IF browse_tag >= 2 OR browse_tag <= -2 THEN
     .tag = browse_tag
     RETURN YES
    ELSE
     'Return once enter/space processed
     RETURN NO
    END IF
   END IF
  ELSE
   IF keyval(scAnyEnter) > 1 THEN cond_editor(cond, default, st)
  END IF

  CONST compare_chars as string = "=<>!"
  'Use strings instead of integers for convenience -- have to decode to use
  STATIC statetable(3, 7) as zstring * 3 => { _
     /'Current comparison type:               '/ _
     /'None  =    <>   <    <=   >    >=  Tag '/ _
      {"=" ,"=" ,"=" ,"<=","=" ,">=","=" ,"=" },  /' = pressed  '/ _
      {"<" ,"<=","<" ,"<" ,"<" ,"<>","<" ,"<" },  /' < pressed  '/ _
      {">" ,">=",">" ,"<>",">" ,">" ,">" ,">" },  /' > pressed  '/ _
      {"<>","<>","=" ,">=",">" ,"<=","<" ,"<>"}   /' ! pressed  '/ _
  }

  'Listen for comparison operator input
  FOR i as integer = 1 TO LEN(intxt)
   DIM inchar as string = MID(intxt, i)
   DIM charnum as integer = INSTR(compare_chars, inchar)

   IF charnum THEN
    entered_operator = YES
    DIM newcomp as CompType = -1

    IF .comp = compNone OR .comp = compTag OR .editstate = 1 OR .editstate = 5 THEN
     'Ignore the current operator; we're pretending there is none
     newcomp = a_findcasei(comp_strings(), inchar)

    ELSE
     'First check whether in the middle of typing a comparison operator.
     'This special check ensure that eg. typing >= causes the operator to
     'change to >= regardless of initial state
     IF .lastinput THEN
      'Only checking input strings of len 2
      newcomp = a_findcasei(comp_strings(), CHR(.lastinput) + inchar)
     END IF

     IF newcomp = -1 THEN
      'This _temp variable is to work around a FB bug, https://sourceforge.net/p/fbc/bugs/816/
      '(Fixed in FB 1.06.)
      'It only occurs when compiling with debug=0 (without -exx. Whether adding/removing -exx causes
      'the bug to occur depends on the surrounding context in the function).
      DIM _temp as integer = charnum - 1
      DIM tempcomp as string = statetable(_temp, .comp)
      newcomp = a_findcasei(comp_strings(), tempcomp)
     END IF
    END IF

    IF newcomp > -1 THEN
     IF .comp = compNone OR .comp = compTag THEN
      'In future, largest allowable tag ID will increase
      .varnum = small(ABS(.tag), maxScriptGlobals)
      .value = 0
      .editstate = 2
     END IF
     .comp = newcomp
    END IF
   END IF

   .lastinput = ASC(inchar)
  NEXT

  'Other input: a finite state machine
  IF .comp = compNone THEN
   'No need to check for entered_operator: the comp would have changed
   .tag = 0
   IF intgrabber(.tag, -max_tag(), max_tag()) THEN
    .comp = compTag
   END IF
  ELSEIF .comp = compTag THEN
   'editstate meaning (asterisks indicate highlighting)
   '0: Tag #=OFF/ON  (no highlight)
   '1: Tag *#*=OFF/ON
   'No need to check for entered_operator
   IF INSTR(intxt, "!") THEN
    .tag = -.tag
   ELSE
    intgrabber(.tag, -max_tag(), max_tag())
   END IF
  ELSE  'Globals
   .varnum = bound(.varnum, 0, maxScriptGlobals)  'Could be negative if it was a tag condition
   'editstate is just a state id, defining the way the condition is edited and displayed
   '(below, asterisks indicate highlighting)
   '0: Global # .. #  (initial)
   '1: Global *#*
   '2: Global # *..*
   '3: Global # .. *#*
   '4: Global # *..* #
   '5: Global # *?* #
   '6: Global *#* .. #
   SELECT CASE .editstate
    CASE 0
     IF keyval(scTab) > 1 THEN
      .editstate = 3
     ELSEIF keyval(scBackspace) > 1 THEN
      'Backspace works from the right...
      intgrabber(.value, -2147483648, 2147483647)
      .editstate = 3
     ELSEIF entered_operator THEN
      .editstate = 4
     ELSE
      '...and numerals enter from the left
      temp = 0
      'Don't erase previous value when trying to inc/decrement it
      IF keyval(ccLeft) > 0 OR keyval(ccRight) > 0 THEN temp = .varnum
      IF intgrabber(temp, 0, maxScriptGlobals, , , YES) THEN
       .varnum = temp
       .editstate = 6
      END IF
     END IF
    CASE 1, 6
     IF .editstate = 6 AND keyval(scTab) > 1 THEN
      .editstate = 3
     ELSEIF entered_operator THEN
      IF .editstate = 1 THEN .editstate = 2 ELSE .editstate = 4
     ELSEIF keyval(scBackspace) > 1 AND .varnum = 0 THEN
      .editstate = 0
      .comp = compNone
     ELSE
      intgrabber(.varnum, 0, maxScriptGlobals)
     END IF
    CASE 3
     IF keyval(scTab) > 1 THEN
      .editstate = 6
     ELSEIF entered_operator THEN
      .editstate = 4
     ELSEIF keyval(scBackspace) > 1 AND .value = 0 THEN
      .editstate = 2
     ELSE
      intgrabber(.value, -2147483648, 2147483647)
     END IF
    CASE 2, 4, 5  'Operator editing
     IF keyval(scTab) > 1 THEN
      .editstate = 3
     ELSEIF .editstate = 5 AND entered_operator THEN
      .editstate = 4
     ELSEIF keyval(scBackspace) > 1 THEN
      DIM newcomp as string = comp_strings(.comp)
      IF .editstate = 5 THEN  'state 5 simulates LEN(newcomp) = 0
       .editstate = 1
      ELSEIF LEN(newcomp) = 1 THEN
       IF .editstate = 2 THEN
        .editstate = 1
       ELSEIF .editstate = 4 THEN
        .editstate = 5
       END IF
      ELSE 'LEN = 2
       .comp = a_findcasei(comp_strings(), LEFT(newcomp, 1))
      END IF
     ELSE
      temp = 0
      'IF .editstate <> 2 THEN temp = .value
      'Don't erase previous value when trying to inc/decrement it
      IF keyval(ccLeft) > 0 OR keyval(ccRight) > 0 THEN temp = .value
      IF intgrabber(temp, -2147483648, 2147483647, , , YES) THEN
       .value = temp
       .editstate = 3
      END IF
     END IF
   END SELECT
  END IF

 END WITH

 'FIXME: check if anything changed, and return YES if so
END FUNCTION

'default: meaning of the null condition (true: ALWAYS, false: NEVER)
SUB cond_editor (cond as Condition, default as bool = NO, outer_state as MenuState)
 DIM menu(10) as string
 DIM compty(10) as integer  'CompType (can't pass that to a_find)
 menu(0)  = "Cancel"
 menu(1)  = "Always"
 menu(2)  = "Never"
 menu(3)  = "Tag # ON"        : compty(3)  = compTag
 menu(4)  = "Tag # OFF"       : compty(4)  = compTag
 menu(5)  = "Global # = #"    : compty(5)  = compEq
 menu(6)  = "Global # <> #"   : compty(6)  = compNe
 menu(7)  = "Global # < #"    : compty(7)  = compLt
 menu(8)  = "Global # <= #"   : compty(8)  = compLe
 menu(9)  = "Global # > #"    : compty(9)  = compGt
 menu(10) = "Global # >= #"   : compty(10) = compGe

 DIM st as MenuState
 st.last = UBOUND(menu)
 st.size = st.last + 1
 DIM starttag as integer = 1

 ' Determine initial menu selection
 IF cond.comp = compTag AND cond.tag = 0 THEN cond.comp = compNone
 IF cond.comp = compNone THEN
  st.pt = IIF(default, 1, 2)
 ELSEIF cond.comp = compTag THEN
  starttag = ABS(cond.tag)
  IF cond.tag = 1 THEN
   st.pt = 2
  ELSEIF cond.tag = -1 THEN
   st.pt = 1
  ELSEIF cond.tag >= 2 THEN
   st.pt = 3
  ELSE
   st.pt = 4
  END IF
 ELSE
  st.pt = a_find(compty(), cond.comp)
  IF st.pt = -1 THEN st.pt = 1   'If cond.comp is invalid
 END IF

 DIM menuopts as MenuOptions
 menuopts.wide = 13 * 8  ' Minimum width
 menuopts.calc_size = YES

 ' Position to draw the menu (calculated next)
 DIM mpos as XYPair = (60, 0)

 ' Precompute the menu size
 calc_menu_rect st, menuopts, mpos, vpage

 ' Calculate screen position of the outer menu
 ' (Note: MenuState doesn't tell where the menu will be drawn; we have to assume 0,0!)
 WITH outer_state
  ' Position the new menu so that the initially selected menu item
  ' is at the same y position as the current item in the previous menu
  mpos.y = (.pt - .top) * .spacing - (st.pt - st.top) * st.spacing
  ' Make sure the new position is fully onscreen (and leave extra space at the bottom of the screen)
  mpos.y = bound(mpos.y, 0, vpages(vpage)->h - .spacing)
  IF mpos.y + st.rect.high > vpages(vpage)->h - 15 THEN mpos.y -= st.rect.high - st.spacing
 END WITH

 DIM holdpage as integer = allocatepage
 copypage vpage, holdpage

 DO
  setwait 55
  setkeys
  IF keyval(ccCancel) > 1 THEN EXIT DO
  IF keyval(scF1) > 1 THEN show_help "cond_editor"

  ' Typing a number could be any type. Select what's under the cursor
  IF compty(st.pt) <> 0 AND INSTR(getinputtext, ANY "0123456789") > 0 THEN
   cond.comp = compty(st.pt)
   cond.varnum = 0  'Clear ID so you can type it in
   cond.editstate = 6  'Editing global ID. Also valid editstate for tags.
   EXIT DO
  END IF

  ' If you start typing a relation, exit to cond_grabber which will handle it
  ' FIXME: regardless of editstate, the cond_grabber ignores the keypress
  IF INSTR(getinputtext, ANY "=<>!") > 0 THEN
   IF cond.comp = compTag OR cond.comp = compNone THEN cond.comp = compEq
   cond.editstate = 6
   EXIT DO
  END IF

  ' Exit on TAB so that you simultaneously change to the selected comparison
  ' and cond_grabber processes the TAB.
  ' Also, press TAB to select a tag option but skip the tag browser.
  IF enter_space_click(st) OR keyval(scTab) > 1 THEN
   IF compty(st.pt) THEN cond.comp = compty(st.pt)
   SELECT CASE st.pt
    CASE 0:
     EXIT DO
    CASE 1:
     IF default THEN
      cond.comp = compNone
     ELSE
      cond.comp = compTag
      cond.tag = -1
     END IF
    CASE 2:
     IF default = NO THEN
      cond.comp = compNone
     ELSE
      cond.comp = compTag
      cond.tag = 1
     END IF
    CASE 3, 4:
     IF st.pt = 4 THEN starttag *= -1  'tag=OFF
     IF keyval(scTab) > 1 THEN
      cond.tag = starttag
     ELSE
      cond.tag = tags_menu(starttag, YES, YES)
     END IF
    CASE ELSE:
     'TODO: global variable browser
     cond.editstate = 6  'start by entering global variable number
   END SELECT
   EXIT DO
  END IF

  usemenu st

  copypage holdpage, vpage
  edgeboxstyle mpos.x - 5, mpos.y - 5, st.rect.wide + 10, st.rect.high + 10, 2, vpage
  DIM msg as string
  IF compty(st.pt) = compNone THEN
  ELSEIF compty(st.pt) = compTag THEN
   msg = "ENTER to pick tag/TAB confirm"
  ELSE
   msg = "Type expression, TAB to switch"
  END IF
  edgeprint "F1 Help  " + msg, pLeft, pBottom, uilook(uiText), vpage
  standardmenu menu(), st, mpos.x, mpos.y, vpage, menuopts
  setvispage vpage
  dowait
 LOOP
 freepage holdpage
END SUB

'Returns a printable representation of a Condition with lots of ${K} colours
'default: the text displayed for a null Condition
'selected: whether this menu item is selected
'wide: max string length to return (not implemented yet)
FUNCTION condition_string (cond as Condition, selected as bool, default as string = "Always", wide as integer = 40) as string
 DIM ret as string = default
 DIM hlcol as integer = uilook(uiHighlight2)

 IF selected = NO THEN
  cond.editstate = 0
  cond.lastinput = 0
 ELSEIF cond.editstate = 0 THEN
  ' Set initial edit state to highlight the relevant part
  IF cond.comp = compTag THEN
   cond.editstate = 1
  ELSEIF cond.comp <> compNone THEN
   cond.editstate = 6   ' Initially, the global ID is edited
  END IF
 END IF

 IF cond.comp = compNone THEN
 ELSEIF cond.comp = compTag THEN
  IF cond.tag = 0 THEN
  ELSE
   IF cond.editstate = 0 THEN
    ret = "Tag " & ABS(cond.tag)
   ELSE
    ret = "Tag " & hilite(STR(ABS(cond.tag)), hlcol)
   END IF
   ret += IIF(cond.tag >= 0, "=ON", "=OFF")
   IF cond.tag = 1 THEN
    ret += " [Never]"
   ELSEIF cond.tag = -1 THEN
    ret += " [Always]"
   ELSE
    ret += " (" & load_tag_name(ABS(cond.tag)) & ")"
   END IF
  END IF
 ELSEIF cond.comp >= compEq AND cond.comp <= compGe THEN
  SELECT CASE cond.editstate
   CASE 0
    ret = "Global #" & cond.varnum & " " & comp_strings(cond.comp) & " " & cond.value
   CASE 1
    ret = "Global #" & hilite(str(cond.varnum), hlcol)
   CASE 2
    ret = "Global #" & cond.varnum & " " & hilite(comp_strings(cond.comp), hlcol)
   CASE 3
    ret = "Global #" & cond.varnum & " " & comp_strings(cond.comp) & hilite(" " & cond.value, hlcol)
   CASE 4
    ret = "Global #" & cond.varnum & " " & hilite(comp_strings(cond.comp), hlcol) & " " & cond.value
   CASE 5
    'FIXME: a tag for text background colour hasn't been implemented yet
    ret = "Global #" & cond.varnum & hilite(" ? ", hlcol) & cond.value
   CASE 6
    ret = "Global #" & hilite(cond.varnum & " ", hlcol) & comp_strings(cond.comp) & " " & cond.value
  END SELECT
 ELSE
  ret = "[Corrupt condition data]"
 END IF

 IF selected THEN
  ' Provide an indication that you can press Enter
  ret += "..."
 END IF

 RETURN ret
END FUNCTION

FUNCTION charpicker() as string
 STATIC pt as integer

 DIM i as integer
 DIM f(255) as integer
 DIM last as integer = -1
 DIM linesize as integer
 DIM offset as XYPair

 FOR i = 32 TO 255
  last = last + 1
  f(last) = i
 NEXT i

 linesize = 16
 offset.x = 160 - (linesize * 9) \ 2
 offset.y = 100 - ((last \ linesize) * 9) \ 2

 DIM tog as integer = 0
 setkeys
 DO
  setwait 55
  setkeys
  tog = tog XOR 1
  IF keyval(ccCancel) > 1 THEN
   setkeys
   RETURN ""
  END IF
  IF keyval(scF1) > 1 THEN show_help "charpicker"

  IF keyval(ccUp) > 1 THEN pt = large(pt - linesize, 0)
  IF keyval(ccDown) > 1 THEN pt = small(pt + linesize, last)
  IF keyval(ccLeft) > 1 THEN pt = large(pt - 1, 0)
  IF keyval(ccRight) > 1 THEN pt = small(pt + 1, last)

  IF enter_or_space() THEN
   setkeys
   RETURN CHR(f(pt))
  END IF
  
  DIM hover as integer = -1
  FOR i = 0 TO last
   IF rect_collide_point(XYWH(offset.x + (i MOD linesize) * 9, offset.y + (i \ linesize) * 9, 9, 9), readmouse.pos) THEN hover = i
  NEXT i
  IF hover >= 0 THEN
   IF (readmouse.buttons AND mouseLeft) ORELSE (readmouse.buttons AND mouseRight) THEN
    pt = hover
   END IF
   IF (readmouse.release AND mouseLeft) THEN
    setkeys
    RETURN CHR(f(pt))
   END IF
  ELSE
   'outside of menu area
   IF (readmouse.release AND mouseLeft) ORELSE (readmouse.release AND mouseRight) THEN
    setkeys
    RETURN ""
   END IF
  END IF

  clearpage dpage
  DIM col as integer
  DIM bg as integer
  FOR i = 0 TO last
   col = uilook(uiMenuItem)
   bg = uilook(uiDisabledItem)
   IF (i MOD linesize) = (pt MOD linesize) OR (i \ linesize) = (pt \ linesize) THEN
    col = uilook(uiMenuItem)
    bg = uilook(uiHighlight)
   END IF
   IF pt = i THEN
    col = uilook(uiSelectedItem + tog)
    bg = 0
   END IF
   IF hover = i THEN
    col = mouse_hover_tinted_color(col)
    bg = mouse_hover_tinted_color(bg)
   END IF
   textcolor col, bg
   printstr CHR(f(i)), offset.x + (i MOD linesize) * 9, offset.y + (i \ linesize) * 9, dpage
  NEXT i

  textcolor uilook(uiMenuItem), 0
  printstr "ASCII " & f(pt), 78, 190, dpage
  FOR i = 2 TO 53
   IF f(pt) = ASC(key2text(2, i)) THEN printstr "ALT+" + UCASE(key2text(0, i)), 178, 190, dpage
   IF f(pt) = ASC(key2text(3, i)) THEN printstr "ALT+SHIFT+" + UCASE(key2text(0, i)), 178, 190, dpage
  NEXT i
  IF f(pt) = 32 THEN printstr "SPACE", 178, 190, dpage

  SWAP vpage, dpage
  setvispage vpage
  dowait
 LOOP
END FUNCTION

'Return initial representation string for percent_cond_grabber
FUNCTION format_percent_cond(cond as AttackElementCondition, default as string, byval decimalplaces as integer = 4) as string
 IF cond.comp = compNone THEN
  RETURN default
 ELSE
  RETURN " " + safe_caption(comp_strings(), cond.comp, "comparetype") + " " + format_percent(cond.value, decimalplaces)
 END IF
END FUNCTION

'This will probably only be used for editing AttackElementConditions, but it's more general than that.
'Returns whether cond was edited. If ret_if_repr_changed, also returns true if repr changed.
FUNCTION percent_cond_grabber(byref cond as AttackElementCondition, byref repr as string, default as string, byval min as double, byval max as double, byval decimalplaces as integer = 4, ret_if_repr_changed as bool = YES) as bool
 WITH cond
  DIM intxt as string = getinputtext
  DIM newcomp as CompType = .comp
  DIM oldrepr as string = repr
  DIM ret as bool

  IF keyval(scDelete) > 1 THEN newcomp = compNone

  'Listen for comparison operator input
  IF INSTR(intxt, "<") THEN newcomp = compLt
  IF INSTR(intxt, ">") THEN newcomp = compGt

  IF newcomp <> .comp THEN
   IF .comp = compNone THEN .value = 0
   .comp = newcomp
   repr = format_percent_cond(cond, default, decimalplaces)
   ret = YES
  ELSEIF .comp = compNone THEN
   DIM temp as string = "0%"
   .value = 0
   'typing 0 doesn't change the value or repr, workaround
   IF percent_grabber(.value, temp, min, max, decimalplaces, NO) OR INSTR(intxt, "0") > 0 THEN
    repr = " < " + temp  'Default
    .comp = compLt
    ret = YES
   END IF
  ELSE
   'Trim comparison operator
   repr = MID(repr, 4)
   IF keyval(scBackspace) > 1 ANDALSO repr = "0%" THEN
    repr = default
    .comp = compNone
    ret = YES
   ELSE
    ret OR= percent_grabber(.value, repr, min, max, decimalplaces, NO)
    'Add the operator back
    repr = " " + comp_strings(.comp) + " " + repr
   END IF
  END IF

  IF ret_if_repr_changed THEN ret OR= (repr <> oldrepr)
  RETURN ret
 END WITH
END FUNCTION

SUB percent_cond_editor (cond as AttackElementCondition, byval min as double, byval max as double, byval decimalplaces as integer = 4, do_what as string = "...", percent_of_what as string = "")
 DIM cond_types(2) as CompType = {compNone, compLt, compGt}
 DIM comp_num as CompType
 FOR i as integer = 0 TO 2
  IF cond.comp = cond_types(i) THEN comp_num = i
 NEXT

 DIM menu(2) as string
 menu(0) = "Previous Menu"
 DIM st as MenuState
 st.size = 18
 st.pt = 1

 DIM repr as string = format_percent(cond.value, decimalplaces)

 DO
  setwait 55
  setkeys YES
  IF keyval(ccCancel) > 1 OR enter_space_click(st) THEN EXIT DO
  IF keyval(scF1) > 1 THEN show_help "percent_cond_editor"
  SELECT CASE st.pt
   CASE 1: IF intgrabber(comp_num, 0, 2) THEN cond.comp = cond_types(comp_num)
   CASE 2: percent_grabber(cond.value, repr, min, max, decimalplaces)
  END SELECT

  'Update
  IF cond.comp = compNone THEN menu(1) = "Condition: Never"
  IF cond.comp = compGt THEN menu(1) = "Condition: " + do_what + " when more than..."
  IF cond.comp = compLt THEN menu(1) = "Condition: " + do_what + " when less than..."
  menu(2) = "Threshold: " + repr + percent_of_what
  st.last = IIF(cond.comp = compNone, 1, 2)

  usemenu st

  clearpage vpage
  standardmenu menu(), st, , , vpage
  setvispage vpage
  dowait
 LOOP
END SUB

'Whether thinggrabber will enter an editor.
FUNCTION enter_or_add_new(state as MenuState) as bool
 RETURN enter_space_click(state) OR keyval(scPlus) > 1 OR keyval(scNumpadPlus) > 1 OR keyval(scInsert) > 1
END FUNCTION

'Returns true if the calling menu needs to refresh.
'Edit an object ID numerically (unless intgrab=NO) or by entering an editor.
'See FnEditor for information about the calling convention.
'This handles enter/space/click, +/insert, digits, left/right, - and backspace.
'offset should be 0 or 1, eg:
' offset=0: datum X means record (eg attack) X
' offset=1: datum 0 is none, X+1 is record X
'min is the minimum value of datum. Usually equal to offset if there is no
'"none" option, or offset-1 if there is. (Not the same as min for zintgrabber!)
'max_record is the maximum object ID, not the upper range of datum.
'add_new_if_none: pass YES only if Enter/Space/Click already was diverted to ThingBrowser.
LOCAL FUNCTION thinggrabber (byref datum as integer, state as MenuState, offset as integer, min as integer, max_record as integer, intgrab as bool = YES, editor_func as FnEditor, add_new_if_none as bool = NO) as bool
 DIM editor_arg as integer  'What to pass to editor_func.
 IF keyval(scPlus) > 1 OR keyval(scNumpadPlus) > 1 OR keyval(scInsert) > 1 THEN
  editor_arg = max_record + 1
 ELSEIF enter_space_click(state) THEN
  ' (Note: this case generally isn't reached, it's handled in attackgrabber, etc,
  ' unless Shift or Ctrl is held)
  editor_arg = datum - offset
  ' If editor_arg < 0, ie initial value is None, then could either go to editor
  ' for record 0 or add new. Shouldn't add new if there's no ThingBrowser to enter.
  IF editor_arg < 0 THEN
   editor_arg = IIF(add_new_if_none, max_record + 1, 0)
  END IF
 ELSE
  ' Check this only after checking scPlus, because intgrabber reads it
  IF intgrab THEN
   IF offset <> 0 AND offset <> 1 THEN showbug "thinggrabber: weird offset " & offset
   IF offset = 0 AND min = -1 ANDALSO (keyval(scDelete) > 1 ORELSE (keyval(scBackspace) > 1 AND datum <= 0)) THEN
    'Special case, intgrabber doesn't know that -1 means None, not 0
    IF datum <> -1 THEN
     datum = -1
     RETURN YES
    END IF
   'min and max for zintgrabber are offset by one: -1 means datum=0
   ELSEIF (offset = 1 ANDALSO zintgrabber(datum, min - 1, max_record)) OR _
          (offset = 0 ANDALSO intgrabber(datum, min, max_record)) THEN
    RETURN YES
   END IF
  END IF
  RETURN NO
 END IF

 DIM newrecord as integer
 newrecord = editor_func(editor_arg)
 IF newrecord = -1 THEN RETURN NO  'Cancelled add-new
 datum = newrecord + offset
 ' Even if the ID hasn't changed you might have edited the data, so need to refresh the menu
 RETURN YES
END FUNCTION

LOCAL FUNCTION want_to_enter_browser(state as MenuState) as bool
 RETURN keyval(scCtrl) = 0 ANDALSO keyval(scShift) = 0 ANDALSO enter_space_click(state)
END FUNCTION

'Edit an attack ID (possibly offset) numerically or by entering the attack editor.
'See thinggrabber.
FUNCTION attackgrabber (byref datum as integer, state as MenuState, offset as integer = 0, min as integer = 0, intgrab as bool = YES) as bool
 IF want_to_enter_browser(state) THEN
  IF offset = 1 THEN
   datum = attack_picker_or_none(datum)
  ELSE
   datum = attack_picker(datum)
  END IF
  RETURN YES
 ELSE
  RETURN thinggrabber(datum, state, offset, min, gen(genMaxAttack), intgrab, @attack_editor, YES)
 END IF
END FUNCTION

'Edit an enemy ID (possibly offset) numerically or by entering the enemy editor.
'See thinggrabber.
FUNCTION enemygrabber (byref datum as integer, state as MenuState, offset as integer = 0, min as integer = 0, intgrab as bool = YES) as bool
 IF want_to_enter_browser(state) THEN
  IF offset = 1 THEN
   datum = enemy_picker_or_none(datum)
  ELSE
   datum = enemy_picker(datum)
  END IF
  RETURN YES
 ELSE
  RETURN thinggrabber(datum, state, offset, min, gen(genMaxEnemy), intgrab, @enemy_editor, YES)
 END IF
END FUNCTION

'Edit a formation ID (possibly offset) numerically or by entering the formation editor.
'See thinggrabber.
FUNCTION formationgrabber (byref datum as integer, state as MenuState, offset as integer = 0, min as integer = 0, intgrab as bool = YES) as bool
 IF want_to_enter_browser(state) THEN
  IF offset = 1 THEN
   datum = formation_picker_or_none(datum)
  ELSE
   datum = formation_picker(datum)
  END IF
  RETURN YES
 ELSE
  RETURN thinggrabber(datum, state, offset, min, gen(genMaxFormation), intgrab, @individual_formation_editor, YES)
 END IF
END FUNCTION

'Edit a textbox ID (possibly offset) numerically or by entering the textbox editor.
'See thinggrabber.
FUNCTION textboxgrabber (byref datum as integer, state as MenuState, offset as integer = 0, min as integer = 0, intgrab as bool = YES) as bool
 'TODO: textbox 0 is never selectable, but ID 0 is None. So this acts a bit weird.
 IF want_to_enter_browser(state) THEN
  IF offset = 1 THEN
   datum = textbox_picker_or_none(datum)
  ELSE
   datum = textbox_picker(datum)
  END IF
  RETURN YES
 ELSE
  RETURN thinggrabber(datum, state, offset, min, gen(genMaxTextbox), intgrab, @text_box_editor, YES)
 END IF
END FUNCTION

' Shared by ui_color_editor and ui_boxstyle_editor
SUB make_ui_editor_sample_menu(sample_menu as MenuDef, sample_state as MenuState)
 WITH sample_menu
  .alignhoriz = alignRight
  .alignvert = alignTop
  .anchorhoriz = alignRight
  .anchorvert = alignTop
  .offset.x = -8
  .offset.y = 4
 END WITH
 append_menu_item sample_menu, "Sample"
 append_menu_item sample_menu, "Example"
 append_menu_item sample_menu, "Disabled"
 sample_menu.last->disabled = YES
 sample_state.active = YES
 init_menu_state sample_state, sample_menu
END SUB

SUB ui_color_editor(palnum as integer)
 DIM index as integer
 DIM default_colors(uiColorLast) as integer

 load_master_and_uicol palnum
 GuessDefaultUIColors master(), default_colors()

 DIM sample_menu as MenuDef
 DIM sample_state as MenuState
 make_ui_editor_sample_menu sample_menu, sample_state


 DIM color_menu(-1 TO uiColorLast) as string
 make_ui_color_editor_menu color_menu(), uilook()

 DIM state as MenuState
 state.first = -1
 state.top = -1
 state.pt = -1
 state.last = UBOUND(color_menu)
 state.autosize = YES
 state.autosize_ignore_pixels = 18

 setkeys
 DO
  setwait 55
  setkeys
  IF keyval(ccCancel) > 1 THEN EXIT DO
  IF keyval(scF1) > 1 THEN show_help "ui_color_editor"
  IF usemenu(state) THEN
   IF uilook(uiShadow) = 0 THEN
    'This is a hack. Unfortunately ellipse slice border and fill colors can't be 0 as that
    'counts as transparent. This is a design mistake in ellipse slices, but is too much trouble to fix
    uilook(uiShadow) = nearcolor(master(), 0, 1)
    state.need_update = YES
   END IF
  END IF
  mouse_update_hover sample_state

  index = state.pt

  IF enter_space_click(state) THEN
   IF state.pt = -1 THEN
    EXIT DO
   ELSE
    'Color browser
    uilook(index) = color_browser_256(uilook(index))
    state.need_update = YES
   END IF
  END IF

  IF index >= 0 THEN
   state.need_update OR= intgrabber(uilook(index), 0, 255)

   IF keyval(scCtrl) > 0 AND keyval(scD) > 1 THEN ' Ctrl+D
    uilook(index) = default_colors(index)
    state.need_update = YES
   END IF
  END IF

  IF state.need_update THEN
   state.need_update = NO
   make_ui_color_editor_menu color_menu(), uilook()
   SaveUIColors uilook(), boxlook(), palnum
  END IF

  '--update sample according to what you have highlighted
  DIM draw_sample as bool = NO
  sample_state.pt = 0
  SELECT CASE index
   CASE uiMenuItem, uiDisabledItem, uiSelectedItem, uiSelectedItem2, uiOutline, uiMouseHoverItem
    draw_sample = YES
   CASE uiSelectedDisabled, uiSelectedDisabled2
    sample_state.pt = 2
    draw_sample = YES
  END SELECT

  '--draw screen
  clearpage dpage
  IF draw_sample THEN draw_menu sample_menu, sample_state, dpage
  standardmenu color_menu(), state, 10, pMenuY, dpage
  FOR i as integer = large(state.top, 0) TO small(state.top + state.size, state.last)
   rectangle 0, pMenuY + 9 * (i - state.top), 9, 9, uilook(i), dpage
  NEXT i
  DIM msg as string = "Ctrl+D to revert to default"
  edgeprint msg, pCentered, pInfoY, uilook(uiText), dpage

  SWAP vpage, dpage
  setvispage vpage
  dowait
 LOOP
END SUB

SUB make_ui_color_editor_menu(m() as string, colors() as integer)
 m(-1) = "Previous Menu"
 FOR i as integer = 0 TO uiColorLast
  m(i) = UiColorCaption(i) & ": " & colors(i)
 NEXT i
END SUB

CONST NUM_BOXSTYLE_OPTIONS = 3

SUB ui_boxstyle_editor(palnum as integer)
 DIM index as integer
 DIM kind as integer
 DIM default_boxes(uiBoxLast) as BoxStyle

 load_master_and_uicol palnum
 GuessDefaultBoxStyles master(), default_boxes()

 DIM sample_menu as MenuDef
 DIM sample_state as MenuState
 make_ui_editor_sample_menu sample_menu, sample_state

 DIM color_menu(-1 TO (uiBoxLast + 1) * NUM_BOXSTYLE_OPTIONS - 1) as string
 make_ui_boxstyle_editor_menu color_menu(), boxlook()

 DIM state as MenuState
 state.top = -1
 state.pt = -1
 state.first = -1
 state.last = UBOUND(color_menu)
 state.autosize = YES
 state.autosize_ignore_pixels = 18

 setkeys
 DO
  setwait 55
  setkeys
  IF keyval(ccCancel) > 1 THEN EXIT DO
  IF keyval(scF1) > 1 THEN show_help "ui_boxstyle_editor"
  usemenu state
  mouse_update_hover sample_state

  index = state.pt \ NUM_BOXSTYLE_OPTIONS
  kind = state.pt MOD NUM_BOXSTYLE_OPTIONS ' 0=bgcol, 1=edgecol, 2=border

  WITH boxlook(index)

   IF enter_space_click(state) THEN
    IF state.pt = -1 THEN
     EXIT DO
    ELSEIF kind = 0 THEN
     'Color browser for bgcol
     .bgcol = color_browser_256(.bgcol)
    ELSEIF kind = 1 THEN
     'Color browser for edgecol
     .edgecol = color_browser_256(.edgecol)
    ELSEIF kind = 2 THEN
     'Box border browser
     DIM boxborderb as BoxborderSpriteBrowser
     .border = boxborderb.browse(.border - 1, YES) + 1
    END IF
    state.need_update = YES
   END IF

   IF state.pt >= 0 THEN
    SELECT CASE kind
     CASE 0:
      state.need_update OR= intgrabber(.bgcol, 0, 255)
     CASE 1:
      state.need_update OR= intgrabber(.edgecol, 0, 255)
     CASE 2:
      state.need_update OR= zintgrabber(.border, -1, gen(genMaxBoxBorder))
    END SELECT

    IF keyval(scCtrl) > 0 ANDALSO keyval(scD) > 1 THEN ' Ctrl+D
     SELECT CASE kind
      CASE 0: .bgcol = default_boxes(index).bgcol
      CASE 1: .edgecol = default_boxes(index).edgecol
      CASE 2: .border = default_boxes(index).border
     END SELECT
     state.need_update = YES
    END IF
   END IF

  END WITH

  '--update sample according to what you have highlighted
  sample_menu.boxstyle = 0
  sample_state.pt = 0
  IF index >= 0 THEN
   sample_menu.boxstyle = index
  END IF

  IF state.need_update THEN
   state.need_update = NO
   make_ui_boxstyle_editor_menu color_menu(), boxlook()
   SaveUIColors uilook(), boxlook(), palnum
  END IF

  '--draw screen
  clearpage dpage
  IF state.pt >= 0 THEN draw_menu sample_menu, sample_state, dpage
  standardmenu color_menu(), state, 10, pMenuY, dpage
  FOR i as integer = large(state.top, 0) TO small(state.top + state.size, state.last)
   WITH boxlook(i \ NUM_BOXSTYLE_OPTIONS)
    DIM itemtype as integer = i MOD NUM_BOXSTYLE_OPTIONS
    IF itemtype = 0 THEN
     rectangle 0, pMenuY + 9 * (i - state.top), 9, 9, .bgcol, dpage
    ELSEIF itemtype = 1 THEN
     rectangle 0, pMenuY + 9 * (i - state.top), 9, 9, .edgecol, dpage
    END IF
   END WITH
  NEXT i
  DIM msg as string = "Ctrl+D to revert to default"
  edgeprint msg, pCentered, pInfoY, uilook(uiText), dpage

  SWAP vpage, dpage
  setvispage vpage
  dowait
 LOOP
END SUB

SUB make_ui_boxstyle_editor_menu(m() as string, boxes() as BoxStyle)
 m(-1) = "Previous Menu"
 FOR i as integer = 0 TO uiBoxLast
  WITH boxes(i)
   DIM off as integer = i * NUM_BOXSTYLE_OPTIONS
   m(off) = "Box style " & i & " color:  " & .bgcol
   m(off + 1) = "Box style " & i & " edge: " & .edgecol
   m(off + 2) = "Box style " & i & " border image: " & defaultint(.border - 1, "none", -1)
  END WITH
 NEXT i
END SUB

'==========================================================================================

FUNCTION RecordPreviewer.getname() as string
 RETURN ""
END FUNCTION

'Ask how to add a new record to an editor.
'what:     Type of a record, like "map"
'maxindex: Max valid ID of an existing record
'getname:  Returns the name or description of a record, or NULL. Alternative to implementing
'          previewer->getname() method, if you already have a function to get the name
'Return value:
' -2  =Cancel
' -1  =New blank
' >=0 =Copy existing
'TODO: this is duplicated in a number of places. Search for 'generic_add_new'
FUNCTION generic_add_new (what as string, maxindex as integer, getname as FnRecordName = NULL, previewer as RecordPreviewer ptr = NULL, helpkey as string = "") as integer
 DIM menu(2) as string
 DIM whichtocopy as integer = 0
 DIM state as MenuState
 state.last = UBOUND(menu)
 state.size = 24
 state.pt = 1
 DIM menuopts as MenuOptions
 menuopts.edged = YES
 menuopts.drawbg = YES

 state.need_update = YES
 setkeys YES
 DO
  setwait 55
  setkeys YES
  IF keyval(ccCancel) > 1 THEN
   '--return cancel
   RETURN -2
  END IF
  IF keyval(scF1) > 1 THEN show_help helpkey
  usemenu state
  IF UpdateScreenSlice() THEN state.need_update = YES  'Regenerate preview on window resize
  IF state.pt = 2 THEN
   IF intgrabber(whichtocopy, 0, maxindex) THEN state.need_update = YES
  END IF
  IF enter_space_click(state) THEN
   SELECT CASE state.pt
   CASE 0 ' cancel
    RETURN -2
   CASE 1 ' blank
    RETURN -1
   CASE 2 ' copy
    RETURN whichtocopy
   END SELECT
  END IF
  IF state.need_update THEN
   state.need_update = NO
   IF previewer THEN previewer->update(whichtocopy)
   DIM name as string
   name = IIF(getname, getname(whichtocopy), previewer->getname)
   menu(0) = "Cancel"
   menu(1) = "New blank " & what
   menu(2) = "Copy of " & what & " " & whichtocopy & " " & name
  END IF
  clearpage vpage
  IF previewer ANDALSO state.pt = 2 THEN previewer->draw(pRight, pBottom, vpage)
  edgeprint "Adding new " & what, pCentered, 4, uilook(uiMenuItem), vpage
  standardmenu menu(), state, 20, 16, vpage, menuopts

  setvispage vpage
  dowait
 LOOP
END FUNCTION


FUNCTION needaddset (byref pt as integer, byref check as integer, what as string) as bool
 IF pt <= check THEN RETURN NO
 IF yesno("Add new " & what & "?") THEN
  check += 1
  RETURN YES
 ELSE
  pt -= 1
 END IF
 RETURN NO
END FUNCTION

'This is intgrabber, and if the 'more' key is pressed when pt=max, asks whether to
'add a new set. DOES NOT INCREMENT max. Check whether pt > max to see whether this
'needs to be handled.
'maxmax is max value of max, of course
FUNCTION intgrabber_with_addset(byref pt as integer, byval min as integer, byval max as integer, byval maxmax as integer=32767, what as string, byval less as KBScancode=ccLeft, byval more as KBScancode=ccRight) as bool
 IF keyval(more) > 1 AND pt = max AND max < maxmax THEN
  IF yesno("Add new " & what & "?") THEN
   pt += 1
   RETURN YES
  END IF
  RETURN NO
 ELSE
  IF less = ccUp ANDALSO more = ccDown THEN
   'special case to work around a strange bug in mac os x sprite brows menu arrow key handling
   RETURN intgrabber(pt, min, max, scComma, scPeriod, NO, NO)
  ELSE
   RETURN intgrabber(pt, min, max, less, more, NO, NO)
  END IF 
 END IF
END FUNCTION

FUNCTION load_vehicle_name(vehID as integer) as string
 IF vehID < 0 OR vehID > gen(genMaxVehicle) THEN RETURN ""
 DIM vehicle as VehicleData
 LoadVehicle game + ".veh", vehicle, vehID
 RETURN vehicle.name
END FUNCTION

FUNCTION load_item_name (it as integer, hidden as integer, offbyone as integer) as string
 'it - the item number
 'hidden - whether to *not* prefix the item number
 'offbyone - whether it is the item number (1), or the itemnumber + 1 (0)
 IF it <= 0 AND offbyone = NO THEN RETURN "NONE"
 DIM itn as integer
 IF offbyone THEN itn = it ELSE itn = it - 1
 DIM result as string = readitemname(itn)
 IF hidden = 0 THEN result = itn & " " & result
 RETURN result
END FUNCTION

' Toggle tagnum between 0 and an unused onetime tag ID (2 to max_onetime)
' This only checks NPC definitions that have already been written
' to disk, so that should be done so before calling
SUB onetimetog(byref tagnum as integer)
 IF tagnum > 0 THEN
  tagnum = 0
  EXIT SUB
 END IF
 DIM onetimeusage(max_onetime \ 16) as integer
 check_used_onetime_npcs onetimeusage()  'Marks bits 0 and 1 as used
 DIM tried as integer = 0
 DIM i as integer = gen(genOneTimeNPC)
 DO
  IF i > max_onetime THEN i = 2
  IF readbit(onetimeusage(), 0, i) = NO THEN EXIT DO
  IF tried = max_onetime THEN
   visible_debug "All onetime usage tags have been used up! Do you really have 16000+ NPCs? This is probably an engine bug!"
   tagnum = 0
   EXIT SUB
  END IF
  i += 1
  tried += 1
 LOOP
 tagnum = i
 gen(genOneTimeNPC) = i
END SUB

LOCAL SUB pal16browse_draw_spriteset(sprite as Frame ptr, pal as Palette16 ptr, x as integer, y as integer, page as integer)
 IF sprite = NULL THEN EXIT SUB
 y -= sprite->h \ 2 - 10
 FOR framenum as integer = 0 TO sprite->arraylen - 1
  frame_draw sprite + framenum, pal, x, y, , page
  x += (sprite + framenum)->w
 NEXT
END SUB

'Browse for a 16-color palette
'picnum may be -1
FUNCTION pal16browse (curpal as integer, picset as SpriteType, picnum as integer, show_default as bool=NO) as integer
 DIM sprite as Frame ptr
 IF picnum >= 0 THEN sprite = frame_load(picset, picnum)
 DIM ret as integer = pal16browse(curpal, sprite, show_default)
 frame_unload @sprite
 RETURN ret
END FUNCTION

'Browse for a 16-color palette
'sprite may be NULL
FUNCTION pal16browse (curpal as integer, sprite as Frame ptr, show_default as bool=NO) as integer
 DIM buf(7) as integer
 REDIM pal16(100) as Palette16 ptr  'At most 100 palettes visible on-screen

 DIM as integer i, j

 DIM state as MenuState
 state.need_update = YES
 state.top = curpal - 1
 state.first = -1
 state.spacing = 20
 state.autosize = YES
 
 IF show_default THEN
  state.first = -2
 END IF
 
 DIM stateOpts as MenuOptions
 stateOpts.itemspacing = 12

 '--Find last palette which is not blank
 loadrecord buf(), game + ".pal", 8, 0
 FOR i = buf(1) TO 0 STEP -1
  state.last = i
  loadrecord buf(), game + ".pal", 8, 1 + i
  FOR j = 0 TO 7
   IF buf(j) <> 0 THEN EXIT FOR, FOR
  NEXT j
 NEXT i

 'We actually want to be able to browse past the last palette to the first empty palette
 state.last += 1

 state.pt = bound(curpal, IIF(show_default, -1, 0), state.last)
 correct_menu_state state

 DIM last_size as integer = state.size

 STATIC pal_clipboard as Palette16 ptr

 DIM prev_mouse_vis as CursorVisibility = getcursorvisibility()
 showmousecursor
 setkeys
 DO
  setwait 55
  setkeys
  state.tog = state.tog XOR 1
  IF keyval(ccCancel) > 1 THEN EXIT DO
  IF keyval(scF1) > 1 THEN show_help "pal16browse"

  calc_menu_rect state, stateOpts, XY(0, 0), dpage
  IF state.size <> last_size THEN state.need_update = YES
  last_size = state.size

  IF usemenu(state) THEN state.need_update = YES

  'Copy/paste palette
  'Allow Alt+C/V as well as Ctrl+C/V because Alt+C/V copy-pastes the palette
  'in the sprite editor
  IF keyval(scAlt) > 0 ORELSE keyval(scCtrl) > 0 THEN
   DIM palidx as integer = state.pt - state.top   'Index in pal16
   IF state.pt >= 0 ANDALSO palidx <= UBOUND(pal16) ANDALSO pal16(palidx) THEN
    IF keyval(scC) > 1 THEN  'Ctrl-C copy
     palette16_unload @pal_clipboard
     pal_clipboard = palette16_duplicate(pal16(palidx))
    END IF
    IF keyval(scV) > 1 THEN  'Ctrl-V paste
     IF pal_clipboard THEN
      palette16_unload @pal16(palidx)
      pal16(palidx) = palette16_duplicate(pal_clipboard)
      palette16_save pal16(palidx), state.pt
     END IF
    END IF
   END IF
  END IF

  DIM temppt as integer = large(state.pt, 0)
  IF intgrabber(temppt, 0, state.last, , , , NO) THEN  'use_clipboard=NO
   state.pt = temppt
   correct_menu_state state
   state.need_update = YES
  END IF
  IF enter_space_click(state) THEN
   IF state.pt > state.first THEN curpal = state.pt
   EXIT DO
  END IF

  IF state.need_update THEN
   state.need_update = NO
   FOR i = 0 TO state.size
    palette16_unload @pal16(i)
    IF state.top + i <= gen(genMaxPal) THEN pal16(i) = palette16_load(state.top + i)
   NEXT i
  END IF

  '--Draw screen
  clearpage dpage
  FOR i = 0 TO state.size
   DIM sloty as integer = i * 20
   DIM cur_slot as integer = state.top + i
   IF cur_slot > state.last THEN CONTINUE FOR
   textcolor uilook(uiMenuItem), 0
   IF cur_slot = state.hover THEN textcolor uilook(uiMouseHoverItem), 0
   IF cur_slot = state.pt THEN textcolor uilook(uiSelectedItem + state.tog), 0
   SELECT CASE cur_slot
    CASE state.first 'Might be -1 or -2 depending on whether default is displayed
     printstr "Cancel", 4, 5 + sloty, dpage
    CASE -1
     printstr "Default", 4, 5 + sloty, dpage
    CASE IS >= 0
     DIM txtwidth as integer = 8 + large(16, textwidth(STR(cur_slot)))
     DIM border_col as integer
     IF cur_slot = state.hover THEN border_col = uilook(uiMouseHoverItem)
     IF cur_slot = state.pt THEN border_col = uilook(uiMenuitem)
     edgebox txtwidth - 1, 1 + sloty, 114, 18, uilook(uiBackground), border_col, dpage
     FOR j = 0 TO 15
      IF pal16(i) THEN
       DIM c as integer = pal16(i)->col(j)
       rectangle txtwidth + j * 7, 2 + sloty, 5, 16, c, dpage
      END IF
     NEXT j
     IF cur_slot <> state.pt THEN
      IF pal16(i) THEN
       pal16browse_draw_spriteset sprite, pal16(i), txtwidth + 150, sloty, dpage
      END IF
     END IF
     printstr STR(cur_slot), 4, 5 + sloty, dpage
   END SELECT
  NEXT i
  IF state.pt >= 0 THEN '--draw the selected spriteset last, on top of all others
   i = state.pt - state.top
   DIM sloty as integer = i * 20
   DIM txtwidth as integer = 8 + large(16, textwidth(STR(state.pt)))
   IF pal16(i) THEN
    pal16browse_draw_spriteset sprite, pal16(i), txtwidth + 125, sloty, dpage
   END IF
  END IF

  SWAP vpage, dpage
  setvispage vpage
  dowait
 LOOP
 setcursorvisibility(prev_mouse_vis)
 setkeys

 FOR i = 0 TO 100
  palette16_unload @pal16(i)
 NEXT
 RETURN curpal
END FUNCTION

' Number of steps before a random formation triggers
FUNCTION formset_step_estimate(freq as integer, suffix as string=" steps") as string
 IF freq = 0 THEN RETURN "never"
 ' Round upwards to get the actual number steps required
 ' gam.random_battle_countdown initialised to range(100, 60), which is 40-160.
 DIM low_est  as integer = (40 + freq - 1) \ freq
 DIM high_est as integer = (160 + freq - 1) \ freq
 ' This average is just an estimate. Add 0.5 due to the rounding-up behaviour.
 DIM average as double = 100 / freq + 0.5
 RETURN low_est & "-" & high_est & suffix & ", avg~" & format(average, "0.0")
END FUNCTION

'Average chance to trigger a formation set on each step
FUNCTION formset_freq_estimate(freq as integer) as double
 IF freq = 0 THEN RETURN 0.
 DIM average_steps as double = 100 / freq + 0.5  'See formset_step_estimate
 RETURN 1. / average_steps
END FUNCTION

'Explanation for a hero/enemy's speed stat. Only applies to active-time mode
FUNCTION speed_estimate(speed as integer) as string
 IF speed = 0 THEN RETURN "never gets a turn"
 DIM ticks as integer = CEIL(1000 / speed)
 DIM result as string = strprintf("%.3g", ticks / ideal_ticks_per_second())
 RETURN "1 turn every " & result & " sec"
END FUNCTION

FUNCTION seconds_estimate(ticks as integer) as string
 IF ticks = 0 THEN RETURN "0"
 RETURN strprintf("%.2f", ticks / ideal_ticks_per_second())
END FUNCTION

'Return a caption shown in hero and enemy editors while editing a stat.
FUNCTION stat_value_caption(statnum as integer, statval as integer) as string
 IF statnum = statSpeed ANDALSO gen(genBattleMode) = 0 THEN  'Active-time
  RETURN speed_estimate(statval)
 ELSEIF statnum = statFocus ANDALSO prefbit(52) = NO THEN  '"Ignore MP~ stat"
  RETURN "Attacks cost " & (100 - statval) & "% normal MP cost"
 ELSEIF statnum = statHitX ANDALSO prefbit(29) = NO THEN  '"Ignore extra Hits
  'stat" Total number of hits is capped to 20. Increasing Hits above 20 will
  'continue to increase the average number of hits, but we don't show that.
  RETURN "Attacks hit 0 to " & small(19, statval) & " extra times"
 ELSEIF statnum >= statCtr THEN  'Ctr, possible Hits and Focus (MP~)
  RETURN "Free stat for any use"
 END IF
END FUNCTION

SUB draw_crosshairs(pos as XYPair, length as integer = 1, zoom as integer = 1, col as integer, page as integer)
  rectangle pos.x - length * zoom, pos.y, length * zoom, zoom, col, page
  rectangle pos.x + zoom, pos.y, length * zoom, zoom, col, page
  rectangle pos.x, pos.y - length * zoom, zoom, length * zoom, col, page
  rectangle pos.x, pos.y + zoom, zoom, length * zoom, col, page
END SUB

'Editor for an x/y position.
'Note that this does not center or scale the slice, unlike xy_position_on_sprite
'UNUSED
SUB xy_position_on_slice (sl as Slice Ptr, byref x as integer, byref y as integer, caption as string, helpkey as string)
 DIM col as integer
 DIM tog as integer
 DIM root as Slice Ptr
 
 setkeys
 DO
  setwait 55
  setkeys
  tog = tog XOR 1

  IF keyval(ccCancel) > 1 THEN EXIT DO
  IF keyval(scF1) > 1 THEN show_help helpkey
  IF enter_or_space() THEN EXIT DO
  IF keyval(ccLeft) > 0  THEN x -= 1
  IF keyval(ccRight) > 0 THEN x += 1
  IF keyval(ccUp) > 0    THEN y -= 1
  IF keyval(ccDown) > 0  THEN y += 1
  DIM dragging as bool = readmouse.drag_dist > 3
  IF readmouse.buttons AND mouseRight ORELSE dragging THEN
   DIM temp as XYPair = readmouse.pos - sl->ScreenPos
   x = temp.x
   y = temp.y
  END IF
  IF enter_or_space() ORELSE (readmouse.release AND mouseLeft ANDALSO dragging = NO) THEN EXIT DO

  clearpage dpage
  DrawSlice sl, dpage
  col = uilook(uiBackground)
  IF tog = 0 THEN col = uilook(uiSelectedItem)
  draw_crosshairs sl->ScreenPos + XY(x, y), 1, 1, col, dpage

  wrapprint caption, pCentered, 0, uilook(uiText), dpage
  edgeprint "Position point and press Enter, Space, or Click", 0, pBottom, uilook(uiText), dpage
  edgeprint XY(x, y), 0, pBottom - 10, uilook(uiText), dpage

  SWAP vpage, dpage
  setvispage vpage
  dowait
 LOOP
END SUB

'xy_position_on_sprite is a bit nicer than xy_position_on_slice, so this wrapper lets you use
'the former with a sprite slice
SUB xy_position_on_sprite_slice (sl as Slice Ptr, byref x as integer, byref y as integer, caption as string, helpkey as string)
 BUG_IF(sl->SliceType <> slSprite, "expected sprite slice")
 WITH *sl->SpriteData
  IF .img.sprite THEN
   xy_position_on_sprite .img, x, y, .frame, caption, helpkey
  END IF
 END WITH
END SUB

SUB xy_position_on_sprite (spr as GraphicPair, byref x as integer, byref y as integer, byval frame as integer, caption as string, helpkey as string)
 DIM col as integer
 DIM tog as integer
 DIM fr as Frame ptr = spr.sprite + frame

 setkeys
 DO
  setwait 55
  setkeys
  tog = tog XOR 1

  'Draw the sprite at 2x zoom unless that won't fit on-screen
  DIM zoom as integer = 1
  IF fr->size * 2 < get_resolution() - XY(5, 30) THEN zoom = 2
  DIM topleft as XYPair
  topleft = (get_resolution() - fr->size * zoom) \ 2

  IF keyval(ccCancel) > 1 THEN EXIT DO
  IF keyval(scF1) > 1 THEN show_help helpkey
  DIM speed as integer = IIF(keyval(scShift), 4, 1)
  IF slowkey(ccLeft, 100)  THEN x -= speed
  IF slowkey(ccRight, 100) THEN x += speed
  IF slowkey(ccUp, 100)    THEN y -= speed
  IF slowkey(ccDown, 100)  THEN y += speed
  DIM dragging as bool = readmouse.drag_dist > 3
  IF readmouse.buttons AND mouseRight ORELSE dragging THEN
   DIM temp as XYPair
   temp = (readmouse.pos - topleft) \ zoom
   x = temp.x
   y = temp.y
  END IF
  IF enter_or_space() ORELSE (readmouse.release AND mouseLeft ANDALSO dragging = NO) THEN EXIT DO

  clearpage dpage
  drawbox topleft.x - 1, topleft.y - 1, fr->w * zoom + 2, fr->h * zoom + 2, uilook(uiSelectedDisabled), 1, dpage
  frame_draw fr, spr.pal, topleft.x, topleft.y, , dpage, DrawOptions(zoom)
  col = uilook(uiBackground)
  IF tog = 0 THEN col = uilook(uiSelectedItem)
  draw_crosshairs topleft + XY(x, y) * zoom, 1, zoom, col, dpage

  wrapprint caption, pCentered, pMenuY, uilook(uiText), dpage
  wrapprint XY(x, y) & !"  Shift for speed\nPosition point and press Enter, Space, or Click", pInfoX, pInfoY, uilook(uiText), dpage

  SWAP vpage, dpage
  setvispage vpage
  dowait
 LOOP
END SUB

'Clear a videopage with a dark textured background which is used to indicate the size of the game's window
SUB draw_textured_background(page as integer)
 clearpage page
 fuzzyrect 0, 0, , , uilook(uiDisabledItem), page, 7
END SUB

'Create a videopage equal in size to the game's resolution
FUNCTION gameres_page() as integer
 'TODO: possibly we could use the game's bitdepth (gen32bitMode) for this page,
 'but that may not work and probably isn't needed
 DIM ret as integer = allocatepage(gen(genResolutionX), gen(genResolutionY))
 vpages(ret)->noresize = YES
 RETURN ret
END FUNCTION

'Draw a videopage at (by default) the bottom-right corner of destpage.
'Typically used to draw a gameres_page, for previewing in-game graphics.
'See also draw_textured_background
SUB draw_viewport_page(srcpage as integer, destpage as integer, byval where as RelPosXY = XY(pRight, pBottom))
 DIM pagesize as XYPair = vpages(srcpage)->size
 rectangle where.x + 1, where.y + 1, pagesize.w + 2, pagesize.h + 2, findrgb(80, 80, 80), destpage
 frame_draw vpages(srcpage), , where.x + showLeft, where.y + showTop, NO, destpage
END SUB

'Returns  "prefix ABS(n) suffix [AUTOSET] (<tagname or zero/one/negonecap>)"
'where everything except the ABS(n) is optional.
LOCAL FUNCTION base_tag_caption(byval n as integer, prefix as string, suffix as string, zerocap as string, onecap as string, negonecap as string, byval allowspecial as bool) as string
 DIM ret as string
 ret = prefix
 IF LEN(ret) > 0 THEN ret &= " "
 ret &= ABS(n) & suffix
 IF allowspecial <> YES ANDALSO tag_is_autoset(n) THEN ret &= " [AUTOSET]"

 'Append " ($cap)"
 DIM cap as string
 cap = load_tag_name(n)
 IF n = 0 AND LEN(zerocap) > 0 THEN cap = zerocap
 IF n = 1 AND LEN(onecap) > 0 THEN cap = onecap
 IF n = -1 AND LEN(negonecap) > 0 THEN cap = negonecap
 cap = TRIM(cap)
 IF LEN(cap) > 0 THEN ret &= " (" & cap & ")"

 RETURN ret
END FUNCTION

FUNCTION tag_toggle_caption(byval n as integer, prefix as string="Toggle tag", byval allowspecial as bool=NO) as string
 RETURN base_tag_caption(n, prefix, "", "N/A", "Unchangeable", "Unchangeable", allowspecial)
END FUNCTION

FUNCTION tag_choice_caption(byval n as integer, prefix as string="", byval allowspecial as bool=NO) as string
 RETURN base_tag_caption(n, prefix, "", "None", "Unchangeable", "Unchangeable", allowspecial)
END FUNCTION

FUNCTION tag_set_caption(byval n as integer, prefix as string="Set Tag", byval allowspecial as bool=NO) as string
 RETURN base_tag_caption(n, prefix, "=" & onoroff(n), "No tag set", "Unchangeable", "Unchangeable", allowspecial)
END FUNCTION

' Note that this similar to textbox_condition[_short]_caption and describe_tag_condition. Sorry!
FUNCTION tag_condition_caption(byval n as integer, prefix as string="Tag", zerocap as string, onecap as string="Never", negonecap as string="Always") as string
 RETURN base_tag_caption(n, prefix, "=" & onoroff(n), zerocap, onecap, negonecap, YES)
END FUNCTION

'Describe a condition which checks two tags (both conditions need to pass)
'zerovalue: meaning of 0. true is always, false is never
FUNCTION describe_two_tag_condition(prefix as string, truetext as string, falsetext as string, byval zerovalue as bool, byval tag1 as integer, byval tag2 as integer) as string
  DIM ret as string = prefix
  DIM true_count as integer
  DIM false_count as integer
  IF tag1 = 0 THEN tag1 = IIF(zerovalue, -1, 1)
  IF tag2 = 0 THEN tag2 = IIF(zerovalue, -1, 1)
  IF tag1 = 1 THEN
   false_count += 1
  ELSEIF tag1 = -1 THEN
   true_count += 1
  ELSE
   ret &= " tag " & ABS(tag1) & " = " & onoroff(tag1)
  END IF
  IF tag2 = 1 THEN
   false_count += 1
  ELSEIF tag2 = -1 THEN
   true_count += 1
  ELSE
   IF true_count = 0 AND false_count = 0 THEN ret &= " and"
   ret &= " tag " & ABS(tag2) & " = " & onoroff(tag2)
  END IF
  
  IF true_count = 2 THEN ret = truetext
  IF false_count > 0 THEN ret = falsetext
  RETURN ret
END FUNCTION

'Get a list of the first letters (lowercase) of every word in each menu() string, except
'those words listed in excludewords. excludewords should be a space-separated
'list (case matters).
'menukeys() should be statically sized.
SUB get_menu_hotkeys (menu() as string, byval menumax as integer, menukeys() as string, excludewords as string = "")
 'Easy exercise for the reader: Write this in three lines of Python
 DIM excludes() as string
 IF excludewords = "" THEN
  REDIM excludes(-1 TO -1)
 ELSE
  split excludewords, excludes(), " "
 END IF
 FOR i as integer = 0 TO menumax
  menukeys(i) = ""
  DIM firstletter as bool = YES
  FOR j as integer = 1 TO LEN(menu(i))
   DIM isalp as integer = isalpha(menu(i)[j - 1])
   IF firstletter ANDALSO isalp THEN
    DIM excluded as bool = NO
    FOR k as integer = 0 TO UBOUND(excludes)
     IF MID(menu(i), j, LEN(excludes(k))) = excludes(k) THEN excluded = YES : EXIT FOR
    NEXT
    IF excluded = NO THEN
     menukeys(i) += LCASE(MID(menu(i), j, 1))
    END IF
   END IF
   firstletter = (isalp = 0)
  NEXT
  'debug "hotkeys from '" & menu(i) & "' -> '" & menukeys(i) & "'"
 NEXT
END SUB

FUNCTION experience_chart (byval expcurve as double=0.2) as double

 'DIM exp_first_level as integer = 30
 'DIM exp_adder as integer = 5
 'DIM exp_uppercap as integer = 1000000

 DIM mode as integer = 0
 STATIC hero_count as integer = 4
 STATIC enemy_id as integer = 0
 DIM enemy as EnemyDef

 STATIC form_id as integer = 0

 DIM startfrom as integer = 4
 DIM menu(startfrom + gen(genMaxLevel)) as string
 menu(0) = "Previous menu..."
 DIM state as MenuState
 WITH state
  .autosize = YES
  .last = UBOUND(menu)
  .need_update = YES
 END WITH

 STATIC first_view as bool = YES

 setkeys
 DO
  setwait 55
  setkeys

  IF keyval(ccCancel) > 1 THEN EXIT DO
  IF keyval(scF1) > 1 THEN show_help "experience_chart"
  usemenu state
  IF enter_space_click(state) THEN
   IF state.pt = 0 THEN EXIT DO
  END IF
  IF state.pt = 1 THEN
   DIM curve_int as integer = expcurve * 100
   IF intgrabber(curve_int, 0, 100) THEN
    expcurve = curve_int / 100
    state.need_update = YES
   END IF
  END IF
  IF state.pt = 2 THEN
   IF intgrabber(mode, 0, 2) THEN state.need_update = YES
  END IF
  IF state.pt = 3 THEN
   IF mode = 1 THEN
    IF enemygrabber(enemy_id, state) THEN state.need_update = YES
   ELSEIF mode = 2 THEN
    IF intgrabber(form_id, 0, gen(genMaxFormation)) THEN state.need_update = YES
   END IF
  END IF
  IF state.pt = 4 THEN
   IF intgrabber(hero_count, 1, 4) THEN state.need_update = YES
  END IF

  IF state.need_update THEN
   DIM test_exp as integer = 0
   DIM test_name as string
   menu(1) = "Experience curve: " & format(expcurve, "0.00")
   IF mode = 0 THEN
    menu(2) = "Preview mode: Total Exp."
    menu(3) = "Compared to N/A"
   ELSEIF mode = 1 THEN
    loadenemydata enemy, enemy_id
    menu(2) = "Preview mode: Enemy"
    menu(3) = "Compared to enemy: " & enemy_id & " " & enemy.name & " (" & enemy.reward.exper & " exp)"
    test_exp = enemy.reward.exper
    test_name = enemy.name
   ELSEIF mode = 2 THEN
    DIM form as Formation
    LoadFormation form, form_id
    test_exp = 0
    FOR i as integer = 0 TO 7
     IF form.slots(i).id >= 0 THEN
      loadenemydata enemy, form.slots(i).id
      test_exp += enemy.reward.exper
     END IF
    NEXT i
    menu(2) = "Compare mode: Formation"
    menu(3) = "Compared to formation: " & form_id & " (" & test_exp & " exp)"
    test_name = "Formation" & form_id
   END IF
   menu(4) = "Distributed to a party of: " & hero_count & " heroes"
   DIM suffix as string
   DIM killcount as string
   FOR lev as integer = 1 TO gen(genMaxLevel)
    IF mode = 0 THEN
     suffix = "total " & total_exp_to_level(lev, expcurve)
    ELSE
     IF test_exp > 0 THEN
      killcount = STR(ceiling(exptolevel(lev, expcurve) / test_exp * hero_count))
     ELSE
      killcount = "infinite"
     END IF
     suffix = "= " & test_name & "*" & killcount
    END IF
    menu(startfrom + lev) = "Level " & lev & " +" & exptolevel(lev, expcurve) & " " & suffix
   NEXT lev
   state.need_update = NO
  END IF

  clearpage vpage
  standardmenu menu(), state, , , vpage
  setvispage vpage
  dowait

  IF first_view THEN
   first_view = NO
   'notification "This screen is informational only. You cannot customize the experience formula yet."
  END IF
 LOOP 
 RETURN expcurve
END FUNCTION

SUB stat_growth_chart ()
 'midpoint should stored in gen()
 DIM midpoint as double = 0.3219  'default to current
 DIM midpoint_repr as string = format_percent(midpoint, 4)

 DIM menu(2) as string
 menu(0) = "Previous menu..."
 DIM state as MenuState
 WITH state
  .size = 24
  .last = UBOUND(menu)
  .need_update = YES
 END WITH

 DIM preview_lev as integer = gen(genMaxLevel) \ 2

 'Position and size of the graph
 DIM rect as RectType
 rect.x = 150
 rect.y = 40
 rect.wide = 150
 rect.high = 140
 DIM origin_y as integer = rect.y + rect.high

 setkeys YES
 DO
  setwait 55
  setkeys YES

  IF keyval(ccCancel) > 1 THEN EXIT DO
  IF keyval(scF1) > 1 THEN show_help "stat_growth"
  usemenu state
  IF enter_space_click(state) THEN
   IF state.pt = 0 THEN EXIT DO
  END IF
  IF state.pt = 1 THEN
   state.need_update OR= percent_grabber(midpoint, midpoint_repr, -0.1, 1.2, 4)
  ELSEIF state.pt = 2 THEN
   state.need_update OR= intgrabber(preview_lev, 0, gen(genMaxLevel))
  END IF

  IF state.need_update THEN
   menu(1) = "Fix value at level " & (gen(genMaxLevel) / 2) & " : " & midpoint_repr
   menu(2) = "Preview: at level " & preview_lev & " = " & format_percent(atlevel_quadratic(preview_lev, 0, 1000000, midpoint) / 1000000, 4)  ' of Level" & gen(genMaxLevel) & " value"
   state.need_update = NO
  END IF

  'Draw screen
  clearpage vpage
  standardmenu menu(), state, , , vpage

  'Draw a 150x150 graph
  'axes
  drawline rect.x, origin_y, rect.x, rect.y, uilook(uiDisabledItem), vpage
  drawline rect.x, origin_y, rect.x + rect.wide, origin_y, uilook(uiDisabledItem), vpage
  'line (drawn so that if genMaxLevel is small, you get a lot of steps, and never sloped line segments)
  DIM lasty as double
  FOR x as integer = 0 TO rect.wide - 1
   DIM lev as integer = INT((gen(genMaxLevel) + 1) * x / rect.wide)  'floor
   DIM y as double = atlevel_quadratic(lev, 0, rect.high * 100, midpoint) / 100
   IF x = 0 THEN lasty = y
   drawline x + rect.x, origin_y - y, x + rect.x, origin_y - lasty, uilook(uiHighlight), vpage
   lasty = y
  NEXT

  'Draw crosshair
  DIM crosshair_lev as double
  IF state.pt = 2 THEN crosshair_lev = preview_lev ELSE crosshair_lev = gen(genMaxLevel) / 2
  DIM as double crosshairx, crosshairy  'in pixels
  crosshairx = rect.wide * crosshair_lev / gen(genMaxLevel)
  crosshairy = atlevel_quadratic(crosshair_lev, 0, rect.high * 100, midpoint) / 100
  drawline rect.x + crosshairx - 3, origin_y - crosshairy, rect.x + crosshairx + 3, origin_y - crosshairy, uilook(uiHighlight2), vpage
  drawline rect.x + crosshairx, origin_y - crosshairy - 3, rect.x + crosshairx, origin_y - crosshairy + 3, uilook(uiHighlight2), vpage

  setvispage vpage
  dowait
 LOOP 
END SUB

#ifndef NO_TEST_GAME

FUNCTION pick_channel_name() as string
 #ifdef __FB_WIN32__
  #ifdef UWP
   'This pipe name is mandatory in UWP. (Should it actually be \.\pipe\LOCAL? Looks like a typo in the docs)
   'Note, named pipes can only be used between apps in the same container.
   return "\\.\pipe\LOCAL"
  #else
   return "\\.\pipe\ohrrpgce_lump_updates_testing_" & randint(100000)
  #endif
 #else
  return tmpdir + ".lump_updates.txt"
 #endif
END FUNCTION

' Used by spawn_game to run gdb or valgrind.
' Returns a ProcessHandle or NO/NULL.
' Note: these's a fair bit of overlap between this and spawn_and_wait for Unix,
' but this is specific to spawn_game. spawn_and_wait has better error checking.
FUNCTION spawn_console_process(executable as string, args as string, title as string) as ProcessHandle
 #IFDEF __FB_UNIX__
  ' On Unix the spawned program doesn't inherit access to our tty even if we have one,
  ' so need to spawn it inside an xterm
  ' TODO: this isn't going to work on OSX... unless maybe you have X11 installed?
  ' Wrap in a xterm call...
  DIM xtermargs as string
  xtermargs = " -geometry 120x30 -bg black -fg gray90 -title '" & title & "' -e " _
              """" & escape_string(executable & " " & args, """\") & """"
  executable = find_helper_app("xterm")
  IF LEN(executable) = 0 THEN
   notification "xterm is missing; can't continue"
   RETURN NO
  END IF

  waitforkeyrelease
  RETURN open_process(executable, xtermargs, YES, YES)

 #ELSEIF defined(__FB_WIN32__)
  waitforkeyrelease
  ' Likewise on Windows need a console. But this is only implemented on Windows
  RETURN open_console_process(executable, args)
 #ELSE
  notification "Running under GDB/Valgrind not implemented on this platform"
  RETURN NO
 #ENDIF
END FUNCTION

'Spawn Game for live-previewing. Puts the ProcessHandle in Game_process.
'At most one of gdb and valgrind should be true.
'Returns true if successfully spawned
FUNCTION spawn_game(gdb as bool = NO, valgrind as bool = NO) as bool
 IF Game_process <> 0 THEN
  'First clean up after the last time we ran Game
  cleanup_process @Game_process
 END IF

 DIM channel_name as string
 channel_name = pick_channel_name()
 IF channel_open_server(channel_to_Game, channel_name) = NO THEN
  notification "Couldn't open channel to communicate with Game"
  RETURN NO
 END IF
 debuginfo "Successfully opened IPC channel " + channel_name

 DIM gameexename as string = GAMEEXE
 DIM executable as string

#ifdef __FB_DARWIN__
 gameexename = "OHRRPGCE-Game"
 CONST mac_game_bundle as string = "OHRRPGCE-Game.app/Contents/MacOS/ohrrpgce-game"
 ' First check for unbundled ohrrpgce-game (because it would be most recently built) then OHRRPGCE-Game.app
 executable = app_dir & GAMEEXE
 IF isfile(executable) = NO THEN
  executable = app_dir & mac_game_bundle
  IF isfile(executable) = NO THEN
   'If Gatekeeper Path Randomization (aka App Translocation) happens, then EXEPATH (and app_dir)
   'is a random path inside a read-only mount.
   'So also check CURDIR, which is the location of the .rpg, or a global location
   executable = CURDIR & SLASH & mac_game_bundle
   IF isfile(executable) = NO THEN
    executable = ENVIRON("HOME") & "/Applications/" & mac_game_bundle
    IF isfile(executable) = NO THEN
     executable = "/Applications/" & mac_game_bundle
    END IF
   END IF
  END IF
 END IF
#else
 executable = app_dir & GAMEEXE
#endif
 IF isfile(executable) = NO THEN
#ifdef __FB_DARWIN__
  IF INSTR(EXEPATH, "AppTranslocation") THEN
   'Gatekeeper Path Randomization is active. Also possible to detect with an API, or by
   'checking whether the filesystem is read-only using statfs().
   visible_debug !"It appears that macOS's Gatekeeper has quarantined OHRRPGCE-Custom, so it can't access OHRRPGCE-Game. To disable the quarantine, move OHRRPGCE-Custom to a different folder using Finder (you can move it back afterwards), and make sure you're not running it from inside your Downloads folder or from inside the DMG you downloaded.\nAlternatively, place OHRRPGCE-Game next to the .rpg file or in your user or global Applications folder."
   RETURN NO
  END IF
#endif
  visible_debug "Couldn't find " & gameexename & !"\nIt should be in the same directory as " & CUSTOMEXE
  RETURN NO
 END IF
 DIM arguments as string
 #IFDEF __FB_UNIX__
  arguments = "--from_Custom " & escape_filename(channel_name)
 #ELSE
  'Not a filename
  arguments = "--from_Custom " & channel_name
 #ENDIF

 IF joysticks_globally_disabled THEN
  'If Custom was run with -nojoy, then test game should use -nojoy too
  arguments &= " -nojoy"
 END IF

 IF gdb THEN
  ' Wrap in a gdb call...
  ' channel_wait_for_client_connection is going to time out if Game isn't
  ' allowed to run normally, so as a compromise break immediately after the
  ' channel is connected. (You can always increase the 3sec delay instead.)

  ' We imitate invoking gdb via the gdbgame.sh/bat script
  DIM cmdfile as string
  cmdfile = finddatafile("misc/gdbcmds1.txt", NO)

  DIM gdbargs as string
  IF LEN(cmdfile) > 0 THEN
   gdbargs = "-x=""" & cmdfile & """ "
   ' gdbcmds1.txt adds breakpoints to catch -exx fatal errors, so disable the -exx handler
   arguments &= " --rawexx"
  ELSE
   gdbargs = "-ex ""break HOOK_AFTER_ATTACH_TO_CUSTOM"" -ex ""run"" "
  END IF
  gdbargs &= "--args " & executable & " " & arguments
  executable = find_helper_app("gdb")
  IF LEN(executable) = 0 THEN
   notification "Couldn't find gdb. You need to install it."
   RETURN NO
  END IF

  Game_process = spawn_console_process(executable, gdbargs, "gdb " & GAMEEXE)

 ELSEIF valgrind THEN
  #IFNDEF __FB_UNIX__
   notification "Valgrind only supported on UNIX"
   RETURN NO
  #ENDIF

  executable = find_helper_app("valgame.sh")
  IF LEN(executable) = 0 THEN
   notification "Couldn't find valgame.sh. You should run from a copy of the source code."
   RETURN NO
  END IF

  arguments = "--no-attach " & arguments
  Game_process = spawn_console_process(executable, arguments, "valgame.sh")

 ELSE
  waitforkeyrelease
  Game_process = open_process(executable, arguments, YES, YES)
 END IF

 'debuginfo "Spawning: " & executable & " " & arguments

 IF Game_process = 0 THEN
  notification "Couldn't run " & gameexename
  RETURN NO
 END IF
 'We currently do nothing at all with Game_process except cleanup (on Unix
 'closing the channel freezes until the child finishes).
 'Instead we test Game is still running by checking channel_to_Game

 'Need Game to connect before we can safely write to the pipe; wait up to 3000ms
 DIM waitms as integer = IIF(valgrind, 9000, IIF(gdb, 5000, 3000))
 IF channel_wait_for_client_connection(channel_to_Game, waitms) = 0 THEN
  notification "Error communicating with " & gameexename & !" (couldn't connect); aborting\n(Press a key)"
  channel_close channel_to_Game
  cleanup_process @Game_process
  RETURN NO
 END IF

 'Write version info
 DIM tmp as string
 'msgtype magickey,proto_ver,program_ver,version_string
 tmp = "V OHRRPGCE," & CURRENT_TESTING_IPC_VERSION & "," & version_revision & "," & short_version
 channel_write_line(channel_to_Game, tmp)
 tmp = "G " & sourcerpg
 channel_write_line(channel_to_Game, tmp)
 tmp = "W " & workingdir
 channel_write_line(channel_to_Game, tmp)
 'If any of these writes fails, channel_to_Game is closed

 IF channel_to_Game THEN
  'If we got this far, start sending lump updates and locking files before writing
  set_OPEN_hook @inworkingdir, YES, YES, @channel_to_Game
 ELSE
  notification "Error communicating with " & gameexename & !" (channel write failure); aborting\n(Press a key)"
  channel_close channel_to_Game
  cleanup_process @Game_process
  RETURN NO
 END IF
 RETURN YES
END FUNCTION

SUB spawn_game_menu(gdb as bool = NO, valgrind as bool = NO)
 #IFDEF __FB_WIN32__
  IF is_windows_9x() THEN
   notification "Testing your game while editing isn't supported on your version of Windows; it requires an NT-based Windows release"
   EXIT SUB
  END IF
 #ENDIF

 'Prod the channel to see whether it's still up (send ping)
 channel_write_line(channel_to_Game, "P ")

 DIM scancode as integer
 IF channel_to_Game THEN
  scancode = notification(!"The game is already running! You can't run multiple test copies of a game, but any edits you make will take effect without restarting.\n" _
                           "Press F1 to see the help file for Test Game.")
 ELSE
  gen(genCurrentDebugMode) = 1
  xbsave game + ".gen", gen(), 1000
  IF spawn_game(gdb, valgrind) THEN
   scancode = notification( "You're running your game in live preview mode. " _
                           !"Press F1 now to read the help file for this if you haven't already.\n\n" _
                            "Press any key")
  END IF
 END IF
 IF scancode = scF1 THEN show_help "test_game"
END SUB

#endif

'Update and save edit_time and last_saved for the game
SUB save_edit_time ()
 DIM gen_root as NodePtr = get_general_reld()
 DIM timenode as NodePtr = GetOrCreateChild(gen_root, "edit_time")
 SetContent(timenode, GetFloat(timenode) + active_seconds - last_active_seconds)
 debuginfo "Saving - been editing for " & format_duration(active_seconds - last_active_seconds, 0)

 SetChildNodeDate(gen_root, "last_saved", NOW)
 
 last_active_seconds = active_seconds
 write_general_reld()
END SUB

'Return true on success
FUNCTION save_current_game (byval genDebugMode_override as integer=-1) as bool
 'Apply the appropriate genCurrentDebugMode
 IF genDebugMode_override >= 0 THEN
  gen(genCurrentDebugMode) = genDebugMode_override
 ELSE
  gen(genCurrentDebugMode) = gen(genDebugMode)
 END IF
 xbsave game + ".gen", gen(), 1000
 save_edit_time

 IF nogfx_mode THEN PRINT "Writing " & sourcerpg
 basic_textbox "LUMPING DATA: please wait.", , vpage, , , YES  'shrink
 setvispage vpage, NO
 '--verify various stuff
 rpg_sanity_checks
 clearpage vpage
 '--lump data to SAVE rpg file
 IF write_rpg_or_rpgdir(workingdir, sourcerpg) THEN
  write_session_info  'Update sourcerpg mtime and reset editing start time
  IF automatic_backup(sourcerpg) = NO THEN
   visible_debug "Successfully saved the game, but couldn't write automatic backup to autobackups folder"
  ELSE
   show_overlay_message "Saved, and copied to autobackups", 1.1
  END IF
  RETURN YES
 END IF
END FUNCTION

'Returns true on success
FUNCTION automatic_backup (rpgfile as string) as bool
 DIM keep_how_many as integer = 10 'FIXME: this could be customized per-game

 DIM backupdir as string = trimfilename(rpgfile) & SLASH & "autobackups"
 IF NOT isdir(backupdir) THEN makedir backupdir
 IF NOT diriswriteable(backupdir) THEN
  debug "Can't do automatic backups: """ & backupdir & """ is not writeable"
  RETURN NO
 END IF
 DIM warnfile as string = backupdir & SLASH & "README-WARNING-automatic-backups.txt"
 IF NOT isfile(warnfile) THEN
  string_to_file !"This folder contains automatic backups. A copy of your game is made here\n" _
                 "every time that CUSTOM saves a game. At most 10 copies per game are kept.\n" _
                 "WARNING! These automatic backups are no substitute for manual backups!\n" _
                 "They are just here to save your slime that one time you forget!\n\n" _
                 "If you don't have any other backup plan, at least remember to e-mail\n" _
                 "a copy of your .rpg file to yourself once a week.", warnfile
 END IF

 'Copy the backup
 DIM datestr as string = MID(DATE, 7, 4) & "-" & MID(DATE, 1, 2) & "-" & MID(DATE, 4, 2)
 DIM destfile as string = backupdir & SLASH & trimextension(trimpath(rpgfile)) & "-" & datestr & "." & justextension(rpgfile)
 DIM ret as bool
 IF isdir(rpgfile) THEN
  IF isdir(destfile) THEN killdir destfile
  ret = confirmed_copydirectory(rpgfile, destfile)
 ELSE
  ret = writeablecopyfile(rpgfile, destfile)
 END IF
 IF ret = NO THEN RETURN NO

 'Cull old backups to avoid bloatclutter.
 REDIM oldfiles() as string
 findfiles backupdir, trimextension(trimpath(rpgfile)) & "-*.rpg", fileTypeFile, , oldfiles()
 REDIM olddirs() as string
 findfiles backupdir, trimextension(trimpath(rpgfile)) & "-*.rpgdir", fileTypeDirectory, , olddirs()
 DIM old as string vector
 v_new old
 FOR i as integer = 0 TO UBOUND(oldfiles)
  IF LEN(oldfiles(i)) THEN v_append old, oldfiles(i)
 NEXT i
 FOR i as integer = 0 TO UBOUND(olddirs)
  IF LEN(olddirs(i)) THEN v_append old, olddirs(i)
 NEXT i
 v_sort old
 DIM oldrpg as string
 FOR i as integer = v_len(old) - 1 - keep_how_many TO 0 STEP -1
  oldrpg = backupdir & SLASH & old[i]
  IF isdir(oldrpg) THEN
   killdir oldrpg
  ELSE
   safekill oldrpg
  END IF
 NEXT i
 v_free old
 RETURN YES
END FUNCTION

' Save all lumps in lumpsdir, except for *.tmp, as a lumped file or .rpgdir directory.
' Returns true on success.
' Note that this can set cleanup_workingdir_on_exit=NO on failure.
FUNCTION write_rpg_or_rpgdir (lumpsdir as string, filetolump as string) as bool
 '--build the list of files to lump. We don't need hidden files
 DIM filelist() as string
 findfiles lumpsdir, ALLFILES, fileTypeFile, NO, filelist()
 'Removes .tmp files
 fixlumporder filelist()
 IF isdir(filetolump) THEN
  '---copy changed files back to source rpgdir---
  IF NOT fileiswriteable(filetolump & SLASH & "archinym.lmp") THEN
   move_unwriteable_rpg filetolump
   makedir filetolump
  END IF
  FOR i as integer = 0 TO UBOUND(filelist)
   safekill filetolump + SLASH + filelist(i)
   IF writeablecopyfile(lumpsdir + SLASH + filelist(i), filetolump + SLASH + filelist(i)) = NO THEN
    pop_warning "Failed to save game to " & filetolump & LINE_END "Look in c_debug.txt for error messages."
    RETURN NO
   END IF
   'Moving files instead would offer no failsafe if something goes wrong while moving
   '(Plus can't move from different mounted filesystem)
  NEXT
 ELSE
  '---relump data into lumpfile package---
  IF NOT fileiswriteable(filetolump) THEN
   move_unwriteable_rpg filetolump
  END IF
  DIM errmsg as string = lumpfiles(filelist(), filetolump, lumpsdir + SLASH)
  IF lumpsdir = workingdir THEN
   cleanup_workingdir_on_exit = (LEN(errmsg) = 0)  'Don't delete workingdir if it hasn't been saved
  END IF
  IF LEN(errmsg) THEN
   'Show a warning instead of a fatal error: this isn't fatal
   pop_warning "Failed to save game to " & filetolump & ": " & errmsg
   RETURN NO
  END IF
 END IF
 RETURN YES
END FUNCTION

SUB move_unwriteable_rpg (filetolump as string)
 clearpage vpage
 DIM newfile as string = documents_dir & SLASH & trimpath(filetolump)
 visible_debug filetolump + " is not writeable. Saving to " + newfile
 filetolump = newfile
END SUB

SUB check_used_onetime_npcs(bits() as integer)
 'Search through all the NPC definitions and figure out which NPC onetime
 ' bits have been used. The result is a bitset array with 0 bits for unused
 ' onetimes and 1 bits for used onetimes.
 flusharray bits()
 setbit bits(), 0, 0, YES ' bit 0 can't be used, 0 indicates usable repeatedly.
 setbit bits(), 0, 1, YES ' bit 1 can't be used, because istag() doesn't allow tag 1.
 REDIM npcdata(0) as NPCType
 FOR m as integer = 0 TO gen(genMaxMap)
  LoadNPCD maplumpname(m, "n"), npcdata()
  check_used_onetime_npcs_npcdata bits(), npcdata(), m
 NEXT m
 LoadNPCD global_npcdef_filename(1), npcdata(), NO  'expect_exists=NO
 check_used_onetime_npcs_npcdata bits(), npcdata(), -1
END SUB

SUB check_used_onetime_npcs_npcdata(bits() as integer, npcdata() as NPCType, mapnum as integer)
 FOR i as integer = 0 TO UBOUND(npcdata)
  WITH npcdata(i)
   IF .usetag > max_onetime THEN
    debugerror "out-of-range onetime tag " & .usetag & " for NPC " & i & IIF(mapnum >= 0, " on map " & mapnum, " global pool " & ABS(mapnum))
   ELSEIF .usetag > 0 THEN
    IF readbit(bits(), 0, .usetag) THEN
     'Don't show a warning for duplicate onetime tags because it happens all the time with map copying
    END IF
    setbit bits(), 0, .usetag, YES
   END IF
  END WITH
 NEXT i
END SUB

SUB menu_of_reorderable_nodes(st as MenuState, menu as MenuDef)
 'This is intended for menus that represent sibling Nodes. The NodePtr
 'is in the .dataptr of the selected menu item
 WITH *menu.items[st.pt]
  IF .dataptr <> 0 THEN
   DIM node as NodePtr = .dataptr
   IF reorderable_node(node) THEN
    st.need_update = YES
   END IF
  END IF
 END WITH
END SUB

FUNCTION reorderable_node(byval node as NodePtr) as integer
 IF keyval(scShift) > 0 THEN
  IF node THEN
   IF keyval(ccUp) > 1 THEN
    SwapNodePrev node
    RETURN YES
   ELSEIF keyval(ccDown) > 1 THEN
    SwapNodeNext node
    RETURN YES
   END IF
  END IF
 END IF
 RETURN NO
END FUNCTION

FUNCTION edit_purchase_enumbrowse(caption as string, byval node as NodePtr, enumstr() as string, helpkey as string) as bool
 DIM choice as integer
 DIM old as integer
 old = a_find(enumstr(), GetString(node), 0)
 choice = multichoice(caption, enumstr(), old, old, helpkey)
 IF choice <> old THEN
  SetContent(node, enumstr(choice))
  RETURN YES
 END IF
 RETURN NO
END FUNCTION

FUNCTION edit_purchase_enumgrabber(byval node as NodePtr, enumstr() as string) as bool
 DIM choice as integer
 choice = a_find(enumstr(), GetString(node), 0)
 IF intgrabber(choice, 0, UBOUND(enumstr)) THEN
  SetContent(node, enumstr(choice))
  RETURN YES
 END IF
 RETURN NO
END FUNCTION

FUNCTION npc_preview_text(byref npc as NPCType) as string
 'This is certaily not an attempt to show everything one might need to
 'know about an NPC, simply a way of showing a little more data so that
 'NPCs with the same sprite can be easily distinguished in the editor at a glance
 IF npc.textbox > 0 THEN RETURN textbox_preview_line(npc.textbox)
 IF npc.script <> 0 THEN RETURN scriptname(npc.script) & " [SCRIPT]"
 IF npc.item > 0 THEN RETURN readitemname(npc.item - 1) & " [ITEM]"
 IF npc.vehicle > 0 THEN RETURN load_vehicle_name(npc.vehicle - 1) & " [VEHICLE]"
END FUNCTION

FUNCTION custom_setoption(opt as string, arg as string) as integer
 IF opt = "distrib" THEN
  SELECT CASE arg
   CASE "zip", "win", "mac", "mac32", "mac64", "tarball", "tarball64", "tarball32", "debian", "debian64", "debian32", "web", "all"
    auto_distrib = arg
    RETURN 2
  END SELECT
  debug "-distrib doesn't know how to export """ & arg & """"
  RETURN 1
 ELSEIF opt = "nowait" THEN
  option_nowait = YES
  RETURN 1
 ELSEIF opt = "hsflags" THEN
  'Have to add the - because it won't be recognised as an argument otherwise
  option_hsflags = "-" & arg
  RETURN 2
 ELSEIF opt = "export-trans" THEN
  IF LEN(arg) THEN
   export_translations_to = arg
   RETURN 2
  ELSE
   RETURN 1
  END IF
 END IF
 RETURN 0
END FUNCTION

'Destructors can't be abstract...
DESTRUCTOR RecordPreviewer()
END DESTRUCTOR

'Draw the menu, to the bottom-right corner of the screen onto a background rect of the size of the game window
SUB preview_menu(menu as MenuDef, mstate as MenuState, viewport_page as integer, destpage as integer = -1)
 draw_textured_background viewport_page
 draw_menu menu, mstate, viewport_page
 IF destpage > -1 THEN draw_viewport_page viewport_page, destpage
END SUB

FUNCTION game_uses_midi_or_bam() as bool
 'Only checks whether a mid or bam exists. Does not go so far as to try and detect whether it ever gets played.
 'In cases where multiple formats exists with the same ID, find_music_lump() detects ogg and module formats firsdt before it finds midi, and bam is detected last
 DIM songlump as string
 DIM songtype as MusicFormatEnum
 FOR songnum as integer = 0 TO gen(genMaxSong)
  songlump = find_music_lump(songnum)
  songtype = getmusictype(songlump)
  IF songtype = FORMAT_MIDI ORELSE songtype = FORMAT_BAM THEN
   RETURN YES
  END IF
 NEXT songnum
 RETURN NO
END FUNCTION
