Skip to content

Commit 597a0c4

Browse files
committed
Respell the subframe count domain as Int64 for 32-bit platforms
Alternative to the saturating fix in #88, per review feedback: use Int64 where the value genuinely needs 64 bits, rather than clamping. At `.max100Days` a subframe count exceeds Int32.max for every frame rate at the 80- and 100-subframe bases (smallest: 23.976fps@80 = 16_588_800_000), so on a 32-bit platform — wasm32, watchOS armv7k/arm64_32 — the `Int` form trapped on overflow. Because the bound is recomputed inside every wrapping add, that took ALL arithmetic on a `.max100Days` timecode with it, however small the operands. Widened, with no platform-conditional logic: - internal: `FrameCount.subFrameCount`, `framesToSubFrames`, `subFramesToFrames`, `FrameCount.init(subFrameCount:base:)`, and the `sfcNew` locals (which infer). - public: `TimecodeFrameRate.maxTotalSubFrames(in:base:)`, `maxSubFrameCountExpressible(in:base:)`, `Timecode.maxSubFrameCountExpressible`. Deliberately NOT widened: `maxTotalFrames`, which peaks at 1_036_800_000 (120fps @ 100 days) and fits a 32-bit Int; and the frames/subFrames components, which are bounded by it. Only the COUNT needs 64 bits. One narrowing remains, at `Timecode.rationalValue`: `Fraction` is Int-based, so a timecode beyond ~Int32.max subframes has no representable rational value on a 32-bit platform. That is a pre-existing limit of `Fraction`, not of the count, and is commented at the site. Unlike the saturating approach this makes `.max100Days` genuinely usable on 32-bit rather than merely non-trapping. Full suite passes: 506 tests in 55 suites.
1 parent 6016639 commit 597a0c4

6 files changed

Lines changed: 74 additions & 23 deletions

File tree

Sources/SwiftTimecodeCore/Timecode/FrameCount/FrameCount.swift

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ extension Timecode {
105105

106106
extension Timecode.FrameCount {
107107
init(
108-
subFrameCount: Int,
108+
subFrameCount: Int64,
109109
base: Timecode.SubFramesBase
110110
) {
111111
let converted = Timecode.subFramesToFrames(
@@ -225,7 +225,7 @@ extension Timecode.FrameCount {
225225
public func multiplying(by factor: Double) -> Self {
226226
let lhsTotalSubFrames = subFrameCount
227227

228-
let resultSubFrameCount = Int(Double(lhsTotalSubFrames) * factor)
228+
let resultSubFrameCount = Int64(Double(lhsTotalSubFrames) * factor)
229229

230230
let newFrames = Timecode.subFramesToFrames(
231231
resultSubFrameCount,
@@ -241,7 +241,7 @@ extension Timecode.FrameCount {
241241
public func dividing(by divisor: Double) -> Self {
242242
let lhsTotalSubFrames = subFrameCount
243243

244-
let resultSubFrameCount = Int(Double(lhsTotalSubFrames) / divisor)
244+
let resultSubFrameCount = Int64(Double(lhsTotalSubFrames) / divisor)
245245

246246
let newFrames = Timecode.subFramesToFrames(
247247
resultSubFrameCount,
@@ -268,7 +268,7 @@ extension Timecode.FrameCount {
268268
}
269269

270270
extension Timecode.FrameCount {
271-
var subFrameCount: Int {
271+
var subFrameCount: Int64 {
272272
Timecode.framesToSubFrames(
273273
frames: wholeFrames,
274274
subFrames: subFrames,
@@ -285,15 +285,19 @@ extension Timecode {
285285
frames: Int,
286286
subFrames: Int,
287287
base: SubFramesBase
288-
) -> Int {
289-
(frames * base.rawValue) + subFrames
288+
) -> Int64 {
289+
// Int64 because a 100-day timecode's subframe count exceeds Int32.max
290+
// for every frame rate, and `Int` is 32-bit on wasm32 / watchOS armv7k.
291+
(Int64(frames) * Int64(base.rawValue)) + Int64(subFrames)
290292
}
291293

292294
/// Internal utility
293-
static func subFramesToFrames(_ subFrames: Int, base: SubFramesBase) -> (frames: Int, subFrames: Int) {
294-
let outSubFrames = subFrames % base.rawValue
295-
let outFrames = (subFrames - outSubFrames) / base.rawValue
295+
static func subFramesToFrames(_ subFrames: Int64, base: SubFramesBase) -> (frames: Int, subFrames: Int) {
296+
// The COUNT needs 64 bits; the resulting frames/subFrames do not —
297+
// max total frames is ~1.04e9 even at 120 fps over 100 days.
298+
let outSubFrames = subFrames % Int64(base.rawValue)
299+
let outFrames = (subFrames - outSubFrames) / Int64(base.rawValue)
296300

297-
return (frames: outFrames, subFrames: outSubFrames)
301+
return (frames: Int(outFrames), subFrames: Int(outSubFrames))
298302
}
299303
}

Sources/SwiftTimecodeCore/Timecode/Math/Timecode Math Internal.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ extension Timecode {
311311
if sfcNew > Double(maxSubFrameCountExpressible) { return nil }
312312

313313
let fcNew = FrameCount(
314-
subFrameCount: Int(sfcNew),
314+
subFrameCount: Int64(sfcNew),
315315
base: subFramesBase
316316
)
317317

@@ -333,7 +333,7 @@ extension Timecode {
333333
base: subFramesBase
334334
)
335335

336-
var sfcNew = Int(Double(fcOrigin.subFrameCount) * factor)
336+
var sfcNew = Int64(Double(fcOrigin.subFrameCount) * factor)
337337

338338
sfcNew = sfcNew.clamped(to: 0 ... maxSubFrameCountExpressible)
339339

@@ -360,7 +360,7 @@ extension Timecode {
360360
base: subFramesBase
361361
)
362362

363-
var sfcNew = Int(Double(fcOrigin.subFrameCount) * factor)
363+
var sfcNew = Int64(Double(fcOrigin.subFrameCount) * factor)
364364

365365
let maxTotalSubFrames = frameRate.maxTotalSubFrames(
366366
in: upperLimit,
@@ -401,7 +401,7 @@ extension Timecode {
401401
base: subFramesBase
402402
)
403403

404-
let sfcNew = Int(Double(fcOrigin.subFrameCount) * factor)
404+
let sfcNew = Int64(Double(fcOrigin.subFrameCount) * factor)
405405

406406
let fcNew = FrameCount(
407407
subFrameCount: sfcNew,
@@ -434,7 +434,7 @@ extension Timecode {
434434
if sfcNew > Double(maxSubFrameCountExpressible) { return nil }
435435

436436
let fcNew = FrameCount(
437-
subFrameCount: Int(sfcNew),
437+
subFrameCount: Int64(sfcNew),
438438
base: subFramesBase
439439
)
440440

@@ -456,7 +456,7 @@ extension Timecode {
456456
base: subFramesBase
457457
)
458458

459-
var sfcNew = Int(Double(fcOrigin.subFrameCount) / divisor)
459+
var sfcNew = Int64(Double(fcOrigin.subFrameCount) / divisor)
460460

461461
sfcNew = sfcNew.clamped(to: 0 ... maxSubFrameCountExpressible)
462462

@@ -483,7 +483,7 @@ extension Timecode {
483483
base: subFramesBase
484484
)
485485

486-
var sfcNew = Int(Double(fcOrigin.subFrameCount) / divisor)
486+
var sfcNew = Int64(Double(fcOrigin.subFrameCount) / divisor)
487487

488488
let maxTotalSubFrames = frameRate.maxTotalSubFrames(
489489
in: upperLimit,
@@ -524,7 +524,7 @@ extension Timecode {
524524
base: subFramesBase
525525
)
526526

527-
let sfcNew = Int(Double(fcOrigin.subFrameCount) / divisor)
527+
let sfcNew = Int64(Double(fcOrigin.subFrameCount) / divisor)
528528

529529
let fcNew = FrameCount(
530530
subFrameCount: sfcNew,

Sources/SwiftTimecodeCore/Timecode/Source/Timecode Rational.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ extension Timecode {
5050
/// fractions.)
5151
public var rationalValue: Fraction {
5252
let frFrac = frameRate.frameDuration
53-
let n = frFrac.numerator * frameCount.subFrameCount
53+
// `Fraction` is `Int`-based, so the 64-bit subframe count is narrowed
54+
// here. On a 32-bit platform a timecode beyond ~Int32.max subframes has
55+
// no representable rational value — a pre-existing limit of `Fraction`,
56+
// not of the count.
57+
let n = frFrac.numerator * Int(frameCount.subFrameCount)
5458
let d = frFrac.denominator * subFramesBase.rawValue
5559

5660
return Fraction(n, d).reduced()

Sources/SwiftTimecodeCore/Timecode/Timecode Validation.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ extension Timecode {
197197
}
198198

199199
/// Returns the `upperLimit` minus 1 subframe expressed as total subframes.
200-
public var maxSubFrameCountExpressible: Int {
200+
public var maxSubFrameCountExpressible: Int64 {
201201
frameRate.maxSubFrameCountExpressible(
202202
in: upperLimit,
203203
base: subFramesBase

Sources/SwiftTimecodeCore/TimecodeFrameRate/TimecodeFrameRate Properties.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -303,16 +303,21 @@ extension TimecodeFrameRate {
303303
public func maxTotalSubFrames(
304304
in extent: Timecode.UpperLimit,
305305
base: Timecode.SubFramesBase
306-
) -> Int {
307-
maxTotalFrames(in: extent) * base.rawValue
306+
) -> Int64 {
307+
// Int64, not Int: at `.max100Days` this product exceeds Int32.max for
308+
// EVERY frame rate (smallest case 23.976fps@80 = 16_588_800_000), so on
309+
// a 32-bit platform — wasm32, watchOS armv7k/arm64_32 — the `Int` form
310+
// trapped on overflow. `maxTotalFrames` stays `Int`: it peaks at
311+
// 1_036_800_000 (120fps @ 100 days), which fits.
312+
Int64(maxTotalFrames(in: extent)) * Int64(base.rawValue)
308313
}
309314

310315
/// Returns max elapsed subframes possible before rolling over to 0.
311316
/// (Number of subframes from 0 to `extent` minus one subframe).
312317
public func maxSubFrameCountExpressible(
313318
in extent: Timecode.UpperLimit,
314319
base: Timecode.SubFramesBase
315-
) -> Int {
320+
) -> Int64 {
316321
maxTotalSubFrames(in: extent, base: base) - 1
317322
}
318323
}

Tests/SwiftTimecodeCoreTests/TimecodeFrameRate/TimecodeFrameRate Properties Tests.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,44 @@ struct TimecodeFrameRate_Properties_Tests {
8989
#expect(frameRate.framesDroppedPerMinute == 0.0)
9090
}
9191

92+
/// `.max100Days` subframe counts must not overflow on a 32-bit platform.
93+
///
94+
/// These counts are `Int64` rather than `Int` because at `.max100Days` the
95+
/// product exceeds `Int32.max` for every frame rate at the 80- and
96+
/// 100-subframe bases — the smallest such case, 23.976 fps at 80 subframes,
97+
/// is already `2_073_600 * 100 * 80 = 16_588_800_000`. (At the
98+
/// `.quarterFrames` base the lower rates do still fit, which is exactly why
99+
/// this is asserted across every rate/base pair rather than spot-checked.) As plain `Int` this trapped on wasm32 and on watchOS
100+
/// armv7k/arm64_32, and because the bound is recomputed inside every
101+
/// wrapping add it took ALL arithmetic on a `.max100Days` timecode with it,
102+
/// however small the operands.
103+
@Test
104+
func maxTotalSubFramesFitsOn32Bit() {
105+
for frameRate in TimecodeFrameRate.allCases {
106+
for base in Timecode.SubFramesBase.allCases {
107+
let total = frameRate.maxTotalSubFrames(in: .max100Days, base: base)
108+
#expect(total == Int64(frameRate.maxTotalFrames(in: .max100Days)) * Int64(base.rawValue))
109+
#expect(frameRate.maxSubFrameCountExpressible(in: .max100Days, base: base) == total - 1)
110+
}
111+
}
112+
}
113+
114+
/// Arithmetic on a `.max100Days` timecode must work on every platform.
115+
///
116+
/// The regression this guards is not about large values — these operands are
117+
/// tiny. It is the upper BOUND, recomputed on each wrapping add.
118+
@Test
119+
func max100DaysArithmeticDoesNotTrap() throws {
120+
var lhs = try Timecode(.realTime(seconds: 1.0), at: .fps59_94)
121+
var rhs = try Timecode(.realTime(seconds: 192.0), at: .fps59_94)
122+
lhs.properties.upperLimit = .max100Days
123+
rhs.properties.upperLimit = .max100Days
124+
125+
let sum = try lhs.adding(rhs, by: .wrapping)
126+
#expect(sum.components.minutes == 3)
127+
#expect(sum.components.seconds == 12)
128+
}
129+
92130
@Test
93131
func initStringValue() {
94132
#expect(TimecodeFrameRate(stringValue: "23.976") == .fps23_976)

0 commit comments

Comments
 (0)