-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathcntrydump.py
More file actions
2683 lines (2225 loc) · 95.3 KB
/
cntrydump.py
File metadata and controls
2683 lines (2225 loc) · 95.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
cntrydump.py - DOS COUNTRY.SYS parser/dumper
Implements the MS-DOS/PC-DOS FAMILY format described in COUNTRY.LST (Matthias Paul).
High-level layout (MS-DOS family):
FileHeader @ 0
+00 BYTE signature (0xFF)
+01 7s magic ("COUNTRY")
+08 8s reserved (usually 0)
+10 WORD entry_table_count
+12 BYTE pointer_info_type (also used as a 'version' byte; usually 1)
+13 DWORD pointer(s) to entry table(s) (often 0000:0017h)
EntryTable @ each entry_table_offset (P)
WORD entry_record_count (X)
X times Country_Codepage_Entry
Country_Codepage_Entry
WORD header_len (expected 0x000C, not counting this WORD)
WORD country
WORD codepage
WORD reserved1 (0)
WORD reserved2 (0)
DWORD pointer to Country_Subfunction_Header (Q)
Country_Subfunction_Header @ Q
WORD subfunction_count (Y)
Y times Subfunction_Entry
Subfunction_Entry
WORD entry_len (expected 0x0006, not counting this WORD)
WORD subfunction_id (authoritative identifier)
DWORD data_offset (absolute) -> Tagged structure
Tagged structure @ data_offset (R)
BYTE tag (usually 0xFF; ARAMODE uses 0x00)
7s magic (padded with ASCII spaces in some cases)
WORD size (length of following payload bytes)
size bytes of payload follow.
"""
from __future__ import annotations
import argparse
import codecs
import html
import json
import os
import struct
import sys
import unicodedata
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
# ====
# ANSI Color Support
# ====
class AnsiColors:
"""ANSI color codes for terminal output (Windows and Linux compatible)."""
RESET = "\033[0m"
BOLD = "\033[1m"
RED = "\033[91m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
BLUE = "\033[94m"
MAGENTA = "\033[95m"
CYAN = "\033[96m"
def _use_colors() -> bool:
"""
Determine if ANSI colors should be used in output.
Returns:
True if output is to a TTY (not piped/redirected), False otherwise
Note:
Checks if stdout is a TTY. If output is piped or redirected to a file,
colors are disabled. Works on both Windows and Linux.
"""
return sys.stdout.isatty()
def _colorize(text: str, color: str, use_colors: bool) -> str:
"""
Apply ANSI color to text if colors are enabled.
Args:
text: Text to colorize
color: ANSI color code
use_colors: Whether to apply colors
Returns:
Colored text if use_colors is True, otherwise plain text
"""
if use_colors:
return f"{color}{text}{AnsiColors.RESET}"
return text
# ====
# Dictionaries / Names
# ====
# Mapping of subfunction IDs to their descriptive names
SUBFUNC_NAMES = {
1: "CTYINFO (Country Information)",
2: "UCASE (Uppercase Table)",
3: "LCASE (Lowercase Table)",
4: "FUCASE (Filename Uppercase Table)",
5: "FCHAR (Filename Terminator Table)",
6: "COLLATE (Collating Sequence Table)",
7: "DBCS (DBCS Lead Byte Table)",
20: "CCTORC (Arabic/Hebrew table)",
21: "ARAMODE (Arabic/Hebrew modes)",
35: "YESNO (Yes/No Prompt Characters)",
}
# Date format codes used in CTYINFO
DATE_FORMAT_NAMES = {0: "MDY", 1: "DMY", 2: "YMD"}
# Time format codes used in CTYINFO
TIME_FORMAT_NAMES = {0: "12-hour", 1: "24-hour"}
# Mapping of country codes to country names
COUNTRY_NAMES = {
1: "United States", 2: "Canada (French)", 3: "Latin America", 4: "Canada (English)",
7: "Russia", 20: "Egypt", 27: "South Africa", 30: "Greece", 31: "Netherlands",
32: "Belgium", 33: "France", 34: "Spain", 36: "Hungary", 38: "Yugoslavia",
39: "Italy", 40: "Romania", 41: "Switzerland", 42: "Czechoslovakia", 43: "Austria",
44: "United Kingdom", 45: "Denmark", 46: "Sweden", 47: "Norway", 48: "Poland",
49: "Germany", 51: "Peru", 52: "Mexico", 54: "Argentina", 55: "Brazil",
56: "Chile", 57: "Colombia", 58: "Venezuela", 60: "Malaysia", 61: "Australia",
62: "Indonesia", 63: "Philippines", 64: "New Zealand", 65: "Singapore",
66: "Thailand", 81: "Japan", 82: "South Korea", 84: "Vietnam", 86: "China (PRC)",
90: "Turkey", 91: "India", 92: "Pakistan", 93: "Afghanistan", 94: "Sri Lanka",
95: "Myanmar", 98: "Iran", 212: "Morocco", 213: "Algeria", 216: "Tunisia",
218: "Libya", 220: "Gambia", 221: "Senegal", 222: "Mauritania", 223: "Mali",
224: "Guinea", 225: "Ivory Coast", 226: "Burkina Faso", 227: "Niger", 228: "Togo",
229: "Benin", 230: "Mauritius", 231: "Liberia", 232: "Sierra Leone", 233: "Ghana",
234: "Nigeria", 235: "Chad", 236: "Central African Republic", 237: "Cameroon",
238: "Cape Verde", 239: "Sao Tome and Principe", 240: "Equatorial Guinea",
241: "Gabon", 242: "Congo", 243: "Zaire", 244: "Angola", 245: "Guinea-Bissau",
246: "Diego Garcia", 247: "Ascension Island", 248: "Seychelles", 249: "Sudan",
250: "Rwanda", 251: "Ethiopia", 252: "Somalia", 253: "Djibouti", 254: "Kenya",
255: "Tanzania", 256: "Uganda", 257: "Burundi", 258: "Mozambique", 260: "Zambia",
261: "Madagascar", 262: "Reunion", 263: "Zimbabwe", 264: "Namibia", 265: "Malawi",
266: "Lesotho", 267: "Botswana", 268: "Swaziland", 269: "Comoros", 290: "St. Helena",
291: "Eritrea", 297: "Aruba", 298: "Faroe Islands", 299: "Greenland", 350: "Gibraltar",
351: "Portugal", 352: "Luxembourg", 353: "Ireland", 354: "Iceland", 355: "Albania",
356: "Malta", 357: "Cyprus", 358: "Finland", 359: "Bulgaria", 370: "Lithuania",
371: "Latvia", 372: "Estonia", 373: "Moldova", 374: "Armenia", 375: "Belarus",
376: "Andorra", 377: "Monaco", 378: "San Marino", 380: "Ukraine", 381: "Serbia",
382: "Montenegro", 385: "Croatia", 386: "Slovenia", 387: "Bosnia and Herzegovina",
389: "Macedonia", 420: "Czech Republic", 421: "Slovakia", 423: "Liechtenstein",
500: "Falkland Islands", 501: "Belize", 502: "Guatemala", 503: "El Salvador",
504: "Honduras", 505: "Nicaragua", 506: "Costa Rica", 507: "Panama",
508: "St. Pierre and Miquelon", 509: "Haiti", 590: "Guadeloupe", 591: "Bolivia",
592: "Guyana", 593: "Ecuador", 594: "French Guiana", 595: "Paraguay",
596: "Martinique", 597: "Suriname", 598: "Uruguay", 599: "Netherlands Antilles",
670: "East Timor", 672: "Antarctica", 673: "Brunei", 674: "Nauru",
675: "Papua New Guinea", 676: "Tonga", 677: "Solomon Islands", 678: "Vanuatu",
679: "Fiji", 680: "Palau", 681: "Wallis and Futuna", 682: "Cook Islands",
683: "Niue", 684: "American Samoa", 685: "Samoa", 686: "Kiribati",
687: "New Caledonia", 688: "Tuvalu", 689: "French Polynesia", 690: "Tokelau",
691: "Micronesia", 692: "Marshall Islands", 850: "North Korea", 852: "Hong Kong",
853: "Macau", 855: "Cambodia", 856: "Laos", 880: "Bangladesh", 886: "Taiwan",
960: "Maldives", 961: "Lebanon", 962: "Jordan", 963: "Syria", 964: "Iraq",
965: "Kuwait", 966: "Saudi Arabia", 967: "Yemen", 968: "Oman",
970: "Palestinian Territory", 971: "United Arab Emirates", 972: "Israel",
973: "Bahrain", 974: "Qatar", 975: "Bhutan", 976: "Mongolia", 977: "Nepal",
}
# Mapping of country codes to ISO 3166-1 alpha-2 codes
COUNTRY_ISO_CODES = {
1: "US", 2: "CA", 3: "LA", 4: "CA", 7: "RU", 20: "EG", 27: "ZA", 30: "GR",
31: "NL", 32: "BE", 33: "FR", 34: "ES", 36: "HU", 38: "YU", 39: "IT", 40: "RO",
41: "CH", 42: "CZ", 43: "AT", 44: "GB", 45: "DK", 46: "SE", 47: "NO", 48: "PL",
49: "DE", 51: "PE", 52: "MX", 54: "AR", 55: "BR", 56: "CL", 57: "CO", 58: "VE",
60: "MY", 61: "AU", 62: "ID", 63: "PH", 64: "NZ", 65: "SG", 66: "TH", 81: "JP",
82: "KR", 84: "VN", 86: "CN", 90: "TR", 91: "IN", 92: "PK", 93: "AF", 94: "LK",
95: "MM", 98: "IR", 212: "MA", 213: "DZ", 216: "TN", 218: "LY", 220: "GM",
221: "SN", 222: "MR", 223: "ML", 224: "GN", 225: "CI", 226: "BF", 227: "NE",
228: "TG", 229: "BJ", 230: "MU", 231: "LR", 232: "SL", 233: "GH", 234: "NG",
235: "TD", 236: "CF", 237: "CM", 238: "CV", 239: "ST", 240: "GQ", 241: "GA",
242: "CG", 243: "CD", 244: "AO", 245: "GW", 246: "DG", 247: "AC", 248: "SC",
249: "SD", 250: "RW", 251: "ET", 252: "SO", 253: "DJ", 254: "KE", 255: "TZ",
256: "UG", 257: "BI", 258: "MZ", 260: "ZM", 261: "MG", 262: "RE", 263: "ZW",
264: "NA", 265: "MW", 266: "LS", 267: "BW", 268: "SZ", 269: "KM", 290: "SH",
291: "ER", 297: "AW", 298: "FO", 299: "GL", 350: "GI", 351: "PT", 352: "LU",
353: "IE", 354: "IS", 355: "AL", 356: "MT", 357: "CY", 358: "FI", 359: "BG",
370: "LT", 371: "LV", 372: "EE", 373: "MD", 374: "AM", 375: "BY", 376: "AD",
377: "MC", 378: "SM", 380: "UA", 381: "RS", 382: "ME", 385: "HR", 386: "SI",
387: "BA", 389: "MK", 420: "CZ", 421: "SK", 423: "LI", 500: "FK", 501: "BZ",
502: "GT", 503: "SV", 504: "HN", 505: "NI", 506: "CR", 507: "PA", 508: "PM",
509: "HT", 590: "GP", 591: "BO", 592: "GY", 593: "EC", 594: "GF", 595: "PY",
596: "MQ", 597: "SR", 598: "UY", 599: "AN", 670: "TL", 672: "AQ", 673: "BN",
674: "NR", 675: "PG", 676: "TO", 677: "SB", 678: "VU", 679: "FJ", 680: "PW",
681: "WF", 682: "CK", 683: "NU", 684: "AS", 685: "WS", 686: "KI", 687: "NC",
688: "TV", 689: "PF", 690: "TK", 691: "FM", 692: "MH", 850: "KP", 852: "HK",
853: "MO", 855: "KH", 856: "LA", 880: "BD", 886: "TW", 960: "MV", 961: "LB",
962: "JO", 963: "SY", 964: "IQ", 965: "KW", 966: "SA", 967: "YE", 968: "OM",
970: "PS", 971: "AE", 972: "IL", 973: "BH", 974: "QA", 975: "BT", 976: "MN",
977: "NP",
}
# Mapping of codepage numbers to their descriptive names
CODEPAGE_NAMES = {
437: "US/OEM", 720: "Arabic", 737: "Greek", 775: "Baltic", 850: "Western European",
852: "Central European", 855: "Cyrillic I", 857: "Turkish", 858: "Western European + Euro",
860: "Portuguese", 861: "Icelandic", 862: "Hebrew", 863: "French Canadian",
864: "Arabic", 865: "Nordic", 866: "Russian Cyrillic", 869: "Greek II", 874: "Thai",
932: "Japanese Shift-JIS", 936: "Chinese Simplified (GBK)", 949: "Korean",
950: "Chinese Traditional (Big5)", 1250: "Windows Central European",
1251: "Windows Cyrillic", 1252: "Windows Western",
}
# Mapping of subfunction IDs to their allowed magic strings
# Used for validation to ensure tagged structures have the correct magic
ALLOWED_MAGICS: Dict[int, Tuple[str, ...]] = {
1: ("CTYINFO",), 2: ("UCASE",), 3: ("LCASE",), 4: ("FUCASE", "UCASE"),
5: ("FCHAR",), 6: ("COLLATE",), 7: ("DBCS",), 20: ("CCTORC",), 21: ("ARAMODE",),
35: ("YESNO",),
}
# Control character display glyphs (CP437 glyphs for 0x00-0x1F)
CONTROL_CHAR_GLYPHS = [
'␀', '☺', '☻', '♥', '♦', '♣', '♠', '•', # 0x00-0x07
'◘', '○', '◙', '♂', '♀', '♪', '♫', '☼', # 0x08-0x0F
'►', '◄', '↕', '‼', '¶', '§', '▬', '↨', # 0x10-0x17
'↑', '↓', '→', '←', '∟', '↔', '▲', '▼', # 0x18-0x1F
]
# Control character names
CONTROL_CHAR_NAMES = [
"NULL", "START OF HEADING", "START OF TEXT", "END OF TEXT",
"END OF TRANSMISSION", "ENQUIRY", "ACKNOWLEDGE", "BELL",
"BACKSPACE", "HORIZONTAL TAB", "LINE FEED", "VERTICAL TAB",
"FORM FEED", "CARRIAGE RETURN", "SHIFT OUT", "SHIFT IN",
"DATA LINK ESCAPE", "DEVICE CONTROL ONE", "DEVICE CONTROL TWO", "DEVICE CONTROL THREE",
"DEVICE CONTROL FOUR", "NEGATIVE ACKNOWLEDGE", "SYNCHRONOUS IDLE", "END OF TRANSMISSION BLOCK",
"CANCEL", "END OF MEDIUM", "SUBSTITUTE", "ESCAPE",
"FILE SEPARATOR", "GROUP SEPARATOR", "RECORD SEPARATOR", "UNIT SEPARATOR",
]
# ====
# Data classes
# ====
@dataclass(frozen=True)
class FarPtr:
"""
Represents a far pointer (segment:offset) from DOS COUNTRY.SYS file.
Attributes:
raw_u32: The raw 32-bit value read from file
seg: Segment portion of the far pointer
off: Offset portion of the far pointer
linear: Calculated linear address (seg*16 + off, or just off if seg==0)
"""
raw_u32: int
seg: int
off: int
linear: int
@dataclass
class Tagged:
"""
Represents a tagged data structure in COUNTRY.SYS.
Tagged structures have the format:
BYTE tag (usually 0xFF)
7 bytes magic string
WORD size
<size> bytes payload
Attributes:
offset: File offset where this tagged structure begins
tag: Tag byte (usually 0xFF, but ARAMODE uses 0x00)
magic_raw: Raw 7-byte magic string from file
magic: Cleaned magic string (stripped of spaces/nulls)
size: Size of payload in bytes
payload: The actual payload data
dbcs_dummy_word: For DBCS tables with size==0, the dummy WORD that follows
"""
offset: int
tag: int
magic_raw: bytes
magic: str
size: int
payload: bytes
dbcs_dummy_word: Optional[int] = None
@dataclass
class SubfuncEntry:
"""
Represents a subfunction entry within a country/codepage entry.
Attributes:
offset: File offset of this subfunction entry
entry_len: Length of this entry (not counting the length WORD itself)
subfunc_id: Subfunction ID (1=CTYINFO, 2=UCASE, etc.)
data_ptr: Far pointer to the tagged data structure
tagged: Parsed tagged structure (if available)
decoded: Decoded/interpreted data (if applicable, e.g., CTYINFO fields)
"""
offset: int
entry_len: int
subfunc_id: int
data_ptr: FarPtr
tagged: Optional[Tagged] = None
decoded: Optional[Dict[str, Any]] = None
@dataclass
class CountryEntry:
"""
Represents a country/codepage entry in COUNTRY.SYS.
Attributes:
offset: File offset of this entry
header_len: Length of entry header (expected 0x0C)
country: Country code
codepage: Codepage number
reserved1: Reserved field (should be 0)
reserved2: Reserved field (should be 0)
subfunc_header_ptr: Far pointer to subfunction header
subfuncs: List of subfunction entries for this country/codepage
"""
offset: int
header_len: int
country: int
codepage: int
reserved1: int
reserved2: int
subfunc_header_ptr: FarPtr
subfuncs: List[SubfuncEntry]
@dataclass
class ParsedCountrySys:
"""
Complete parsed representation of a COUNTRY.SYS file.
Attributes:
file_size: Total size of the file in bytes
entry_table_count: Number of entry tables (usually 1)
pointer_info_type: Pointer type/version byte (usually 1)
entry_table_ptrs: List of far pointers to entry tables
entries: List of all country/codepage entries
warnings: List of warning messages generated during parsing
"""
file_size: int
entry_table_count: int
pointer_info_type: int
entry_table_ptrs: List[FarPtr]
entries: List[CountryEntry]
warnings: List[str]
# ====
# Helpers
# ====
class ValidationError(Exception):
"""Raised when file format validation fails."""
pass
def _country_name(c: int) -> str:
"""
Get the descriptive name for a country code.
Args:
c: Country code (may be in form 4NCCC for multi-language entries)
Returns:
Country name string, or "unknown" if not in dictionary
Note:
Multi-language entries use form 4NCCC where N is index, CCC is country code.
We extract the base country code by taking modulo 1000.
"""
# Handle multi-language entries: form 4NCCC where N is index, CCC is country code
if c >= 40000:
c = c % 1000
return COUNTRY_NAMES.get(c, "unknown")
def _country_iso_code(c: int) -> str:
"""
Get the ISO 3166-1 alpha-2 code for a country code.
Args:
c: Country code
Returns:
2-letter ISO code, or "XX" if not found
"""
if c >= 40000:
c = c % 1000
return COUNTRY_ISO_CODES.get(c, "XX")
def _codepage_name(cp: int) -> str:
"""
Get the descriptive name for a codepage number.
Args:
cp: Codepage number
Returns:
Codepage name string, or "unknown" if not in dictionary
"""
return CODEPAGE_NAMES.get(cp, "unknown")
def _asciiz(b: bytes) -> str:
"""
Convert a null-terminated byte string to a displayable ASCII string.
Args:
b: Byte string (may contain null terminator)
Returns:
String with printable ASCII characters, non-printable shown as \\xHH,
or "<unspecified>" if empty
Note:
Splits at first null byte, then converts remaining bytes to ASCII.
Non-printable characters (outside 0x20-0x7E) are shown in hex notation.
This is used for currency symbols, separators, and other locale strings.
"""
s = b.split(b"\x00", 1)[0]
out = []
for c in s:
if 0x20 <= c < 0x7F:
out.append(chr(c))
else:
out.append(f"\\x{c:02X}")
if not out:
out.append("<unspecified>")
return "".join(out)
def _hex(b: bytes) -> str:
"""
Format bytes as space-separated hex values.
Args:
b: Bytes to format
Returns:
String like "0x01 0x02 0x03"
"""
return " ".join(f"0x{x:02X}" for x in b)
def _format_byte_table(data: bytes, per_row: int = 8) -> str:
"""
Format bytes as decimal, 3-char wide, right-aligned, space-padded, 8 per row.
Args:
data: Bytes to format
per_row: Number of values per row (default 8)
Returns:
Multi-line string with "db" prefix for each row, suitable for
displaying case conversion and collation tables
Example:
db 128 154 69 65 142 65 143 128
db 69 69 69 73 73 73 142 143
Note:
Uses decimal for easier comparison with FreeDOS country.asm source.
"""
lines = []
for i in range(0, len(data), per_row):
row = data[i:i+per_row]
formatted = " ".join(f"{b:3d}" for b in row)
lines.append(f" db {formatted}")
return "\n".join(lines)
def decode_far_ptr(u32: int, file_len: int, warnings: List[str], ctx: str) -> FarPtr:
"""
Decode a far pointer from a 32-bit value.
Args:
u32: Raw 32-bit pointer value from file
file_len: Total file length (for bounds checking)
warnings: List to append warning messages to
ctx: Context string for warning messages
Returns:
FarPtr object with decoded segment, offset, and linear address
Note:
DOS far pointers are segment:offset pairs. Linear address is seg*16+off.
However, COUNTRY.SYS often uses segment=0, so linear address is just offset.
If the calculated linear address is beyond EOF but offset is valid, we use
the offset as the linear address (common in COUNTRY.SYS files).
"""
raw = u32 & 0xFFFFFFFF
off = raw & 0xFFFF
seg = (raw >> 16) & 0xFFFF
linear = seg * 16 + off
if seg == 0:
linear = off
if linear > file_len and off <= file_len:
warnings.append(f"{ctx}: far ptr {seg:04X}:{off:04X} => {linear:#x} beyond EOF; using low16 {off:#x}")
linear = off
if linear > file_len:
warnings.append(f"{ctx}: pointer {linear:#x} beyond file length {file_len}")
if linear != off != raw:
warnings.append(f"{ctx}: {raw:04X} != {off:04X} != {linear:#x}; using low16 {off:#x}")
linear = off
return FarPtr(raw_u32=raw, seg=seg, off=off, linear=linear)
def token_fit_unpack(data: bytes, fields: List[Tuple[str, str, Any]]) -> Dict[str, Any]:
"""
Unpack a structure using "token-fit" parsing.
Args:
data: Byte data to parse
fields: List of (name, format, default) tuples where format is struct format char
Returns:
Dictionary with field names as keys, plus "_used" (bytes consumed) and
"_extra" (remaining unparsed bytes)
Note:
Token-fit pafrsing allows for variable-length structures. If there aren't
enough bytes for a field, the default value is used instead. This handles
different versions of COUNTRY.SYS that may have shorter CTYINFO structures
(e.g., MS-DOS 3.x vs 6.x).
"""
pos = 0
out: Dict[str, Any] = {}
for name, fmt, default in fields:
sz = struct.calcsize("<" + fmt)
if pos + sz <= len(data):
out[name] = struct.unpack_from("<" + fmt, data, pos)[0]
pos += sz
else:
out[name] = default
out["_used"] = pos
out["_extra"] = data[pos:]
return out
# ====
# Tagged / Subfunction decoders
# ====
def parse_tagged(buf: bytes, off: int, warnings: List[str], ctx: str) -> Tagged:
"""
Parse a tagged data structure from the file buffer.
Args:
buf: Complete file buffer
off: Offset where tagged structure begins
warnings: List to append warning messages to
ctx: Context string for warning messages
Returns:
Tagged object with parsed header and payload
Note:
Tagged structures have format: tag(1) + magic(7) + size(2) + payload(size).
DBCS tables with size==0 have a special dummy WORD after the header.
"""
flen = len(buf)
if off + 10 > flen:
warnings.append(f"{ctx}: tagged header truncated at {off:#x}")
return Tagged(offset=off, tag=0, magic_raw=b"", magic="", size=0, payload=b"")
tag = buf[off]
magic_raw = buf[off + 1: off + 8]
size = struct.unpack_from("<H", buf, off + 8)[0]
magic_clean = magic_raw.rstrip(b" \x00").decode("ascii", "replace")
end = off + 10 + size
if end > flen:
warnings.append(f"{ctx}: payload truncated (wanted {size} bytes) at {off:#x}")
payload = buf[off + 10: flen]
else:
payload = buf[off + 10: end]
# DBCS tables with size==0 have a dummy WORD following the header
dbcs_dummy = None
if magic_clean == "DBCS" and size == 0:
if off + 12 <= flen:
dbcs_dummy = struct.unpack_from("<H", buf, off + 10)[0]
return Tagged(offset=off, tag=tag, magic_raw=magic_raw, magic=magic_clean,
size=size, payload=payload, dbcs_dummy_word=dbcs_dummy)
def decode_ctyinfo(tagged: Tagged, country: int) -> Dict[str, Any]:
"""
Decode CTYINFO (Country Information) structure.
Args:
tagged: Tagged structure with CTYINFO data
Returns:
Dictionary with decoded country information fields
Note:
Uses token-fit parsing to handle different CTYINFO versions across DOS releases.
Standard fields include country ID, codepage, date/time formats,
currency symbol, separators, etc. Extra bytes are tracked for debugging.
"""
fields = [
("country_id", "H", 0), ("codepage", "H", 0), ("date_format", "H", 0),
("currency_symbol", "5s", b""), ("thousands_sep", "2s", b""),
("decimal_sep", "2s", b""), ("date_sep", "2s", b""), ("time_sep", "2s", b""),
("currency_format", "B", 0), ("currency_decimals", "B", 0),
("time_format", "B", 0), ("case_map_ptr_raw", "I", 0),
("data_sep", "2s", b""), ("reserved", "10s", b""),
]
t = token_fit_unpack(tagged.payload, fields)
return {
"country_id": t["country_id"], "codepage": t["codepage"],
"date_format": t["date_format"],
"date_format_name": DATE_FORMAT_NAMES.get(t["date_format"], "unknown"),
"currency_symbol": _asciiz(t["currency_symbol"]),
"thousands_sep": _asciiz(t["thousands_sep"]),
"decimal_sep": _asciiz(t["decimal_sep"]),
"date_sep": _asciiz(t["date_sep"]), "time_sep": _asciiz(t["time_sep"]),
"currency_format": t["currency_format"],
"currency_decimals": t["currency_decimals"],
"time_format": t["time_format"],
"time_format_name": TIME_FORMAT_NAMES.get(t["time_format"], "unknown"),
"case_map_ptr_raw": t["case_map_ptr_raw"],
"data_sep": _asciiz(t["data_sep"]),
"reserved_len": len(t["reserved"]) if isinstance(t["reserved"], (bytes, bytearray)) else 0,
"extra_len": len(t["_extra"]),
}
def decode_fchar(tagged: Tagged) -> Dict[str, Any]:
"""
Decode FCHAR (Filename Character) table.
Args:
tagged: Tagged structure with FCHAR data
Returns:
Dictionary with filename character restrictions and terminator list
Note:
FCHAR defines which characters are valid in filenames and which
characters terminate filename parsing. Used by DOS to validate
8.3 filenames.
"""
p = tagged.payload
if len(p) < 8:
return {"raw_hex": _hex(p)}
characteristics, low, high, r1, ex1, ex2, r2, nterm = struct.unpack_from("<BBBBBBBB", p, 0)
# Bounds check: ensure we don't read past payload end
nterm = min(nterm, len(p) - 8)
terms = p[8:8 + nterm]
return {
"characteristics": characteristics, "lowest_char": low, "highest_char": high,
"excluded_first": ex1, "excluded_last": ex2, "num_terminators": nterm,
"terminators_hex": _hex(terms),
}
def decode_yesno(tagged: Tagged) -> Dict[str, Any]:
"""
Decode YESNO (Yes/No prompt characters) structure.
Args:
tagged: Tagged structure with YESNO data
Returns:
Dictionary with yes/no characters
Note:
Some COUNTRY.SYS files (notably FreeDOS) have a bug where null bytes
are encoded as ASCII '0' (0x30) instead of 0x00. We detect and report
this to help identify malformed files. Note that only the first byte
of the yes/no fields are used so file is still functionally fine unless
field is treated as DBCS value.
"""
p = tagged.payload
yes = _asciiz(p[0:2]) if len(p) >= 2 else ""
# Detect FreeDOS bug: 0x00 mis-encoded as '0' (0x30)
if len(p) >= 2 and p[1] == 0x30:
yes += f" ==> {chr(p[0])} followed by zero mis-encoded as \'0\' == 0x30"
no = _asciiz(p[2:4]) if len(p) >= 4 else ""
return {"yes": yes, "no": no, "raw_hex": _hex(p)}
def decode_dbcs(tagged: Tagged) -> Dict[str, Any]:
"""
Decode DBCS (Double-Byte Character Set) lead byte table.
Args:
tagged: Tagged structure with DBCS data
Returns:
Dictionary with DBCS lead byte ranges
Note:
DBCS tables list ranges of lead bytes for double-byte character sets
(e.g., Japanese Shift-JIS, Chinese GBK). The list is terminated by 0x00 0x00.
Some DBCS tables have size==0 and only contain a dummy WORD (used as a
placeholder when no DBCS support is needed for that codepage).
"""
p = tagged.payload
ranges = []
i = 0
while i + 2 <= len(p):
start = p[i]
end = p[i + 1]
if start == 0 and end == 0:
break
ranges.append((start, end))
i += 2
return {"ranges": ranges, "dbcs_dummy_word": tagged.dbcs_dummy_word, "payload_len": len(p)}
def validate_magic_and_tag(subfunc_id: int, tagged: Tagged, warnings: List[str], strict: bool) -> None:
"""
Validate that a tagged structure has the correct magic string and tag.
Args:
subfunc_id: Subfunction ID that should match the magic
tagged: Tagged structure to validate
warnings: List to append warning messages to
strict: If True, raise ValidationError on mismatch; if False, just warn
Raises:
ValidationError: If strict mode and validation fails
Note:
Each subfunction ID has an expected magic string (e.g., sf 1 = CTYINFO).
ARAMODE is special and uses tag 0x00 instead of 0xFF. This flags
corrupted files or mismatched subfunction IDs.
"""
allowed = ALLOWED_MAGICS.get(subfunc_id)
if allowed and tagged.magic not in allowed:
msg = f"sf {subfunc_id}: magic mismatch: got \'{tagged.magic}\', expected one of {allowed} at {tagged.offset:#x}"
if strict:
raise ValidationError(msg)
warnings.append(msg)
if tagged.magic == "ARAMODE":
if tagged.tag != 0x00:
msg = f"ARAMODE: expected tag 0x00, got {tagged.tag:#x} at {tagged.offset:#x}"
if strict:
raise ValidationError(msg)
warnings.append(msg)
else:
if tagged.tag not in (0xFF, 0x00):
msg = f"tag unusual: got {tagged.tag:#x} at {tagged.offset:#x}"
warnings.append(msg)
# ====
# Main parser
# ====
def parse_country_sys(buf: bytes, strict: bool = False) -> ParsedCountrySys:
"""
Parse a complete COUNTRY.SYS file.
Args:
buf: Complete file contents as bytes
strict: If True, treat validation warnings as fatal errors
Returns:
ParsedCountrySys object with all parsed data and warnings
Raises:
ValidationError: If file format is invalid or strict mode validation fails
Note:
This is the main entry point for parsing. It validates the file header,
parses all entry tables, country/codepage entries, subfunction entries,
and tagged data structures. Warnings are collected rather than raising
exceptions (unless strict mode is enabled).
"""
warnings: List[str] = []
flen = len(buf)
# Validate minimum file size for header
if flen < 0x17:
raise ValidationError("File too short for COUNTRY.SYS header")
# Validate signature and magic
sig = buf[0]
magic = buf[1:8]
if sig != 0xFF or magic.rstrip(b"\x00") != b"COUNTRY":
raise ValidationError("Bad signature/magic (expected 0xFF + \'COUNTRY\')")
# Check reserved bytes (should be all zeros, but not fatal if not)
reserved = buf[8:16]
if strict and reserved != b"\x00" * 8:
warnings.append("reserved bytes in file header are not all zero")
# Parse header fields
entry_table_count = struct.unpack_from("<H", buf, 0x10)[0]
pointer_info_type = buf[0x12]
# Parse entry table pointers
ptrs: List[FarPtr] = []
base = 0x13
for i in range(entry_table_count):
if base + i * 4 + 4 > flen:
warnings.append("pointer array truncated")
break
u32 = struct.unpack_from("<I", buf, base + i * 4)[0]
ptrs.append(decode_far_ptr(u32, flen, warnings, f"entry_table_ptr[{i}]"))
# Parse all country/codepage entries
entries: List[CountryEntry] = []
for t_i, p in enumerate(ptrs):
P = p.linear
if P + 2 > flen:
warnings.append(f"entry_table[{t_i}]: offset {P:#x} beyond EOF")
continue
X = struct.unpack_from("<H", buf, P)[0] # Number of entries in this table
pos = P + 2
for x_i in range(X):
if pos + 2 > flen:
warnings.append(f"entry_table[{t_i}] entry[{x_i}]: truncated")
break
header_len = struct.unpack_from("<H", buf, pos)[0]
if header_len == 0:
warnings.append(f"entry_table[{t_i}] entry[{x_i}]: header_len=0 at {pos:#x}")
if pos + 2 + header_len > flen:
warnings.append(f"entry_table[{t_i}] entry[{x_i}]: header extends beyond EOF")
break
# Parse country/codepage entry fields
country = codepage = reserved1 = reserved2 = 0
qptr = decode_far_ptr(0, flen, warnings, f"entry[{x_i}].subfunc_header_ptr")
if header_len >= 12:
country, codepage, reserved1, reserved2, u32q = struct.unpack_from("<HHHHI", buf, pos + 2)
qptr = decode_far_ptr(u32q, flen, warnings, f"entry[{country}:{codepage}].subfunc_header_ptr")
else:
warnings.append(f"entry_table[{t_i}] entry[{x_i}]: header_len {header_len} < 12 (cannot parse fields)")
# Parse subfunctions for this country/codepage
Q = qptr.linear
subfuncs: List[SubfuncEntry] = []
if Q + 2 <= flen:
Y = struct.unpack_from("<H", buf, Q)[0] # Number of subfunctions
sfpos = Q + 2
for y_i in range(Y):
if sfpos + 2 > flen:
warnings.append(f"subfunc_header {Q:#x}: truncated")
break
entry_len = struct.unpack_from("<H", buf, sfpos)[0]
if entry_len == 0:
warnings.append(f"subfunc_header {Q:#x}: entry_len=0 at {sfpos:#x} (would stall)")
break
if sfpos + 2 + entry_len > flen:
warnings.append(f"subfunc_header {Q:#x}: entry beyond EOF at {sfpos:#x}")
break
# Parse subfunction entry fields
sf_id = 0
dptr = decode_far_ptr(0, flen, warnings, f"entry[{country}:{codepage}].sf[{y_i}].data_ptr")
if entry_len >= 6:
sf_id = struct.unpack_from("<H", buf, sfpos + 2)[0]
u32d = struct.unpack_from("<I", buf, sfpos + 4)[0]
dptr = decode_far_ptr(u32d, flen, warnings, f"entry[{country}:{codepage}].sf[{sf_id}].data_ptr")
s = SubfuncEntry(offset=sfpos, entry_len=entry_len, subfunc_id=sf_id, data_ptr=dptr)
# Parse tagged data structure if valid
if sf_id != 0 and dptr.linear < flen:
tagged = parse_tagged(buf, dptr.linear, warnings, f"sf[{sf_id}]")
validate_magic_and_tag(sf_id, tagged, warnings, strict)
s.tagged = tagged
# Decode known subfunction types
if sf_id == 1 and tagged.magic == "CTYINFO":
s.decoded = decode_ctyinfo(tagged, country)
elif sf_id == 5 and tagged.magic == "FCHAR":
s.decoded = decode_fchar(tagged)
elif sf_id == 7 and tagged.magic == "DBCS":
s.decoded = decode_dbcs(tagged)
elif sf_id == 35 and tagged.magic in ("YESNO", "ARAMODE"):
s.decoded = decode_yesno(tagged)
subfuncs.append(s)
sfpos += 2 + entry_len
entries.append(CountryEntry(
offset=pos, header_len=header_len, country=country,
codepage=codepage, reserved1=reserved1, reserved2=reserved2,
subfunc_header_ptr=qptr, subfuncs=subfuncs
))
pos += 2 + header_len
# Warn about unusual header values (not fatal)
if entry_table_count != 1:
warnings.append(f"entry_table_count={entry_table_count} (normally 1); parsed all available pointers")
if pointer_info_type != 1:
warnings.append(f"pointer_info_type={pointer_info_type} (normally 1); continuing anyway")
return ParsedCountrySys(
file_size=flen, entry_table_count=entry_table_count,
pointer_info_type=pointer_info_type, entry_table_ptrs=ptrs,
entries=entries, warnings=warnings
)
# ====
# Copyright / Version detection
# ====
def find_copyright_and_version(buf: bytes) -> Optional[Dict[str, Any]]:
"""
Scan from end of file for copyright string and optional VERSION tag.
Args:
buf: Complete file contents as bytes
Returns:
Dictionary with 'copyright' string and optional 'version' (major.minor),
or None if no copyright found
Note:
Scans backward from EOF looking for a 0x00 byte. If found, scans backward
for a tagged structure (0xFF + magic + size). If the 0x00 is past the end
of this structure, everything after the 0x00 is the copyright string.
If the tagged structure has magic "VERSION" and size==4, it contains
major and minor version WORDs. Limits search to last 1000 bytes for perf.
"""
flen = len(buf)
if flen < 10:
return None
# Scan backward from end looking for 0x00 byte (usually preceeds terminating string)
zero_pos = None
for i in range(flen - 1, -1, -1):
if buf[i] == 0x00:
zero_pos = i
break
if zero_pos is None:
return None
# Scan backward from zero_pos looking for tagged structure (0xFF byte)
tagged_start = None
for i in range(zero_pos - 1, max(0, zero_pos - 1000), -1): # Limit search to 1000 bytes
if buf[i] == 0xFF:
# Check if this looks like a valid tagged structure
if i + 10 <= flen:
magic_raw = buf[i + 1: i + 8]
size = struct.unpack_from("<H", buf, i + 8)[0]
tagged_end = i + 10 # End of header (payload not included in check)
# Check if zero_pos is past the tagged header
if tagged_end < zero_pos:
tagged_start = i
break
if tagged_start is None:
return None
# Extract copyright (everything after the 0x00)
copyright_bytes = buf[zero_pos + 1:]
copyright_str = copyright_bytes.decode("ascii", "replace").rstrip("\x00 \r\n")
result: Dict[str, Any] = {"copyright": copyright_str}
# Check if the tagged structure is VERSION
magic_raw = buf[tagged_start + 1: tagged_start + 8]
magic = magic_raw.rstrip(b" \x00").decode("ascii", "replace")
size = struct.unpack_from("<H", buf, tagged_start + 8)[0]
if magic == "VERSION" and size == 4:
# Parse version: <major>\x00<minor>\x00