diff --git a/changelog.txt b/changelog.txt index db0f77fb0e..0d94e9a3c9 100644 --- a/changelog.txt +++ b/changelog.txt @@ -36,6 +36,7 @@ Template for new versions: - `fix/population-cap`: fixes the situation where you continue to get migrant waves even when you are above your configured population cap - `fix/occupancy`: fixes issues where you can't build somewhere because the game tells you an item/unit/building is in the way but there's nothing there - `fix/sleepers`: (reinstated) fixes sleeping units belonging to a camp that never wake up. +- `timestream`: (reinstated) keep the game running quickly even when there are large numbers of units on the map ## New Features - `buildingplan`: dimension tooltip is now displayed for constructions and buildings that are designated over an area, like bridges and farm plots diff --git a/docs/timestream.rst b/docs/timestream.rst index aa15ea5ff0..ff9fa0a3ed 100644 --- a/docs/timestream.rst +++ b/docs/timestream.rst @@ -3,52 +3,135 @@ timestream .. dfhack-tool:: :summary: Fix FPS death. - :tags: unavailable + :tags: fort gameplay fps Do you remember when you first start a new fort, your initial 7 dwarves zip around the screen and get things done so quickly? As a player, you never had -to wait for your initial dwarves to move across the map. Don't you wish that -your fort of 200 dwarves could be as zippy? This tool can help. +to wait for your initial dwarves to move across the map. Do you wish that your +fort of 200 dwarves and 800 animals could be as zippy? This tool can help. -``timestream`` keeps the game running quickly by dynamically adjusting the -calendar speed relative to the frames per second that your computer can support. -Your dwarves spend the same amount of in-game time to do their tasks, but the -time that you, the player, have to wait for the dwarves to do things speeds up. -This means that the dwarves in your fully developed fort appears as energetic as -a newly created one, and mature forts are much more fun to play. +``timestream`` keeps the game running quickly by tweaking the game simulation +according to the frames per second that your computer can support. This means +that your dwarves spend the same amount of time relative to the in-game +calendar to do their tasks, but the time that you, the player, have to wait for +the dwarves to do get things done is reduced. The result is that the dwarves in +your fully developed fort appear as energetic as the dwarves in a newly created +fort, and mature forts are much more fun to play. -If you just want to change the game calendar speed without adjusting dwarf -speed, this tool can do that too. Your dwarves will just be able to get -less/more done per season (depending on whether you speed up or slow down the -calendar). +Note that whereas your dwarves zip around like you're running at 100 FPS, the +onscreen FPS counter, if enabled, will still show a lower number. See the +`Technical details`_ section below if you're interested in what's going on +under the hood. + +You can also use this tool to change the in-game calendar speed. Your dwarves +will be able to get less/more done per season (depending on whether you speed +up or slow down the calendar). Usage ----- -``timestream --units [--fps ]`` - Keep the game running as responsively as it did when it was running at the - given frames per second. Dwarves get the same amount done per game day, but - game days go by faster. If a target FPS is not given, it defaults to 100. -``timestream --rate ``, ``timestream --fps `` - Just change the rate of the calendar, without corresponding adjustments to - units. Game responsiveness will not change, but dwarves will be able to get - more (or less) done per game day. A rate of ``1`` is "normal" calendar - speed. Alternately, you can run the calendar at a rate that it would have - moved at while the game was running at the specified frames per second. +:: + + enable timestream + timestream [status] + timestream set + timestream reset Examples -------- -``timestream --units`` - Keep the game running as quickly and smoothly as it did when it ran - "naturally" at 100 FPS. This mode makes things much more pleasant for the - player without giving any advantage/disadvantage to your in-game dwarves. -``timestream --rate 2`` - Calendar runs at 2x normal speed and units get half as much done as usual - per game day. -``timestream --fps 100`` - Calendar runs at a dynamic speed to simulate 100 FPS. Units get a varying - amount of work done per game day, but will get less and less done as your - fort grows and your unadjusted FPS decreases. -``timestream --rate 1`` - Reset everything back to normal. +``enable timestream`` + Start adjusting the simulation to run at the currently configured apparent + FPS (default is whatever you have the FPS cap set to in the DF settings, + which is usually 100). + +``timestream set fps 50`` + Tweak the simulation so dwarves move at an apparent 50 frames per second. + +``timestream set calendar-rate 0.5`` + Make the days twice as long and allow dwarves to accomplish twice as much + per day (as long as the target FPS is sufficiently above the FPS the game + is actually running at). + +``timestream reset`` + Reset settings to defaults: the vanilla FPS cap with no calendar speed + advantage or disadvantage. + +Settings +-------- + +:fps: Set the target simulated FPS. The default target FPS is whatever you have + the FPS cap set to in the DF settings, and the minimum is 10. Setting the + target FPS *below* your current actual FPS will have no effect. You have + to set the vanilla FPS cap for that. Set a target FPS of -1 to make no + adjustment at all to the apparent FPS of the game. + +:calendar-rate: Set the calendar rate in relation to the target FPS. A calendar + rate factor of 1 means time flows "normally" for the units in the game. + Values between 0 and 1 slow the calendar relative to the world, allowing + units to get more done per day, and values above 1 speed the calendar + relative to the world, causing the days to pass quicker and preventing + units from getting as much done per day. The actual fps must be below the + configured target ``fps`` setting for the ``calendar-rate`` setting to take + effect. + +:max-frame-skip: Set the maximum number of ticks that can be skipped in one + step. Dwarves can perform at most one action per step, and if too many + frames are skipped in one step, dwarves will "lose time" compared to the + movement of the calendar. The default is 4, which allows a target FPS of up + to 4x your actual FPS while still allowing dwarves to walk at full speed. + Raise this value if speed of the simulation is more important to you than + its accuracy and smoothness. + +Technical details +----------------- + +So what is this magic? How does this tool make it look like the game is +suddenly running so much faster? + +Maybe an analogy would help. Pretend you're standing at the bottom of a +staircase and you want to walk up the stairs. You can walk up one stair every +second, and there are 100 stairs, so it will take you 100 seconds to walk up +all the stairs. + +Now let's use the Hand of Armok and fiddle with reality a bit. Let's say that +instead of walking up one step, you walk up 5 steps at once. At the same time +we move the wall clock 5 seconds ahead. If you look at the clock after reaching +the top of the stairs, it will still look like it took 100 seconds, but you did +it all in fewer "steps". + +That's essentially what ``timestream`` is doing to the game. All "actions" in +DF have counters associated with them. For example, when a dwarf wants to walk +to the next tile, a counter is initialized to 500. Every "tick" of the game +(the "frame" in FPS) decrements that counter by by a certain amount. When the +counter gets to zero, the dwarf appears on the next tile. + +When ``timestream`` is active, it monitors all those counters and makes them +decrement more per tick. It then balances things out by proportionally +advancing the in-game calendar. Therefore, more "happens" per step, and DF has +to simulate fewer "steps" for the same amount of work to get done. + +The cost of this simplification is that the world becomes less "smooth". As the +discrepancy between the "natural" and simulated FPS grows, more and more +dwarves will move to their next tiles at *exactly* the same time. Moreover, the +rate of action completion per unit is effectively capped at the granularity of +the simulation, so very fast units will lose some of their advantage. In the +extreme case, with the computer struggling to run at 1 FPS and ``timestream`` +simulating thousands of FPS (and the ``max-frame-skip`` cap increased to 20), +all units will perform exactly one action per frame. This would make the game +look robotic. With default settings, it will never get this bad, but you can +always choose to alter the ``timestream`` configuration to your preferred +balance of speed vs. accuracy. + +Limitations +----------- + +Right now, not all aspects of the game are perfectly adjusted. For example, +armies on world map will move at the same (real-time) rate regardless of +changes that ``timestream`` is making to the calendar. + +Here is a (likely incomplete) list of game elements that are not affected by +``timestream``: + +- Army movement across the world map (including raids sent out from the fort) +- Liquid movement and evaporation diff --git a/internal/control-panel/registry.lua b/internal/control-panel/registry.lua index ded9eb3832..2d296e838f 100644 --- a/internal/control-panel/registry.lua +++ b/internal/control-panel/registry.lua @@ -121,6 +121,7 @@ COMMANDS_BY_IDX = { desc='Displays percentages on partially-consumed items like hospital cloth.'}, {command='pop-control', group='gameplay', mode='enable'}, {command='starvingdead', group='gameplay', mode='enable'}, + {command='timestream', group='gameplay', mode='enable'}, {command='work-now', group='gameplay', mode='enable'}, } diff --git a/timestream.lua b/timestream.lua index ff4b978ed4..69b00da650 100644 --- a/timestream.lua +++ b/timestream.lua @@ -1,438 +1,323 @@ --- speeds up the calendar, units, or both --- based on https://gist.github.com/IndigoFenix/cf358b8c994caa0f93d5 +--@module = true +--@enable = true ---[====[ +local argparse = require('argparse') +local repeatutil = require("repeat-util") +local utils = require('utils') -timestream -========== -Controls the speed of the calendar and creatures. Fortress mode only. Experimental. +------------------------------------ +-- state management -The script is also capable of dynamically speeding up the game based on your current FPS to mitigate the effects of FPS death. See examples below to see how. +local GLOBAL_KEY = 'timestream' -Usage:: +local SETTINGS = { + { + name='fps', + validate=function(arg) + local val = argparse.positiveInt(arg, 'fps') + if val < 10 then qerror('target fps must be at least 10') end + return val + end, + default=function() return df.global.init.fps_cap end, + }, + { + name='calendar-rate', + internal_name='calendar_rate', + validate=function(arg) + local val = tonumber(arg) + if not val or val <= 0 then qerror('calendar-rate must be larger than 0') end + return val + end, + default=1.0, + }, + { + name='max-frame-skip', + internal_name='max_frame_skip', + validate=function(arg) return argparse.positiveInt(arg, 'max-frame-skip') end, + default=4, + }, +} - timestream [-rate R] [-fps FPS] [-units [FLAG]] [-debug] +local function get_default_state() + local settings = {} + for _, v in ipairs(SETTINGS) do + settings[v.internal_name or v.name] = utils.getval(v.default) + end + return { + enabled=false, + settings=settings, + } +end -Examples: +state = state or get_default_state() -- ``timestream -rate 2``: - Calendar runs at x2 normal speed, units run at normal speed -- ``timestream -fps 100``: - Calendar runs at dynamic speed to simulate 100 FPS, units normal -- ``timestream -fps 100 -units``: - Calendar & units are simulated at 100 FPS -- ``timestream -rate 1``: - Resets everything back to normal, regardless of other arguments -- ``timestream -rate 1 -fps 50 -units``: - Same as above -- ``timestream -fps 100 -units 2``: - Activates a different mode for speeding up units, using the native DF - ``debug_turbospeed`` flag (similar to `fastdwarf` 2) instead of adjusting - timers of all units. This results in rubberbanding unit motion, so it is not - recommended over the default method. +function isEnabled() + return state.enabled +end -Original timestream.lua: https://gist.github.com/IndigoFenix/cf358b8c994caa0f93d5 -]====] +local function persist_state() + dfhack.persistent.saveSiteData(GLOBAL_KEY, state) +end +------------------------------------ +-- business logic -local MINIMAL_FPS = 10 -- This ensures you won't get crazy values on pausing/saving, or other artefacts on extremely low FPS. -local DEFAULT_MAX_FPS = 100 +local TICKS_PER_DAY = 1200 +local TICKS_PER_WEEK = 7 * TICKS_PER_DAY +-- determined from reverse engineering; don't skip these tick thresholds +-- something important happens when tick % == +-- please keep remainder list elements in **descending** order +local SEASON_TICK_TRIGGERS = { + {mod=TICKS_PER_DAY//10, rem={0x6e, 0x50, 0x46, 0x3c, 0x32, 0x28, 0x14, 10, 0}}, + {mod=TICKS_PER_WEEK//10, rem={0x32, 0x1e}}, +} +local YEAR_TICK_TRIGGERS = { + {mod=100, rem={0}}, -- crop growth +} ---- DO NOT CHANGE BELOW UNLESS YOU KNOW WHAT YOU'RE DOING --- +-- additional ticks we would like to skip at the next opportunity +local timeskip_deficit, calendar_timeskip_deficit = 0.0, 0.0 -local utils = require("utils") -args = utils.processArgs({...}, utils.invert({ - 'rate', - 'fps', - 'units', - 'debug', -})) -local rate = tonumber(args.rate) or -1 -local desired_fps = tonumber(args.fps) -local simulating_units = tonumber(args.units) -if args.units == '' then - simulating_units = 1 -end -local debug_mode = not not args.debug - -local current_fps = desired_fps -local prev_tick = 0 -local ticks_left = 0 -local simulating_desired_fps = false -local prev_frames = df.global.world.frame_counter -local last_frame = df.global.world.frame_counter -local prev_time = df.global.enabler.clock -local ui_main = df.global.plotinfo.main -local saved_game_frame = -1 -local frames_until_speeding = 0 -local speedy_frame_delta = desired_fps or DEFAULT_MAX_FPS - -local SEASON_LEN = 3360 -local YEAR_LEN = 403200 - -if not dfhack.world.isFortressMode() then - print("timestream: Will start when fortress mode is loaded.") +local function get_desired_timeskip(real_fps, desired_fps) + return (desired_fps / real_fps) - 1 end -if rate == nil then - rate = 1 -elseif rate < 0 then - rate = 0 +local function get_next_timed_event_season_tick() + local next_event_tick = math.huge + for _, event in ipairs(df.global.timed_events) do + if event.season == df.global.cur_season then + next_event_tick = math.min(next_event_tick, event.season_ticks) + end + end + return next_event_tick end -simulating_desired_fps = true -if desired_fps == nil then - desired_fps = DEFAULT_MAX_FPS - if simulating_units ~= 1 and simulating_units ~= 2 then - simulating_desired_fps = false +local function get_next_trigger_tick(triggers, next_tick, is_tick_boundary) + local next_trigger_tick = math.huge + for _, trigger in ipairs(triggers) do + local cur_rem = next_tick % trigger.mod + for _, rem in ipairs(trigger.rem) do + if cur_rem < rem or (cur_rem == rem and is_tick_boundary) then + next_trigger_tick = math.min(next_trigger_tick, next_tick + (rem - cur_rem)) + goto continue + end + end + next_trigger_tick = math.min(next_trigger_tick, next_tick + trigger.mod - cur_rem + trigger.rem[#trigger.rem]) + ::continue:: end -elseif desired_fps < MINIMAL_FPS then - desired_fps = MINIMAL_FPS + return next_trigger_tick end -current_fps = desired_fps - -eventNow = false -seasonNow = false -timestream = 0 -counter = 0 -if df.global.cur_season_tick < SEASON_LEN then - month = 1 -elseif df.global.cur_season_tick < SEASON_LEN * 2 then - month = 2 -else - month = 3 + +local function get_next_trigger_year_tick() + return get_next_trigger_tick(YEAR_TICK_TRIGGERS, df.global.cur_year_tick + 1, true) end -dfhack.onStateChange.loadTimestream = function(code) - if code==SC_MAP_LOADED then - if rate ~= 1 then - last_frame = df.global.world.frame_counter - --if rate > 0 then -- Won't behave well with unit simulation - if rate > 1 and not simulating_desired_fps then - print('timestream: Time running at x'..rate..".") - else - print('timestream: Time running dynamically to simulate '..desired_fps..' FPS.') - if rate ~= 0 then - print('timestream: Rate setting ignored.') - end - reset_frame_count() - rate = 1 - if simulating_units == 1 or simulating_units == 2 then - print("timestream: Unit simulation is on.") - if simulating_units ~= 2 then - df.global.debug_turbospeed = false - end - end +local function get_next_trigger_season_tick() + local is_season_tick = (df.global.cur_year_tick+1) % 10 == 0 + local next_season_tick = df.global.cur_season_tick + (is_season_tick and 1 or 0) + return get_next_trigger_tick(SEASON_TICK_TRIGGERS, next_season_tick, is_season_tick) +end + +local function clamp_timeskip(timeskip) + if timeskip <= 0 then return 0 end + local next_important_season_tick = math.min(get_next_timed_event_season_tick(), get_next_trigger_season_tick()) + return math.min(timeskip, + get_next_trigger_year_tick()-df.global.cur_year_tick-1, + df.global.cur_year_tick - (df.global.cur_year_tick % 10 + 1) + (next_important_season_tick - df.global.cur_season_tick)*10) +end + +local function has_caste_flag(unit, flag) + if unit.curse.rem_tags1[flag] then return false end + if unit.curse.add_tags1[flag] then return true end + return dfhack.units.casteFlagSet(unit.race, unit.caste, df.caste_raw_flags[flag]) +end + +local function adjust_units(timeskip) + for _, unit in ipairs(df.global.world.units.active) do + if not dfhack.units.isActive(unit) then goto continue end + if unit.sex == df.pronoun_type.she then + if unit.pregnancy_timer > 0 then + unit.pregnancy_timer = math.max(1, unit.pregnancy_timer - timeskip) end - ticks_left = rate - 1 - - eventNow = false - seasonNow = false - timestream = 0 - if df.global.cur_season_tick < SEASON_LEN then - month = 1 - elseif df.global.cur_season_tick < SEASON_LEN * 2 then - month = 2 + end + dfhack.units.subtractGroupActionTimers(unit, timeskip, df.unit_action_type_group.All) + local c2 = unit.counters2 + if not has_caste_flag(unit, 'NO_EAT') then + c2.hunger_timer = c2.hunger_timer + timeskip + end + if not has_caste_flag(unit, 'NO_DRINK') then + c2.thirst_timer = c2.thirst_timer + timeskip + end + local job = unit.job.current_job + if not has_caste_flag(unit, 'NO_SLEEP') then + if job and job.job_type == df.job_type.Sleep then + c2.sleepiness_timer = math.max(0, c2.sleepiness_timer - timeskip * 19) else - month = 3 + c2.sleepiness_timer = c2.sleepiness_timer + timeskip end - if loaded ~= true then - dfhack.timeout(1,"frames",function() update() end) - loaded = true - end - else - print('timestream: Time set to normal speed.') - loaded = false - df.global.debug_turbospeed = false end - if debug_mode then - print("timestream: Debug mode is on.") + if job and job.job_type == df.job_type.Rest then + c2.sleepiness_timer = math.max(0, c2.sleepiness_timer - timeskip * 200) end + ::continue:: end end -function update() - loaded = false - prev_tick = df.global.cur_year_tick - local current_frame = df.global.world.frame_counter - if (rate ~= 1 or simulating_desired_fps) and dfhack.world.isFortressMode() then - if last_frame + 1 == current_frame then - timestream = 0 - - --[[if rate < 1 then - if df.global.cur_year_tick - math.floor(df.global.cur_year_tick/10)*10 == 5 then - if counter > 1 then - counter = counter - 1 - timestream = -1 - else - counter = counter + math.floor(ticks_left) - end - end - else - --]] - --counter = counter + rate-1 - counter = counter + math.floor(ticks_left) - while counter >= 10 do - counter = counter - 10 - timestream = timestream + 1 - end - --end - eventFound = false - for i=0,#df.global.timed_events-1,1 do - event=df.global.timed_events[i] - if event.season == df.global.cur_season and event.season_ticks <= df.global.cur_season_tick then - if eventNow == false then - --df.global.cur_season_tick=event.season_ticks - event.season_ticks = df.global.cur_season_tick - eventNow = true - end - eventFound = true - end - end - if eventFound == false then eventNow = false end - - if df.global.cur_season_tick >= SEASON_LEN - 1 and df.global.cur_season_tick < SEASON_LEN * 2 - 1 and month == 1 then - seasonNow = true - month = 2 - if df.global.cur_season_tick > SEASON_LEN - 1 then - df.global.cur_season_tick = SEASON_LEN - end - elseif df.global.cur_season_tick >= SEASON_LEN * 2 - 1 and df.global.cur_season_tick < SEASON_LEN * 3 - 1 and month == 2 then - seasonNow = true - month = 3 - if df.global.cur_season_tick > SEASON_LEN * 2 - 1 then - df.global.cur_season_tick = SEASON_LEN * 2 - end - elseif df.global.cur_season_tick >= SEASON_LEN * 3 - 1 then - seasonNow = true - month = 1 - if df.global.cur_season_tick > SEASON_LEN * 3 then - df.global.cur_season_tick = SEASON_LEN * 3 - 1 - end - else - seasonNow = false - end - - if df.global.cur_year > 0 then - if timestream ~= 0 then - if df.global.cur_season_tick < 0 then - df.global.cur_season_tick = df.global.cur_season_tick + SEASON_LEN * 3 - df.global.cur_season = df.global.cur_season-1 - eventNow = true - end - if df.global.cur_season < 0 then - df.global.cur_season = df.global.cur_season + 4 - df.global.cur_year_tick = df.global.cur_year_tick + YEAR_LEN - df.global.cur_year = df.global.cur_year - 1 - eventNow = true - end - if (eventNow == false and seasonNow == false) or timestream < 0 then - if timestream > 0 then - df.global.cur_season_tick=df.global.cur_season_tick + timestream - remainder = df.global.cur_year_tick - math.floor(df.global.cur_year_tick/10)*10 - df.global.cur_year_tick=(df.global.cur_season_tick*10)+((df.global.cur_season)*(SEASON_LEN * 3 * 10)) + remainder - elseif timestream < 0 then - df.global.cur_season_tick=df.global.cur_season_tick - df.global.cur_year_tick=(df.global.cur_season_tick*10)+((df.global.cur_season)*(SEASON_LEN * 3 * 10)) - end - end - end - end +local function adjust_armies(timeskip) + -- TODO +end - if simulating_desired_fps then - if saved_game_frame ~= -1 and saved_game_frame + 2 == current_frame then - if debug_mode then - print("Game was saved two ticks ago (saved_game_frame(".. saved_game_frame .. ") + 2 == current_frame(" .. current_frame ..")") - end - reset_frame_count() - saved_game = -1 - end - local counted_frames = current_frame - prev_frames - if counted_frames >= desired_fps then - current_fps = 1000 * desired_fps / (df.global.enabler.clock - prev_time) - if current_fps < desired_fps then - rate = desired_fps/current_fps - else - rate = 1 -- We don't want to slow down the game - end - reset_frame_count() - if current_fps < MINIMAL_FPS then - current_fps = MINIMAL_FPS - end - local missing_frames = desired_fps - current_fps - speedy_frame_delta = desired_fps/missing_frames - if missing_frames == 0 then - speedy_frame_delta = desired_fps - end - if debug_mode then - print("prev_frames: " .. prev_frames .. ", current_fps: ".. current_fps.. ", rate: " .. rate) - end - end - - if simulating_units == 2 then - if frames_until_speeding <= 0 then - frames_until_speeding = frames_until_speeding + speedy_frame_delta - if debug_mode then - print("speedy_frame_delta: "..speedy_frame_delta..", speedy_frame: "..counted_frames.."/"..desired_fps) - end - df.global.debug_turbospeed = true - last_frame_sped_up = current_frame - else - frames_until_speeding = frames_until_speeding - 1 - if df.global.debug_turbospeed then - df.global.debug_turbospeed = false - end - end - elseif simulating_units == 1 then - local dec = math.floor(ticks_left) - 1 -- A value used to determine how much more to decrement from the timers per tick. - for k1, unit in pairs(df.global.world.units.active) do - if dfhack.units.isActive(unit) then - if unit.sex == 0 then -- Check to see if unit is female. - local ptimer = unit.pregnancy_timer - if ptimer > 0 then - ptimer = ptimer - dec - if ptimer < 1 then - ptimer = 1 - end - unit.pregnancy_timer = ptimer - end - end - for k2, action in pairs(unit.actions) do - local action_type = action.type - if action_type == df.unit_action_type.Move then - local d = action.data.move.timer - dec - if d < 1 then - d = 1 - end - action.data.move.timer = d - - elseif action_type == df.unit_action_type.Attack then - local d = action.data.attack.timer1 - dec - if d <= 1 then - d = 1 - action.data.attack.timer2 = 1 -- I don't know why, but if I don't add this line then there's a bug where people just dogpile each other and don't fight. - end - d = action.data.attack.timer2 - dec - if d < 1 then - d = 1 - end - action.data.attack.timer2 = d - elseif action_type == df.unit_action_type.HoldTerrain then - local d = action.data.holdterrain.timer - dec - if d < 1 then - d = 1 - end - action.data.holdterrain.timer = d - elseif action_type == df.unit_action_type.Climb then - local d = action.data.climb.timer - dec - if d < 1 then - d = 1 - end - action.data.climb.timer = d - elseif action_type == df.unit_action_type.Job then - local d = action.data.job.timer - dec - if d < 1 then - d = 1 - end - action.data.job.timer = d - elseif action_type == df.unit_action_type.Talk then - local d = action.data.talk.timer - dec - if d < 1 then - d = 1 - end - action.data.talk.timer = d - elseif action_type == df.unit_action_type.Unsteady then - local d = action.data.unsteady.timer - dec - if d < 1 then - d = 1 - end - action.data.unsteady.timer = d - elseif action_type == df.unit_action_type.StandUp then - local d = action.data.standup.timer - dec - if d < 1 then - d = 1 - end - action.data.standup.timer = d - elseif action_type == df.unit_action_type.LieDown then - local d = action.data.liedown.timer - dec - if d < 1 then - d = 1 - end - action.data.liedown.timer = d - elseif action_type == df.unit_action_type.JobRecover then - local d = action.data.jobrecover.timer - dec - if d < 1 then - d = 1 - end - action.data.job2.timer = d - elseif action_type == df.unit_action_type.PushObject then - local d = action.data.pushobject.timer - dec - if d < 1 then - d = 1 - end - action.data.pushobject.timer = d - elseif action_type == df.unit_action_type.SuckBlood then - local d = action.data.suckblood.timer - dec - if d < 1 then - d = 1 - end - action.data.suckblood.timer = d - elseif action_type == df.unit_action_type.Mount then - local d = action.data.mount.timer - dec - if d < 1 then - d = 1 - end - action.data.mount.timer = d - elseif action_type == df.unit_action_type.Dismount then - local d = action.data.dismount.timer - dec - if d < 1 then - d = 1 - end - action.data.dismount.timer = d - elseif action_type == df.unit_action_type.HoldItem then - local d = action.data.holditem.timer - dec - if d < 1 then - d = 1 - end - action.data.holditem.timer = d - end - end - end - end - end - end - ticks_left = ticks_left - math.floor(ticks_left) + rate - last_frame = current_frame - else - if debug_mode then - print("last_frame("..last_frame..") + 1 != current_frame("..current_frame..")") +local function adjust_caravans(season_timeskip) + for i, caravan in ipairs(df.global.plotinfo.caravans) do + if caravan.trade_state == df.caravan_state.T_trade_state.Approaching or + caravan.trade_state == df.caravan_state.T_trade_state.AtDepot + then + local was_before_message_threshold = caravan.time_remaining >= 501 + caravan.time_remaining = caravan.time_remaining - season_timeskip + if was_before_message_threshold and caravan.time_remaining <= 500 then + caravan.time_remaining = 501 + need_season_tick = true end - reset_frame_count() end - if ui_main.autosave_request then - if debug_mode then - print("Save state detected") - end - saved_game_frame = current_frame + if caravan.time_remaining <= 0 then + caravan.time_remaining = 0 + dfhack.run_script('caravan', 'leave', tostring(i)) end - if not loaded then - loaded = true - dfhack.timeout(1,"frames",function() update() end) + end +end + +local noble_cooldowns = {'manager_cooldown', 'bookkeeper_cooldown'} +local function adjust_nobles(season_timeskip) + for _, field in ipairs(noble_cooldowns) do + df.global.plotinfo.nobles[field] = df.global.plotinfo.nobles[field] - season_timeskip + if df.global.plotinfo.nobles[field] < 0 then + df.global.plotinfo.nobles[field] = 0 end end end -function reset_frame_count() - if debug_mode then - print("Resetting frame count") +local function on_tick() + local real_fps = math.max(1, df.global.enabler.calculated_fps) + if real_fps >= state.settings.fps then + timeskip_deficit, calendar_timeskip_deficit = 0.0, 0.0 + return + end + + local desired_timeskip = get_desired_timeskip(real_fps, state.settings.fps) + timeskip_deficit + local timeskip = math.min(math.floor(clamp_timeskip(desired_timeskip)), state.settings.max_frame_skip) + timeskip_deficit = math.min(desired_timeskip - timeskip, state.settings.max_frame_skip) + if timeskip <= 0 then return end + + local desired_calendar_timeskip = (timeskip * state.settings.calendar_rate) + calendar_timeskip_deficit + local calendar_timeskip = math.max(1, math.floor(desired_calendar_timeskip)) + if need_season_tick then + local old_ones = df.global.cur_year_tick % 10 + local new_ones = (df.global.cur_year_tick + calendar_timeskip) % 10 + if new_ones == 9 then + need_season_tick = false + elseif old_ones + calendar_timeskip >= 10 then + calendar_timeskip = 9 - old_ones + need_season_tick = false + end + end + calendar_timeskip_deficit = math.max(0, desired_calendar_timeskip - calendar_timeskip) + + local new_cur_year_tick = df.global.cur_year_tick + calendar_timeskip + local season_timeskip = new_cur_year_tick//10 - df.global.cur_year_tick//10 + + df.global.cur_season_tick = df.global.cur_season_tick + season_timeskip + df.global.cur_year_tick = new_cur_year_tick + + adjust_units(timeskip) + adjust_armies(timeskip) + adjust_caravans(season_timeskip) + adjust_nobles(season_timeskip) +end + +------------------------------------ +-- hook management + +local function do_enable() + timeskip_deficit, calendar_timeskip_deficit = 0.0, 0.0 + need_season_tick = false + state.enabled = true + repeatutil.scheduleEvery(GLOBAL_KEY, 1, 'ticks', on_tick) +end + +local function do_disable() + state.enabled = false + repeatutil.cancel(GLOBAL_KEY) +end + +dfhack.onStateChange[GLOBAL_KEY] = function(sc) + if sc == SC_MAP_UNLOADED then + do_disable() + return + end + if sc ~= SC_MAP_LOADED or not dfhack.world.isFortressMode() then + return + end + state = get_default_state() + utils.assign(state, dfhack.persistent.getSiteData(GLOBAL_KEY, state)) + if state.enabled then + do_enable() end - prev_time = df.global.enabler.clock - prev_frames = df.global.world.frame_counter end ---Initial call +------------------------------------ +-- interface -if dfhack.isMapLoaded() then - dfhack.onStateChange.loadTimestream(SC_MAP_LOADED) +if dfhack_flags.module then + return end + +if not dfhack.world.isFortressMode() or not dfhack.isMapLoaded() then + qerror('needs a loaded fortress map to work') +end + +local function print_status() + print(GLOBAL_KEY .. ' is ' .. (state.enabled and 'enabled' or 'not enabled')) + print() + print('settings:') + for _,v in ipairs(SETTINGS) do + print((' %15s: %s'):format(v.name, state.settings[v.internal_name or v.name])) + end +end + +local function do_set(setting_name, arg) + if not setting_name or not arg then + qerror('must specify setting and value') + end + local _, setting = utils.linear_index(SETTINGS, setting_name, 'name') + if not setting then + qerror('setting not found: ' .. setting_name) + end + state.settings[setting.internal_name or setting.name] = setting.validate(arg) + print(('set %s to %s'):format(setting_name, state.settings[setting.internal_name or setting.name])) +end + +local function do_reset() + state = get_default_state() +end + +local args = {...} +local command = table.remove(args, 1) + +if dfhack_flags and dfhack_flags.enable then + if dfhack_flags.enable_state then do_enable() + else do_disable() + end +elseif command == 'set' then + do_set(args[1], args[2]) +elseif command == 'reset' then + do_reset() +elseif not command or command == 'status' then + print_status() + return +else + print(dfhack.script_help()) + return +end + +persist_state()