DS2Revival/tools/package.py

332 lines
26 KiB
Python

"""Build the player package (dist/DS2Revival + dist/DS2Revival.zip). Players never run an installer: they unzip it into
the folder with DungeonSiege2.exe (drag and drop; also to update, replacing files) and delete files to turn things off.
usage:
python -I tools/package.py build dist/DS2Revival (+ .zip)
python -I tools/package.py --with-controller same plus an opt-in 'Optional - controller' folder (not installed)
python -I tools/package.py --install "E:\\...\\Dungeon Siege 2"
developer tool: copy what the zip holds into a game folder, as
extracting the zip there would
The zip holds version.dll (Ultimate ASI Loader) and the plugins in scripts\\ (each works on its own; deleting one turns it
off), a "Broken World" folder with version.dll and only scripts\\DS2Revival.asi (mods/revival_loader: it loads the main
scripts folder's plugins into the Broken World process - one set of plugins for both games; harmless without the expansion:
DS2BrokenWorld starts Broken World only when Broken World\\DungeonSiege2.exe exists), a readme, "Firewall setup.cmd" and a tool. It does NOT hold DS2Revival.ini: a
drag-and-drop update would overwrite the player's settings. The plugins create DS2Revival.ini next to DungeonSiege2.exe on their first start
from the default text compiled into them (mods/common/DS2Revival.ini is the one source; mods/build.py generates a header
from it), move the values of older per-plugin inis (DS2Multiplayer.ini, ...) into it and rename those to .old, and add the
settings a later version brings (mods/common/settings_file.h; tools/test_settings_ini.py tests it).
The plugins in the zip are those in mods/build.py RELEASE. --install never touches game files; it refuses to overwrite a
version.dll that is not the ASI loader. (Finer glow is done in memory by DS2Graphics; no data mod is written.)
"""
import hashlib, importlib.util, os, shutil, sys, zipfile
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DIST = os.path.join(ROOT, 'dist', 'DS2Revival')
LOADER = os.path.join(ROOT, 'build', 'asiloader', 'dinput8.dll') # Ultimate ASI Loader (x86)
MODS = os.path.join(ROOT, 'build', 'mods')
def release_plugins():
"""The plugins of the release: mods/build.py RELEASE (one list, so a plugin ships as soon as it is in it)."""
spec = importlib.util.spec_from_file_location('ds2_mods_build', os.path.join(ROOT, 'mods', 'build.py'))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return list(mod.RELEASE), mod.BW_LOADER
PLUGINS, BW_LOADER = release_plugins()
BW_FOLDER = 'Broken World'
README_NAME = 'DS2Revival README.txt' # not README.txt: extracted into the game folder it must not replace a file of the game
README = """Dungeon Siege II revival (Steam v2.3, and Broken World)
=======================================================
Install and update: unzip this archive into your Dungeon Siege 2 folder (the one with DungeonSiege2.exe), or drag its
contents there. When you update to a newer version, do the same and allow replacing the files. That is all - there
is nothing else to run or set up. Your settings are not in the archive (the plugins create them, see "Settings" below), so
an update never overwrites them. Nothing of the game is modified.
To remove everything, delete version.dll and the scripts folder (also in the "Broken World" folder if you use it).
To turn one plugin off, delete its .asi file from the scripts folder.
Use it with the unmodified game: it is not meant to be combined with mods that replace DungeonSiege2.exe or
version.dll (e.g. Dungeon Siege II Remastered, OpenSpy's version.dll).
What is in it (every setting is explained in DS2Revival.ini, next to DungeonSiege2.exe, created at the first start):
Online Multiplayer and LAN without GameSpy, on our free server; no port forwarding; no DirectPlay install
(our own replacement is used when Windows has none: also Linux and Steam Deck); the game's own
"new patch" banner shows our news; a joiner whose game differs from the host's is told so and goes back to
the game list (Dungeon Siege II); the website dsii.uk has the Armory, 23 achievements, leaderboards
and a save history of your online parties (restore an older version on the account page).
Picture A borderless window at your desktop resolution, ultrawide included; the interface sized like a 720p
screen at every resolution (decimal scales too, with crisp text; item labels, the map and the loading
screen follow it); anti-aliasing, sharpening, anisotropic filtering, the Shaders fix, shadow size and
finer glow in any display mode, not only borderless; photo mode with a free camera; wider view and
zoom options; the mouse pointer in the game's own exclusive fullscreen; intro movies without a black flash; black
hero portraits above 1280 px fixed (also in Broken World).
Stability A crash in the engine's vertex upload; memory each newly visited region kept for good; graphics
options that silently switched off on AMD cards; Broken World multiplayer hosts running out of memory
on 1440p+ screens; save folders with Cyrillic or Chinese user names or a blocked Documents folder;
Cyrillic text with Windows' UTF-8 setting; plugin logs capped at 4 MB.
Broken World One plugin set and one settings file for both games; old saves load, the official editions' saves
(Steam, GOG exe, Broken World, with or without the 2018 Widescreen Fix) are recognised by name; the HUD
portrait at every resolution; no frozen joiner when the host presses START; the 2018 repack's
Widescreen Fix is retired so nobody is split from the other players; missing binkw32.dll / mss32.dll
are found in the main game's folder; Steam heroes import; the frame-rate fix.
Handy Plugins can be switched off in DS2Revival.ini ([Plugins]); updating = dragging the new files over the
old ones; the sign-up form and the minimum-spec warning are skipped; mods can say which game they are for.
Roadmap and what is planned next: https://dsii.uk/roadmap
Every fix is a separate plugin in the scripts folder and works on its own. All are on by default;
delete a plugin's .asi file (or set it to 0 under [Plugins] in DS2Revival.ini) to turn it off.
version.dll Ultimate ASI Loader (loads the plugins)
scripts\\DS2Multiplayer.asi online and LAN play without GameSpy:
- re-enables the Multiplayer menu (the Steam build greys it out)
- points the game's online services at the revival server
- gives Steam copies a CD-key identity (per user; your registry's game keys
are never touched)
- fixes the bugs that stop a fresh session from hosting (START button,
lobby server, join timeout, Windows Firewall question, the lobby
staying on screen after leaving it); warns and offers to turn on Windows' DirectPlay feature
when DirectPlay (needed for online play) is missing
- up to 8 players: the game's hidden 8-player modes ("Old School Mode":
8 players with their main hero; "Couples Mode++": 6 players with two
characters) can be picked when hosting (only the host needs this)
- a joiner whose DungeonSiege2.exe differs from the host's is told what differs and
goes back to the game list instead of waiting in the staging area (JoinVersionMessage)
- relays games through the server when the host is behind a router, so no
port forwarding is needed
DS2Revival.ini all settings of all plugins, next to DungeonSiege2.exe: created at the first start (see "Settings" below)
scripts\\DS2Widescreen.asi the display: a borderless window at your desktop resolution (ultrawide
included): the world is rendered natively, the 800x600 menus are scaled to fit
with black bars. Works with every Steam launch option and with the Steam overlay;
a failed graphics reset (alt-tab, overlay) is retried instead of crashing the
game. The in-game interface is sized like a 720p screen at any resolution (HudScale,
decimals allowed; HudFilter=sharp keeps text crisp). Wide screens show more to the sides (the original cut the top and bottom off); optional camera
settings: zoom out further, flatter or top-down view. Photo mode (Insert) and the
NVIDIA RTX Remix profile are here too. The game's own exclusive fullscreen (Mode=off) gets its
mouse pointer back (ExclusiveCursor; Windows shows none there).
scripts\\DS2Graphics.asi the picture: anti-aliasing (4x multisampling plus SMAA, no dgVoodoo needed)
and sharpening of the 3D world (text and interface stay sharp; the 3D scenes of
the hero menus are smoothed too), anisotropic texture filtering, the Video option "Shaders"
that the game forgot at every start, optional SSAO, shadow size, finer glow (built
in memory, no files), and the black
hero portraits above 1280 pixels wide. Works on its own too, in any
window mode. Delete it for the game's own picture; DS2Widescreen then keeps
the window, interface size and photo mode (photo mode pictures then have no
anti-aliasing).
scripts\\DS2Fixes.asi stops the game from making typing lag in every other program while it runs,
and the stutter while moving or clicking the mouse; and makes the game's
vertex upload (shadows, particles, character meshes) stop reading up to
168 bytes past the end of its source data, which could crash it when that
data ends right at the end of the memory around it;
and keeps saves working: a Documents path with Cyrillic or Chinese letters is given
to the game as an ASCII path, and a blocked Documents folder is explained;
Cyrillic text works with Windows' UTF-8 setting (TextCodepage); skips the
Gas Powered Games sign-up form and the minimum-spec warning;
and gives back the animation memory of far-away objects: every region you
visit used to keep about 7 MB of it for the whole session, which filled the
game's 2 GB of address space after roughly 70 regions (on in Dungeon Siege II, off in
Broken World until it is proven there: DormantAnimations)
scripts\\DS2BrokenWorld.asi starts Broken World when it is installed (see below), lets it import
heroes made in the Steam game and old saves (and the official editions' saves,
by name: AcceptKnownEditions), retires the 2018 repack's
Widescreen Fix.ds2res, finds binkw32.dll / mss32.dll when its own are
missing, and fixes its low frame rate on PCs where Windows runs its
hypervisor (Memory Integrity, WSL2, Hyper-V, Docker...)
scripts\\DS2ModLoader.asi mods without touching the game's files (see "Mods" below)
Mods: make a "Mods" folder next to DungeonSiege2.exe and give every mod its own folder in it:
Mods\\<mod name>\\*.ds2res, *.ds2map archives (tanks), loaded on top of the game's own
Mods\\<mod name>\\art\\..., world\\..., ui\\... loose files: they replace the file with the same path inside
the game's archives (e.g. Mods\\MyShader\\world\\global\\effects\\
shaders\\glow.fx) - no packing needed
Mods\\*.ds2res, *.ds2map archives dropped straight into Mods are loaded too (before the folders)
Mods\\<mod name>\\art\\...\\NAME.glb a glTF model (.glb or .gltf, e.g. exported from Blender) replaces the
mesh NAME.asp at the same path - nothing to convert: the game uses it,
with its textures. A glTF outside art\\ (also straight in Mods) goes
where the game keeps the asset of that name. Skinned models work too;
their animations named after a .prs (a_...) replace that animation, and
a glTF named a_....glb is one animation. Name a material after a
texture (b_...) to replace that texture (e.g. one an object's
template sets). Converted copies are kept in Mods\\.cache and rebuilt
when the glTF changes; a glTF that cannot be read is skipped (logged)
and the game's own asset stays.
Mods override the game, Broken World's content and its patches. When two mods change the same file, the one
loaded later wins: mods load in folder-name order, or in the order of an optional Mods\\load_order.txt (one
folder name per line, later lines win; "-Name" turns a mod off; folders not listed load after the listed
ones). Inside one mod, its loose files win over its archives. Folders starting with "_" or "." are ignored.
Maps in Mods appear in the map list like the game's own. scripts\\logs\\DS2ModLoader.log lists every mod, its files,
which mod overrides which, and what the game mounted; broken archives are skipped (and logged), never fatal.
Note: a plain copy of one of the game's own archives in Mods is ignored by the game (it is already loaded);
put only the changed files in a mod, as loose files or in a new archive.
Which game a mod is for: Broken World loads the main Mods folder first, then its own "Broken World\\Mods".
- A mod in the main Mods folder is for both games, unless its folder holds a modinfo.ini:
[Mod]
Games=ds2 only the original game (Games=bw: only Broken World; Games=both: both)
- A mod in "Broken World\\Mods" is Broken World's by its place: the original game never loads it, and it
wins over the main folder's mods there.
- Without Games=, Broken World protects its own changes: a loose file of a main-folder mod (or a glTF file's
converted result) that has the same path as a file in Broken World's own archives (x*.ds2res - the
expansion changed it) is skipped, each one named in DS2ModLoader.log. Games=both says "this mod is
meant for both" and loads everything. Archives (.ds2res/.ds2map) are loaded whole or not at all.
- Files and archives straight in Mods (no folder) have no modinfo.ini: they load in both games, with the
protection above for glTF files. Put such a mod into a folder to give it a modinfo.ini.
Multiplayer: a skipped file does not count for the game's content check (a loose .gas file does when it loads).
Broken World (optional): the archive also puts version.dll and one small plugin, DS2Revival.asi, into a "Broken World"
folder (inside your Dungeon Siege 2 folder, next to scripts). Broken World runs the same plugins as the main game: DS2Revival.asi
loads them from the main scripts folder, so there is nothing extra to install and one plugin set to update. If you own the
expansion, put its files in that folder; starting the game then starts Broken World, hold Shift while it starts for the
original game. No registry patch is needed. From the expansion's folder only these are used:
DungeonSiege2.exe, system_detail.gas, system\\EBUEula.dll, Resources\\x*.ds2res, Maps\\x*.ds2map
(the other files duplicate the base game's or belong to the expansion's own setup program). A plugin deleted from the main
scripts folder is off for Broken World too, and it uses the main folder's settings. In DS2Revival.ini, [Plugins] DS2Graphics=ds2
keeps one plugin out of Broken World (1 = both games, 0 = off, ds2 = Dungeon Siege II only, bw = Broken World only).
If you do not own Broken World, the folder does nothing: the game is only started as Broken World when
Broken World\\DungeonSiege2.exe exists. You can delete the folder.
Updating from an older version: older archives put full copies of the plugins into Broken World\\scripts. Delete the .asi
files there except DS2Revival.asi; while they exist they run instead of the main folder's plugins (scripts\\logs\\DS2Revival.log
in the main Dungeon Siege 2 folder says so).
A Broken World folder on its own (not inside the Dungeon Siege 2 folder): drag version.dll and the scripts folder of this
archive into the Broken World folder, the plugins work on their own there, and ignore the archive's "Broken World" folder.
Linux / Steam Deck (Proton): the plugins load through version.dll (the ASI loader). Wine and Valve's Proton may prefer their
own built-in version.dll, in which case nothing of this loads. In Steam, open the game's Properties, Launch Options and enter
WINEDLLOVERRIDES="version=n,b" %command%
(n,b = prefer the native version.dll in the game folder). The plugin logs appear in the game folder
(steamapps/common/Dungeon Siege 2/scripts/logs/<plugin>.log); if there are none, the override is not applied. Broken World is
started by the game folder's launcher as on Windows (hold Shift for the original game) and needs the same launch option.
Multiplayer under Proton uses our DirectPlay replacement (DS2Revival.ini [Network] DirectPlay=auto picks it, because Proton's built-in
DirectPlay cannot host).
Difficulty unlock: tools\\ds2_unlock.py unlocks Veteran and Elite for a party, as if it had finished the
earlier difficulties (the game's own journal flags; the old save is kept as .unlockbak). Needs Python 3 from
python.org. Close the game, then drag the party's .ds2party file (Documents\\My Games\\Dungeon Siege 2\\Save\\...)
onto ds2_unlock.py. Run it with --help for the options (e.g. --to veteran, or --world ds2x_world for Broken World).
Firewall: double-click "Firewall setup.cmd" once (asks for administrator rights) so other players can
connect to games you host. Port forwarding is optional: with UDP 2302 forwarded to the hosting PC
players connect directly instead of through the relay (Relay=off in DS2Revival.ini [Network] to force that).
Settings: the first time the game starts, the plugins create DS2Revival.ini next to DungeonSiege2.exe, the one settings file of all plugins,
with every setting, its default value and a short comment (one section per plugin). Open it in Notepad, change a value,
restart the game. Delete a plugin's .asi to turn that plugin off completely; a section without its plugin does nothing.
Broken World started from the Dungeon Siege 2 folder uses the same file (a Broken World folder on its own keeps its own, next to its exe).
The file is not in the archive, so updating never overwrites your values. When a new
version has new settings they are added to your file, with their comments, at the next start; your values and your own
comments are never changed - except that when a version changes the DEFAULT of a setting (1.3: AntiAliasing smaa ->
msaa4+smaa), a value that is still the old default is changed to the new one, once (the log says so; the "[Settings]
Version" line at the end of the file keeps count). A value you chose yourself stays. If you had changed values in older per-plugin files (DS2Multiplayer.ini, DS2Widescreen.ini,
...), they are moved into DS2Revival.ini when it is first created and those files are renamed to .ini.old. To get the
default file back, delete DS2Revival.ini: it is created again at the next start.
Versions 1.2.0 and 1.2.1 kept the file in the scripts folder; the first start of this version moves it next to DungeonSiege2.exe.
Logs: the plugins write their log files (DS2Widescreen.log, DS2Multiplayer.log, ...) to scripts\\logs. Please send them when you report a problem.
"""
def copy_crlf(src, folder, name=None):
"""Copy a text file for players with Windows line endings, whatever the checkout's (a Linux CI checkout
has LF)."""
data = open(src, 'rb').read().replace(b'\r\n', b'\n').replace(b'\n', b'\r\n')
with open(os.path.join(folder, name or os.path.basename(src)), 'wb') as f:
f.write(data)
CONTROLLER_README = """DS2Revival controller support (preview, optional)
Not installed by default. To try it: close the game, then copy DS2Controller.asi and DS2Controller.ini from this folder
into the game's scripts folder. Broken World loads it from there too (Broken World\\scripts\\DS2Revival.asi runs the main
folder's plugins) and reads the same DS2Controller.ini. Start the game from
Steam and use an Xbox, PlayStation (4/5), Switch Pro or other standard pad; with Steam Input choose a Gamepad layout (not
a keyboard/mouse or desktop one), or switch Steam Input off for the game. The button prompts follow the pad you hold and show only
while the pad is in use. Delete DS2Controller.asi to remove it. The pad never steers a mouse pointer: menus use a focus
highlight, the world uses the sticks, A/B/X/Y and the LB/RB/Back wheels. Controls and settings are listed in DS2Controller.ini (the controller keeps its own settings file; it is not part of DS2Revival.ini).
Contains SDL3 (zlib licence), see THIRD-PARTY-NOTICES.txt.
"""
def add_loader_and_plugins(folder, plugins):
"""version.dll and scripts\\<plugins>.asi in folder (the game folder, and the Broken World folder)."""
os.makedirs(os.path.join(folder, 'scripts'))
shutil.copy(LOADER, os.path.join(folder, 'version.dll'))
for p in plugins:
shutil.copy(os.path.join(MODS, p + '.asi'), os.path.join(folder, 'scripts'))
def build(with_controller=False):
if os.path.isdir(DIST):
shutil.rmtree(DIST)
os.makedirs(DIST)
add_loader_and_plugins(DIST, PLUGINS) # the game folder: version.dll + scripts\*.asi
# Broken World: the loader and DS2Revival.asi, which loads the main folder's plugins into the Broken World process
add_loader_and_plugins(os.path.join(DIST, BW_FOLDER), [BW_LOADER])
with open(os.path.join(DIST, README_NAME), 'w', newline='\r\n') as f:
f.write(README)
copy_crlf(os.path.join(ROOT, 'tools', 'firewall-setup.ps1'), DIST)
os.makedirs(os.path.join(DIST, 'tools'))
shutil.copy(os.path.join(ROOT, 'tools', 'ds2_unlock.py'), os.path.join(DIST, 'tools'))
copy_crlf(os.path.join(ROOT, 'tools', 'third_party', 'THIRD-PARTY-NOTICES.txt'), DIST)
if with_controller: # opt-in preview: a folder beside scripts\, never loaded until the player copies it in
opt = os.path.join(DIST, 'Optional - controller')
os.makedirs(opt)
shutil.copy(os.path.join(MODS, 'DS2Controller.asi'), opt)
copy_crlf(os.path.join(ROOT, 'mods', 'controller', 'DS2Controller.ini'), opt)
with open(os.path.join(opt, 'README.txt'), 'w', newline='\r\n') as f:
f.write(CONTROLLER_README)
with open(os.path.join(DIST, 'Firewall setup.cmd'), 'w', newline='\r\n') as f:
f.write('@echo off\npowershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0firewall-setup.ps1" -GameDir "%~dp0."\n')
z = DIST + '.zip'
with zipfile.ZipFile(z, 'w', zipfile.ZIP_DEFLATED) as zf:
for base, _, files in sorted(os.walk(DIST)):
for n in sorted(files):
full = os.path.join(base, n)
zf.write(full, os.path.relpath(full, DIST)) # files at the root: extract straight into the game folder
print('built', DIST, 'and', z)
def sha(p):
return hashlib.sha256(open(p, 'rb').read()).hexdigest()
def ours(folder):
vd = os.path.join(folder, 'version.dll')
return not os.path.exists(vd) or sha(vd) == sha(os.path.join(DIST, 'version.dll'))
def install(game):
"""What extracting the zip into the game folder does (a developer tool: players unzip it themselves). The
Broken World part only where Broken World's exe is, so that a test folder does not gain one."""
if not os.path.isfile(os.path.join(game, 'DungeonSiege2.exe')):
sys.exit('no DungeonSiege2.exe in %s' % game)
if not ours(game):
sys.exit('%s exists and is not our ASI loader - not overwriting' % os.path.join(game, 'version.dll'))
skip_bw = not os.path.isfile(os.path.join(game, BW_FOLDER, 'DungeonSiege2.exe'))
if not skip_bw and not ours(os.path.join(game, BW_FOLDER)):
print('note: %s is not our ASI loader - Broken World left alone' % os.path.join(game, BW_FOLDER, 'version.dll'))
skip_bw = True
for base, dirs, files in os.walk(DIST):
rel = os.path.relpath(base, DIST)
if rel == BW_FOLDER or rel.startswith(BW_FOLDER + os.sep):
if skip_bw:
continue
dst = game if rel == '.' else os.path.join(game, rel)
os.makedirs(dst, exist_ok=True)
for n in files:
shutil.copy(os.path.join(base, n), os.path.join(dst, n))
print('copied the package into', game, '(DS2Revival.ini is created by the plugins at the first start)')
if __name__ == '__main__':
a = sys.argv[1:]
build('--with-controller' in a)
if '--install' in a:
try:
install(a[a.index('--install') + 1])
except PermissionError as e:
sys.exit('cannot replace %s - close the game (and Broken World) and try again' % e.filename)