mtrevival 0.3.1__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.
- mtrevival/__init__.py +3 -0
- mtrevival/__main__.py +95 -0
- mtrevival/bin/wmsource-shim.dll +0 -0
- mtrevival/d3denum.py +114 -0
- mtrevival/displays.py +115 -0
- mtrevival/fixpack.py +316 -0
- mtrevival/gameconfig.py +97 -0
- mtrevival/music.py +142 -0
- mtrevival-0.3.1.dist-info/METADATA +235 -0
- mtrevival-0.3.1.dist-info/RECORD +14 -0
- mtrevival-0.3.1.dist-info/WHEEL +5 -0
- mtrevival-0.3.1.dist-info/entry_points.txt +2 -0
- mtrevival-0.3.1.dist-info/licenses/LICENSE +21 -0
- mtrevival-0.3.1.dist-info/top_level.txt +1 -0
mtrevival/__init__.py
ADDED
mtrevival/__main__.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Command line entry point: ``python -m mtrevival``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import fixpack
|
|
11
|
+
|
|
12
|
+
_RESOLUTION = re.compile(r"^(\d{3,4})x(\d{3,4})$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolution(text: str) -> tuple[int, int]:
|
|
16
|
+
"""argparse type for WIDTHxHEIGHT."""
|
|
17
|
+
m = _RESOLUTION.match(text.strip().lower())
|
|
18
|
+
if not m:
|
|
19
|
+
raise argparse.ArgumentTypeError(
|
|
20
|
+
"expected WIDTHxHEIGHT such as 1920x1080, got %r" % text)
|
|
21
|
+
return int(m.group(1)), int(m.group(2))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main(argv: list[str] | None = None) -> int:
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="mtrevival",
|
|
27
|
+
description="Make Monopoly Tycoon (2001) run on modern Windows.")
|
|
28
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
29
|
+
|
|
30
|
+
def common(p: argparse.ArgumentParser) -> None:
|
|
31
|
+
p.add_argument("--game-dir", type=Path, default=None,
|
|
32
|
+
help="install directory containing mc.exe")
|
|
33
|
+
p.add_argument("--resolution", type=resolution,
|
|
34
|
+
default=(fixpack.DEFAULT_WIDTH, fixpack.DEFAULT_HEIGHT),
|
|
35
|
+
metavar="WxH",
|
|
36
|
+
help="display mode, default %dx%d; the adapter must list it"
|
|
37
|
+
% (fixpack.DEFAULT_WIDTH, fixpack.DEFAULT_HEIGHT))
|
|
38
|
+
|
|
39
|
+
for name, help_text in (("check", "report what would be done, change nothing"),
|
|
40
|
+
("fix", "write config.cfg with a working adapter")):
|
|
41
|
+
p = sub.add_parser(name, help=help_text)
|
|
42
|
+
common(p)
|
|
43
|
+
p.add_argument("--windowed", action="store_true",
|
|
44
|
+
help="run in a window instead of fullscreen (needs patch 1.2)")
|
|
45
|
+
p.add_argument("--music", action="store_true",
|
|
46
|
+
help="restore the soundtrack: install the wmsource-shim "
|
|
47
|
+
"for this user and write music 0")
|
|
48
|
+
common(sub.add_parser("adapters", help="list adapters and whether they fit"))
|
|
49
|
+
|
|
50
|
+
args = parser.parse_args(argv)
|
|
51
|
+
width, height = args.resolution
|
|
52
|
+
windowed = getattr(args, "windowed", False)
|
|
53
|
+
with_music = getattr(args, "music", False)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
game_dir = fixpack.find_install(args.game_dir)
|
|
57
|
+
except fixpack.FixError as error:
|
|
58
|
+
print("error: %s" % error, file=sys.stderr)
|
|
59
|
+
return 2
|
|
60
|
+
|
|
61
|
+
if args.command == "adapters":
|
|
62
|
+
adapters, source = fixpack.read_adapters(game_dir)
|
|
63
|
+
print("source: %s" % source)
|
|
64
|
+
for adapter in adapters:
|
|
65
|
+
fits = adapter.supports(width, height)
|
|
66
|
+
print(" adapter %d %-34s modes=%-4d %s%s"
|
|
67
|
+
% (adapter.index, adapter.description or "(unnamed)",
|
|
68
|
+
len(adapter.modes),
|
|
69
|
+
("OK %dx%d" if fits else "no %dx%d") % (width, height),
|
|
70
|
+
" [portrait]" if adapter.is_portrait else ""))
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
plan = fixpack.build_plan(game_dir, width, height, windowed, with_music)
|
|
74
|
+
print(fixpack.describe(plan))
|
|
75
|
+
|
|
76
|
+
if args.command == "check":
|
|
77
|
+
return 0 if plan.ok else 1
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
backup = fixpack.apply(plan)
|
|
81
|
+
except fixpack.FixError as error:
|
|
82
|
+
print("\nerror: %s" % error, file=sys.stderr)
|
|
83
|
+
return 1
|
|
84
|
+
|
|
85
|
+
if plan.music:
|
|
86
|
+
print("\nInstalled %s and registered it for this user." % plan.shim_target)
|
|
87
|
+
print("\nWrote %s" % plan.config_path)
|
|
88
|
+
if backup:
|
|
89
|
+
print("Backed up previous config to %s" % backup)
|
|
90
|
+
print("Launch the game with mc.exe.")
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
sys.exit(main())
|
|
Binary file
|
mtrevival/d3denum.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Parser for Monopoly Tycoon's own ``D3DEnum.txt``.
|
|
2
|
+
|
|
3
|
+
The game writes this file when it enumerates Direct3D 8 adapters. It is the
|
|
4
|
+
authoritative record of what *the game* sees, which is what matters when
|
|
5
|
+
choosing an adapter index for ``config.cfg`` — it can differ from what Windows
|
|
6
|
+
reports, and a rotated display shows portrait modes here.
|
|
7
|
+
|
|
8
|
+
Example of the format::
|
|
9
|
+
|
|
10
|
+
Number of display Adapters :- 2
|
|
11
|
+
|
|
12
|
+
Adapter #0
|
|
13
|
+
nvldumd.dll
|
|
14
|
+
NVIDIA GeForce RTX 4080
|
|
15
|
+
Device Validated
|
|
16
|
+
Available Video Modes = 522
|
|
17
|
+
Vid Mode 480 X 640 X 32
|
|
18
|
+
Valid Video Modes = 46
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
|
|
26
|
+
_ADAPTER = re.compile(r"^Adapter #(\d+)\s*$")
|
|
27
|
+
_MODE = re.compile(r"^Vid Mode\s+(\d+)\s+X\s+(\d+)\s+X\s+(\d+)\s*$")
|
|
28
|
+
_COUNT = re.compile(r"^Number of display Adapters\s*:-\s*(\d+)\s*$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Adapter:
|
|
33
|
+
"""One Direct3D 8 adapter as the game enumerated it."""
|
|
34
|
+
|
|
35
|
+
index: int
|
|
36
|
+
description: str = ""
|
|
37
|
+
validated: bool = False
|
|
38
|
+
modes: list[tuple[int, int, int]] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
def supports(self, width: int, height: int, bpp: int | None = None) -> bool:
|
|
41
|
+
"""True if this adapter offers the given mode."""
|
|
42
|
+
for w, h, b in self.modes:
|
|
43
|
+
if w == width and h == height and (bpp is None or b == bpp):
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_portrait(self) -> bool:
|
|
49
|
+
"""True if every mode is taller than it is wide (a rotated display)."""
|
|
50
|
+
return bool(self.modes) and all(h > w for w, h, _ in self.modes)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse(text: str) -> list[Adapter]:
|
|
54
|
+
"""Parse the contents of ``D3DEnum.txt`` into a list of adapters."""
|
|
55
|
+
adapters: list[Adapter] = []
|
|
56
|
+
current: Adapter | None = None
|
|
57
|
+
# A description is the first non-empty line after the DLL name line.
|
|
58
|
+
lines_since_header = 0
|
|
59
|
+
|
|
60
|
+
for raw in text.splitlines():
|
|
61
|
+
line = raw.strip()
|
|
62
|
+
|
|
63
|
+
m = _ADAPTER.match(line)
|
|
64
|
+
if m:
|
|
65
|
+
current = Adapter(index=int(m.group(1)))
|
|
66
|
+
adapters.append(current)
|
|
67
|
+
lines_since_header = 0
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
if current is None:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
m = _MODE.match(line)
|
|
74
|
+
if m:
|
|
75
|
+
current.modes.append((int(m.group(1)), int(m.group(2)), int(m.group(3))))
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
if line == "Device Validated":
|
|
79
|
+
current.validated = True
|
|
80
|
+
continue
|
|
81
|
+
if line == "Device is Not Valid":
|
|
82
|
+
current.validated = False
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
if line:
|
|
86
|
+
lines_since_header += 1
|
|
87
|
+
# line 1 is the driver DLL, line 2 is the human-readable description
|
|
88
|
+
if lines_since_header == 2 and not current.description:
|
|
89
|
+
current.description = line
|
|
90
|
+
|
|
91
|
+
return adapters
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def declared_adapter_count(text: str) -> int | None:
|
|
95
|
+
"""The adapter count the game printed, or None if the line is absent."""
|
|
96
|
+
for raw in text.splitlines():
|
|
97
|
+
m = _COUNT.match(raw.strip())
|
|
98
|
+
if m:
|
|
99
|
+
return int(m.group(1))
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def choose_adapter(adapters: list[Adapter], width: int, height: int,
|
|
104
|
+
bpp: int | None = None) -> int | None:
|
|
105
|
+
"""Pick the lowest-numbered validated adapter that supports the mode.
|
|
106
|
+
|
|
107
|
+
Returns the adapter index for ``SysSetup device``, or None when no adapter
|
|
108
|
+
offers the mode. Adapter 0 is preferred when it qualifies, because that is
|
|
109
|
+
what the game uses by default and it keeps the config closest to stock.
|
|
110
|
+
"""
|
|
111
|
+
for adapter in sorted(adapters, key=lambda a: a.index):
|
|
112
|
+
if adapter.validated and adapter.supports(width, height, bpp):
|
|
113
|
+
return adapter.index
|
|
114
|
+
return None
|
mtrevival/displays.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Windows display enumeration, used only when ``D3DEnum.txt`` is absent.
|
|
2
|
+
|
|
3
|
+
``D3DEnum.txt`` is preferred because it records what the *game* sees. This
|
|
4
|
+
module is the fallback for a first run, before the game has ever started.
|
|
5
|
+
|
|
6
|
+
Caveat worth knowing: Direct3D 8 adapter ordering is not guaranteed to match
|
|
7
|
+
``EnumDisplayDevices`` ordering. In practice both follow the desktop device
|
|
8
|
+
order, with the primary display first, but a config produced from this fallback
|
|
9
|
+
should be re-derived from ``D3DEnum.txt`` once the game has run once.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ctypes
|
|
15
|
+
from ctypes import wintypes
|
|
16
|
+
|
|
17
|
+
from .d3denum import Adapter
|
|
18
|
+
|
|
19
|
+
ENUM_CURRENT_SETTINGS = -1
|
|
20
|
+
DISPLAY_DEVICE_ATTACHED_TO_DESKTOP = 0x00000001
|
|
21
|
+
DISPLAY_DEVICE_PRIMARY_DEVICE = 0x00000004
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DISPLAY_DEVICEW(ctypes.Structure):
|
|
25
|
+
_fields_ = [
|
|
26
|
+
("cb", wintypes.DWORD),
|
|
27
|
+
("DeviceName", wintypes.WCHAR * 32),
|
|
28
|
+
("DeviceString", wintypes.WCHAR * 128),
|
|
29
|
+
("StateFlags", wintypes.DWORD),
|
|
30
|
+
("DeviceID", wintypes.WCHAR * 128),
|
|
31
|
+
("DeviceKey", wintypes.WCHAR * 128),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _POINTL(ctypes.Structure):
|
|
36
|
+
_fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DEVMODEW(ctypes.Structure):
|
|
40
|
+
_fields_ = [
|
|
41
|
+
("dmDeviceName", wintypes.WCHAR * 32),
|
|
42
|
+
("dmSpecVersion", wintypes.WORD),
|
|
43
|
+
("dmDriverVersion", wintypes.WORD),
|
|
44
|
+
("dmSize", wintypes.WORD),
|
|
45
|
+
("dmDriverExtra", wintypes.WORD),
|
|
46
|
+
("dmFields", wintypes.DWORD),
|
|
47
|
+
("dmPosition", _POINTL),
|
|
48
|
+
("dmDisplayOrientation", wintypes.DWORD),
|
|
49
|
+
("dmDisplayFixedOutput", wintypes.DWORD),
|
|
50
|
+
("dmColor", ctypes.c_short),
|
|
51
|
+
("dmDuplex", ctypes.c_short),
|
|
52
|
+
("dmYResolution", ctypes.c_short),
|
|
53
|
+
("dmTTOption", ctypes.c_short),
|
|
54
|
+
("dmCollate", ctypes.c_short),
|
|
55
|
+
("dmFormName", wintypes.WCHAR * 32),
|
|
56
|
+
("dmLogPixels", wintypes.WORD),
|
|
57
|
+
("dmBitsPerPel", wintypes.DWORD),
|
|
58
|
+
("dmPelsWidth", wintypes.DWORD),
|
|
59
|
+
("dmPelsHeight", wintypes.DWORD),
|
|
60
|
+
("dmDisplayFlags", wintypes.DWORD),
|
|
61
|
+
("dmDisplayFrequency", wintypes.DWORD),
|
|
62
|
+
("dmICMMethod", wintypes.DWORD),
|
|
63
|
+
("dmICMIntent", wintypes.DWORD),
|
|
64
|
+
("dmMediaType", wintypes.DWORD),
|
|
65
|
+
("dmDitherType", wintypes.DWORD),
|
|
66
|
+
("dmReserved1", wintypes.DWORD),
|
|
67
|
+
("dmReserved2", wintypes.DWORD),
|
|
68
|
+
("dmPanningWidth", wintypes.DWORD),
|
|
69
|
+
("dmPanningHeight", wintypes.DWORD),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def enumerate_adapters() -> list[Adapter]:
|
|
74
|
+
"""Enumerate attached displays and their modes, in desktop device order."""
|
|
75
|
+
user32 = ctypes.windll.user32
|
|
76
|
+
adapters: list[Adapter] = []
|
|
77
|
+
index = 0
|
|
78
|
+
device_number = 0
|
|
79
|
+
|
|
80
|
+
while True:
|
|
81
|
+
dev = DISPLAY_DEVICEW()
|
|
82
|
+
dev.cb = ctypes.sizeof(DISPLAY_DEVICEW)
|
|
83
|
+
if not user32.EnumDisplayDevicesW(None, device_number, ctypes.byref(dev), 0):
|
|
84
|
+
break
|
|
85
|
+
device_number += 1
|
|
86
|
+
if not dev.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
adapter = Adapter(index=index, description=dev.DeviceString, validated=True)
|
|
90
|
+
index += 1
|
|
91
|
+
|
|
92
|
+
mode_number = 0
|
|
93
|
+
seen: set[tuple[int, int, int]] = set()
|
|
94
|
+
while True:
|
|
95
|
+
dm = DEVMODEW()
|
|
96
|
+
dm.dmSize = ctypes.sizeof(DEVMODEW)
|
|
97
|
+
if not user32.EnumDisplaySettingsW(dev.DeviceName, mode_number,
|
|
98
|
+
ctypes.byref(dm)):
|
|
99
|
+
break
|
|
100
|
+
mode_number += 1
|
|
101
|
+
entry = (int(dm.dmPelsWidth), int(dm.dmPelsHeight), int(dm.dmBitsPerPel))
|
|
102
|
+
if entry not in seen:
|
|
103
|
+
seen.add(entry)
|
|
104
|
+
adapter.modes.append(entry)
|
|
105
|
+
|
|
106
|
+
# Primary display sorts first, matching how D3D8 numbers adapters.
|
|
107
|
+
adapters.append((0 if dev.StateFlags & DISPLAY_DEVICE_PRIMARY_DEVICE else 1,
|
|
108
|
+
adapter))
|
|
109
|
+
|
|
110
|
+
adapters.sort(key=lambda pair: pair[0])
|
|
111
|
+
result = []
|
|
112
|
+
for i, (_, adapter) in enumerate(adapters):
|
|
113
|
+
adapter.index = i
|
|
114
|
+
result.append(adapter)
|
|
115
|
+
return result
|
mtrevival/fixpack.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Apply the Windows 11 fixes to an installed copy of Monopoly Tycoon.
|
|
2
|
+
|
|
3
|
+
Two crashes stop the 2001 game on modern Windows, both the same defect: the
|
|
4
|
+
game creates an object, never checks whether it succeeded, then dereferences
|
|
5
|
+
null.
|
|
6
|
+
|
|
7
|
+
1. ``IDirect3D8::CreateDevice`` fails when the game asks for 640x480 exclusive
|
|
8
|
+
fullscreen on an adapter with no such mode — for example a rotated portrait
|
|
9
|
+
primary display. It then calls ``SetViewport`` on the null device.
|
|
10
|
+
Fixed by pointing ``SysSetup device`` at an adapter that has the mode.
|
|
11
|
+
|
|
12
|
+
2. Opening ``gamedata\\sound\\music\\music_intro.wma`` fails because the Windows
|
|
13
|
+
Media DirectShow source filter no longer ships with Windows.
|
|
14
|
+
Avoided by ``SysSetup music 1``, which suppresses that path; fixed by
|
|
15
|
+
``--music``, which installs a stand-in for the missing filter and writes
|
|
16
|
+
``music 0`` (see ``music.py``).
|
|
17
|
+
|
|
18
|
+
A third problem must be solved before either fix can persist: the game cannot
|
|
19
|
+
write ``config.cfg`` inside Program Files under a standard user account.
|
|
20
|
+
|
|
21
|
+
The same mechanism sets the resolution. Any mode the chosen adapter enumerates
|
|
22
|
+
works, fullscreen or (on patch 1.2) windowed; 1920x1080 and 1280x720 windowed
|
|
23
|
+
were verified on one machine.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import shutil
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from datetime import datetime
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from . import d3denum, gameconfig, music
|
|
35
|
+
|
|
36
|
+
DEFAULT_INSTALL = Path(r"C:\Program Files (x86)\Infogrames\Monopoly Tycoon")
|
|
37
|
+
DEFAULT_WIDTH = gameconfig.DEFAULT_WIDTH
|
|
38
|
+
DEFAULT_HEIGHT = gameconfig.DEFAULT_HEIGHT
|
|
39
|
+
TARGET_BPP = 32
|
|
40
|
+
|
|
41
|
+
# MD5 of mc.exe for the builds this tool has been run against. Patch 1.2
|
|
42
|
+
# renamed the windowed-mode key, so the version decides which keys are honoured.
|
|
43
|
+
KNOWN_BUILDS = {
|
|
44
|
+
"fd34022887dc347b664b689fedeb9a37": "1.0",
|
|
45
|
+
"5965dbb1a4be4f58fa4452df78786e63": "1.2",
|
|
46
|
+
}
|
|
47
|
+
WINDOWED_NEEDS = "1.2"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FixError(Exception):
|
|
51
|
+
"""A problem that stops the fix being applied."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Plan:
|
|
56
|
+
"""What the fix would do, before it does it."""
|
|
57
|
+
|
|
58
|
+
game_dir: Path
|
|
59
|
+
game_version: str
|
|
60
|
+
writable: bool
|
|
61
|
+
adapter: int | None
|
|
62
|
+
adapter_source: str
|
|
63
|
+
adapter_description: str
|
|
64
|
+
width: int
|
|
65
|
+
height: int
|
|
66
|
+
windowed: bool
|
|
67
|
+
config_path: Path
|
|
68
|
+
config_exists: bool
|
|
69
|
+
rendered: str
|
|
70
|
+
music: bool = False
|
|
71
|
+
shim_source: Path | None = None
|
|
72
|
+
shim_target: Path | None = None
|
|
73
|
+
reader_available: bool = True
|
|
74
|
+
run_as_admin: bool = False
|
|
75
|
+
tool_elevated: bool = False
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def ok(self) -> bool:
|
|
79
|
+
return (self.writable and self.adapter is not None
|
|
80
|
+
and not self.music_problems)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def music_problems(self) -> list[str]:
|
|
84
|
+
"""What stops ``--music`` from being applied. Empty when music is off."""
|
|
85
|
+
if not self.music:
|
|
86
|
+
return []
|
|
87
|
+
out = []
|
|
88
|
+
if self.shim_source is None:
|
|
89
|
+
out.append("%s is not bundled with this copy of mtrevival. Build it "
|
|
90
|
+
"with tools\\wmsource-shim\\build.ps1 (needs the MSVC "
|
|
91
|
+
"Build Tools) or install a release that includes it."
|
|
92
|
+
% music.SHIM_NAME)
|
|
93
|
+
if not self.reader_available:
|
|
94
|
+
out.append("The WM ASF Reader (Windows Media) is not registered for "
|
|
95
|
+
"32-bit programs. On Windows N editions install the "
|
|
96
|
+
"Media Feature Pack, then retry.")
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def warnings(self) -> list[str]:
|
|
101
|
+
"""Things the user should know that do not stop the fix.
|
|
102
|
+
|
|
103
|
+
Only 1.0 was observed ignoring the Window key. An unrecognised build
|
|
104
|
+
gets told exactly that, not a prediction.
|
|
105
|
+
"""
|
|
106
|
+
out = []
|
|
107
|
+
if self.windowed and self.game_version == "unknown":
|
|
108
|
+
out.append("Windowed mode is only verified on patch %s, and this "
|
|
109
|
+
"mc.exe is not a build this tool recognises. The Window "
|
|
110
|
+
"key is written; whether the game honours it is unproven."
|
|
111
|
+
% WINDOWED_NEEDS)
|
|
112
|
+
elif self.windowed and self.game_version != WINDOWED_NEEDS:
|
|
113
|
+
out.append("Windowed mode needs patch %s; game version here is %s, "
|
|
114
|
+
"which ignores the Window key and runs fullscreen."
|
|
115
|
+
% (WINDOWED_NEEDS, self.game_version))
|
|
116
|
+
if self.music and self.run_as_admin:
|
|
117
|
+
out.append("mc.exe is set to run as administrator. An elevated "
|
|
118
|
+
"process ignores per-user COM registrations, so it will "
|
|
119
|
+
"not see the music shim and will crash with music 0. "
|
|
120
|
+
"Clear that compatibility setting before playing.")
|
|
121
|
+
if self.music and self.tool_elevated:
|
|
122
|
+
out.append("This shell is elevated. The shim is registered for the "
|
|
123
|
+
"account running this command; the game must run as the "
|
|
124
|
+
"same account, not elevated. If you elevated as a "
|
|
125
|
+
"different user, rerun this from a normal shell.")
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def find_install(explicit: Path | None = None) -> Path:
|
|
130
|
+
"""Locate the game directory, or raise FixError.
|
|
131
|
+
|
|
132
|
+
An explicitly supplied directory is never overridden by a search: writing
|
|
133
|
+
into a different install than the one the caller named would be worse than
|
|
134
|
+
failing.
|
|
135
|
+
"""
|
|
136
|
+
if explicit is not None:
|
|
137
|
+
if (explicit / "mc.exe").is_file():
|
|
138
|
+
# Absolute: the path is written into the registry and must not
|
|
139
|
+
# depend on the shell's working directory.
|
|
140
|
+
return explicit.resolve()
|
|
141
|
+
raise FixError("Could not find mc.exe in %s" % explicit)
|
|
142
|
+
|
|
143
|
+
for path in (DEFAULT_INSTALL,
|
|
144
|
+
Path(r"C:\Program Files\Infogrames\Monopoly Tycoon"),
|
|
145
|
+
Path(r"C:\Program Files (x86)\Infogrames Interactive\Monopoly Tycoon")):
|
|
146
|
+
if (path / "mc.exe").is_file():
|
|
147
|
+
return path
|
|
148
|
+
raise FixError(
|
|
149
|
+
"Could not find mc.exe. Pass the install directory with --game-dir.")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def game_version(game_dir: Path) -> str:
|
|
153
|
+
"""'1.0', '1.2', or 'unknown', from the hash of mc.exe."""
|
|
154
|
+
digest = hashlib.md5((game_dir / "mc.exe").read_bytes()).hexdigest()
|
|
155
|
+
return KNOWN_BUILDS.get(digest, "unknown")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def is_writable(directory: Path) -> bool:
|
|
159
|
+
"""True if this account can create files in the game directory."""
|
|
160
|
+
probe = directory / ".mtrevival-write-probe"
|
|
161
|
+
try:
|
|
162
|
+
probe.touch()
|
|
163
|
+
probe.unlink()
|
|
164
|
+
return True
|
|
165
|
+
except OSError:
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def grant_command(directory: Path) -> str:
|
|
170
|
+
"""The elevated command that makes the game directory writable."""
|
|
171
|
+
return ('icacls "%s" /grant "$($env:USERNAME):(OI)(CI)M" /T' % directory)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def read_adapters(game_dir: Path) -> tuple[list[d3denum.Adapter], str]:
|
|
175
|
+
"""Adapters as the game sees them, falling back to Windows enumeration."""
|
|
176
|
+
enum_file = game_dir / "D3DEnum.txt"
|
|
177
|
+
if enum_file.is_file():
|
|
178
|
+
adapters = d3denum.parse(enum_file.read_text(errors="replace"))
|
|
179
|
+
if adapters:
|
|
180
|
+
return adapters, "D3DEnum.txt"
|
|
181
|
+
from . import displays # imported lazily; Windows-only
|
|
182
|
+
return displays.enumerate_adapters(), "Windows display enumeration"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def build_plan(game_dir: Path, width: int = DEFAULT_WIDTH,
|
|
186
|
+
height: int = DEFAULT_HEIGHT, windowed: bool = False,
|
|
187
|
+
with_music: bool = False) -> Plan:
|
|
188
|
+
"""Work out what to do without changing anything."""
|
|
189
|
+
adapters, source = read_adapters(game_dir)
|
|
190
|
+
index = d3denum.choose_adapter(adapters, width, height, TARGET_BPP)
|
|
191
|
+
if index is None:
|
|
192
|
+
# Retry ignoring colour depth: some sources record modes without bpp.
|
|
193
|
+
index = d3denum.choose_adapter(adapters, width, height)
|
|
194
|
+
|
|
195
|
+
description = ""
|
|
196
|
+
for adapter in adapters:
|
|
197
|
+
if adapter.index == index:
|
|
198
|
+
description = adapter.description
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
config_path = game_dir / "config.cfg"
|
|
202
|
+
config = gameconfig.default_config(index if index is not None else 0,
|
|
203
|
+
width, height, windowed, with_music)
|
|
204
|
+
return Plan(
|
|
205
|
+
game_dir=game_dir,
|
|
206
|
+
game_version=game_version(game_dir),
|
|
207
|
+
writable=is_writable(game_dir),
|
|
208
|
+
adapter=index,
|
|
209
|
+
adapter_source=source,
|
|
210
|
+
adapter_description=description,
|
|
211
|
+
width=width,
|
|
212
|
+
height=height,
|
|
213
|
+
windowed=windowed,
|
|
214
|
+
config_path=config_path,
|
|
215
|
+
config_exists=config_path.is_file(),
|
|
216
|
+
rendered=config.render(),
|
|
217
|
+
music=with_music,
|
|
218
|
+
shim_source=music.bundled_shim() if with_music else None,
|
|
219
|
+
shim_target=game_dir / music.SHIM_NAME,
|
|
220
|
+
reader_available=music.reader_available() if with_music else True,
|
|
221
|
+
run_as_admin=music.run_as_admin_flagged(game_dir / "mc.exe") if with_music else False,
|
|
222
|
+
tool_elevated=music.is_elevated() if with_music else False,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def install_music(plan: Plan) -> None:
|
|
227
|
+
"""Copy the shim into the game directory and register it for this user.
|
|
228
|
+
|
|
229
|
+
The copy lives next to mc.exe rather than inside the Python package so
|
|
230
|
+
the registration survives the package being upgraded, moved, or run from
|
|
231
|
+
a temporary environment.
|
|
232
|
+
"""
|
|
233
|
+
if plan.music_problems:
|
|
234
|
+
raise FixError("\n".join(plan.music_problems))
|
|
235
|
+
assert plan.shim_source is not None and plan.shim_target is not None
|
|
236
|
+
try:
|
|
237
|
+
if not (plan.shim_target.is_file()
|
|
238
|
+
and plan.shim_target.read_bytes() == plan.shim_source.read_bytes()):
|
|
239
|
+
shutil.copy2(plan.shim_source, plan.shim_target)
|
|
240
|
+
except OSError as error:
|
|
241
|
+
raise FixError("Could not copy %s into the game directory (%s). "
|
|
242
|
+
"Close the game if it is running, then retry."
|
|
243
|
+
% (music.SHIM_NAME, error))
|
|
244
|
+
try:
|
|
245
|
+
music.register(plan.shim_target)
|
|
246
|
+
except OSError as error:
|
|
247
|
+
raise FixError("Could not register %s for this user (%s)."
|
|
248
|
+
% (music.SHIM_NAME, error))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def apply(plan: Plan) -> Path | None:
|
|
252
|
+
"""Write config.cfg, backing up any existing file. Returns the backup path.
|
|
253
|
+
|
|
254
|
+
With music requested, the shim is installed and registered first, so a
|
|
255
|
+
failure there leaves the previous config (and its ``music 1``) in place.
|
|
256
|
+
"""
|
|
257
|
+
if not plan.writable:
|
|
258
|
+
raise FixError(
|
|
259
|
+
"The game directory is not writable by this account.\n"
|
|
260
|
+
"Run this once in an elevated PowerShell, then try again:\n\n "
|
|
261
|
+
+ grant_command(plan.game_dir))
|
|
262
|
+
if plan.adapter is None:
|
|
263
|
+
raise FixError(
|
|
264
|
+
"No display adapter offers %dx%d. Every adapter reported only other "
|
|
265
|
+
"modes — a rotated display can do this. Pick a resolution the "
|
|
266
|
+
"adapter lists (see `adapters`), rotate a display to landscape, or "
|
|
267
|
+
"attach one that supports the mode."
|
|
268
|
+
% (plan.width, plan.height))
|
|
269
|
+
if plan.music:
|
|
270
|
+
install_music(plan)
|
|
271
|
+
|
|
272
|
+
backup = None
|
|
273
|
+
if plan.config_exists:
|
|
274
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
275
|
+
backup = plan.config_path.with_suffix(".cfg.%s.bak" % stamp)
|
|
276
|
+
shutil.copy2(plan.config_path, backup)
|
|
277
|
+
|
|
278
|
+
plan.config_path.write_bytes(plan.rendered.encode("ascii"))
|
|
279
|
+
return backup
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def describe(plan: Plan) -> str:
|
|
283
|
+
"""A human-readable summary of the plan."""
|
|
284
|
+
lines = [
|
|
285
|
+
"Game directory : %s" % plan.game_dir,
|
|
286
|
+
"Game version : %s" % plan.game_version,
|
|
287
|
+
"Writable : %s" % ("yes" if plan.writable else "NO"),
|
|
288
|
+
"Resolution : %dx%d %s" % (plan.width, plan.height,
|
|
289
|
+
"windowed" if plan.windowed else "fullscreen"),
|
|
290
|
+
"Adapter source : %s" % plan.adapter_source,
|
|
291
|
+
]
|
|
292
|
+
if plan.adapter is None:
|
|
293
|
+
lines.append("Adapter : none supports %dx%d" % (plan.width, plan.height))
|
|
294
|
+
else:
|
|
295
|
+
lines.append("Adapter : %d%s"
|
|
296
|
+
% (plan.adapter,
|
|
297
|
+
(" (%s)" % plan.adapter_description)
|
|
298
|
+
if plan.adapter_description else ""))
|
|
299
|
+
lines.append("config.cfg : %s"
|
|
300
|
+
% ("exists, will be backed up" if plan.config_exists else "new"))
|
|
301
|
+
if plan.music:
|
|
302
|
+
lines.append("Music : restore (music 0; %s -> %s, registered "
|
|
303
|
+
"per user)" % (music.SHIM_NAME, plan.shim_target))
|
|
304
|
+
else:
|
|
305
|
+
lines.append("Music : off (music 1); add --music to restore it")
|
|
306
|
+
for problem in plan.music_problems:
|
|
307
|
+
lines.append("PROBLEM : %s" % problem)
|
|
308
|
+
for warning in plan.warnings:
|
|
309
|
+
lines.append("WARNING : %s" % warning)
|
|
310
|
+
lines.append("")
|
|
311
|
+
lines.append("config.cfg to write:")
|
|
312
|
+
lines += [" " + line for line in plan.rendered.replace("\r\n", "\n").rstrip().split("\n")]
|
|
313
|
+
if not plan.writable:
|
|
314
|
+
lines += ["", "Run this once in an elevated PowerShell first:",
|
|
315
|
+
" " + grant_command(plan.game_dir)]
|
|
316
|
+
return "\n".join(lines)
|
mtrevival/gameconfig.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Read and write Monopoly Tycoon's ``config.cfg``.
|
|
2
|
+
|
|
3
|
+
The file is a list of console commands, one per line, CRLF-terminated::
|
|
4
|
+
|
|
5
|
+
SysSetup api D3D
|
|
6
|
+
SysSetup device 1
|
|
7
|
+
SysSetup width 640
|
|
8
|
+
|
|
9
|
+
Verified by reading the game's process memory after it parsed a file written by
|
|
10
|
+
hand: the exact bytes appeared on the heap, and ``device`` changed which adapter
|
|
11
|
+
``CreateDevice`` targeted.
|
|
12
|
+
|
|
13
|
+
Not every key is honoured on every code path. ``device``, ``bitdepth 32``,
|
|
14
|
+
``music``, ``width``, ``height`` and (on patch 1.2) ``Window`` were observed
|
|
15
|
+
taking effect; the 1.0 key ``windowed`` and ``bitdepth 16`` were observed being
|
|
16
|
+
ignored. Treat any other key as unproven until you watch it work.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
# Key spellings exactly as the game's own format strings write them. The 1.0
|
|
24
|
+
# keys come first in the order 1.0 writes them; the patch 1.2 keys follow, in
|
|
25
|
+
# the position verified running (``Window`` after ``music``). 1.0 ignores keys
|
|
26
|
+
# it does not know.
|
|
27
|
+
KEY_ORDER = ["api", "device", "width", "height", "bitdepth", "texbitdepth",
|
|
28
|
+
"windowed", "Texdetail", "sound", "music", "shware", "avail",
|
|
29
|
+
"Window", "Fog", "Halos", "Multitexture", "No3d", "NoMovie"]
|
|
30
|
+
|
|
31
|
+
LINE_ENDING = "\r\n"
|
|
32
|
+
COMMAND = "SysSetup"
|
|
33
|
+
|
|
34
|
+
DEFAULT_WIDTH = 640
|
|
35
|
+
DEFAULT_HEIGHT = 480
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Config:
|
|
40
|
+
"""The SysSetup settings, preserving the order they should be written in."""
|
|
41
|
+
|
|
42
|
+
values: dict[str, str]
|
|
43
|
+
|
|
44
|
+
def __getitem__(self, key: str) -> str:
|
|
45
|
+
return self.values[key]
|
|
46
|
+
|
|
47
|
+
def get(self, key: str, default: str | None = None) -> str | None:
|
|
48
|
+
return self.values.get(key, default)
|
|
49
|
+
|
|
50
|
+
def set(self, key: str, value) -> None:
|
|
51
|
+
self.values[key] = str(value)
|
|
52
|
+
|
|
53
|
+
def render(self) -> str:
|
|
54
|
+
"""Serialise back to the game's format, CRLF-terminated."""
|
|
55
|
+
known = [k for k in KEY_ORDER if k in self.values]
|
|
56
|
+
extra = [k for k in self.values if k not in KEY_ORDER]
|
|
57
|
+
lines = ["%s %s %s" % (COMMAND, k, self.values[k]) for k in known + extra]
|
|
58
|
+
return LINE_ENDING.join(lines) + LINE_ENDING
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse(text: str) -> Config:
|
|
62
|
+
"""Parse ``config.cfg`` text. Unrecognised lines are ignored."""
|
|
63
|
+
values: dict[str, str] = {}
|
|
64
|
+
for raw in text.splitlines():
|
|
65
|
+
parts = raw.strip().split(None, 2)
|
|
66
|
+
if len(parts) == 3 and parts[0].lower() == COMMAND.lower():
|
|
67
|
+
values[parts[1]] = parts[2]
|
|
68
|
+
return Config(values)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def default_config(device: int, width: int = DEFAULT_WIDTH,
|
|
72
|
+
height: int = DEFAULT_HEIGHT, windowed: bool = False,
|
|
73
|
+
music: bool = False) -> Config:
|
|
74
|
+
"""The configuration verified to run the game on Windows 11.
|
|
75
|
+
|
|
76
|
+
``music 1`` suppresses the WMA playback path, which crashes because the
|
|
77
|
+
Windows Media DirectShow source filter no longer ships with Windows.
|
|
78
|
+
``music 0`` enables it, and is only safe once the wmsource-shim is
|
|
79
|
+
registered (see ``music.py``); 1280x720 windowed with ``music 0`` was
|
|
80
|
+
verified playing on patch 1.2.
|
|
81
|
+
``bitdepth 32`` matches the format the game actually selects; the 16-bit
|
|
82
|
+
path asks for D3DFMT_R5G6B5, which modern drivers do not offer fullscreen.
|
|
83
|
+
``Window 1`` is only written when asked for: it is a patch 1.2 key, and a
|
|
84
|
+
stock config should stay as close to what the game itself writes as it can.
|
|
85
|
+
"""
|
|
86
|
+
values = {
|
|
87
|
+
"api": "D3D",
|
|
88
|
+
"device": str(device),
|
|
89
|
+
"width": str(width),
|
|
90
|
+
"height": str(height),
|
|
91
|
+
"bitdepth": "32",
|
|
92
|
+
"texbitdepth": "16",
|
|
93
|
+
"music": "0" if music else "1",
|
|
94
|
+
}
|
|
95
|
+
if windowed:
|
|
96
|
+
values["Window"] = "1"
|
|
97
|
+
return Config(values)
|
mtrevival/music.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Restore the WMA soundtrack.
|
|
2
|
+
|
|
3
|
+
The game plays music through a DirectShow graph whose source filter is the
|
|
4
|
+
2001 "Windows Media Source Filter", CLSID ``{6B6D0800-...}`` from
|
|
5
|
+
``dxmasf.dll``. Modern Windows ships neither that class nor a registration
|
|
6
|
+
for it, so the game's ``CoCreateInstance`` fails and, with music enabled, the
|
|
7
|
+
game dereferences the null result. ``SysSetup music 1`` only avoids the path.
|
|
8
|
+
|
|
9
|
+
``wmsource-shim.dll`` (source in ``tools/wmsource-shim``) answers that CLSID
|
|
10
|
+
by aggregating the filter that replaced it, the WM ASF Reader, and adapting
|
|
11
|
+
the two things the game needs: a fresh reader per ``Load`` (the reader refuses
|
|
12
|
+
a second one) and ``FindPin(L"Stream 1")`` mapped onto the reader's output
|
|
13
|
+
pin. See ``docs/music.md``.
|
|
14
|
+
|
|
15
|
+
Registration is per user, in the 32-bit view of ``HKCU\\Software\\Classes``
|
|
16
|
+
(the game is a 32-bit process), so no elevation is needed and nothing outside
|
|
17
|
+
the user's profile changes. An elevated process ignores per-user COM classes,
|
|
18
|
+
so the game must not be run as administrator.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
LEGACY_CLSID = "{6B6D0800-9ADA-11D0-A520-00A0D10129C0}"
|
|
26
|
+
ASF_READER_CLSID = "{187463A0-5BB7-11D3-ACBE-0080C75E246E}"
|
|
27
|
+
SHIM_NAME = "wmsource-shim.dll"
|
|
28
|
+
CLASSES = r"Software\Classes"
|
|
29
|
+
DESCRIPTION = "Windows Media Source Filter (mtrevival wmsource-shim)"
|
|
30
|
+
LAYERS = r"Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def bundled_shim() -> Path | None:
|
|
34
|
+
"""The shim DLL shipped inside this package, or None if it was not built."""
|
|
35
|
+
path = Path(__file__).with_name("bin") / SHIM_NAME
|
|
36
|
+
return path if path.is_file() else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _winreg():
|
|
40
|
+
try:
|
|
41
|
+
import winreg
|
|
42
|
+
except ImportError: # not Windows
|
|
43
|
+
return None
|
|
44
|
+
return winreg
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read_value(hive_name: str, subkey: str, name: str,
|
|
48
|
+
view_32bit: bool = True) -> str | None:
|
|
49
|
+
winreg = _winreg()
|
|
50
|
+
if winreg is None:
|
|
51
|
+
return None
|
|
52
|
+
access = winreg.KEY_READ | (winreg.KEY_WOW64_32KEY if view_32bit
|
|
53
|
+
else winreg.KEY_WOW64_64KEY)
|
|
54
|
+
try:
|
|
55
|
+
with winreg.OpenKey(getattr(winreg, hive_name), subkey, 0, access) as key:
|
|
56
|
+
value, _ = winreg.QueryValueEx(key, name)
|
|
57
|
+
except OSError:
|
|
58
|
+
return None
|
|
59
|
+
return str(value)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def reader_available() -> bool:
|
|
63
|
+
"""True if the WM ASF Reader is registered for 32-bit processes.
|
|
64
|
+
|
|
65
|
+
It is part of Windows Media, which N editions lack until the Media
|
|
66
|
+
Feature Pack is installed. Without it the shim cannot play anything.
|
|
67
|
+
"""
|
|
68
|
+
subkey = r"%s\CLSID\%s\InprocServer32" % (CLASSES, ASF_READER_CLSID)
|
|
69
|
+
return _read_value("HKEY_LOCAL_MACHINE", subkey, "") is not None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def registered_server(classes: str = CLASSES) -> str | None:
|
|
73
|
+
"""Path of the DLL currently registered for the legacy CLSID, per user."""
|
|
74
|
+
subkey = r"%s\CLSID\%s\InprocServer32" % (classes, LEGACY_CLSID)
|
|
75
|
+
return _read_value("HKEY_CURRENT_USER", subkey, "")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def register(dll: Path, classes: str = CLASSES) -> None:
|
|
79
|
+
"""Register ``dll`` as the in-process server for the legacy CLSID, per user."""
|
|
80
|
+
winreg = _winreg()
|
|
81
|
+
if winreg is None:
|
|
82
|
+
raise OSError("COM registration needs Windows")
|
|
83
|
+
access = winreg.KEY_WRITE | winreg.KEY_WOW64_32KEY
|
|
84
|
+
clsid = r"%s\CLSID\%s" % (classes, LEGACY_CLSID)
|
|
85
|
+
with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, clsid, 0, access) as key:
|
|
86
|
+
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, DESCRIPTION)
|
|
87
|
+
with winreg.CreateKeyEx(key, "InprocServer32", 0, access) as server:
|
|
88
|
+
winreg.SetValueEx(server, "", 0, winreg.REG_SZ, str(dll))
|
|
89
|
+
winreg.SetValueEx(server, "ThreadingModel", 0, winreg.REG_SZ, "Both")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def unregister(classes: str = CLASSES) -> bool:
|
|
93
|
+
"""Remove the per-user registration. Returns True if there was one.
|
|
94
|
+
|
|
95
|
+
Only the two keys ``register`` creates are deleted. If something else
|
|
96
|
+
added subkeys under the CLSID, that key is left in place rather than
|
|
97
|
+
torn down blindly, and the InprocServer32 removal alone already unhooks
|
|
98
|
+
the class.
|
|
99
|
+
"""
|
|
100
|
+
winreg = _winreg()
|
|
101
|
+
if winreg is None:
|
|
102
|
+
return False
|
|
103
|
+
access = winreg.KEY_WRITE | winreg.KEY_WOW64_32KEY
|
|
104
|
+
clsid = r"%s\CLSID\%s" % (classes, LEGACY_CLSID)
|
|
105
|
+
removed = False
|
|
106
|
+
for subkey in (clsid + r"\InprocServer32", clsid):
|
|
107
|
+
try:
|
|
108
|
+
winreg.DeleteKeyEx(winreg.HKEY_CURRENT_USER, subkey, access, 0)
|
|
109
|
+
removed = True
|
|
110
|
+
except FileNotFoundError:
|
|
111
|
+
pass
|
|
112
|
+
except PermissionError: # key has other subkeys; leave it
|
|
113
|
+
break
|
|
114
|
+
return removed
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def run_as_admin_flagged(exe: Path) -> bool:
|
|
118
|
+
"""True if Windows is set to run ``exe`` elevated (compatibility tab).
|
|
119
|
+
|
|
120
|
+
Elevated processes do not see per-user COM registrations, so the shim
|
|
121
|
+
would be invisible to the game and music would crash it again. The
|
|
122
|
+
Layers key is read in the 64-bit view so a 32-bit Python sees the same
|
|
123
|
+
machine-wide entry the shell wrote.
|
|
124
|
+
"""
|
|
125
|
+
for hive in ("HKEY_CURRENT_USER", "HKEY_LOCAL_MACHINE"):
|
|
126
|
+
value = _read_value(hive, LAYERS, str(exe), view_32bit=False)
|
|
127
|
+
if value and "RUNASADMIN" in value.upper():
|
|
128
|
+
return True
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def is_elevated() -> bool:
|
|
133
|
+
"""True if this process runs with an elevated token.
|
|
134
|
+
|
|
135
|
+
Registration goes into the running account's HKCU. From a shell elevated
|
|
136
|
+
as a different account it would land in the wrong hive.
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
import ctypes
|
|
140
|
+
return bool(ctypes.windll.shell32.IsUserAnAdmin())
|
|
141
|
+
except (AttributeError, OSError):
|
|
142
|
+
return False
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mtrevival
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Make Monopoly Tycoon (2001) run on modern Windows
|
|
5
|
+
Author: Nasser Albusaidi
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/NasserAlbusaidi/monopoly-tycoon-revival
|
|
8
|
+
Project-URL: Issues, https://github.com/NasserAlbusaidi/monopoly-tycoon-revival/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/NasserAlbusaidi/monopoly-tycoon-revival/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: monopoly-tycoon,game-preservation,windows,directshow
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Games/Entertainment
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# Monopoly Tycoon Revival
|
|
24
|
+
|
|
25
|
+
Make **Monopoly Tycoon** (2001, Deep Red Games / Infogrames) install and run on
|
|
26
|
+
Windows 10 and 11.
|
|
27
|
+
|
|
28
|
+
The game installs fine on modern Windows and then crashes on startup — usually
|
|
29
|
+
right after the studio logo, sometimes before anything appears. There are two
|
|
30
|
+
separate causes, both fixable in about a minute. No patched executable, no
|
|
31
|
+
compatibility mode. The soundtrack needs one small COM shim registered for
|
|
32
|
+
your user account; everything else is a text file.
|
|
33
|
+
|
|
34
|
+
**This repository contains no game content.** You need your own copy of the game.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Quick fix, by hand
|
|
39
|
+
|
|
40
|
+
### 1. Let the game write to its own folder
|
|
41
|
+
|
|
42
|
+
The game stores settings, profiles and savegames next to `mc.exe`. Under
|
|
43
|
+
`C:\Program Files (x86)` a standard user account cannot write there, so the game
|
|
44
|
+
silently fails to save anything — including the settings that fix the crash.
|
|
45
|
+
|
|
46
|
+
Run once in an **elevated** PowerShell:
|
|
47
|
+
|
|
48
|
+
```powershell
|
|
49
|
+
icacls "C:\Program Files (x86)\Infogrames\Monopoly Tycoon" /grant "$($env:USERNAME):(OI)(CI)M" /T
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 2. Create `config.cfg`
|
|
53
|
+
|
|
54
|
+
In the game folder, create a file named `config.cfg`:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
SysSetup api D3D
|
|
58
|
+
SysSetup device 1
|
|
59
|
+
SysSetup width 640
|
|
60
|
+
SysSetup height 480
|
|
61
|
+
SysSetup bitdepth 32
|
|
62
|
+
SysSetup texbitdepth 16
|
|
63
|
+
SysSetup music 1
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**`device` is the display adapter index and is specific to your machine.** Use
|
|
67
|
+
`0` if your primary display is a normal landscape monitor. Use `1` (or higher)
|
|
68
|
+
if your primary display is rotated to portrait, or does not offer a 640×480
|
|
69
|
+
mode. The tool below works this out for you.
|
|
70
|
+
|
|
71
|
+
`music 1` means music **off**. That is the safe setting without the shim
|
|
72
|
+
described under [Music](#music); the game runs, silently.
|
|
73
|
+
|
|
74
|
+
That is the whole fix. Launch `mc.exe`.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Working it out automatically
|
|
79
|
+
|
|
80
|
+
Requires Python 3.12 or newer. Standard library only.
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
py -m pip install mtrevival # or: pipx install mtrevival
|
|
84
|
+
mtrevival adapters # list adapters, show which support 640x480
|
|
85
|
+
mtrevival check # show what would be written, change nothing
|
|
86
|
+
mtrevival fix # write config.cfg, backing up any existing one
|
|
87
|
+
|
|
88
|
+
mtrevival fix --resolution 1920x1080 # any mode the adapter lists
|
|
89
|
+
mtrevival fix --resolution 1280x720 --windowed # windowed needs patch 1.2
|
|
90
|
+
mtrevival fix --music # restore the soundtrack
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`py -m mtrevival …` works too. From a git checkout use `py -m pip install .`;
|
|
94
|
+
the music shim then needs building (see `CONTRIBUTING.md`) or the DLL from a
|
|
95
|
+
[release](https://github.com/NasserAlbusaidi/monopoly-tycoon-revival/releases).
|
|
96
|
+
|
|
97
|
+
Example on a machine with a portrait primary display:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
source: D3DEnum.txt
|
|
101
|
+
adapter 0 NVIDIA GeForce RTX 4080 modes=46 no 640x480 [portrait]
|
|
102
|
+
adapter 1 NVIDIA GeForce RTX 4080 modes=54 OK 640x480
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`fix` never overwrites an existing `config.cfg` without copying it to a
|
|
106
|
+
timestamped `.bak` first.
|
|
107
|
+
|
|
108
|
+
### Music
|
|
109
|
+
|
|
110
|
+
The game plays its WMA soundtrack through a DirectShow filter that Windows
|
|
111
|
+
dropped years ago. `fix --music` copies `wmsource-shim.dll` (built from
|
|
112
|
+
[`tools/wmsource-shim`](tools/wmsource-shim), source included) next to
|
|
113
|
+
`mc.exe`, registers it **for your user only** — no elevation, nothing outside
|
|
114
|
+
your profile — and writes `music 0`. The shim hands the game Windows' own
|
|
115
|
+
WM ASF Reader with the two adaptations the game needs. Details, evidence and
|
|
116
|
+
removal in [`docs/music.md`](docs/music.md).
|
|
117
|
+
|
|
118
|
+
Do not run the game "as administrator" with music on: an elevated process
|
|
119
|
+
cannot see per-user COM registrations. `fix --music` warns if that flag is set.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## What actually goes wrong
|
|
124
|
+
|
|
125
|
+
Both crashes are the same defect: the game creates an object, never checks
|
|
126
|
+
whether creation succeeded, and then dereferences the null pointer.
|
|
127
|
+
|
|
128
|
+
### Crash 1 — access violation at `mc.exe+0x000E792E`
|
|
129
|
+
|
|
130
|
+
Windows logs exception `0xC0000005`, fault offset `0x000E792E`, faulting module
|
|
131
|
+
`mc.exe`. The instruction is:
|
|
132
|
+
|
|
133
|
+
```asm
|
|
134
|
+
004E78FE call dword ptr [edx+0x3C] ; IDirect3D8::CreateDevice (vtable slot 15)
|
|
135
|
+
004E7901 mov eax, [ebp+0x44] ; ...continues without checking the HRESULT
|
|
136
|
+
004E792E mov ecx, [eax] ; <-- faults, EAX = 0
|
|
137
|
+
004E7932 call dword ptr [ecx+0xA0] ; IDirect3DDevice8::SetViewport (slot 40)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The game requests **640×480 exclusive fullscreen** on adapter 0. Recovered
|
|
141
|
+
`D3DPRESENT_PARAMETERS` from a crash dump:
|
|
142
|
+
|
|
143
|
+
| Field | Value |
|
|
144
|
+
|---|---|
|
|
145
|
+
| BackBufferWidth / Height | 640 × 480 |
|
|
146
|
+
| BackBufferFormat | 22 (`D3DFMT_X8R8G8B8`) |
|
|
147
|
+
| Windowed | 0 — exclusive fullscreen |
|
|
148
|
+
| returned device | **NULL** |
|
|
149
|
+
|
|
150
|
+
For exclusive fullscreen, Direct3D 8 requires the width, height and format to
|
|
151
|
+
match an enumerated display mode. If your primary display is rotated to
|
|
152
|
+
portrait, its mode list contains `480 X 640` and **no** `640 X 480`.
|
|
153
|
+
`CreateDevice` fails, and the unchecked null device is dereferenced by
|
|
154
|
+
`SetViewport`.
|
|
155
|
+
|
|
156
|
+
The game writes its own evidence to `D3DEnum.txt` in the install folder. Look for
|
|
157
|
+
an adapter whose modes are all taller than they are wide — that is the culprit.
|
|
158
|
+
|
|
159
|
+
**Fix:** point `SysSetup device` at an adapter that offers 640×480.
|
|
160
|
+
|
|
161
|
+
### Crash 2 — access violation at `mc.exe+0x000A801B`
|
|
162
|
+
|
|
163
|
+
```asm
|
|
164
|
+
004A8007 call dword ptr [0x96E4E8] ; MultiByteToWideChar -> filename
|
|
165
|
+
004A800D mov eax, [ebx+0x2CE8] ; media interface -> NULL
|
|
166
|
+
004A801B mov edx, [eax] ; <-- faults, EAX = 0
|
|
167
|
+
004A801E call dword ptr [edx+0xC]
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The filename recovered from the dump is
|
|
171
|
+
`gamedata\sound\music\music_intro.wma`. The game builds a DirectShow graph to
|
|
172
|
+
play its WMA soundtrack and asks for the 2001 Windows Media DirectShow source
|
|
173
|
+
filter by CLSID. Windows 11 keeps only a 5 KB stub of `dxmasf.dll` and no
|
|
174
|
+
longer registers the class, so `CoCreateInstance` fails. `quartz.dll` and
|
|
175
|
+
`devenum.dll` load; the Windows Media source does not. The interface comes
|
|
176
|
+
back null and is used anyway.
|
|
177
|
+
|
|
178
|
+
**Fix:** `SysSetup music 1` suppresses that path, and the game runs without
|
|
179
|
+
its music. `fix --music` restores it: the filter the game asks for,
|
|
180
|
+
`{6B6D0800-…}`, is answered by a shim that wraps Windows' WM ASF Reader,
|
|
181
|
+
creating a fresh reader per track (the reader refuses a second `Load`) and
|
|
182
|
+
mapping the game's `FindPin(L"Stream 1")` onto the reader's `Raw Audio 0`.
|
|
183
|
+
The 2001 Windows Media redistributable on the CD is not touched. See
|
|
184
|
+
[`docs/music.md`](docs/music.md).
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Compatibility notes
|
|
189
|
+
|
|
190
|
+
- Tested on Windows 11 Pro (build 26200), NVIDIA RTX 4080, English CD.
|
|
191
|
+
Verified with game version 1.0 and again after applying the official patch
|
|
192
|
+
1.2 — the same `config.cfg` works for both. Verified on one machine only.
|
|
193
|
+
- Patch 1.2 applies cleanly on Windows 11 when run **elevated**. It changes
|
|
194
|
+
`mc.exe`, three Lua scripts and the string tables, and leaves `config.cfg`
|
|
195
|
+
alone. Both crashes above are still present in the 1.2 executable. Details
|
|
196
|
+
in [`docs/patch-1.2.md`](docs/patch-1.2.md).
|
|
197
|
+
- Resolution is free. `SysSetup width` / `height` accept any mode the chosen
|
|
198
|
+
adapter enumerates; 1920×1080 exclusive fullscreen and 1280×720 windowed
|
|
199
|
+
(`SysSetup Window 1`, patch 1.2 only) were verified running with a sane UI.
|
|
200
|
+
The 1.0 key `windowed` is ignored; 1.2 renamed it.
|
|
201
|
+
- Music was verified on patch 1.2 (intro track by ear, all ten tracks and
|
|
202
|
+
repeated track changes in a DirectShow harness). It needs Windows Media,
|
|
203
|
+
which N editions get from the Media Feature Pack.
|
|
204
|
+
- Patch 1.2 drops an empty `MTS.txt` in the game folder while it runs and
|
|
205
|
+
removes it on exit. If the game is killed, delete that file, or the next
|
|
206
|
+
launch offers Safe Mode and overwrites `config.cfg`.
|
|
207
|
+
- GameSpy multiplayer is dead and out of scope.
|
|
208
|
+
|
|
209
|
+
## Modding
|
|
210
|
+
|
|
211
|
+
The game is unusually open for its age, and this is the reason the project
|
|
212
|
+
exists. All verified:
|
|
213
|
+
|
|
214
|
+
- **200 plain-text Lua scripts** in `scripts\`, across 56 namespaces —
|
|
215
|
+
`DEFAULT`, `MAPS`, `SCENARIO1`–`SCENARIO20`, `TUTORIAL1`–`TUTORIAL12`,
|
|
216
|
+
`NET0`–`NET8`. Economy tuning lives in `businesssettings.lua`,
|
|
217
|
+
`blocksettings.lua` and `commoditysettings.lua`.
|
|
218
|
+
- **Saves are Lua too.** `profiles\<name>\settings.lua` contains
|
|
219
|
+
`g_ProfileOptions.BoardName = "gb"`, which selects among **19 national
|
|
220
|
+
boards** shipped on the disc (`usa`, `f`, `ger`, `aus`, `swe`, and more).
|
|
221
|
+
- `parameters\*.prm` holds per-decade tuning, plus `aiparameter.prm` and
|
|
222
|
+
`consumer.prm`.
|
|
223
|
+
- The shipped executable still contains debug flags (`AIDEBUG`, `DEBUGTEXT`,
|
|
224
|
+
`SHOWALLCARS`, `CHEATINGGIT`) and remnants of the developers' block and
|
|
225
|
+
building editors.
|
|
226
|
+
|
|
227
|
+
See [`docs/phase-0-findings.md`](docs/phase-0-findings.md) for the full
|
|
228
|
+
investigation, and
|
|
229
|
+
[`docs/superpowers/specs/`](docs/superpowers/specs/) for the project design.
|
|
230
|
+
|
|
231
|
+
## Licence
|
|
232
|
+
|
|
233
|
+
MIT — see [LICENSE](LICENSE). This covers the tools and documentation in this
|
|
234
|
+
repository only. Monopoly Tycoon itself is the property of its rights holders
|
|
235
|
+
and is not distributed here.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
mtrevival/__init__.py,sha256=FxsriInuBdOk4p7tfnhG6mw1eqf4rMV2A53KNKjnwZY,75
|
|
2
|
+
mtrevival/__main__.py,sha256=TzvOohn3m7sljHnZ7Ky9sKu2IQ7iZG81YQ7JdgLqxQQ,3611
|
|
3
|
+
mtrevival/d3denum.py,sha256=9DTb-TgyhDpVa1boAiZLMsN1MVKtBRP6j3wN0Tg35aY,3791
|
|
4
|
+
mtrevival/displays.py,sha256=3583xYnyRNsgdv0gE0lSa9vfoA2ODLLYIQxIUugetlc,4153
|
|
5
|
+
mtrevival/fixpack.py,sha256=27au2V1WIEoBe2qwXE7CZU7GELuYgu0H65VfsvMmKi8,13226
|
|
6
|
+
mtrevival/gameconfig.py,sha256=59g6gHwJaC-vcVwMOyKBh_bMZ65YfPrLNrxKbvO9sP4,3738
|
|
7
|
+
mtrevival/music.py,sha256=kJT85-ZORQ_dzteazIkuP6JqUNq4ZJDEfU_jf4aH7so,5694
|
|
8
|
+
mtrevival/bin/wmsource-shim.dll,sha256=kIFOZR7JrDwQ-8W0XeOSJMxMoI7uOyhLgzPIZyxtX2c,137728
|
|
9
|
+
mtrevival-0.3.1.dist-info/licenses/LICENSE,sha256=ijmA-7hcYuMe431-THkzD_Yyv90qJdNAY-1OtRw6T3M,1094
|
|
10
|
+
mtrevival-0.3.1.dist-info/METADATA,sha256=3ILFlQRWeo3QqX6EID8i-QRrTCox33faSWgpgNy6ZQ4,9966
|
|
11
|
+
mtrevival-0.3.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
mtrevival-0.3.1.dist-info/entry_points.txt,sha256=iGJ7lmZzfq-eZI8anMRdIvsQTCHyUHKdVPSC8mPjSDE,54
|
|
13
|
+
mtrevival-0.3.1.dist-info/top_level.txt,sha256=tkWpmmgpaXFN3k38a0F7K8Eu14QquEgd51xAAP7NNcA,10
|
|
14
|
+
mtrevival-0.3.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nasser Albusaidi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mtrevival
|