Skip to content

The beat's dungeon rules measure time in beats, not seconds, so they run slow below four frames a second #1613

Closed

Description

@sdubois777

The finding

The dungeon rules that run on the quarter-second beat measure time in beats
rather than in seconds, so below four frames a second they all run slow.

ACataclysmDungeonGameMode::Tick accumulates the real frame delta and fires the
beat whenever it reaches SecondsBetweenWaveChecks (0.25s), then resets the
accumulator to zero rather than subtracting the threshold:

SinceWaveCheckSeconds += DeltaSeconds;
if (SinceWaveCheckSeconds < SecondsBetweenWaveChecks)
{
    return;
}
SinceWaveCheckSeconds = 0.0f;

So one frame carrying a delta of 1.0s produces one beat, not four. Every
rule that then treats a beat as a quarter of a second under-counts by whatever
the frame took past 0.25s.

Two rules do that today, both in
game/Source/Cataclysm/Dungeon/CataclysmDungeonGameMode.cpp:

Rule What it computes per beat At 2 frames a second
Forced March (StepForcedMarch) Maximum * Share / 100 * SecondsBetweenWaveChecks — 1% of maximum health a second per stack deals 0.5% a second, half its stated rate
Death's Embrace (StepDeathsEmbrace) DeathsEmbraceSecondsOnFloor += SecondsBetweenWaveChecks — a stack every 10 seconds a stack every 20 real seconds

Forced March's stack count is not affected, because
ForcedMarchStacksAfter reads SecondsSinceMoved() off the world clock. It is
only the per-beat damage figure that shrinks. Death's Embrace is affected in full,
because its whole clock is the accumulator.

Why it is not obviously a bug

It fails in the player's favour and it is consistent between the two rules,
which is why it was written this way rather than by oversight: Death's Embrace
followed the convention Forced March had already set, deliberately, so the two
rules agree with each other.

It also does not bite at playable frame rates. The beat is 0.25s and the game
runs far above 4 fps; the gap only opens on a hitching frame, a load spike, or a
breakpoint, and there it costs a fraction of one beat.

Why it is worth recording anyway

  1. It makes a design figure untrue under load. docs/DECISIONS.md says
    Death's Embrace reaches its cap in fifty seconds. That is fifty seconds of
    beats, not fifty seconds.
  2. It is a trap for the next rule on the beat. Anything that accumulates
    SecondsBetweenWaveChecks inherits this silently, and the next author has no
    reason to look.
  3. It is invisible in tests. The automation tests drive the beat by calling
    Tick(SecondsBetweenWaveChecks) exactly, so beats and seconds agree there by
    construction and no test can see the difference.

The fix, and why there is only one candidate

Do not touch the zeroing. It is deliberate and the code says so, at
CataclysmDungeonGameMode.cpp:521:

SET BACK TO ZERO RATHER THAN HAVING THE INTERVAL TAKEN OFF IT. A frame long
enough to cover several intervals should bring one wave in, not four.

That is the right answer for the wave arrival, which is what the beat exists
for, and it is the reason the obvious fix — subtract the threshold and loop
until the debt is paid — is wrong here. It would bring four waves in on a long
frame, which is exactly what that comment refuses.

So the fix belongs in the rules, not in the beat: pass the real elapsed time
into StepFloorRulesThatChange and have each rule scale by it rather than by
SecondsBetweenWaveChecks.
The wave cadence is untouched, the two rules become
frame-rate independent, and the constant stops doing duty as a measure of time.

It is a handful of lines, but it changes Forced March's damage figure, so it
wants its own change with its own test rather than riding along inside a dungeon
modifier slice.

How it was found

By writing Death's Embrace's clock and asking what one beat is worth, then
reading Tick to check rather than assuming. The reset to zero is the line that
makes it true, and it is easy to read as a subtraction.

Activity

  1. added
    bugSomething isn't working
    unrealUnreal Engine project work
    on Sep 12, 2026
  2. sdubois777 commented on Sep 12, 2026

    @sdubois777
    OwnerAuthor

    This is not closed by the branch it was routed to, and the reason is a mix-up

    of two separate findings

    Triage routed this to the branch feat/healing-received-reduction on the reading
    that its commit 3759e32 "already gives every beat rule one applier". That
    commit does do that, and it is a different fault with a different fix.
    Both came
    out of the same piece of work, which is how the two got joined.

    Finding What it is Fixed?
    Every rule on the beat assembled its own set of dungeon stat modifiers, and ApplyToCharacter replaces the whole set — so a second such rule zeroed the first's field four times a second a real fault, introduced the moment a second beat-driven rule existed yes, commit 3759e32
    The rules on the beat measure time in BEATS rather than seconds, so below four frames a second they run slow this issue no

    Nothing in 3759e32 touches how the beat measures time. One applier was about
    which fields get written; this issue is about how much time one beat is taken
    to be worth.

    What that branch does change about this issue

    It widens it from one rule to two, and documents it. Once that branch merges,
    development carries two rules with this property rather than one:

    • Forced March, which computes Maximum * Share / 100 * SecondsBetweenWaveChecks
      per beat;
    • Death's Embrace, which accumulates SecondsBetweenWaveChecks into a
      floor clock.

    The branch also writes the caveat into
    game/Source/Cataclysm/Dungeon/CataclysmDungeonGameMode.h, beside the clock
    field, and into that change's docs/DECISIONS.md entry — so the design figure
    "fifty seconds to the cap" is recorded as fifty seconds of beats rather than read
    as a promise about wall-clock time.

    So this issue should stay open, and its rule list is right as written. After
    that merge, both rules it names are on development.

    The fix still belongs in the rules, not in the beat

    Repeating it here because it is the part most likely to be got wrong by whoever
    picks this up. The obvious fix — subtract the interval instead of zeroing the
    accumulator, and loop until the debt is paid — is wrong, and the code says so
    at CataclysmDungeonGameMode.cpp:

    SET BACK TO ZERO RATHER THAN HAVING THE INTERVAL TAKEN OFF IT. A frame long
    enough to cover several intervals should bring one wave in, not four.

    That is the right behaviour for the wave arrival, which is what the beat exists
    for. Changing it would bring four waves in on a long frame. The wave arrival is
    also under a standing instruction not to be changed without asking first.

    The narrower fix: pass the real elapsed time into
    StepFloorRulesThatChange and have each rule scale by it rather than by the
    constant.
    The wave cadence is untouched, both rules become frame-rate
    independent, and the constant stops doing duty as a measure of time.

    It changes Forced March's damage figure, so it wants its own change with its own
    test rather than riding inside a dungeon-modifier slice. It is a handful of lines.

  3. sdubois777 commented on Oct 28, 2026

    @sdubois777
    OwnerAuthor

    Fixed in #2578 (merged as 4e8d2b7). The floor rules now count the time each beat covered, clamped to 1.0 s, instead of a fixed 0.25 s. Window w77 at 3f6b0af: the three new tests 3/3; whole Unreal suite 4347 performed, 4347 succeeded, 0 failed; Python JUnit tests=7030, failures=0; ruff clean; proofs P1 (BeatSeconds back to 0.25 s) and P2 (1 s limit removed) both PROVED.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingunrealUnreal Engine project work

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions