ankigammon 1.0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. ankigammon/__init__.py +7 -0
  2. ankigammon/__main__.py +6 -0
  3. ankigammon/analysis/__init__.py +13 -0
  4. ankigammon/analysis/score_matrix.py +391 -0
  5. ankigammon/anki/__init__.py +6 -0
  6. ankigammon/anki/ankiconnect.py +216 -0
  7. ankigammon/anki/apkg_exporter.py +111 -0
  8. ankigammon/anki/card_generator.py +1325 -0
  9. ankigammon/anki/card_styles.py +1054 -0
  10. ankigammon/gui/__init__.py +8 -0
  11. ankigammon/gui/app.py +192 -0
  12. ankigammon/gui/dialogs/__init__.py +10 -0
  13. ankigammon/gui/dialogs/export_dialog.py +594 -0
  14. ankigammon/gui/dialogs/import_options_dialog.py +201 -0
  15. ankigammon/gui/dialogs/input_dialog.py +762 -0
  16. ankigammon/gui/dialogs/note_dialog.py +93 -0
  17. ankigammon/gui/dialogs/settings_dialog.py +420 -0
  18. ankigammon/gui/dialogs/update_dialog.py +373 -0
  19. ankigammon/gui/format_detector.py +377 -0
  20. ankigammon/gui/main_window.py +1611 -0
  21. ankigammon/gui/resources/down-arrow.svg +3 -0
  22. ankigammon/gui/resources/icon.icns +0 -0
  23. ankigammon/gui/resources/icon.ico +0 -0
  24. ankigammon/gui/resources/icon.png +0 -0
  25. ankigammon/gui/resources/style.qss +402 -0
  26. ankigammon/gui/resources.py +26 -0
  27. ankigammon/gui/update_checker.py +259 -0
  28. ankigammon/gui/widgets/__init__.py +8 -0
  29. ankigammon/gui/widgets/position_list.py +166 -0
  30. ankigammon/gui/widgets/smart_input.py +268 -0
  31. ankigammon/models.py +356 -0
  32. ankigammon/parsers/__init__.py +7 -0
  33. ankigammon/parsers/gnubg_match_parser.py +1094 -0
  34. ankigammon/parsers/gnubg_parser.py +468 -0
  35. ankigammon/parsers/sgf_parser.py +290 -0
  36. ankigammon/parsers/xg_binary_parser.py +1097 -0
  37. ankigammon/parsers/xg_text_parser.py +688 -0
  38. ankigammon/renderer/__init__.py +5 -0
  39. ankigammon/renderer/animation_controller.py +391 -0
  40. ankigammon/renderer/animation_helper.py +191 -0
  41. ankigammon/renderer/color_schemes.py +145 -0
  42. ankigammon/renderer/svg_board_renderer.py +791 -0
  43. ankigammon/settings.py +315 -0
  44. ankigammon/thirdparty/__init__.py +7 -0
  45. ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
  46. ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
  47. ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
  48. ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
  49. ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
  50. ankigammon/utils/__init__.py +13 -0
  51. ankigammon/utils/gnubg_analyzer.py +590 -0
  52. ankigammon/utils/gnuid.py +577 -0
  53. ankigammon/utils/move_parser.py +204 -0
  54. ankigammon/utils/ogid.py +326 -0
  55. ankigammon/utils/xgid.py +387 -0
  56. ankigammon-1.0.6.dist-info/METADATA +352 -0
  57. ankigammon-1.0.6.dist-info/RECORD +61 -0
  58. ankigammon-1.0.6.dist-info/WHEEL +5 -0
  59. ankigammon-1.0.6.dist-info/entry_points.txt +2 -0
  60. ankigammon-1.0.6.dist-info/licenses/LICENSE +21 -0
  61. ankigammon-1.0.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,577 @@
1
+ """GNUID format parsing and encoding.
2
+
3
+ GNUID (GNU Backgammon ID) is GNU Backgammon's position identification format.
4
+
5
+ Format: PositionID:MatchID
6
+
7
+ Position ID:
8
+ - 14-character Base64 string
9
+ - Encodes 10 bytes (80 bits)
10
+ - Variable-length bit encoding of checker positions
11
+ - Format per point: [player 1-bits][0][opponent 1-bits][0]
12
+
13
+ Match ID:
14
+ - 12-character Base64 string
15
+ - Encodes 9 bytes (72 bits)
16
+ - Contains cube value, owner, scores, dice, match length, etc.
17
+
18
+ Example: 4HPwATDgc/ABMA:8IhuACAACAAE
19
+ - Position: 4HPwATDgc/ABMA (starting position)
20
+ - Match: 8IhuACAACAAE (match state)
21
+
22
+ Encoding Algorithm (Position ID):
23
+ 1. Start with empty bit string
24
+ 2. For each point (from player on roll's perspective):
25
+ - Append N ones (N = player on roll's checkers)
26
+ - Append M ones (M = opponent's checkers)
27
+ - Append one zero (separator)
28
+ 3. Pad to 80 bits with zeros
29
+ 4. Pack into 10 bytes (little-endian)
30
+ 5. Base64 encode (without padding)
31
+
32
+ Note: In our internal model:
33
+ - Player.X = TOP player (positive values)
34
+ - Player.O = BOTTOM player (negative values)
35
+ """
36
+
37
+ import base64
38
+ from typing import Dict, Optional, Tuple
39
+
40
+ from ankigammon.models import Position, Player, CubeState
41
+
42
+
43
+ def parse_gnuid(gnuid: str) -> Tuple[Position, Dict]:
44
+ """
45
+ Parse a GNUID string into a Position and metadata.
46
+
47
+ Args:
48
+ gnuid: GNUID string (e.g., "4HPwATDgc/ABMA:8IhuACAACAAE")
49
+
50
+ Returns:
51
+ Tuple of (Position, metadata_dict)
52
+ """
53
+ # Remove "GNUID=" or "GNUBGID=" prefix if present
54
+ gnuid = gnuid.strip()
55
+ if gnuid.upper().startswith("GNUID="):
56
+ gnuid = gnuid[6:]
57
+ elif gnuid.upper().startswith("GNUBGID="):
58
+ gnuid = gnuid[8:]
59
+ elif gnuid.upper().startswith("GNUBGID "):
60
+ gnuid = gnuid[8:]
61
+
62
+ # Split into Position ID and Match ID
63
+ parts = gnuid.split(':')
64
+ if len(parts) < 1:
65
+ raise ValueError("Invalid GNUID format: no colon separator found")
66
+
67
+ position_id = parts[0].strip()
68
+ match_id = parts[1].strip() if len(parts) > 1 else None
69
+
70
+ # Parse position ID
71
+ if len(position_id) != 14:
72
+ raise ValueError(f"Invalid Position ID length: expected 14 chars, got {len(position_id)}")
73
+
74
+ # Decode position (GNUID Position ID always uses Player 1/X's perspective)
75
+ position = _decode_position_id(position_id)
76
+
77
+ # Parse metadata from match ID
78
+ metadata = {}
79
+ if match_id:
80
+ metadata = _decode_match_id(match_id)
81
+
82
+ return position, metadata
83
+
84
+
85
+ def _decode_position_id(position_id: str) -> Position:
86
+ """
87
+ Decode a 14-character Position ID into a Position object.
88
+
89
+ Position IDs always encode from Player X's perspective.
90
+
91
+ Args:
92
+ position_id: 14-character Base64 string
93
+
94
+ Returns:
95
+ Position object
96
+ """
97
+ # Base64 decode to 10 bytes (no padding)
98
+ try:
99
+ # Add padding if needed for decoding
100
+ position_bytes = base64.b64decode(position_id + "==")
101
+ except Exception as e:
102
+ raise ValueError(f"Invalid Position ID Base64: {e}")
103
+
104
+ if len(position_bytes) != 10:
105
+ raise ValueError(f"Invalid Position ID: expected 10 bytes, got {len(position_bytes)}")
106
+
107
+ # Convert bytes to bit string (80 bits, little-endian)
108
+ bits = []
109
+ for byte in position_bytes:
110
+ for i in range(8):
111
+ bits.append((byte >> i) & 1)
112
+
113
+ # Decode bit string into TanBoard structure
114
+ # Format: [player0-25points][player1-25points]
115
+ # Each point: [N consecutive 1s][separator 0]
116
+ anBoard = [[0] * 25 for _ in range(2)]
117
+
118
+ bit_idx = 0
119
+ player = 0 # Start with player 0 (X)
120
+ point = 0 # Start with point 0
121
+
122
+ while bit_idx < len(bits) and player < 2:
123
+ # Count consecutive 1s (checkers on this point)
124
+ checker_count = 0
125
+ while bit_idx < len(bits) and bits[bit_idx] == 1:
126
+ checker_count += 1
127
+ bit_idx += 1
128
+
129
+ # Store checker count
130
+ if point < 25:
131
+ anBoard[player][point] = checker_count
132
+
133
+ # Skip separator (0-bit)
134
+ if bit_idx < len(bits) and bits[bit_idx] == 0:
135
+ bit_idx += 1
136
+
137
+ # Move to next point
138
+ point += 1
139
+ if point >= 25:
140
+ # Move to next player
141
+ player += 1
142
+ point = 0
143
+
144
+ # Convert TanBoard to our Position model
145
+ position = _convert_tanboard_to_position(anBoard)
146
+
147
+ return position
148
+
149
+
150
+ def _convert_tanboard_to_position(anBoard: list) -> Position:
151
+ """
152
+ Convert TanBoard structure to internal Position model.
153
+
154
+ TanBoard structure:
155
+ - anBoard[0][0-23] = Player X checkers on points 0-23
156
+ - anBoard[0][24] = Player X bar
157
+ - anBoard[1][0-23] = Player O checkers on points 0-23
158
+ - anBoard[1][24] = Player O bar
159
+
160
+ Point numbering mapping:
161
+ - Player X: anBoard[0][0] = point 24, anBoard[0][23] = point 1
162
+ - Player O: anBoard[1][0] = point 1, anBoard[1][23] = point 24
163
+
164
+ Internal model:
165
+ - points[0] = X's bar
166
+ - points[1-24] = board points
167
+ - points[25] = O's bar
168
+ - Positive values = X checkers, Negative values = O checkers
169
+
170
+ Args:
171
+ anBoard: TanBoard structure [2 players][25 points]
172
+
173
+ Returns:
174
+ Position object
175
+ """
176
+ position = Position()
177
+
178
+ # Player X with reverse numbering
179
+ for i in range(24):
180
+ our_point = 24 - i
181
+ position.points[our_point] += anBoard[0][i]
182
+ position.points[0] = anBoard[0][24]
183
+
184
+ # Player O with direct numbering
185
+ for i in range(24):
186
+ our_point = i + 1
187
+ position.points[our_point] -= anBoard[1][i]
188
+ position.points[25] = -anBoard[1][24]
189
+
190
+ # Calculate borne-off checkers
191
+ total_x = sum(count for count in position.points if count > 0)
192
+ total_o = sum(abs(count) for count in position.points if count < 0)
193
+
194
+ position.x_off = 15 - total_x
195
+ position.o_off = 15 - total_o
196
+
197
+ return position
198
+
199
+
200
+ def _convert_position_to_tanboard(position: Position) -> list:
201
+ """
202
+ Convert internal Position model to TanBoard structure.
203
+
204
+ Args:
205
+ position: Internal position
206
+
207
+ Returns:
208
+ TanBoard structure [2 players][25 points]
209
+ """
210
+ anBoard = [[0] * 25 for _ in range(2)]
211
+
212
+ # Player X with reverse mapping
213
+ for our_point in range(1, 25):
214
+ if position.points[our_point] > 0:
215
+ anBoard[0][24 - our_point] = position.points[our_point]
216
+ anBoard[0][24] = position.points[0]
217
+
218
+ # Player O with direct mapping
219
+ for our_point in range(1, 25):
220
+ if position.points[our_point] < 0:
221
+ anBoard[1][our_point - 1] = -position.points[our_point]
222
+ anBoard[1][24] = -position.points[25] if position.points[25] < 0 else 0
223
+
224
+ return anBoard
225
+
226
+
227
+ def _convert_gnuid_to_position(
228
+ player_checkers: list,
229
+ opponent_checkers: list,
230
+ on_roll: Player
231
+ ) -> Position:
232
+ """
233
+ Convert GNUID perspective arrays to internal Position model.
234
+
235
+ Args:
236
+ player_checkers: Checkers from player on roll's perspective [0-25]
237
+ opponent_checkers: Opponent's checkers from same perspective [0-25]
238
+ on_roll: Player who is on roll
239
+
240
+ Returns:
241
+ Position object
242
+ """
243
+ position = Position()
244
+
245
+ if on_roll == Player.X:
246
+ # Map board points with reverse numbering
247
+ for gnuid_pt in range(24):
248
+ our_pt = 24 - gnuid_pt
249
+ position.points[our_pt] = player_checkers[gnuid_pt]
250
+ position.points[our_pt] -= opponent_checkers[gnuid_pt]
251
+
252
+ position.points[0] = player_checkers[24]
253
+ position.points[25] = -opponent_checkers[25]
254
+
255
+ else:
256
+ # Map board points with direct numbering
257
+ for gnuid_pt in range(24):
258
+ our_pt = gnuid_pt + 1
259
+ position.points[our_pt] = -player_checkers[gnuid_pt]
260
+ position.points[our_pt] += opponent_checkers[gnuid_pt]
261
+
262
+ position.points[25] = -player_checkers[24]
263
+ position.points[0] = opponent_checkers[25]
264
+
265
+ # Calculate borne-off checkers
266
+ total_x = sum(count for count in position.points if count > 0)
267
+ total_o = sum(abs(count) for count in position.points if count < 0)
268
+
269
+ position.x_off = 15 - total_x
270
+ position.o_off = 15 - total_o
271
+
272
+ return position
273
+
274
+
275
+ def _decode_match_id(match_id: str) -> Dict:
276
+ """
277
+ Decode a 12-character Match ID into metadata.
278
+
279
+ Args:
280
+ match_id: 12-character Base64 string
281
+
282
+ Returns:
283
+ Dictionary with metadata fields
284
+ """
285
+ if len(match_id) != 12:
286
+ raise ValueError(f"Invalid Match ID length: expected 12 chars, got {len(match_id)}")
287
+
288
+ try:
289
+ # Base64 decode to 9 bytes
290
+ match_bytes = base64.b64decode(match_id + "=")
291
+ except Exception as e:
292
+ raise ValueError(f"Invalid Match ID Base64: {e}")
293
+
294
+ if len(match_bytes) != 9:
295
+ raise ValueError(f"Invalid Match ID: expected 9 bytes, got {len(match_bytes)}")
296
+
297
+ # Convert to bit array
298
+ bits = []
299
+ for byte in match_bytes:
300
+ for i in range(8):
301
+ bits.append((byte >> i) & 1)
302
+
303
+ metadata = {}
304
+
305
+ # Bits 0-3: Cube value
306
+ cube_log = _extract_bits(bits, 0, 4)
307
+ metadata['cube_value'] = 2 ** cube_log if cube_log < 15 else 1
308
+
309
+ # Bits 4-5: Cube owner
310
+ cube_owner_bits = _extract_bits(bits, 4, 2)
311
+ if cube_owner_bits == 3:
312
+ metadata['cube_owner'] = CubeState.CENTERED
313
+ elif cube_owner_bits == 0:
314
+ metadata['cube_owner'] = CubeState.X_OWNS
315
+ else:
316
+ metadata['cube_owner'] = CubeState.O_OWNS
317
+
318
+ # Bit 7: Crawford
319
+ metadata['crawford'] = bits[7] == 1
320
+
321
+ # Bits 8-10: Game state
322
+ game_state = _extract_bits(bits, 8, 3)
323
+ metadata['game_state'] = game_state
324
+
325
+ # Bit 11: Turn
326
+ turn_bit = bits[11]
327
+ metadata['on_roll'] = Player.O if turn_bit == 1 else Player.X
328
+
329
+ # Bit 12: Doubled
330
+ metadata['doubled'] = bits[12] == 1
331
+
332
+ # Bits 13-14: Resigned
333
+ resign_bits = _extract_bits(bits, 13, 2)
334
+ metadata['resigned'] = resign_bits
335
+
336
+ # Bits 15-17: Die 0
337
+ die0 = _extract_bits(bits, 15, 3)
338
+ # Bits 18-20: Die 1
339
+ die1 = _extract_bits(bits, 18, 3)
340
+
341
+ if die0 > 0 and die1 > 0:
342
+ metadata['dice'] = (die0, die1)
343
+
344
+ # Bits 21-35: Match length
345
+ match_length = _extract_bits(bits, 21, 15)
346
+ metadata['match_length'] = match_length
347
+
348
+ # Bits 36-50: Player X score
349
+ score_0 = _extract_bits(bits, 36, 15)
350
+ metadata['score_x'] = score_0
351
+
352
+ # Bits 51-65: Player O score
353
+ score_1 = _extract_bits(bits, 51, 15)
354
+ metadata['score_o'] = score_1
355
+
356
+ return metadata
357
+
358
+
359
+ def _extract_bits(bits: list, start: int, count: int) -> int:
360
+ """Extract an integer from a bit array."""
361
+ value = 0
362
+ for i in range(count):
363
+ if start + i < len(bits):
364
+ value |= (bits[start + i] << i)
365
+ return value
366
+
367
+
368
+ def encode_gnuid(
369
+ position: Position,
370
+ cube_value: int = 1,
371
+ cube_owner: CubeState = CubeState.CENTERED,
372
+ dice: Optional[Tuple[int, int]] = None,
373
+ on_roll: Player = Player.X,
374
+ score_x: int = 0,
375
+ score_o: int = 0,
376
+ match_length: int = 0,
377
+ crawford: bool = False,
378
+ only_position: bool = False,
379
+ ) -> str:
380
+ """
381
+ Encode a position and metadata as a GNUID string.
382
+
383
+ Args:
384
+ position: The position to encode
385
+ cube_value: Doubling cube value
386
+ cube_owner: Who owns the cube
387
+ dice: Dice values (if any)
388
+ on_roll: Player on roll
389
+ score_x: Player X's (player 0) score
390
+ score_o: Player O's (player 1) score
391
+ match_length: Match length (0 for money)
392
+ crawford: Crawford game flag
393
+ only_position: If True, only return Position ID (no Match ID)
394
+
395
+ Returns:
396
+ GNUID string (PositionID:MatchID or just PositionID)
397
+ """
398
+ # Encode position ID (always from Player X's perspective)
399
+ position_id = _encode_position_id(position)
400
+
401
+ if only_position:
402
+ return position_id
403
+
404
+ # Encode match ID
405
+ match_id = _encode_match_id(
406
+ cube_value=cube_value,
407
+ cube_owner=cube_owner,
408
+ dice=dice,
409
+ on_roll=on_roll,
410
+ score_x=score_x,
411
+ score_o=score_o,
412
+ match_length=match_length,
413
+ crawford=crawford,
414
+ )
415
+
416
+ return f"{position_id}:{match_id}"
417
+
418
+
419
+ def _encode_position_id(position: Position) -> str:
420
+ """
421
+ Encode a Position into a 14-character Position ID.
422
+
423
+ Args:
424
+ position: The position to encode
425
+
426
+ Returns:
427
+ 14-character Base64 Position ID
428
+ """
429
+ # Convert our position to TanBoard structure
430
+ anBoard = _convert_position_to_tanboard(position)
431
+
432
+ # Build bit string - ALL player 0 points, then ALL player 1 points
433
+ bits = []
434
+
435
+ for player in range(2):
436
+ for point in range(25):
437
+ # Add checkers as 1s
438
+ for _ in range(anBoard[player][point]):
439
+ bits.append(1)
440
+ # Add separator 0
441
+ bits.append(0)
442
+
443
+ # Pad to 80 bits
444
+ while len(bits) < 80:
445
+ bits.append(0)
446
+
447
+ # Pack into 10 bytes (little-endian)
448
+ position_bytes = bytearray(10)
449
+ for i, bit in enumerate(bits[:80]):
450
+ byte_idx = i // 8
451
+ bit_idx = i % 8
452
+ position_bytes[byte_idx] |= (bit << bit_idx)
453
+
454
+ # Base64 encode (remove padding)
455
+ position_id = base64.b64encode(bytes(position_bytes)).decode('ascii').rstrip('=')
456
+
457
+ return position_id
458
+
459
+
460
+ def _convert_position_to_gnuid(position: Position, on_roll: Player) -> Tuple[list, list]:
461
+ """
462
+ Convert internal Position to GNUID perspective arrays.
463
+
464
+ Args:
465
+ position: Internal position
466
+ on_roll: Player on roll
467
+
468
+ Returns:
469
+ Tuple of (player_checkers[26], opponent_checkers[26])
470
+ """
471
+ player_checkers = [0] * 26
472
+ opponent_checkers = [0] * 26
473
+
474
+ if on_roll == Player.X:
475
+ # Map board points with reverse numbering
476
+ for our_pt in range(1, 25):
477
+ gnuid_pt = 24 - our_pt
478
+ count = position.points[our_pt]
479
+ if count > 0:
480
+ player_checkers[gnuid_pt] = count
481
+ elif count < 0:
482
+ opponent_checkers[gnuid_pt] = -count
483
+
484
+ player_checkers[24] = position.points[0]
485
+ opponent_checkers[25] = -position.points[25]
486
+
487
+ else:
488
+ # Map board points with direct numbering
489
+ for our_pt in range(1, 25):
490
+ gnuid_pt = our_pt - 1
491
+ count = position.points[our_pt]
492
+ if count < 0:
493
+ player_checkers[gnuid_pt] = -count
494
+ elif count > 0:
495
+ opponent_checkers[gnuid_pt] = count
496
+
497
+ player_checkers[24] = -position.points[25]
498
+ opponent_checkers[25] = position.points[0]
499
+
500
+ return player_checkers, opponent_checkers
501
+
502
+
503
+ def _encode_match_id(
504
+ cube_value: int,
505
+ cube_owner: CubeState,
506
+ dice: Optional[Tuple[int, int]],
507
+ on_roll: Player,
508
+ score_x: int,
509
+ score_o: int,
510
+ match_length: int,
511
+ crawford: bool,
512
+ ) -> str:
513
+ """
514
+ Encode match metadata into a 12-character Match ID.
515
+
516
+ Returns:
517
+ 12-character Base64 Match ID
518
+ """
519
+ bits = [0] * 72
520
+
521
+ # Bits 0-3: Cube value
522
+ cube_log = 0
523
+ temp = cube_value
524
+ while temp > 1:
525
+ temp //= 2
526
+ cube_log += 1
527
+ _set_bits(bits, 0, 4, cube_log)
528
+
529
+ # Bits 4-5: Cube owner
530
+ if cube_owner == CubeState.CENTERED:
531
+ cube_owner_val = 3
532
+ elif cube_owner == CubeState.X_OWNS:
533
+ cube_owner_val = 0
534
+ else:
535
+ cube_owner_val = 1
536
+ _set_bits(bits, 4, 2, cube_owner_val)
537
+
538
+ # Bit 7: Crawford
539
+ bits[7] = 1 if crawford else 0
540
+
541
+ # Bits 8-10: Game state
542
+ _set_bits(bits, 8, 3, 1)
543
+
544
+ # Bit 11: Turn
545
+ bits[11] = 1 if on_roll == Player.O else 0
546
+
547
+ # Bits 15-17: Die 0
548
+ # Bits 18-20: Die 1
549
+ if dice:
550
+ _set_bits(bits, 15, 3, dice[0])
551
+ _set_bits(bits, 18, 3, dice[1])
552
+
553
+ # Bits 21-35: Match length
554
+ _set_bits(bits, 21, 15, match_length)
555
+
556
+ # Bits 36-50: Player X score
557
+ _set_bits(bits, 36, 15, score_x)
558
+
559
+ # Bits 51-65: Player O score
560
+ _set_bits(bits, 51, 15, score_o)
561
+
562
+ # Pack into bytes
563
+ match_bytes = bytearray(9)
564
+ for i in range(72):
565
+ byte_idx = i // 8
566
+ bit_idx = i % 8
567
+ match_bytes[byte_idx] |= (bits[i] << bit_idx)
568
+
569
+ match_id = base64.b64encode(bytes(match_bytes)).decode('ascii').rstrip('=')
570
+
571
+ return match_id
572
+
573
+
574
+ def _set_bits(bits: list, start: int, count: int, value: int):
575
+ """Set bits in a bit array from an integer value."""
576
+ for i in range(count):
577
+ bits[start + i] = (value >> i) & 1