Repository navigation
The beat's dungeon rules measure time in beats, not seconds, so they run slow below four frames a second #1613
Description
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
- It makes a design figure untrue under load.
docs/DECISIONS.mdsays
Death's Embrace reaches its cap in fifty seconds. That is fifty seconds of
beats, not fifty seconds. - It is a trap for the next rule on the beat. Anything that accumulates
SecondsBetweenWaveChecksinherits this silently, and the next author has no
reason to look. - 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
sdubois777 commentedon Sep 12, 2026
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-reductionon the readingthat its commit
3759e32"already gives every beat rule one applier". Thatcommit 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.
ApplyToCharacterreplaces the whole set — so a second such rule zeroed the first's field four times a second3759e32Nothing in
3759e32touches how the beat measures time. One applier was aboutwhich 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,
developmentcarries two rules with this property rather than one:Maximum * Share / 100 * SecondsBetweenWaveChecksper beat;
SecondsBetweenWaveChecksinto afloor clock.
The branch also writes the caveat into
game/Source/Cataclysm/Dungeon/CataclysmDungeonGameMode.h, beside the clockfield, and into that change's
docs/DECISIONS.mdentry — 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: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
StepFloorRulesThatChangeand have each rule scale by it rather than by theconstant. 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.
Reduce the health that healing restores, and build Death's Embrace (#41…
The floor rules count the time each beat covered (#1613): window w77'…
The floor rules count the time each beat covered, up to one second (#…
sdubois777 commentedon Oct 28, 2026
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.