Skip to content

Latest commit

 

History

History
321 lines (265 loc) · 17.4 KB

File metadata and controls

321 lines (265 loc) · 17.4 KB

Time

One quantum, one ladder, one place where real seconds exist.

Everything the world does — a march, a harvest, a lord's errand, the sun — is measured in ticks. The tick is an integer, the clock is a single uint64, and every unit above it is a whole number of the one below.

1 tick                                the world's quantum
64 ticks     = 1 real second   (2^6)  the fixed simulation step
8192 ticks   = 1 game day      (2^13) = 128 real seconds
32 days      = 1 season        (2^5)
128 days     = 1 year          (2^7)  = 2^20 ticks exactly

core/time.h owns all of it. macro/seasons.h owns the top two rungs.

Why an integer

The world used to run on four unrelated rhythms: the frame's dt, a float minute accumulator, a 0.5-second AI cadence, and a daily queue budgeted per frame. Four rhythms is four places to drift, a save that could not state the time to better than a fraction of a minute, and a simulation whose outcome depended on the frame rate of the machine that ran it. All four are gone — each is now a whole number of ticks on the one ladder.

Now the frame earns whole steps and nothing else moves the world. The clock cannot drift because it never accumulates: it is a counter, and the calendar is read off it.

The minute is not stored

A day holds 1440 minutes and 1440 is not a power of two. It does not need to be, because the minute is derived, and on an 8192-tick day the derivation is exact integer arithmetic:

1440 / 8192 = 45 / 256    =>  minute = (t * 45) >> 8      // 0..1439
  24 / 8192 =  3 / 1024   =>  hour   = (t *  3) >> 10     // 0..23

Since floor(floor(a/b)/c) == floor(a/(b*c)), the hour derived directly and the hour implied by minute / 60 are the same number for every tick that exists — two readings of one instant cannot disagree. time_ladder_test walks all 8192 ticks of a day and proves it, along with: the display is gap-free (no minute is ever skipped) and onto (every one of the 1440 is shown), and naming an hh:mm round-trips back to the first tick that reads it.

The price, stated plainly: an hour is 341.33 ticks, so hour boundaries fall between ticks and a minute lasts 5 ticks or 6. That is 16 ms of real time, and it never accumulates.

Advancing by a duration aims at a target, not at a length. "Six minutes from here" depends on where in the current minute you stand, so ticks_to_advance_minutes(from, n) computes the first tick that reads n minutes later. Ask for a rounded length instead and a clock standing a quarter of the way into 08:00 can advance five minutes when six were paid for.

The frame IS the world's clock

THE TICK IS PRIMARY. The world's time is the number of ticks that have RUN — a day is 8192 of them, a year is 2^20, and "128 real seconds" is only what that comes to on a machine keeping up. Ticks are born from the loop turning, never from the clock moving.

So one turn of the loop is one tick and one frame — the frame rate and the world's tick rate are the same number. A low frame rate is therefore not a choppier picture of a world moving at its usual pace, as it was under the old variable-dt loop where a bigger dt covered the gap; it is the world itself living slower, because fewer ticks happened.

The wall clock is consulted for exactly one purpose: if a turn finished quicker than a tick is worth, wait for the remainder, so the world can never run FASTER than nominal. It is never consulted to decide that ticks are owed. There is no accumulator and no debt.

loop turn:  poll input
            ONE fixed step of the world
            draw once
            if the turn was quicker than a tick, wait out the difference

The wait lands ON the beat, not near it. No OS sleep is exact, and one that returns EARLY would start the next tick before its time — the world running faster than nominal, which this rule forbids. So the loop sleeps almost all of the remainder and spins out the last ~0.2 ms. The spin is a real cost (a busy loop burns a core), which is why it is a sliver and not the whole millisecond SDL_Delay's rounding would force: about 1 % of one core. If a sleep overshoots anyway, the turn is simply late — being late is allowed, being early is not.

Everything follows from that without a single special case:

  • a slow turn is just a slow turn — one tick, later. Nothing is lost.
  • a machine that cannot sustain the rate runs at a lower frame rate and a slower world. It does not make the world live less.
  • a suspended process — a closed laptop, an hour on a breakpoint — ran no turns and advanced no ticks. On resume it carries on. There is no gap to detect and nothing to catch up, because real time was never what produced ticks in the first place.

The present mode is a TIME decision, not a graphics one. The world advances one tick per turn, so anything that gates how fast the loop may turn also gates how fast the world lives. VK_PRESENT_MODE_FIFO_KHR blocks until the display's refresh, which on a 60 Hz screen would hold the loop to 60 turns a second — the world running 6 % slow because of the monitor, not because the game was busy. That is the real world reaching into the simulation through a side door.

So the swapchain prefers MAILBOX: it returns immediately and shows the newest finished frame, leaving the pace to the loop's own wait — exactly the tick rate on any display. FIFO remains the fallback because it is the only mode Vulkan guarantees, and where it is all the surface offers, the world's rate follows the display. The debug HUD says which one is in force.

Note the difference from a slow machine, which is legitimate: if a TICK takes longer than its period the frame rate falls and the world slows, and that is the model working. What must not happen is the world slowing while the game had time to spare.

The developer simspeed multiplier runs several ticks per turn and carries its fractional part, so 1.0 is exact and only a deliberate fast-forward rounds.

THE tick promotion door

One turn is one tick unless something buys more, and there are exactly two buyers: the fast-forward multiplier (toolbar >>, console simspeed) and the rest aim (Z, console rest). Both go through one door, promote_turn_ticks (app/main.cpp), which is the only producer of the tick count frame() is given — the loop keeps no promotion state of its own. Rest REPLACES the multiplier rather than multiplying with it: the rest is aimed at a full bar, not at a pace, and its own 128 ticks a turn is the whole of what it is allowed to buy.

Promotion belongs to the live map and nowhere else — fast_forward_allowed is Playing && worldLoaded && !subworld.active(). The subworld is real time (a swing, a fall and a cast are all quoted in it); a menu or an unloaded world has no clock worth promoting. A scene that refuses drops both aims to 1× rather than suspending them (owner: «очень жёстко»), so the world, the toolbar button and the console readout cannot disagree, and climbing back onto the map never resumes a speed armed in a scene the player has left.

The permission is derived every turn, never stored — the same law as THE pause (ARCHITECTURE.md, pause_reasons), and for the same reason. The multiplier used to be gated once, on the toolbar BUTTON, by !subworld.active(); a gate on the button guards the ACT, never the STATE, so an armed 4× survived a dive into the subworld, Esc to the menu and starting a whole new game, because after the click nothing ever asked again (owner, in play 2026-09-09). A derived gate cannot leak, because there is nothing to forget — and the exits are precisely the paths that would have been forgotten one at a time: a load that returns early, the menu reached from a modal, a subworld entered by falling down a hole.

App::simSpeed is therefore an INTENT, not a permission. Nothing outside that door may reset it — the load path, the menu and the subworld entrance deliberately do not know the field exists.

Underground the day stretches

In the subworld, kSubworldTickDivisor = 64 simulation steps buy one tick of world time: a game hour costs ~341 real seconds instead of 5. The simulation does not slow down — your body still moves at the full step rate, it is the day that stretches. The leftover steps are kept as a whole number in the tick runtime, so pausing, saving or walking out mid-divisor loses nothing.

The divisor is also the visual-pace rung (owner verdict, 2026-09-03): the subworld walking speed DERIVES from it (movement_cost.h kSubworldWalkTilesPerSecond — a formula, not a number) under the parity anchor «crossing a cell's scene below costs the same game minutes as crossing that cell above». ÷16 made every body race at 96 tiles/s; ÷64 reads as 24 — tested in play and accepted. Retuning the feel = moving this one rung (128 → 12 t/s, 256 → 6 t/s); the trade of bigger rungs is that a dungeon crawl spends almost no world time.

Because the whole macro world reads the same clock, the lords outside slow down with it. Nobody crosses the continent while you clear one room.

That is literal, not a figure of speech: macro NPC AI is quoted in world ticks (kAiTicks = 32), so it wakes once per half-hour of game time wherever the player is standing. Measured on the seed-locked subworld_time smoke, a thousand simulation steps underground: 24186 NPC thinks before, 852 after — one sweep instead of thirty-one. Today that saves a fraction of a millisecond, because a fresh world holds ~850 macro NPCs. It is written for the world that holds thousands of parties, where it is the difference between a subworld that runs and one that does not.

Every rate is a game-time rate

Real seconds appear in exactly one constant, kTicksPerRealSecond. Everything else is denominated in game time:

Rate Unit Where
macro march 8 cells per game hour macro/movement_cost.h
bar recovery ⅛ of the bar per game hour, in BOTH worlds macro/player_recovery.cpp
travel stamina 1 SP × terrain weight per cell, not per hour macro/movement_cost.h
macro NPC thinking every 32 ticks macro/npc_ai.h
player time-in-cell every 32 ticks app/main.cpp
subworld walk 96 tiles per real second app/main.cpp
spell cooldown authored seconds → steps content/spells/spell_book.cpp
melee cooldown authored seconds → steps ecs/systems.cpp
sustained mana drain per step content/spells/spell_book.cpp

The last four rows are the subworld's own denominator, and all four are now honest. The three cooldown/drain rows are quoted in STEPS, below. The walk row — once the A8 debt ("two walking speeds diverging ×4") — is CLOSED BY DERIVATION (2026-08-24): the march was recalibrated to kMacroWalkCellsPerHour = 8 (owner: «степени двойки»; a brisk paved pace at the world's own scale — the old 32 was a courier's gallop miscalled walking), with kStaminaPerCell = 2 as the pure level-1 base (retuned 2026-09-09 — the 2026-08-24 value of 1 put the anchor 1.75× out, see below) — every modifier (travel skill, overload, terrain √) multiplies ON TOP, a fresh walker drops after ~6.9 game hours of ROAD and ~4.9 of open country, and a night's rest (⅛ of the bar per hour) buys the whole bar back.

The anchor is now a compile-time gate (kRoadHoursPerFreshBar), and the reason is a time-ladder lesson worth keeping: what is balanced is GAME HOURS, what is priced is the CELL, and the hours are the PRODUCT of the per-cell price and the pace. A product has no name to fail under. The 2026-08-24 pass moved both factors (pace 32 → 8, price 7/16 → 1); the product fell 14 → 8 SP per game hour and every hour quoted in the comments grew 1.75× without a single test going red — every test derived its expectation from the same constants it was guarding. Design numbers belong in literals, asserted; derived numbers belong in code, never in prose. With the map no longer galloping, kSubworldWalkTilesPerSecond = 96 carries its derivation beside it (app/main.cpp): 8 cells/game hour = 8000 tiles per subworld game hour, and an hour down there lasts 85⅓ real seconds, so the honest rate is 8000 / 85.33 = 93.75 tiles/s — rounded up 2.4 % to 96, a named fantasy allowance, not a tuned number. The 4× disagreement was the map's, not the ground floor's.

The STEP, not the second. The three cooldown rows above used to be floats decremented by a dt of real seconds, while this page claimed exactly ONE exception — so the law said one thing and the code did another in three places, which is how an audit ends up reporting an exploit that does not exist (19.26 claimed magic was 16× more productive underground; it is not, because nothing regenerates underground at all). They are integers now, counted in simulation STEPS (core/time.h kStepsPerSecond), which changes nothing about how a fight feels and everything about what it is: no wall clock, no float, deterministic, and the same length of FIGHT whether the clock above races or crawls.

Taking "everything in ticks" literally was measured and rejected: a world tick underground is 0.25 real seconds, so a one-second cooldown would become sixteen real seconds and combat would stop being combat.

The point of the rest of the table: the length of a day is a matter of feel, not of balance. Lengthen it and the world simply takes longer to live through — the marching economy does not move a point, because the march was never quoted in real seconds.

Simulated, or merely drawn

The step is for things the world does. The frame is for things the player sees. tick_macro_npc_visuals — the easing of a macro NPC's drawn position toward the cell its AI put it in — is interpolation for the eye, and it runs once per frame, because that is when there is something to look at.

But it is drawn tighter than "once per frame": nothing that writes game state is ever handed the real duration of a turn. Every dt inside a step is the compile-time constant kStepSeconds, and the easing — pure interpolation for the eye, but it WRITES TO THE ECS — is handed the ticks the world actually lived this turn, stats.timeTick.ticksAdvanced × kStepSeconds. So a slow machine cannot smooth it at a different pace than the world moved it.

Once per frame is not once per tick, and that gap was a shipped bug (2026-09-09). The easing used to be handed one tick's worth flat — the same number only while a turn IS one tick. A turn is several whenever time is promoted (>>, the rest aim), and then a squad's Position ran N times further than its VisualPos was allowed to follow: the gap grew every frame until it passed the snap bound and the squad teleported. On the road at rest speed that was exactly three cells a frame — 128 / kAiTicks = 4 thinks × 0.75 cells — and the owner saw it as squads jumping.

The denominator has to be TICKS and not frames for a reason worth stating, since the numbers happen to coincide at 1×: MacroNpcRuntime::visualSpeed is quoted in cells per kAiPeriodSeconds, which IS kAiTicks of world clock. Two quantities counted in different units only agree by accident. It also carries the subworld for free — down there tick_world_subworld_steps advances the macro clock once per kSubworldTickDivisor steps, so macro squads think rarely and smooth rarely, in the same crawling ratio.

One consequence, deliberate: a paused world lives no ticks, so a body caught mid-glide by the pause now holds its intermediate position instead of easing into its cell over a stopped world.

Real time is read in exactly three places in the whole game, and not one of them can change what the world does:

where what for touches the world
the loop's wait whether to pause before the next turn no
macro.record(… SDL_GetTicks()) shader animation clock (water shimmer) no, drawing only
new-game seed when none is given which world you get, not how it runs no

There is no fourth row, and that is the point. It used to have one — the measured length of a turn, handed to the macro NPC visual easing — and it was removed for being the sort of thing that gets mistaken for simulation later.

Consequences worth knowing

  • The save states the instant exactly, in one uint64 (since kSaveVersion 18; today the save is v42) — a tick number, not a duration.
  • A month of resting and a single frame cost the same three lines. Minutes, hours and days are linear in the tick, so what an advance covered is a subtraction however large the jump — world_tick.cpp no longer walks the clock forward a minute at a time.
  • 0 HP means dead, reliably. The coarse pre-tick frame used to run recovery and the death check in the same call, so a player at exactly zero could round his way back to 1 before anything noticed. On a fixed step the rule bites the same way every time.

Tests

  • time_ladder_test — the ladder itself: derivation exact over a whole day, gap-free, onto, invertible, whole-day advances at zero residue, and the year at exactly 2^20 ticks.
  • world_tick_parity_testno drift: ten thousand one-tick advances and one ten-thousand-tick advance land on the same instant, report the same elapsed time and queue the same daily work. Plus the subworld divisor keeping its remainder across a split.
  • save_roundtrip_test — the clock survives a save exactly, mid-minute.
  • macro_travel_parity_test — the travel economy in cells per game hour.