--------------------------------------------------------------------------------------------
-- Mod reader code created by Rainault
-- Released to the public domain
-- Last update: 2024-02-21
--------------------------------------------------------------------------------------------

function easeLerp(t, from, to)
   return (1 - t) * from + t * to
end

function easePtLerp(t, from, to)
    return {
        easeLerp(t, from[1], to[1]),
        easeLerp(t, from[2], to[2]),
    }
end

function easeBlend(n, t, fromFunc, toFunc)
   return easeLerp(n, fromFunc(t), toFunc(t))
end

function easeCrossFade(t, fromFunc, toFunc)
   return easeBlend(t, t, fromFunc, toFunc)
end

function easeLinear(t) return t end
function easeSmoothStart2(t) return t * t end
function easeSmoothStart3(t) return t * t * t end
function easeSmoothStart4(t) return t * t * t * t end
function easeSmoothEnd2(t) return 1 - easeSmoothStart2(1 - t) end
function easeSmoothEnd3(t) return 1 - easeSmoothStart3(1 - t) end
function easeSmoothEnd4(t) return 1 - easeSmoothStart4(1 - t) end
function easeSmoothStep2(t) return easeCrossFade(t, easeSmoothStart2, easeSmoothEnd2) end
function easeSmoothStep3(t) return easeCrossFade(t, easeSmoothStart3, easeSmoothEnd3) end
function easeSmoothStep4(t) return easeCrossFade(t, easeSmoothStart4, easeSmoothEnd4) end
function easeRebound2(t) return 4 * t * (1 - t) end
function easeRebound4(t) local t2 = easeRebound2(t); return 1 - (1 - t2) * (1 - t2) end
function easeSpringEnd(t) return t >= 1 and 1 or math.pow(2, (-10 * t)) * math.sin((t - 0.3 / 4) * (2 * math.pi) / 0.3) + 1 end

local function evalCommon(self, beat)
   local t = nil
   if beat >= self.beatEnd then
      t = 1
   elseif beat <= self.beatStart then
      t = 0
   else
      t = (beat - self.beatStart) / (self.beatEnd - self.beatStart)
   end

   local easeT = self.ease(t)
   if type(self.valueFrom) == 'table' then
      local result = {}
      for i=1,#self.valueFrom do
         result[#result+1] = easeLerp(easeT, self.valueFrom[i], self.valueTo[i])
      end
      return result
   else
      return easeLerp(easeT, self.valueFrom, self.valueTo)
   end
end

local function normalizePlayer(player)
   local pidx, pid = nil, nil

   if player == 1 or player == PLAYER_1 then
      pidx = 1
      pid = PLAYER_1
   elseif player == 2 or player == PLAYER_2 then
      pidx = 2
      pid = PLAYER_2
   end

   return pidx, pid
end

function calculatePlayerMetrics(pidx)
   pidx, _ = normalizePlayer(pidx)

   -- Ripped from a TaroNuke script, I think
   local musicRate = 1
   local soptions = GAMESTATE:GetSongOptions('ModsLevel_Song')
   local xmu = string.find(soptions,'xMusic')
   if xmu then
      local begin = xmu-1
      while begin > 0 and string.match(string.sub(soptions,begin,begin),'[.%d]') do
         begin = begin - 1
      end
      local rateString = string.sub(soptions, begin + 1, xmu - 1)
      musicRate = tonumber(rateString)
   end

   local options = GAMESTATE:GetPlayerState(pidx-1):GetPlayerOptions('ModsLevel_Song')

   local metrics = {}
   metrics.difficulty = GAMESTATE:GetCurrentSteps(pidx-1):GetDifficulty()
   metrics.scrollDirection = options:Reverse() == 0 and 1 or -1
   metrics.mirror = options:Mirror()
   metrics.arrowScale = 1 - options:Mini() / 2

   local cMod = options:MMod() or options:CMod()
   if cMod then
      metrics.speed = cMod / GAMESTATE:GetCurrentSong():GetDisplayBpms()[2]
   else
      metrics.speed = options:XMod()
   end

   metrics.musicRate = musicRate

   metrics.getColumnEndpoints = function(self, columnIdx)
      local columnOffset = GAMESTATE:GetCurrentStyle():GetName() == 'double' and 4.5 or 2.5
      local columnStart = {
         64 * (columnIdx - columnOffset) * self.scrollDirection,
         -135 / self.arrowScale * self.scrollDirection,
      }
      local columnEnd = {
         columnStart[1],
         columnStart[2] + 1024 * self.scrollDirection,
      }
      return columnStart, columnEnd
   end

   return metrics
end

getVar = nil
reportError = nil
reportErrorOnce = nil

local kActorPropDefaults = {
   align={0.5, 0.5},
   cropbottom=0,
   cropleft=0,
   cropright=0,
   croptop=0,
   diffuse={1, 1, 1, 1},
   glow={0, 0, 0, 0},
   rotationx=0,
   rotationy=0,
   rotationz=0,
   shadowcolor={0, 0, 0, 0},
   shadowlength=0,
   shadowlengthx=0,
   shadowlengthy=0,
   skewx=0,
   skewy=0,
   visible=true,
   x=0,
   y=0,
   z=0,
   zoom=1,
   zoomx=1,
   zoomy=1,
   zoomz=1,
}

function LibActor(args)
   local inputTable = args.inputTable
   local inputActors = args.actors
   local useRealTime = args.useRealTime
   local usePlayFieldMods = args.usePlayFieldMods
   local showDebug = args.showDebug

   local cErrors = {}
   local cUniqueErrorSet = {}

   reportError = function(msg)
      cErrors[#cErrors+1] = msg
   end

   reportErrorOnce = function(msg)
      cUniqueErrorSet[msg] = true
   end

   local songBaseBpm = GAMESTATE:GetCurrentSong():GetDisplayBpms()[2]

   -- Initialize mod table
   local modTable = {}
   local actorCmdTable = {}
   local msgTable = {}
   do
      -- If the section name matches the song name, test only that section
      local sectionIdx = nil
      local songName = GAMESTATE:GetCurrentSong():GetDisplayMainTitle()
      for idx, section in ipairs(inputTable) do
         if songName == section.name then
            sectionIdx = idx
            break
         end
      end

      local easeFuncs = {
         Linear=easeLinear,
         SmoothStart2=easeSmoothStart2,
         SmoothStart3=easeSmoothStart3,
         SmoothStart4=easeSmoothStart4,
         SmoothEnd2=easeSmoothEnd2,
         SmoothEnd3=easeSmoothEnd3,
         SmoothEnd4=easeSmoothEnd4,
         SmoothStep2=easeSmoothStep2,
         SmoothStep3=easeSmoothStep3,
         SmoothStep4=easeSmoothStep4,
         Rebound2=easeRebound2,
         Rebound4=easeRebound4,
         SpringEnd=easeSpringEnd,
      }

      -- Converts perspective mod values to Skew, Tilt value pairs
      local kPerspectiveMods = {
         Distant=function(value) return 0, value end,
         Hallway=function(value) return 0, -value end,
         Incoming=function(value) return value, -value end,
         Overhead=function(value) return 0, 0 end,
         Space=function(value) return value, value end,
      }

      local function appendMod(beat, length, name, valueFrom, valueTo, ease, player, diffs)
         length = length or 0
         valueTo = valueTo or 1
         ease = type(ease) == 'string' and easeFuncs[ease] or ease or easeLinear
         if beat == 0 and length == 0 and ease(1) == 0 then
            -- Special case: Ignore init mods with an ease func that rebounds to 0
         else
            local _, pid = normalizePlayer(player)

            local p1Candidate = (not pid or pid == PLAYER_1) and GAMESTATE:IsPlayerEnabled(PLAYER_1)
            local p1Valid = p1Candidate and
               (not diffs or diffs[ToEnumShortString(GAMESTATE:GetCurrentSteps(0):GetDifficulty())])
            local p2Candidate = (not pid or pid == PLAYER_2) and GAMESTATE:IsPlayerEnabled(PLAYER_2)
            local p2Valid = p2Candidate and
               (not diffs or diffs[ToEnumShortString(GAMESTATE:GetCurrentSteps(1):GetDifficulty())])

            if not p1Valid and not p2Valid then
               -- Both players invalidated
               return
            end

            if p1Candidate and p2Candidate then
               if not p2Valid then
                  pid = PLAYER_1
               elseif not p1Valid then
                  pid = PLAYER_2
               end
            end

            local perspectiveMod = kPerspectiveMods[name]
            if perspectiveMod then
               local skewFrom, tiltFrom = nil, nil
               if valueFrom then
                  skewFrom, tiltFrom = perspectiveMod(valueFrom)
               end
               local skewTo, tiltTo = nil, nil
               if valueTo == 'user' then
                  skewTo, tiltTo = 'user', 'user'
               else
                  skewTo, tiltTo = perspectiveMod(valueTo)
               end

               modTable[#modTable+1] = {
                  beat=beat,
                  length=length,
                  name='Skew',
                  valueFrom=skewFrom,
                  valueTo=skewTo,
                  ease=ease,
                  pid=pid,
               }
               modTable[#modTable+1] = {
                  beat=beat,
                  length=length,
                  name='Tilt',
                  valueFrom=tiltFrom,
                  valueTo=tiltTo,
                  ease=ease,
                  pid=pid,
               }
            else
               if name == 'MMod' then
                  -- MMods are not settable during gameplay, so convert to an XMod
                  name = 'XMod'
                  if valueFrom then valueFrom = valueFrom / songBaseBpm end
                  if valueTo ~= 'user' then valueTo = valueTo / songBaseBpm end
               end

               modTable[#modTable+1] = {
                  beat=beat,
                  length=length,
                  name=name,
                  valueFrom=valueFrom,
                  valueTo=valueTo,
                  ease=ease,
                  pid=pid,
               }
            end
         end
      end

      local function appendActorCmd(beat, length, actorName, actorProp, valueFrom, valueTo, ease)
         if kActorPropDefaults[actorProp] == nil then
            reportError('Invalid prop name "' .. actorProp .. '"')
         else
            length = length or 0
            valueTo = valueTo or 1
            ease = type(ease) == 'string' and easeFuncs[ease] or ease or easeLinear
            if beat == 0 and length == 0 and ease(1) == 0 then
               -- Special case: Ignore init mods with an ease func that rebounds to 0
            else
               actorCmdTable[#actorCmdTable+1] = {
                  beat=beat,
                  length=length,
                  actorName=actorName,
                  actorProp=actorProp,
                  valueFrom=valueFrom,
                  valueTo=valueTo,
                  ease=ease,
               }
            end
         end
      end

      local function appendMsg(beat, name)
         msgTable[#msgTable+1] = {
            beat=beat,
            name=name,
         }
      end

      local function mergeDiffs(allowDiff, blockDiff, minDiff, maxDiff)
         if not allowDiff and not blockDiff and not minDiff and not maxDiff then return nil end

         if not minDiff then minDiff = ToEnumShortString(Difficulty[1]) end
         if not maxDiff then maxDiff = ToEnumShortString(Difficulty[#Difficulty]) end

         local function matchDiff(d, diff)
            if type(diff) == 'string' then return d == diff end
            for _, v in ipairs(diff) do
               if d == v then return true end
            end
            return false
         end

         local diffs = {}
         local withinRange = false
         for _, d in ipairs(Difficulty) do
            local shortD = ToEnumShortString(d)
            if shortD == minDiff then withinRange = true end
            if withinRange and
               (not allowDiff or matchDiff(shortD, allowDiff)) and
               (not blockDiff or not matchDiff(shortD, blockDiff))
            then
               diffs[shortD] = true
            end
            if shortD == maxDiff then break end
         end

         return diffs
      end

      if not sectionIdx then
         -- Full song, so load the full mod table
         local lengthSoFar = 0
         for idx, section in ipairs(inputTable) do
            for _, group in ipairs(section.groups) do
               if not group.test then
                  for _, mod in ipairs(group.mods) do
                     local beat = mod.beat + lengthSoFar + group.offset
                     if mod.msg then
                        appendMsg(beat, mod.msg)
                     elseif mod.actor then
                        appendActorCmd(beat, mod.len, mod.actor, mod.prop, mod.from, mod.val, mod.ease)
                     elseif mod.mod then
                        local player = mod.player or group.player
                        local diffs = mergeDiffs(
                           mod.diff or group.diff,
                           mod.notDiff or group.notDiff,
                           mod.minDiff or group.minDiff,
                           mod.maxDiff or group.maxDiff)
                        appendMod(beat, mod.len, mod.mod, mod.from, mod.val, mod.ease, player, diffs)
                     else
                        --Error?
                     end
                  end
               end
            end
            lengthSoFar = lengthSoFar + section.length
         end
      else
         -- Initialize end state from mods of all previous sections.
         -- (Messages are ignored; fire the message manually in a test group if you need this.)
         -- TODO: Support mid-interp values.
         -- TODO: Account for beats in case table is out-of-order.
         local initModTable = {}
         local initActorTable = {}
         for i = 1, sectionIdx-1 do
            for _, group in ipairs(inputTable[i].groups) do
               for _, mod in ipairs(group.mods) do
                  if mod.mod then
                     initModTable[mod.mod] = { group=group, mod=mod }
                  elseif mod.actor then
                     if not initActorTable[mod.actor] then
                        initActorTable[mod.actor] = {}
                     end
                     initActorTable[mod.actor][mod.prop] = { mod=mod }
                  end
               end
            end
         end

         for initModName, initMod in pairs(initModTable) do
            if initMod.mod.value ~= 0 then
               local player = initMod.mod.player or initMod.group.player
               local diffs = mergeDiffs(
                  initMod.mod.diff or initMod.group.diff,
                  initMod.mod.notDiff or initMod.group.notDiff,
                  initMod.mod.minDiff or initMod.group.minDiff,
                  initMod.mod.maxDiff or initMod.group.maxDiff)
               appendMod(0, 0, initModName, nil, initMod.mod.val, initMod.mod.ease, player, diffs)
            end
         end

         for initActorName, initActorProps in pairs(initActorTable) do
            for initPropName, initActorCmd in pairs(initActorProps) do
               appendActorCmd(0, 0, initActorName, initPropName, nil, initActorCmd.mod.val, initActorCmd.mod.ease)
            end
         end

         -- Collect mods only for this section
         local section = inputTable[sectionIdx]
         for _, group in ipairs(section.groups) do
            for _, mod in ipairs(group.mods) do
               local beat = mod.beat + section.rebase + group.offset
               if mod.msg then
                  appendMsg(beat, mod.msg)
               elseif mod.actor then
                  appendActorCmd(beat, mod.len, mod.actor, mod.prop, mod.from, mod.val, mod.ease)
               elseif mod.mod then
                  local player = mod.player or group.player
                  local diffs = mergeDiffs(
                     mod.diff or group.diff,
                     mod.notDiff or group.notDiff,
                     mod.minDiff or group.minDiff,
                     mod.maxDiff or group.maxDiff)
                  appendMod(beat, mod.len, mod.mod, mod.from, mod.val, mod.ease, player, diffs)
               else
                  --Error?
               end
            end
         end
      end

      table.sort(modTable, function(left, right)
         return left.beat < right.beat or left.beat == right.beat and left.length < right.length
      end)
      table.sort(actorCmdTable, function(left, right)
         return left.beat < right.beat or left.beat == right.beat and left.length < right.length
      end)
      table.sort(msgTable, function(left, right)
         return left.beat < right.beat
      end)
   end

   local cState = {
      modTable=modTable,
      modNextIdx=1,
      actorCmdTable=actorCmdTable,
      actorCmdNextIdx=1,
      msgTable=msgTable,
      msgNextIdx=1,
      players={},
      prevSongBeat=-1,
      actors={},
   }

   for pidx, pid in ipairs({PLAYER_1, PLAYER_2}) do
      if GAMESTATE:IsPlayerEnabled(pid) then
         cState.players[pid] = {
            idx=pidx,
            options=nil,
            playField=nil,
            basePos=nil,
            userMods={},
            userSpeedMod=nil,
            userSpeedValue=nil,
            activeMods={},
            debugModTargets={},
         }
      end
   end

   getVar = function(name, pid)
      _, pid = normalizePlayer(pid)

      local player = nil
      if pid then
         player = cState.players[pid]
      else
         for _, firstPlayer in pairs(cState.players) do
            player = firstPlayer
            break
         end
      end

      if not player or not player.options then
         return 0
      end

      local value = 0
      if not player.options[name] then
         local activeMod = player.activeMods[name]
         if activeMod and activeMod.value then
            value = activeMod.value
         end
      end
      return value
   end

   local kBinaryMods = {
      AttackMines=true,
      Backwards=true,
      Big=true,
      BMRize=true,
      Cosecant=true,
      DizzyHolds=true,
      Echo=true,
      Floored=true,
      HoldRolls=true,
      Left=true,
      Little=true,
      Mines=true,
      Mirror=true,
      MuteOnError=true,
      NoFakes=true,
      NoHands=true,
      NoHolds=true,
      NoJumps=true,
      NoLifts=true,
      NoMines=true,
      NoQuads=true,
      NoRolls=true,
      NoStretch=true,
      Overhead=true,
      Planted=true,
      Quick=true,
      Right=true,
      Shuffle=true,
      Skippy=true,
      SoftShuffle=true,
      StealthPastReceptors=true,
      StealthType=true,
      Stomp=true,
      SuperShuffle=true,
      TurnNone=true,
      Twister=true,
      Wide=true,
      ZBuffer=true,
   }

   local kPlayFieldModFuncs = {
      PF_MoveX=function(player, value) player.playField:x(player.basePos.x + value) end,
      PF_MoveY=function(player, value) player.playField:y(player.basePos.y + value) end,
      PF_MoveZ=function(player, value) player.playField:z(player.basePos.z + value) end,
      PF_RotateX=function(player, value) player.playField:rotationx(value) end,
      PF_RotateY=function(player, value) player.playField:rotationy(value) end,
      PF_RotateZ=function(player, value) player.playField:rotationz(value) end,
      PF_Zoom=function(player, value) player.playField:zoom(1 + value) end,
      PF_ZoomX=function(player, value) player.playField:basezoomx(1 + value) end,
      PF_ZoomY=function(player, value) player.playField:basezoomy(1 + value) end,
      PF_ZoomZ=function(player, value) player.playField:basezoomz(1 + value) end,
      PF_SkewX=function(player, value) player.playField:skewx(value) end,
   }

   --[[
   for name, children in pairs(SCREENMAN:GetTopScreen():GetChildren()) do
      reportError(name)
      if name == '' then
         local actors = type(children) == 'table' and children or { children }
         for _, actor in ipairs(actors) do
            for innerName, innerChildren in pairs(actor:GetChildren()) do
               reportError(':' .. innerName)
            end
         end
      end
   end
   --]]

   local function update(self)
      local function isDynamicSpeedMod(mod)
         -- Excludes MMod because it can't be set during gameplay
         return mod == 'XMod' or mod == 'CMod'
      end

      local function getSpeedMod(options)
         local cMod = options:CMod()
         if cMod then return 'CMod', cMod end
         local mMod = options:MMod()
         if mMod then return 'MMod', mMod end
         return 'XMod', options:XMod()
      end

      local function convertSpeedMod(fromMod, fromValue, toMod)
         if fromMod == 'XMod' and toMod ~= 'XMod' then
            return fromValue * songBaseBpm
         elseif fromMod ~= 'XMod' and toMod == 'XMod' then
            return fromValue / songBaseBpm
         else
            return fromValue
         end
      end

      for pid, player in pairs(cState.players) do
         if not player.options then
            player.options = GAMESTATE:GetPlayerState(player.idx-1):GetPlayerOptions('ModsLevel_Song')

            local userModNames = {}
            for _, mod in pairs(cState.modTable) do
               if mod.valueTo == 'user' and not isDynamicSpeedMod(mod.name) then
                  userModNames[mod.name] = true
               end
            end

            for userModName, _ in pairs(userModNames) do
               player.userMods[userModName] = player.options[userModName](player.options)
            end

            player.userSpeedMod, player.userSpeedValue = getSpeedMod(player.options)
            if player.userSpeedMod == 'MMod' then
               player.userSpeedMod = 'XMod'
               player.userSpeedValue = convertSpeedMod('MMod', player.userSpeedValue, 'XMod')
               --player.options:XMod(player.userSpeedValue, 10000)
            end
         end

         if not player.playField then
            local topScreen = SCREENMAN:GetTopScreen()
			--[[
			local outStr = ''
			for childIdx, child in ipairs(topScreen:GetChild('Underlay'):GetChild('')) do
				if child['GetChildren'] then
					outStr = outStr .. '#' .. childIdx .. ' '
					for name2, child2 in pairs(child:GetChildren()) do
						outStr = outStr .. '[' .. name2 .. '] '
					end
				end
			end
            error(outStr)
			--]]
            player.playField = topScreen:GetChild('PlayerP'..player.idx)

            -- Get edit mode playfield
            if not player.playField and
               usePlayFieldMods and
               pid == PLAYER_1 and
               GAMESTATE:IsPlayerEnabled(PLAYER_1) and
               topScreen:GetChild('EditHelp')
            then
               local playFieldCandidates = topScreen:GetChild('')
               if type(playFieldCandidates) ~= 'table' then
                  playFieldCandidates = { playFieldCandidates }
               end

               for _, actor in ipairs(playFieldCandidates) do
                  if actor:GetChild('NoteField') then
                     player.playField = actor
                     break
                  end
               end
            end
         end

         if player.playField and not player.basePos then
            player.basePos = {
               x=player.playField:GetX(),
               y=player.playField:GetY(),
               z=player.playField:GetZ(),
            }
         end
      end

      local songBeat =
         useRealTime and
            math.max(0, GAMESTATE:GetCurMusicSeconds() / 60 * songBaseBpm) or
            GAMESTATE:GetSongBeat()
      if songBeat > cState.prevSongBeat then
         -- Process mod table
         while cState.modNextIdx <= #cState.modTable do
            local mod = cState.modTable[cState.modNextIdx]
            if songBeat < mod.beat then break end
            cState.modNextIdx = cState.modNextIdx + 1

            for pid, player in pairs(cState.players) do
               if not mod.pid or mod.pid == pid then
                  local modValueFrom = mod.valueFrom
                  if not modValueFrom then
                     local activeMod = player.activeMods[mod.name]
                     if activeMod then
                        modValueFrom = activeMod:eval(mod.beat)
                     elseif player.options[mod.name] then
                        if isDynamicSpeedMod(mod.name) then
                           local speedMod, speedValue = getSpeedMod(player.options)
                           modValueFrom = convertSpeedMod(speedMod, speedValue, mod.name)
                        else
                           modValueFrom = player.options[mod.name](player.options)
                           if kBinaryMods[mod.name] and modValueFrom then
                              modValueFrom = 1
                           end
                        end
                     end

                     modValueFrom = modValueFrom or 0
                  end

                  local modValueTo = mod.valueTo
                  if modValueTo == 'user' then
                     if isDynamicSpeedMod(mod.name) then
                        modValueTo = convertSpeedMod(player.userSpeedMod, player.userSpeedValue, mod.name)
                     else
                        modValueTo = player.userMods[mod.name]
                     end
                  end

                  if modValueFrom ~= modValueTo then
                     player.activeMods[mod.name] = {
                        beatStart=mod.beat,
                        beatEnd=mod.beat + mod.length,
                        valueFrom=modValueFrom,
                        valueTo=modValueTo,
                        ease=mod.ease,
                        eval=evalCommon,
                     }

                     player.debugModTargets[mod.name] = modValueTo
                  end
               end
            end
         end

         -- Update active mods
         for pid, player in pairs(cState.players) do
            for modName, activeMod in pairs(player.activeMods) do
               if player.options[modName] then
                  local modValue = activeMod:eval(songBeat)
                  if kBinaryMods[modName] then
                     player.options[modName](player.options, modValue ~= 0 and true or false)
                  elseif isDynamicSpeedMod(modName) then
                     player.options[player.userSpeedMod](player.options,
                        convertSpeedMod(modName, modValue, player.userSpeedMod),
                        10000)
                  else
                     player.options[modName](player.options, modValue, 10000)
                  end
               else
                  activeMod.value = activeMod:eval(songBeat)
               end

               if songBeat >= activeMod.beatEnd then
                  player.debugModTargets[modName] = activeMod:eval(activeMod.beatEnd)
                  if player.debugModTargets[modName] == 0 then
                     player.debugModTargets[modName] = nil
                  end
               end
            end
         end

         -- Update play field mods
         if usePlayFieldMods then
            for pid, player in pairs(cState.players) do
               if player.playField then
                  player.playField:sleep(0)
                  for modName, func in pairs(kPlayFieldModFuncs) do
                     func(player, getVar(modName, pid))
                  end
               end
            end
         end

         -- Process actor comand table
         while cState.actorCmdNextIdx <= #cState.actorCmdTable do
            local actorCmd = cState.actorCmdTable[cState.actorCmdNextIdx]
            if songBeat < actorCmd.beat then break end
            cState.actorCmdNextIdx = cState.actorCmdNextIdx + 1

            local cmdValueFrom = actorCmd.valueFrom
            if not cmdValueFrom then
               local activeActorCmd = cState.actors[actorCmd.actorName][actorCmd.actorProp]
               if activeActorCmd then
                  cmdValueFrom = activeActorCmd:eval(actorCmd.beat)
               else
                  cmdValueFrom = kActorPropDefaults[actorCmd.actorProp]
               end
            end

            local valueChanged = false
            if type(cmdValueFrom) == 'table' then
               for i=1,#cmdValueFrom do
                  if cmdValueFrom[i] ~= actorCmd.valueTo[i] then
                     valueChanged = true
                     break
                  end
               end
            else
               valueChanged = cmdValueFrom ~= actorCmd.valueTo
            end

            if valueChanged then
               cState.actors[actorCmd.actorName][actorCmd.actorProp] = {
                  beatStart=actorCmd.beat,
                  beatEnd=actorCmd.beat + actorCmd.length,
                  valueFrom=cmdValueFrom,
                  valueTo=actorCmd.valueTo,
                  ease=actorCmd.ease,
                  eval=evalCommon,
               }
            end
         end

         -- Update active actor commands
         for actorName, actorCmds in pairs(cState.actors) do
            if next(actorCmds) ~= nil then
               local actorRef = self:GetChild(actorName)
               actorRef:sleep(0)
               for actorProp, actorCmd in pairs(actorCmds) do
                  actorRef[actorProp](actorRef, actorCmd:eval(songBeat))
               end
            end
         end

         -- Process messages
         while cState.msgNextIdx <= #cState.msgTable do
            local msg = cState.msgTable[cState.msgNextIdx]
            if songBeat < msg.beat then break end
            cState.msgNextIdx = cState.msgNextIdx + 1

            MESSAGEMAN:Broadcast(msg.name)
         end

         cState.prevSongBeat = songBeat
      end
   end

   local frame = Def.ActorFrame{
      InitCommand=function(self) self:SetUpdateFunction(update) end,
      Def.Quad{
         InitCommand=function(self) self:visible(false) end,
         OnCommand=function(self) self:sleep(9999) end
      }
   }

   if inputActors then
      for _, inputActor in ipairs(inputActors) do
         local actor = LoadActor(inputActor.src) .. {
            OnCommand=function(self)
               self:name(inputActor.name)
               if inputActor.init then
                  for propName, propValue in pairs(inputActor.init) do
                     if kActorPropDefaults[propName] == nil then
                        reportError('Invalid prop name "' .. propName .. '"')
                     end

                     self[propName](self, propValue)
                  end
               end
            end,
         }

         cState.actors[inputActor.name] = {}

         if inputActor.init then
            for propName, propValue in pairs(inputActor.init) do
               cState.actors[inputActor.name][propName] = {
                  beatStart=0,
                  beatEnd=0,
                  valueFrom=propValue,
                  valueTo=propValue,
                  ease=easeLinear,
                  eval=evalCommon,
               }
            end
         end

         frame[#frame+1] = actor
      end
   end

   if showDebug then
      local function debugUpdate(self)
         local debugStr = ''

         for _, msg in ipairs(cErrors) do
            debugStr = debugStr .. 'ERROR: ' .. msg .. '\n'
         end

         for msg, _ in pairs(cUniqueErrorSet) do
            debugStr = debugStr .. 'ERROR: ' .. msg .. '\n'
         end

         if not cState.modTable or not cState.msgTable then
            debugStr = debugStr .. 'Waiting on mod table initialization\n'
         else
            debugStr = debugStr .. 'Beat: ' .. math.floor(GAMESTATE:GetSongBeat())

            local player = cState.players[PLAYER_1]
            local sortedModNames = {{},{},{}}
            for modName, _ in pairs(player.debugModTargets) do
               local group = nil
               if kBinaryMods[modName] then
                  group = sortedModNames[2]
               elseif modName == 'XMod' or modName == 'CMod' then
                  group = sortedModNames[1]
               else
                  group = sortedModNames[3]
               end

               group[#group+1] = modName
            end

            for _, group in ipairs(sortedModNames) do
               table.sort(group)
            end

            for _, group in ipairs(sortedModNames) do
               for _, modName in ipairs(group) do
                  local debugModTarget = player.debugModTargets[modName]
                  local lineStr = nil
                  if not player.options[modName] then
                     lineStr = debugModTarget .. ' ' .. modName
                  elseif kBinaryMods[modName] then
                     lineStr = (debugModTarget == 0 and 'No ' or '') .. modName
                  elseif modName == 'XMod' then
                     lineStr = debugModTarget .. 'x'
                  elseif modName == 'CMod' then
                     lineStr = 'C' .. debugModTarget
                  else
                     lineStr = (debugModTarget*100) .. '% ' .. modName
                  end

                  debugStr = debugStr .. '\n' ..lineStr
               end
            end
         end

         self:GetChild("DebugText"):settext(debugStr)
      end

      frame[#frame+1] = Def.ActorFrame{
         InitCommand=function(self) self:SetUpdateFunction(debugUpdate) end,
         Def.BitmapText{
            Name="DebugText",
            Font="Common normal",
            InitCommand=function(self)
               self:x(SCREEN_WIDTH*3/4)
               self:y(SCREEN_CENTER_Y)
            end,
         }
      }
   end

   return frame
end
