xgparse 0.1.0__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.
xgparse/xgstruct.py ADDED
@@ -0,0 +1,964 @@
1
+ #
2
+ # xgstruct.py - classes to read XG file structures
3
+ # Copyright (C) 2013,2014 Michael Petch <mpetch@gnubg.org>
4
+ # <mpetch@capp-sysware.com>
5
+ #
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU Lesser General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU Lesser General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU Lesser General Public License
17
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
+ #
19
+ # This code is based upon Delphi data structures provided by
20
+ # Xavier Dufaure de Citres <contact@extremegammon.com> for purposes
21
+ # of interacting with the ExtremeGammon XG file formats. Field
22
+ # descriptions derived from xg_format.pas. The file formats are
23
+ # published at http://www.extremegammon.com/xgformat.aspx
24
+ #
25
+
26
+ from __future__ import annotations
27
+
28
+ import binascii
29
+ import os
30
+ import struct
31
+ import uuid
32
+ from dataclasses import dataclass, field
33
+ from enum import IntEnum
34
+ from typing import BinaryIO
35
+
36
+ import xgutils
37
+
38
+ # Convenience alias used throughout: a 26-element signed-byte board position.
39
+ Position = tuple[int, ...]
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Helper: read an exact number of bytes or raise EOFError
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ def _read(stream: BinaryIO, size: int) -> bytes:
48
+ data = stream.read(size)
49
+ if len(data) < size:
50
+ raise EOFError(f"Expected {size} bytes, got {len(data)}")
51
+ return data
52
+
53
+
54
+ # ===========================================================================
55
+ # Outer container — the RichGame / GDF header
56
+ # ===========================================================================
57
+
58
+
59
+ @dataclass
60
+ class GameDataFormatHdrRecord:
61
+ """The 8 232-byte Rich Game Format header that wraps every .xg file.
62
+
63
+ Fields correspond to ``TRichGameHeader`` in ``xg_format.pas``.
64
+ """
65
+
66
+ SIZE = 8232
67
+
68
+ magic_number: str = "" # Must be "HMGR"
69
+ header_version: int = 0 # Must be 1
70
+ header_size: int = 0 # Byte length of this header
71
+ thumbnail_offset: int = 0 # Offset to thumbnail JPEG (from end of header)
72
+ thumbnail_size: int = 0 # Byte length of thumbnail JPEG; 0 = absent
73
+ game_guid: str = "" # Game GUID
74
+ game_name: str = "" # Unicode game name
75
+ save_name: str = "" # Unicode save name
76
+ level_name: str = "" # Unicode level name
77
+ comments: str = "" # Unicode comments
78
+
79
+ @classmethod
80
+ def from_stream(cls, stream: BinaryIO) -> "GameDataFormatHdrRecord | None":
81
+ """Return a populated instance, or ``None`` if the magic/version is wrong."""
82
+ try:
83
+ data = struct.unpack(
84
+ "<4BiiQiLHHBB6s1024H1024H1024H1024H",
85
+ stream.read(cls.SIZE),
86
+ )
87
+ except struct.error:
88
+ return None
89
+
90
+ magic = bytearray(data[0:4][::-1]).decode("ascii")
91
+ if magic != "HMGR" or data[4] != 1:
92
+ return None
93
+
94
+ guid_p1, guid_p2, guid_p3, guid_p4, guid_p5 = data[8:13]
95
+ guid_p6 = int(binascii.b2a_hex(data[13]), 16)
96
+ game_guid = str(
97
+ uuid.UUID(fields=(guid_p1, guid_p2, guid_p3, guid_p4, guid_p5, guid_p6))
98
+ )
99
+
100
+ return cls(
101
+ magic_number=magic,
102
+ header_version=data[4],
103
+ header_size=data[5],
104
+ thumbnail_offset=data[6],
105
+ thumbnail_size=data[7],
106
+ game_guid=game_guid,
107
+ game_name=xgutils.utf16intarraytostr(data[14:1038]),
108
+ save_name=xgutils.utf16intarraytostr(data[1038:2062]),
109
+ level_name=xgutils.utf16intarraytostr(data[2062:3086]),
110
+ comments=xgutils.utf16intarraytostr(data[3086:4110]),
111
+ )
112
+
113
+
114
+ # ===========================================================================
115
+ # Sub-records embedded inside game records
116
+ # ===========================================================================
117
+
118
+
119
+ @dataclass
120
+ class TimeSettingRecord:
121
+ """Clock settings stored inside a :class:`HeaderMatchEntry` (v25+)."""
122
+
123
+ SIZE = 32
124
+
125
+ clock_type: int = 0 # 0=None, 1=Fischer, 2=Bronstein
126
+ per_game: bool = False # Reset clock after each game
127
+ time1: int = 0 # Initial time in seconds
128
+ time2: int = 0 # Time added (Fischer) / reserved (Bronstein) per move
129
+ penalty: int = 0 # Point penalty when time expires
130
+ time_left1: int = 0 # Current time left, player 1
131
+ time_left2: int = 0 # Current time left, player 2
132
+ penalty_money: int = 0 # Monetary penalty when time expires
133
+
134
+ @classmethod
135
+ def from_stream(cls, stream: BinaryIO) -> "TimeSettingRecord":
136
+ d = struct.unpack("<lBxxxllllll", _read(stream, cls.SIZE))
137
+ return cls(
138
+ clock_type=d[0],
139
+ per_game=bool(d[1]),
140
+ time1=d[2],
141
+ time2=d[3],
142
+ penalty=d[4],
143
+ time_left1=d[5],
144
+ time_left2=d[6],
145
+ penalty_money=d[7],
146
+ )
147
+
148
+
149
+ @dataclass
150
+ class EvalLevelRecord:
151
+ """Analysis level attached to each candidate move."""
152
+
153
+ SIZE = 4
154
+
155
+ level: int = 0 # See PLAYERLEVEL table in xg_format.pas
156
+ is_double: bool = False # Analysis assumes double on the next move
157
+
158
+ @classmethod
159
+ def from_stream(cls, stream: BinaryIO) -> "EvalLevelRecord":
160
+ d = struct.unpack("<hBb", _read(stream, cls.SIZE))
161
+ return cls(level=d[0], is_double=bool(d[1]))
162
+
163
+
164
+ @dataclass
165
+ class EngineStructBestMoveRecord:
166
+ """Full checker-play analysis for one position (``EngineStructBestMove``)."""
167
+
168
+ SIZE = 2184
169
+
170
+ pos: Position = field(default_factory=tuple) # Current position (26 bytes)
171
+ dice: tuple[int, int] = (0, 0) # Dice values
172
+ level: int = 0 # Analysis level requested
173
+ score: tuple[int, int] = (0, 0) # Match score
174
+ cube: int = 0 # Cube value
175
+ cube_pos: int = 0 # 0=centre, +1=owns, -1=opponent
176
+ crawford: int = 0
177
+ jacoby: int = 0
178
+ n_moves: int = 0 # Number of candidates (max 32)
179
+ pos_played: tuple[Position, ...] = field(default_factory=tuple)
180
+ moves: tuple[tuple[int, ...], ...] = field(default_factory=tuple)
181
+ eval_level: tuple[EvalLevelRecord, ...] = field(default_factory=tuple)
182
+ eval: tuple[tuple[float, ...], ...] = field(default_factory=tuple)
183
+ unused: int = 0
184
+ met: int = 0
185
+ choice0: int = 0 # 1-ply computer choice (index into pos_played)
186
+ choice3: int = 0 # 3-ply computer choice
187
+
188
+ @classmethod
189
+ def from_stream(cls, stream: BinaryIO) -> "EngineStructBestMoveRecord":
190
+ d = struct.unpack("<26bxx2ll2llllll", _read(stream, 68))
191
+ pos_played = tuple(
192
+ struct.unpack("<26b", _read(stream, 26))[0:26] for _ in range(32)
193
+ )
194
+ moves = tuple(struct.unpack("<8b", _read(stream, 8))[0:8] for _ in range(32))
195
+ eval_level = tuple(EvalLevelRecord.from_stream(stream) for _ in range(32))
196
+ evals = tuple(struct.unpack("<7f", _read(stream, 28)) for _ in range(32))
197
+ tail = struct.unpack("<bbbb", _read(stream, 4))
198
+
199
+ return cls(
200
+ pos=d[0:26],
201
+ dice=(d[26], d[27]),
202
+ level=d[28],
203
+ score=(d[29], d[30]),
204
+ cube=d[31],
205
+ cube_pos=d[32],
206
+ crawford=d[33],
207
+ jacoby=d[34],
208
+ n_moves=d[35],
209
+ pos_played=pos_played,
210
+ moves=moves,
211
+ eval_level=eval_level,
212
+ eval=evals,
213
+ unused=tail[0],
214
+ met=tail[1],
215
+ choice0=tail[2],
216
+ choice3=tail[3],
217
+ )
218
+
219
+
220
+ @dataclass
221
+ class EngineStructDoubleAction:
222
+ """Cube-decision analysis for one position (``EngineStructDoubleAction``)."""
223
+
224
+ SIZE = 132
225
+
226
+ pos: Position = field(default_factory=tuple)
227
+ level: int = 0
228
+ score: tuple[int, int] = (0, 0)
229
+ cube: int = 0
230
+ cube_pos: int = 0
231
+ jacoby: int = 0
232
+ crawford: int = 0
233
+ met: int = 0
234
+ flag_double: int = 0 # 0=don't double, 1=double
235
+ is_beaver: int = 0
236
+ eval_nd: tuple[float, ...] = field(default_factory=tuple) # No-double equities
237
+ equ_b: float = 0.0 # Equity: No Double
238
+ equ_double: float = 0.0 # Equity: Double/Take
239
+ equ_drop: float = 0.0 # Equity: Double/Drop (should be -1)
240
+ level_request: int = 0
241
+ double_choice3: int = 0 # 3-ply choice (Double + 2*Take)
242
+ eval_double: tuple[float, ...] = field(
243
+ default_factory=tuple
244
+ ) # Double/Take equities
245
+
246
+ @classmethod
247
+ def from_stream(cls, stream: BinaryIO) -> "EngineStructDoubleAction":
248
+ d = struct.unpack("<26bxxl2llllhhhh7ffffhh7f", _read(stream, 132))
249
+ return cls(
250
+ pos=d[0:26],
251
+ level=d[26],
252
+ score=(d[27], d[28]),
253
+ cube=d[29],
254
+ cube_pos=d[30],
255
+ jacoby=d[31],
256
+ crawford=d[32],
257
+ met=d[33],
258
+ flag_double=d[34],
259
+ is_beaver=d[35],
260
+ eval_nd=d[36:43],
261
+ equ_b=d[43],
262
+ equ_double=d[44],
263
+ equ_drop=d[45],
264
+ level_request=d[46],
265
+ double_choice3=d[47],
266
+ eval_double=d[48:55],
267
+ )
268
+
269
+
270
+ # ===========================================================================
271
+ # Game-file record types (EntryType values in TSaveRec)
272
+ # ===========================================================================
273
+
274
+
275
+ class EntryType(IntEnum):
276
+ HEADER_MATCH = 0
277
+ HEADER_GAME = 1
278
+ CUBE = 2
279
+ MOVE = 3
280
+ FOOTER_GAME = 4
281
+ FOOTER_MATCH = 5
282
+ COMMENT = 6 # unused by XG, treated as UnimplementedEntry
283
+ MISSING = 7
284
+
285
+
286
+ # All TSaveRec records are padded to exactly this size on disk.
287
+ _SAVE_REC_SIZE = 2560
288
+
289
+
290
+ @dataclass
291
+ class HeaderMatchEntry:
292
+ """Match metadata — the first record in every ``temp.xg`` file."""
293
+
294
+ entry_type: EntryType = EntryType.HEADER_MATCH
295
+ version: int = 0 # File format version; forward to all other records
296
+
297
+ # ANSI (XG1 compatibility) player/event/location/round names
298
+ s_player1: str = ""
299
+ s_player2: str = ""
300
+ s_event: str = ""
301
+ s_location: str = ""
302
+ s_round: str = ""
303
+
304
+ match_length: int = 0 # 99999 = unlimited (money game)
305
+ variation: int = 0 # 0=backgammon 1=Nack 2=Hyper 3=Longgammon
306
+ crawford: bool = False
307
+ jacoby: bool = False
308
+ beaver: bool = False
309
+ auto_double: bool = False
310
+ elo1: float = 0.0
311
+ elo2: float = 0.0
312
+ exp1: int = 0
313
+ exp2: int = 0
314
+ date: str = ""
315
+ game_id: int = 0
316
+ comp_level1: int = -1 # See PLAYERLEVEL table
317
+ comp_level2: int = -1
318
+ count_for_elo: bool = False
319
+ add_to_profile1: bool = False
320
+ add_to_profile2: bool = False
321
+ game_mode: int = 0 # See GAMEMODE table
322
+ imported: bool = False
323
+ invert: int = 0
324
+ magic: int = 0x494C4D44
325
+ money_init_g: int = 0
326
+ money_init_score: tuple[int, int] = (0, 0)
327
+ entered: bool = False
328
+ counted: bool = False
329
+ unrated_imp: bool = False
330
+ comment_header_match: int = -1
331
+ comment_footer_match: int = -1
332
+ is_money_match: bool = False
333
+ win_money: float = 0.0
334
+ lose_money: float = 0.0
335
+ currency: int = 0
336
+ fee_money: float = 0.0
337
+ table_stake: float = 0.0
338
+ site_id: int = -1
339
+ # v8+
340
+ cube_limit: int = 0
341
+ auto_double_max: int = 0
342
+ # v24+
343
+ transcribed: bool = False
344
+ event: str = ""
345
+ player1: str = ""
346
+ player2: str = ""
347
+ location: str = ""
348
+ round: str = ""
349
+ # v25+
350
+ time_setting: TimeSettingRecord | None = None
351
+ # v26+
352
+ tot_time_delay_move: int = 0
353
+ tot_time_delay_cube: int = 0
354
+ tot_time_delay_move_done: int = 0
355
+ tot_time_delay_cube_done: int = 0
356
+ # v30+
357
+ transcriber: str = ""
358
+
359
+ @classmethod
360
+ def from_stream(cls, stream: BinaryIO, version: int = 0) -> "HeaderMatchEntry":
361
+ d = struct.unpack(
362
+ "<9x41B41BxllBBBBddlld129BxxxlllBBB129BlB129BxxllLl2lBBBxllBxxxfflfll",
363
+ _read(stream, 612),
364
+ )
365
+ obj = cls(version=version)
366
+ obj.s_player1 = xgutils.delphishortstrtostr(d[0:41])
367
+ obj.s_player2 = xgutils.delphishortstrtostr(d[41:82])
368
+ obj.match_length = d[82]
369
+ obj.variation = d[83]
370
+ obj.crawford = bool(d[84])
371
+ obj.jacoby = bool(d[85])
372
+ obj.beaver = bool(d[86])
373
+ obj.auto_double = bool(d[87])
374
+ obj.elo1 = d[88]
375
+ obj.elo2 = d[89]
376
+ obj.exp1 = d[90]
377
+ obj.exp2 = d[91]
378
+ obj.date = str(xgutils.delphidatetimeconv(d[92]))
379
+ obj.s_event = xgutils.delphishortstrtostr(d[93:222])
380
+ obj.game_id = d[222]
381
+ obj.comp_level1 = d[223]
382
+ obj.comp_level2 = d[224]
383
+ obj.count_for_elo = bool(d[225])
384
+ obj.add_to_profile1 = bool(d[226])
385
+ obj.add_to_profile2 = bool(d[227])
386
+ obj.s_location = xgutils.delphishortstrtostr(d[228:357])
387
+ obj.game_mode = d[357]
388
+ obj.imported = bool(d[358])
389
+ obj.s_round = xgutils.delphishortstrtostr(d[359:487])
390
+ obj.invert = d[488]
391
+ obj.version = d[489]
392
+ obj.magic = d[490]
393
+ obj.money_init_g = d[491]
394
+ obj.money_init_score = (d[492], d[493])
395
+ obj.entered = bool(d[494])
396
+ obj.counted = bool(d[495])
397
+ obj.unrated_imp = bool(d[496])
398
+ obj.comment_header_match = d[497]
399
+ obj.comment_footer_match = d[498]
400
+ obj.is_money_match = bool(d[499])
401
+ obj.win_money = d[500]
402
+ obj.lose_money = d[501]
403
+ obj.currency = d[502]
404
+ obj.fee_money = d[503]
405
+ obj.table_stake = d[504]
406
+ obj.site_id = d[505]
407
+
408
+ if obj.version >= 8:
409
+ d2 = struct.unpack("<ll", _read(stream, 8))
410
+ obj.cube_limit = d2[0]
411
+ obj.auto_double_max = d2[1]
412
+
413
+ if obj.version >= 24:
414
+ d2 = struct.unpack("<Bx129H129H129H129H129H", _read(stream, 1292))
415
+ obj.transcribed = bool(d2[0])
416
+ obj.event = xgutils.utf16intarraytostr(d2[1:130])
417
+ obj.player1 = xgutils.utf16intarraytostr(d2[130:259])
418
+ obj.player2 = xgutils.utf16intarraytostr(d2[259:388])
419
+ obj.location = xgutils.utf16intarraytostr(d2[388:517])
420
+ obj.round = xgutils.utf16intarraytostr(d2[517:646])
421
+
422
+ if obj.version >= 25:
423
+ obj.time_setting = TimeSettingRecord.from_stream(stream)
424
+
425
+ if obj.version >= 26:
426
+ d2 = struct.unpack("<llll", _read(stream, 16))
427
+ obj.tot_time_delay_move = d2[0]
428
+ obj.tot_time_delay_cube = d2[1]
429
+ obj.tot_time_delay_move_done = d2[2]
430
+ obj.tot_time_delay_cube_done = d2[3]
431
+
432
+ if obj.version >= 30:
433
+ d2 = struct.unpack("<129H", _read(stream, 258))
434
+ obj.transcriber = xgutils.utf16intarraytostr(d2[0:129])
435
+
436
+ return obj
437
+
438
+
439
+ @dataclass
440
+ class HeaderGameEntry:
441
+ """Per-game header — one instance per game inside ``temp.xg``."""
442
+
443
+ entry_type: EntryType = EntryType.HEADER_GAME
444
+ version: int = 0
445
+
446
+ score1: int = 0 # Player 1 score at game start
447
+ score2: int = 0 # Player 2 score at game start
448
+ crawford_apply: bool = False # Crawford rule applies to this game
449
+ pos_init: Position = field(default_factory=lambda: (0,) * 26)
450
+ game_number: int = 0 # 1-based
451
+ in_progress: bool = False
452
+ comment_header_game: int = -1
453
+ comment_footer_game: int = -1
454
+ # v26+
455
+ number_of_auto_doubles: int = 0
456
+
457
+ @classmethod
458
+ def from_stream(cls, stream: BinaryIO, version: int = 0) -> "HeaderGameEntry":
459
+ d = struct.unpack("<9xxxxllB26bxlBxxxlll", _read(stream, 68))
460
+ obj = cls(version=version)
461
+ obj.score1 = d[0]
462
+ obj.score2 = d[1]
463
+ obj.crawford_apply = bool(d[2])
464
+ obj.pos_init = d[3:29]
465
+ obj.game_number = d[29]
466
+ obj.in_progress = bool(d[30])
467
+ obj.comment_header_game = d[31]
468
+ obj.comment_footer_game = d[32]
469
+ if version >= 26:
470
+ obj.number_of_auto_doubles = d[33]
471
+ return obj
472
+
473
+
474
+ @dataclass
475
+ class CubeEntry:
476
+ """A cube-decision record (double / take / beaver / raccoon)."""
477
+
478
+ entry_type: EntryType = EntryType.CUBE
479
+ version: int = 0
480
+
481
+ active_p: int = 0 # Active player (1 or 2)
482
+ double: int = 0 # 0=no double, 1=doubled
483
+ take: int = 0 # 0=no, 1=take, 2=beaver
484
+ beaver_r: int = 0 # 0=no, 1=accept, 2=raccoon
485
+ raccoon_r: int = 0
486
+ cube_b: int = 0 # Cube level: 0=centre, +n=2^n own, -n=2^n opponent
487
+ position: Position = field(default_factory=tuple)
488
+ doubled: EngineStructDoubleAction | None = None
489
+ err_cube: float = 0.0 # Error on doubling (-1000 = not analysed)
490
+ dice_rolled: str = ""
491
+ err_take: float = 0.0
492
+ rollout_index_d: int = 0
493
+ comp_choice_d: int = 0
494
+ analyze_c: int = 0
495
+ err_beaver: float = 0.0
496
+ err_raccoon: float = 0.0
497
+ analyze_cr: int = 0
498
+ is_valid: int = 0
499
+ tutor_cube: int = 0
500
+ tutor_take: int = 0
501
+ err_tutor_cube: float = 0.0
502
+ err_tutor_take: float = 0.0
503
+ flagged_double: bool = False
504
+ comment_cube: int = -1
505
+ # v24+
506
+ edited_cube: bool = False
507
+ # v26+
508
+ time_delay_cube: bool = False
509
+ time_delay_cube_done: bool = False
510
+ # v27+
511
+ number_of_auto_double_cube: int = 0
512
+ # v28+
513
+ time_bot: int = 0
514
+ time_top: int = 0
515
+
516
+ @classmethod
517
+ def from_stream(cls, stream: BinaryIO, version: int = 0) -> "CubeEntry":
518
+ d = struct.unpack("<9xxxxllllll26bxx", _read(stream, 64))
519
+ obj = cls(version=version)
520
+ obj.active_p = d[0]
521
+ obj.double = d[1]
522
+ obj.take = d[2]
523
+ obj.beaver_r = d[3]
524
+ obj.raccoon_r = d[4]
525
+ obj.cube_b = d[5]
526
+ obj.position = d[6:32]
527
+ obj.doubled = EngineStructDoubleAction.from_stream(stream)
528
+
529
+ d2 = struct.unpack(
530
+ "<xxxxd3BxxxxxdlllxxxxddllbbxxxxxxddBxxxlBBBxlll",
531
+ _read(stream, 116),
532
+ )
533
+ obj.err_cube = d2[0]
534
+ obj.dice_rolled = xgutils.delphishortstrtostr(d2[1:4])
535
+ obj.err_take = d2[4]
536
+ obj.rollout_index_d = d2[5]
537
+ obj.comp_choice_d = d2[6]
538
+ obj.analyze_c = d2[7]
539
+ obj.err_beaver = d2[8]
540
+ obj.err_raccoon = d2[9]
541
+ obj.analyze_cr = d2[10]
542
+ obj.is_valid = d2[11]
543
+ obj.tutor_cube = d2[12]
544
+ obj.tutor_take = d2[13]
545
+ obj.err_tutor_cube = d2[14]
546
+ obj.err_tutor_take = d2[15]
547
+ obj.flagged_double = bool(d2[16])
548
+ obj.comment_cube = d2[17]
549
+
550
+ if version >= 24:
551
+ obj.edited_cube = bool(d2[18])
552
+ if version >= 26:
553
+ obj.time_delay_cube = bool(d2[19])
554
+ obj.time_delay_cube_done = bool(d2[20])
555
+ if version >= 27:
556
+ obj.number_of_auto_double_cube = d2[21]
557
+ if version >= 28:
558
+ obj.time_bot = d2[22]
559
+ obj.time_top = d2[23]
560
+
561
+ return obj
562
+
563
+
564
+ @dataclass
565
+ class MoveEntry:
566
+ """A checker-play record."""
567
+
568
+ entry_type: EntryType = EntryType.MOVE
569
+ version: int = 0
570
+
571
+ position_i: Position = field(default_factory=tuple) # Before the move
572
+ position_end: Position = field(default_factory=tuple) # After the move
573
+ active_p: int = 0
574
+ moves: tuple[int, ...] = field(default_factory=tuple) # From1,die1,… –1=terminator
575
+ dice: tuple[int, int] = (0, 0)
576
+ cube_a: int = 0
577
+ error_m: float = 0.0 # Unused
578
+ n_move_eval: int = 0
579
+ data_moves: EngineStructBestMoveRecord | None = None
580
+ played: bool = False
581
+ err_move: float = 0.0 # Checker-play error (-1000 = not analysed)
582
+ err_luck: float = 0.0
583
+ comp_choice: int = 0
584
+ init_eq: float = 0.0
585
+ rollout_index_m: tuple[int, ...] = field(default_factory=tuple)
586
+ analyze_m: int = 0
587
+ analyze_l: int = 0
588
+ invalid_m: int = 0
589
+ position_tutor: Position = field(default_factory=tuple)
590
+ tutor: int = 0
591
+ err_tutor_move: float = 0.0
592
+ flagged: bool = False
593
+ comment_move: int = -1
594
+ # v24+
595
+ edited_move: bool = False
596
+ # v26+
597
+ time_delay_move: int = 0
598
+ time_delay_move_done: int = 0
599
+ # v27+
600
+ number_of_auto_double_move: int = 0
601
+
602
+ @classmethod
603
+ def from_stream(cls, stream: BinaryIO, version: int = 0) -> "MoveEntry":
604
+ d = struct.unpack("<9x26b26bxxxl8l2lldl", _read(stream, 124))
605
+ obj = cls(version=version)
606
+ obj.position_i = d[0:26]
607
+ obj.position_end = d[26:52]
608
+ obj.active_p = d[52]
609
+ obj.moves = d[53:61]
610
+ obj.dice = (d[61], d[62])
611
+ obj.cube_a = d[63]
612
+ obj.error_m = d[64]
613
+ obj.n_move_eval = d[65]
614
+ obj.data_moves = EngineStructBestMoveRecord.from_stream(stream)
615
+
616
+ d2 = struct.unpack("<Bxxxddlxxxxd32llll26bbxdBxxxl", _read(stream, 220))
617
+ obj.played = bool(d2[0])
618
+ obj.err_move = d2[1]
619
+ obj.err_luck = d2[2]
620
+ obj.comp_choice = d2[3]
621
+ obj.init_eq = d2[4]
622
+ obj.rollout_index_m = d2[5:37]
623
+ obj.analyze_m = d2[37]
624
+ obj.analyze_l = d2[38]
625
+ obj.invalid_m = d2[39]
626
+ obj.position_tutor = d2[40:66]
627
+ obj.tutor = d2[66]
628
+ obj.err_tutor_move = d2[67]
629
+ obj.flagged = bool(d2[68])
630
+ obj.comment_move = d2[69]
631
+
632
+ if version >= 24:
633
+ obj.edited_move = bool(struct.unpack("<B", _read(stream, 1))[0])
634
+ if version >= 26:
635
+ d3 = struct.unpack("<xxxLL", _read(stream, 11))
636
+ obj.time_delay_move = d3[0]
637
+ obj.time_delay_move_done = d3[1]
638
+ if version >= 27:
639
+ obj.number_of_auto_double_move = struct.unpack("<l", _read(stream, 4))[0]
640
+
641
+ return obj
642
+
643
+
644
+ @dataclass
645
+ class FooterGameEntry:
646
+ """End-of-game summary record."""
647
+
648
+ entry_type: EntryType = EntryType.FOOTER_GAME
649
+ version: int = 0
650
+
651
+ score1g: int = 0
652
+ score2g: int = 0
653
+ crawford_applyg: bool = False
654
+ winner: int = 0 # +1=player1, -1=player2
655
+ points_won: int = 0
656
+ termination: int = (
657
+ 0 # 0=drop 1=single 2=gammon 3=backgammon +100=resign +1000=settle
658
+ )
659
+ err_resign: float = 0.0
660
+ err_take_resign: float = 0.0
661
+ eval: tuple[float, ...] = field(default_factory=tuple)
662
+ eval_level: int = 0
663
+
664
+ @classmethod
665
+ def from_stream(cls, stream: BinaryIO, version: int = 0) -> "FooterGameEntry":
666
+ d = struct.unpack("<9xxxxllBxxxlllxxxxdd7dl", _read(stream, 116))
667
+ return cls(
668
+ version=version,
669
+ score1g=d[0],
670
+ score2g=d[1],
671
+ crawford_applyg=bool(d[2]),
672
+ winner=d[3],
673
+ points_won=d[4],
674
+ termination=d[5],
675
+ err_resign=d[6],
676
+ err_take_resign=d[7],
677
+ eval=d[8:15],
678
+ eval_level=d[15],
679
+ )
680
+
681
+
682
+ @dataclass
683
+ class FooterMatchEntry:
684
+ """End-of-match summary record."""
685
+
686
+ entry_type: EntryType = EntryType.FOOTER_MATCH
687
+ version: int = 0
688
+
689
+ score1m: int = 0
690
+ score2m: int = 0
691
+ winner_m: int = 0
692
+ elo1m: float = 0.0
693
+ elo2m: float = 0.0
694
+ exp1m: int = 0
695
+ exp2m: int = 0
696
+ datem: str = ""
697
+
698
+ @classmethod
699
+ def from_stream(cls, stream: BinaryIO, version: int = 0) -> "FooterMatchEntry":
700
+ d = struct.unpack("<9xxxxlllddlld", _read(stream, 56))
701
+ return cls(
702
+ version=version,
703
+ score1m=d[0],
704
+ score2m=d[1],
705
+ winner_m=d[2],
706
+ elo1m=d[3],
707
+ elo2m=d[4],
708
+ exp1m=d[5],
709
+ exp2m=d[6],
710
+ datem=str(xgutils.delphidatetimeconv(d[7])),
711
+ )
712
+
713
+
714
+ @dataclass
715
+ class MissingEntry:
716
+ """Placeholder for a missing / unknown position."""
717
+
718
+ entry_type: EntryType = EntryType.MISSING
719
+ version: int = 0
720
+
721
+ missing_err_luck: float = 0.0
722
+ missing_winner: int = 0
723
+ missing_points: int = 0
724
+
725
+ @classmethod
726
+ def from_stream(cls, stream: BinaryIO, version: int = 0) -> "MissingEntry":
727
+ d = struct.unpack("<9xxxxxxxxdll", _read(stream, 32))
728
+ return cls(
729
+ version=version,
730
+ missing_err_luck=d[0],
731
+ missing_winner=d[1],
732
+ missing_points=d[2],
733
+ )
734
+
735
+
736
+ @dataclass
737
+ class UnimplementedEntry:
738
+ """Catch-all for record types not yet parsed."""
739
+
740
+ entry_type: EntryType = EntryType.COMMENT
741
+ version: int = 0
742
+
743
+ @classmethod
744
+ def from_stream(cls, stream: BinaryIO, version: int = 0) -> "UnimplementedEntry":
745
+ return cls(version=version)
746
+
747
+
748
+ # Union type for all possible game-file record payloads.
749
+ GameRecord = (
750
+ HeaderMatchEntry
751
+ | HeaderGameEntry
752
+ | CubeEntry
753
+ | MoveEntry
754
+ | FooterGameEntry
755
+ | FooterMatchEntry
756
+ | MissingEntry
757
+ | UnimplementedEntry
758
+ )
759
+
760
+ # Maps the raw EntryType byte to the appropriate dataclass.
761
+ _RECORD_CLASSES: dict[EntryType, type[GameRecord]] = {
762
+ EntryType.HEADER_MATCH: HeaderMatchEntry,
763
+ EntryType.HEADER_GAME: HeaderGameEntry,
764
+ EntryType.CUBE: CubeEntry,
765
+ EntryType.MOVE: MoveEntry,
766
+ EntryType.FOOTER_GAME: FooterGameEntry,
767
+ EntryType.FOOTER_MATCH: FooterMatchEntry,
768
+ EntryType.COMMENT: UnimplementedEntry,
769
+ EntryType.MISSING: MissingEntry,
770
+ }
771
+
772
+ _SAVE_REC_HDR_SIZE = 9 # 8 unused bytes + 1 EntryType byte
773
+
774
+
775
+ def read_game_record(stream: BinaryIO, version: int = -1) -> GameRecord | None:
776
+ """Read one ``TSaveRec`` from *stream* and return the parsed record.
777
+
778
+ Returns ``None`` at end-of-file. The *version* must be the value read
779
+ from the preceding :class:`HeaderMatchEntry`; pass ``-1`` before the
780
+ first header is encountered.
781
+
782
+ The function always advances the stream to the start of the next record
783
+ (records are padded to exactly 2 560 bytes on disk).
784
+ """
785
+ start_pos = stream.tell()
786
+ raw = stream.read(_SAVE_REC_HDR_SIZE)
787
+ if len(raw) < _SAVE_REC_HDR_SIZE:
788
+ return None
789
+
790
+ entry_type = EntryType(struct.unpack("<8xB", raw)[0])
791
+ stream.seek(-_SAVE_REC_HDR_SIZE, os.SEEK_CUR)
792
+
793
+ cls = _RECORD_CLASSES[entry_type]
794
+ record: GameRecord = cls.from_stream(stream, version=version)
795
+
796
+ # Pad to the fixed record size.
797
+ consumed = stream.tell() - start_pos
798
+ stream.seek(_SAVE_REC_SIZE - consumed, os.SEEK_CUR)
799
+
800
+ return record
801
+
802
+
803
+ # ===========================================================================
804
+ # Rollout file (temp.xgr)
805
+ # ===========================================================================
806
+
807
+
808
+ @dataclass
809
+ class RolloutContextEntry:
810
+ """One ``TRolloutContext`` record from ``temp.xgr``."""
811
+
812
+ SIZE = 2184
813
+
814
+ version: int = 0
815
+
816
+ # Inputs
817
+ truncated: bool = False
818
+ error_limited: bool = False
819
+ truncate: int = 0
820
+ min_roll: int = 0
821
+ error_limit: float = 0.0
822
+ max_roll: int = 0
823
+ level1: int = 0
824
+ level2: int = 0
825
+ level_cut: int = 0
826
+ variance: bool = False
827
+ cubeless: bool = False
828
+ time: bool = False
829
+ level1c: int = 0
830
+ level2c: int = 0
831
+ time_limit: int = 0
832
+ truncate_bo: int = 0
833
+ random_seed: int = 0
834
+ random_seed_i: int = 0
835
+ roll_both: bool = False
836
+ search_interval: float = 0.0
837
+ met: int = 0
838
+ first_roll: bool = False
839
+ do_double: bool = False
840
+ extent: bool = False
841
+
842
+ # Outputs
843
+ rolled: int = 0
844
+ double_first: bool = False
845
+ sum1: tuple[float, ...] = field(default_factory=tuple)
846
+ sum_square1: tuple[float, ...] = field(default_factory=tuple)
847
+ sum2: tuple[float, ...] = field(default_factory=tuple)
848
+ sum_square2: tuple[float, ...] = field(default_factory=tuple)
849
+ stdev1: tuple[float, ...] = field(default_factory=tuple)
850
+ stdev2: tuple[float, ...] = field(default_factory=tuple)
851
+ rolled_d: tuple[int, ...] = field(default_factory=tuple)
852
+ error1: float = 0.0
853
+ error2: float = 0.0
854
+ result1: tuple[float, ...] = field(default_factory=tuple)
855
+ result2: tuple[float, ...] = field(default_factory=tuple)
856
+ mwc1: float = 0.0
857
+ mwc2: float = 0.0
858
+ prev_level: int = 0
859
+ prev_eval: tuple[float, ...] = field(default_factory=tuple)
860
+ prev_nd: float = 0.0
861
+ prev_d: float = 0.0
862
+ duration: float = 0.0
863
+ level_trunc: int = 0
864
+ rolled2: int = 0
865
+ multiple_min: int = 0
866
+ multiple_stop_all: bool = False
867
+ multiple_stop_one: bool = False
868
+ multiple_stop_all_value: float = 0.0
869
+ multiple_stop_one_value: float = 0.0
870
+ as_take: bool = False
871
+ rotation: int = 0
872
+ user_interrupted: bool = False
873
+ ver_maj: int = 0
874
+ ver_min: int = 0
875
+
876
+ @classmethod
877
+ def from_stream(cls, stream: BinaryIO, version: int = 0) -> "RolloutContextEntry":
878
+ d = struct.unpack(
879
+ "<BBxxllxxxxdllllBBBxllLlllBxxx"
880
+ "flBBBxlBxxxxxxx37d37d37d37d37d37d37l"
881
+ "ff7f7fffl7fllllllBBxxffBxxxlBxHH",
882
+ _read(stream, 2174),
883
+ )
884
+ return cls(
885
+ version=version,
886
+ truncated=bool(d[0]),
887
+ error_limited=bool(d[1]),
888
+ truncate=d[2],
889
+ min_roll=d[3],
890
+ error_limit=d[4],
891
+ max_roll=d[5],
892
+ level1=d[6],
893
+ level2=d[7],
894
+ level_cut=d[8],
895
+ variance=bool(d[9]),
896
+ cubeless=bool(d[10]),
897
+ time=bool(d[11]),
898
+ level1c=d[12],
899
+ level2c=d[13],
900
+ time_limit=d[14],
901
+ truncate_bo=d[15],
902
+ random_seed=d[16],
903
+ random_seed_i=d[17],
904
+ roll_both=bool(d[18]),
905
+ search_interval=d[19],
906
+ met=d[20],
907
+ first_roll=bool(d[21]),
908
+ do_double=bool(d[22]),
909
+ extent=bool(d[23]),
910
+ rolled=d[24],
911
+ double_first=bool(d[25]),
912
+ sum1=d[26:63],
913
+ sum_square1=d[63:100],
914
+ sum2=d[100:137],
915
+ sum_square2=d[137:174],
916
+ stdev1=d[174:211],
917
+ stdev2=d[211:248],
918
+ rolled_d=d[248:285],
919
+ error1=d[285],
920
+ error2=d[286],
921
+ result1=d[287:294],
922
+ result2=d[294:301],
923
+ mwc1=d[301],
924
+ mwc2=d[302],
925
+ prev_level=d[303],
926
+ prev_eval=d[304:311],
927
+ prev_nd=d[311],
928
+ prev_d=d[312],
929
+ duration=d[313],
930
+ level_trunc=d[314],
931
+ rolled2=d[315],
932
+ multiple_min=d[316],
933
+ multiple_stop_all=bool(d[317]),
934
+ multiple_stop_one=bool(d[318]),
935
+ multiple_stop_all_value=d[319],
936
+ multiple_stop_one_value=d[320],
937
+ as_take=bool(d[321]),
938
+ rotation=d[322],
939
+ user_interrupted=bool(d[323]),
940
+ ver_maj=d[324],
941
+ ver_min=d[325],
942
+ )
943
+
944
+
945
+ _ROLLOUT_REC_SIZE = 2184
946
+
947
+
948
+ def read_rollout_record(
949
+ stream: BinaryIO, version: int = 0
950
+ ) -> RolloutContextEntry | None:
951
+ """Read one rollout record from *stream*.
952
+
953
+ Returns ``None`` at end-of-file. Records are padded to exactly 2 184 bytes.
954
+ """
955
+ start_pos = stream.tell()
956
+ probe = stream.read(1)
957
+ if not probe:
958
+ return None
959
+ stream.seek(-1, os.SEEK_CUR)
960
+
961
+ record = RolloutContextEntry.from_stream(stream, version=version)
962
+ consumed = stream.tell() - start_pos
963
+ stream.seek(_ROLLOUT_REC_SIZE - consumed, os.SEEK_CUR)
964
+ return record