Skip to content

Update 3.0 - #50

Merged
xezon merged 81 commits into
mainfrom
update-24
Sep 17, 2026
Merged

xezon merged 81 commits into
mainfrom
update-24

Conversation

xezon and others added 9 commits August 15, 2026 17:54
Add TextTransform class to evaluate the text params in one place

__RequiresTextTransform and __TransformTextLines both read the same params
and repeated the same conditions for them, so a new param had to be added
twice and the two lists could silently disagree.

TextTransform evaluates each param exactly once on construction and answers
both questions, whether a transformation is required and what it does to the
text. The text reading, writing and filtering that only exists for these
params moves along with it, which leaves BuildCopy with a single
__WriteTextFile helper for the single source and multi source cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tool was distributed as a PyInstaller onedir freeze, archived with a
bundled 7z.exe and downloaded by consumers via batch scripts that pinned a
hardcoded size and sha256. Building it required three virtual environments
driven by a data-driven buildproject.py, plus Poetry.

None of that is needed to ship a Python package. Remove it wholesale:

- ModBuilder/generalsmodbuilder -> generalsmodbuilder (repo root), so the
  repo works directly as a git submodule, a plain file copy, or a
  `pip install git+...` target with no subdirectory indirection.
- Delete buildproject.py, build.json, build-lite.json, the windows/*.bat
  helpers and the bundled 7z.exe / 7z.dll.
- Delete poetry.toml, poetry.lock and poetry-instructions.txt.
- Add __init__.py to the changelog and config directories so every
  importable directory is a real package.
- Add a root .gitignore. The build/ and dist/ rules are anchored to the
  repo root so they cannot match the generalsmodbuilder/build package.
- Repath the vscode configs and drop the now dead "ModBuilder - Build"
  launch configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the Poetry metadata with a standard [project] table so the repo can
be installed by any PEP 517 frontend, and declare a console script entry
point so `generalsmodbuilder` is a real command after install.

Drop scikit-image. It was declared as a dependency but is imported nowhere
in the source; it alone pulled in scipy, PyWavelets, imageio, tifffile and
networkx, which is why the old PyInstaller step needed postDeleteFiles rules
to strip skimage sample data and scipy.libs back out of the dist. numpy and
Pillow still arrive transitively through psd-tools, the only real consumer.
The resolved dependency set drops from ~30 packages to 13.

Raise requires-python to >=3.12 and unpin the remaining dependencies from
their 2022-era versions. Nothing on the consumer machine constrains this
anymore because uv downloads a suitable CPython itself.

Make generalsmodbuilder/__version__.py the single source of truth for the
version, read by [tool.hatch.version], instead of duplicating it here and
there. Bump to 3.0, since the distribution contract changes completely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
util.GetAbsSmartFileDir existed solely to redirect package-relative paths to
the PyInstaller executable directory. With no frozen build left it is dead
weight, so drop it along with g_isFrozen and resolve g_appDir directly from
the package location. All seven call sites move to the existing
GetAbsFileDir, which is what they already did when running from source.

Point the built-in tools configuration at a per-user cache directory when
--load-default-tools is used without --tools-root-dir. It previously
resolved relative to the json file itself, which is now inside the installed
package; a pip installed copy would try to download blender into
site-packages. Consumer tools configurations are unaffected, since those
still resolve against their own directory.

Add __main__.py so `python -m generalsmodbuilder` works alongside the
console script, and drop the leftover PyInstaller pkg_resources workaround
comment in main.py.

Also fix ToolFile.VerifyInstall comparing self.md5 against the computed
sha256, which made sha256 verification of downloaded tools always fail on
that path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
modbuilder.cmd is the entire consumer-facing install story. It installs uv
if the machine does not have it, and uv then downloads a suitable CPython
and the locked dependency set into a virtual environment beside the script
before running the tool. This replaces the previous flow, where a consumer
project shipped its own 7z.exe and ~120 lines of batch to curl a release
archive, check a hardcoded size and sha256, and extract it.

modbuilder.sh is the equivalent for POSIX shells.

`uv run` is deliberately used without --locked so that pulling a submodule
update with a changed lock file heals itself rather than failing. CI uses
--locked instead to catch drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
modbuilder.sh is checked out on Windows too, where the repo default of
text=auto would give it CRLF endings that sh cannot parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
xezon and others added 20 commits September 17, 2026 17:38
Runs on Windows and Linux: syncs the locked dependency set, exercises both
entry points, imports every module, builds the wheel and asserts the wheel
carries the built-in configurations and the gui icon.

The import step is what earns its keep. It catches a dependency update
breaking beeprint, psd-tools or markdownmaker, and catches a Python built
without tkinter, which the gui needs. A real mod build cannot run in CI,
because crunch, gametextcompiler and blender are Windows binaries that are
downloaded at runtime.

uv sync uses --locked here, unlike the launcher, so that a lock file which
has drifted from pyproject.toml fails the build instead of being silently
regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the release-archive install instructions with the three supported
ways to consume the tool: git submodule, plain copy, or an installed Python
package. Add a short developer and release section, since the repo
previously had no written build or release procedure at all.

Reword the safety section. The sha256 pinning of the downloaded game tools
is unchanged and still applies, but the Mod Builder itself is no longer a
downloaded archive, so it has no archive hash to pin. Its integrity now
comes from the submodule commit or git tag, and its dependency hashes from
uv.lock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The duplicate target file check in BundleItem compared raw strings, so an
item declaring Data/INI/Weapon.ini and data/ini/weapon.ini passed
verification and then silently overwrote one target with the other during
the copy, whichever came last. File names are case insensitive on Windows
and in big archives, so both spellings name the same file. The same held
for the duplicate item name check.

Add util.VerifyUniqueNames as the single place that decides whether two
names collide, and route every name uniqueness check through it. It
distinguishes an exact duplicate from a collision that differs in upper
and lower case only, so the error says which of the two problems it found.

Several collisions were not checked at all and are now rejected at config
load time, before any build step runs:

- two items building a big file of the same name, which distinct item
  names can still produce through namePrefix and nameSuffix,
- two packs with the same name, which had no uniqueness check whatsoever,
  or building a release .zip file of the same name,
- a pack listing the same item more than once, which built that item into
  the pack twice,
- two items of the same pack building a file of the same name into it.

The last one may reject configurations that build today, where a later
item in a pack overwrites a file of an earlier one. That is not a
documented overlay mechanism and the build treats it as ambiguous, since
the diff registry keys targets by lowercased path and keeps only one entry
per target.

Move the big file and release file naming into BundleItem.GetBigFileName,
BundleItem.GetPackTargetFileNames and BundlePack.GetReleaseFileName, and
call those from the engine, so that the names the verification checks
cannot drift from the names the build produces.

Tool names in tools.py are deliberately left alone. Their dict keys are
case sensitive while every lookup uses a lowercase literal, so a tool
named Crunch becomes an unreachable second entry rather than an override,
but changing the key semantics would change how configurations layer on
top of DefaultTools.json.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A missing build tool was only noticed at the moment it was used, deep inside
the Build step and, with multi processing enabled, inside a pool worker. A tool
that was defined but whose executable was absent was not checked at all and
failed with a bare FileNotFoundError from the process call.

The Build step now collects the tools that its build files require and verifies
them before it copies the first file. A tool is missing when it is not defined
or disabled, or when its runnable executable does not exist. The error names the
tool and the first target file that requires it.

The required tool is derived from the same dispatch tables that the copy itself
uses. Copy functions that call a tool are marked with a RequiresTool decorator,
and BuildCopy.GetRequiredToolName resolves a copy without performing it, so
there is no second table that can drift out of sync.

To keep that answer exact, the decision whether a DDS to DDS copy needs crunch
moved from inside __CopyToDDS up into __GetCopyFunction. Without params such a
copy is a plain file copy and requires no tool, which is unchanged behaviour.

BuildCopy.__GetToolExePath is left as a plain dictionary lookup, because it runs
once per copied file and in every pool worker.

The four blanket tool warnings in BuildSetup.VerifyValues are removed. They
warned regardless of whether the bundles contained such files and are superseded
by the precise check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Linux CI job failed with ModuleNotFoundError: No module named 'winreg'.
util.py imported winreg unconditionally, and nearly every module imports util,
so the whole package was unimportable outside Windows.

Guard the import and set winreg to None when it is absent. GetRegKeyValue then
returns None and SetRegKeyValue returns False, which is what both already do
when a key is missing, so callers need no change.

The HKEY_LOCAL_MACHINE default arguments were evaluated at import time and had
to move into the function bodies behind a None default. Every caller uses the
default root, so behaviour on Windows is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VerifyType built its message with expectedType.__name__, which only a plain
type carries. When the expected type was a tuple of types or a union, the
message raised AttributeError instead of the AssertionError that was meant
to describe the mismatch. Both shapes are in use: VerifyParamsType passes a
tuple, and the changelog parser passes str | date | datetime.

Name the expected type through GetTypeName, which handles a plain type, a
tuple of types and a union, so a mismatch is always reported as intended.

Add pytest and wire it into the CI workflow, so that the checks added by the
following json parser changes can be verified as they land.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The parsers read raw json with dict.get and let a later VerifyTypes pass
report a bad value. That pass can only name the python field the value ended
up in, so a user is told that "Folders.absReleaseDir" is not a string without
being told that the key to look at is folders.releaseDir. Values also reach
path joins and string methods before that pass runs, where a wrong type
raises a bare TypeError instead of a described failure.

JsonContext names the place a value is read from and is built up while
descending into the data, so a failure reads

    ModBundleItems.json: bundles.items[3] 'GameFiles'.files[2].target
    is type:int but should be type:str

GetOptional and GetMandatory distinguish a key the format allows to be
absent from one it requires, and both verify the value where it is read.
The optional elementType verifies the members of a list at the same time,
which is what most of the list valued keys need.

No parser uses it yet. The parsers move over to it in a following change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ToolCallInstruction defaulted callArgs to the ParamsT type instead of an
instance of it, so a callList entry that did not set callArgs made the
verification call items() on a type and raise AttributeError.

Replacing aliases in a callArgs dict called replace() on every value, but a
call argument may also be a number, a bool or a list, which raised
AttributeError. It also wrote the results back into the parsed json data.
Only string values are rewritten now, into a new dict.

The http error message was missing its f prefix and printed the placeholder
instead of the code.

Tool.Install raised on failure, so the flag it returned was always True and
the branch that tested it in InstallTools could never be taken. Both now
return nothing.

BuildFiles.Normalize rebound the loop variable and wrote nothing back, so
build file paths were never normalized. AddBuildFilesFromJsons was annotated
to return Bundles while returning nothing, and BundleItem.ResolveWildcards
was annotated to return nothing while returning a list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…joining

MakeToolsFromJsons assigned the directory of the first json that carried a
tools section to its rootDir parameter. Every later tools json then resolved
its targets against that first directory, although the tools-root-dir help
states that a tools json roots its tools in its own directory. An explicit
override still applies to all of them.

Aliases were replaced after the target had been joined to the root directory,
so an alias standing for an absolute path was appended to the root instead of
replacing it. They are replaced before the join now.

The blender addon install argument in DefaultTools.json named THIS_DIR, which
is the directory of the packaged configuration. Since load-default-tools
began installing into the per user cache directory, the zip is downloaded
there while the argument still pointed inside the installed package, so the
addon was installed from a file that does not exist. It names ROOT_DIR now,
which is where the zip is actually downloaded to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both parsers read their keys with dict.get and left every type to a later
VerifyTypes pass. A wrong value was reported by the python field it ended up
in, so a user was told that Folders.absReleaseDir is not a string rather than
that the key to look at is folders.releaseDir, and a value could reach a path
join before the pass ran.

Their keys are read through JsonContext now, typed and marked optional where
they are read. VerifyTypes keeps only what a read cannot see, which for
folders is that the merged result has both directories, since either may be
set by any of the configuration files.

ParsedData and FinalizeParsedData capture the phase order that all six
parsers follow, so the sequence is stated once instead of being repeated by
hand, and the class documents which checks belong in which phase. The
remaining parsers move over to it in the following changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The runner keys are read through JsonContext now, typed and marked optional
where they are read, so a wrong value names the file and the key instead of
the python field. VerifyTypes keeps only the check that a read cannot
express, which is the types of the values inside the arguments dict.

The four registry keys were read by four near identical blocks. They share
one helper now, which also guards against a registry value that is a number
rather than a path, since a value read from the registry may be either and
joining a number as a path raises TypeError.

The regular game data files were normalized before they were joined to the
installation directory, so the paths that are actually used were never
normalized. They are joined before the phases run instead.

Runner.ResolveWildcardsInFileList is replaced by util.ResolveFileWildcards.
The two differed in one way that matters: the runner list says which game
data files are allowed to be present, not which ones are required, so it must
neither verify that a listed file exists nor report a wildcard that matches
nothing. Almost every entry belongs to a language that is not installed.
ResolveFileWildcards states that difference as filesMustExist.

VerifyStringListType had no caller left and is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tools keys are read through JsonContext now, typed and marked optional or
mandatory where they are read, so a wrong value names the json file, the tool
and the file index instead of the python field it ended up in.

Reading the format version as an int also settles a case that could not be
reported at all: the version is compared against 1 to tell the two tool
version layouts apart, and comparing a string against a number raised
TypeError before any check ran.

VerifyTypes keeps only what a read cannot see, which is the types of the
values inside a callArgs dict. The messages of the remaining value checks
name the json keys rather than the python fields.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
changeconfig was the one parser that already checked values where it read
them, but its messages named the bare key, without the file, the record or
the element, and the file lists were joined into paths without checking that
their entries are strings, where a number raised a bare TypeError.

Its keys are read through JsonContext now, so a record is named by its index
and a bad list entry by its position. sourceList and targetList are read as
mandatory, which is what they already were in effect, since a record without
them failed later with a message about a python field being None.

VerifyTypes is gone from all three classes. Every type it checked is now
either guaranteed where the value is read or is state this module builds
itself.

The checked getters in util stay, because the yaml changelog parser uses
them and is not part of this work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bundles keys are read through JsonContext now, typed and marked optional
or mandatory where they are read, so a bad value names the json file, the
item or pack it belongs to and the element index inside it. A missing source
in a sourceTargetList entry, for example, used to surface as an
absSourceFiles value being None, several steps away from the key at fault.

VerifyTypes is reduced to what a read cannot see, which is the types of the
values inside a params dict. The messages of the value checks name json keys
rather than python fields.

The registry list was joined into absolute paths inside the parsed json data,
which is the caller's data. A new list is built instead. BundleRegistryDefinition
keeps doing its work in its constructor rather than through the shared phases,
because one definition is shared by every file of the entry that declares it
and a wildcard entry expands into very many of those, so running the phases
per file would repeat the same work. Its messages named a class that does not
exist.

The json name of all seventeen event types was rebuilt for every item and
every pack. The lookup is built once at module level.

Verified against both real projects: the sample project and the game patch
parse unchanged, and the four parser level negative fixtures of the sample
project still fail with their original messages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sumes

A files entry that named none of source, sourceList, sourceTargetList,
multiSource or multiSourceTargetList contributed no file at all and said
nothing about it, so a misspelled source key was invisible. An empty
sourceList, sourceTargetList or multiSourceTargetList did the same, although
an empty multiSource was already rejected. Both are rejected now, so the rule
that multiSource already followed holds for all five keys.

These checks run after the check that source and multiSource are not named
together, so an entry naming both keeps being reported as that rather than as
something else.

A target given next to a sourceList or a sourceTargetList alone was silently
ignored, because those derive their targets from their own source files. It
is rejected unless the entry also has a source or a multiSource to consume
it, which keeps the multiSource with sourceTargetList combination valid that
the sample project uses.

The multiSource messages name the file, the item and the entry now, like the
other messages do.

That params and registryDef are shared by every file an entry builds is
stated where they are declared, since nothing enforces it and the build
relies on them not being modified after parsing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
When no game installation directory was found, the directory stayed empty and
Normalize turned it into the current directory, which passes isdir. The
failure then blamed the game executable for not existing at a path that had
never been searched, and the regular game data files silently resolved
against whatever directory the build was started from. The same happened when
gameExeFile was absent, because the search is driven by that file name and
was skipped entirely without it.

An empty path is left empty by Normalize now, gameExeFile is required, and a
missing installation is reported as such, naming the executable that was
looked for, the four keys that can supply a directory, and every directory
that was actually searched.

folders rejects a releaseDir that names the same directory as buildDir, since
the release is built out of the build directory and would overwrite it. The
two are compared the way file names are compared on Windows, where paths that
differ in upper and lower case only name the same directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tool.version held the numeric version of a version 1 tools json and was read
by nothing, while the two log lines only ever printed versionStr, which stays
empty under version 1. A version 1 tool was therefore logged with no version
at all, and the float check rejected an integer version. The field is gone and
a numeric version is kept as a string, so both layouts log the same way.

A callList entry without a call was parsed, kept and then silently skipped
when the tool installs, so a mistyped key meant the call never happened. The
call is required now, which also removes the guards that every use of it
needed.

A tool may mark only one file as runnable. Only the first one can ever be the
executable, so any further one was accepted and then ignored.

A file that has no url and whose target is missing or does not match its size
or hash ends as NoInstall, which matched none of the message branches, so the
failure said only that the file was not installed. It says why now. This is
the reachable failure for the extracted blender executable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An unrecognized sort direction became Sort.Zero, which the changelog
generator compares against neither Ascending nor Descending, so it left both
its comparison results false and the entries were treated as equal. A typo
such as "ascendign" therefore dropped that sort rule without a word. The
direction must now be one of the two, named in the failure.

A sortList entry naming neither date nor label was skipped just as quietly.
An entry naming both was accepted and its label ignored, because only the
date was read. An entry must name exactly one of them now.

Sort.Zero was only ever produced by the case that is now rejected and was
consumed by nothing, so it is removed. SortDefinition.VerifyValues guarded
against a definition that is neither kind, which the parser could not build
before and cannot build now, so it goes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Build sections were followed exactly one level. A json file pulled in through
build.files could declare its own build section, which nothing ever read, so
the files it listed were silently left out of the build. They are followed to
any depth now.

Each file is read once, identified by the file it actually resolves to.
Listing the same file twice, reaching it through a diamond of includes, or
naming it by two different paths used to read it twice, which failed much
later with a bundle item name being used more than once. A cycle terminates
instead of recursing forever.

The files that were given on the command line keep their order and come
first, as before, because a later configuration file overrides an earlier one
and the bundle prefixes carry over from one file to the next.

An entry that is empty, and an entry naming a file that is not json, are
rejected. Both used to pass the parser and then be dropped without a word,
the first by a guard against an unusable path and the second by the extension
filter that decides which files are read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A key that the format does not define was ignored in silence by all six
parsers, so a misspelled key behaved exactly as if it had never been written.
A mistyped onPreBuild meant the script never ran, a mistyped sourceList meant
the files were never built, and nothing said so. Every section now declares
the keys it defines and rejects anything else, naming the file, the section
and the key. The info key of a tool is declared even though nothing consumes
it, because it describes the tool for whoever reads the configuration.

Only tools read its version. The other five sections declared one that nobody
looked at. Each section reads and verifies its version now, so a
configuration written for a newer format is rejected rather than parsed as if
it were the current one. All current data is version 1, so there is one
layout to parse, but the version is where a future breaking change is
handled, which is what it exists for.

SETTINGS.md states both rules, documents the tools section, which was never
listed, and corrects sourceTargetList[].target, which the table called
mandatory while the code has always defaulted it to the source file.

Verified against every json file of the sample project and the game patch:
all of them parse, and every key they use is a known key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
xezon and others added 26 commits September 17, 2026 17:38
The multi source table described a str target built from str sources twice,
once as appended and once as merged, and left the reader to guess which one
happens. It now says that a str target is appended while every source is a str
file and is merged as soon as one of them is a csf file, which is what the
builder does.

The params of a file entry were only documented by pointing at the sample
project. They are listed now, with the values they take, the file types they
apply to and the defaults, since the configuration is verified against exactly
that list. The behaviour that the audit settled is written down with them: line
endings are kept unless forceEOL names one, an exclusion marker region is
counted over all source files of a target together, the w3d exporter has no
mode for two of the eight flag combinations, and a dds file is only compressed
again for a param that asks for texture work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The known param names had to be written in lower case, because the lookup lowers
the key it is given. Nothing enforced that, so a name declared in any other case
would simply never match and the param would go unverified in silence.

Lower the names once, where the tables are merged, and write each of them the way
the mod projects and the build step spell it. Write the w3d names out one per line
like the other tables instead of running a comprehension over them. The merged
table is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The game install directory, executable and arguments could only come from the
runner section of the configuration files, which a mod project commits to its
repository. A user whose game sits where the registry keys do not name had to
edit a tracked project file.

JsonRunner holds what the configuration files say, UserRunner holds what one
user set for their own machine, and MakeRunner merges them into the Runner the
engine consumes. A user install directory is the only candidate searched, so it
is never passed over in silence, and a user argument list replaces the
configured one entirely.

Runner.gameExeArgs is now the finished argument list, so the engine passes it to
the process without converting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…guments

--game-install-path, --game-exe-file and --game-exe-args build a UserRunner that
wins over the runner configuration, so a game that sits where the registry keys
do not name is reached without editing a project file.

SplitGameExeArgs turns the one string a user writes into the argument list.
Posix splitting is avoided because it eats the backslashes of an unquoted
windows path.

The argument parser moves into MakeArgumentParser so the mapping from arguments
to a UserRunner can be tested on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gui needs somewhere to keep what the user set, and it must not be next to
the project, where it would be committed to the mod repository. UserSettings.json
holds a userRunner section under the platformdirs user config directory, beside
the tools cache that the default tools configuration already uses.

The section carries a version and rejects an unknown key like every other
configuration section, so a hand edit is reported the same way. A file that
cannot be read is reported and leaves the settings that change nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three fields below the existing frames set the game install path, the executable
and its arguments, with a Browse button for the directory. They are seeded from
the command line where it gave a setting and from the saved user settings
otherwise, and they are written back when a job starts and when the window
closes.

An empty field means no setting, so the runner configuration is used. Every
action passes the fields on, so Install, Uninstall and Run all use the same
game directory.

JoinGameExeArgs writes an argument list back as the command line it came from,
which is what fills the arguments field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The seven build operations were spelled out five times over: a BooleanVar
field, a checkbox, a button, an entry in _SetJobElementsState and a run
method that differed from its neighbours in one keyword argument.

They are now one Operation table that both columns and the state handling
walk, and the seven run methods collapse into _RunOperation. The arguments
of a job stay lazily evaluated, because the bundle pack selection and the
engine are made by _OnWorkBegin after the button is pressed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gui used whatever ttk theme the platform provided. It now carries a
dark palette of five colours, registered with ttkbootstrap, which builds
its widget art from a colour dict and so can take colours that are ours.

The palette is only legible on a dark background, which the tests assert
by contrast ratio, so there is deliberately no light mode.

The window grows a little to fit the larger font. Its layout is unchanged
here and is rebuilt in the next step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every group was a solid relief frame with a label floating above it, which
drew a notch where the empty label of a LabelFrame goes and left the boxes
of one row starting at different heights.

There is now one Section helper that every box is built from, so the boxes
line up by construction. The columns are reordered, the game launch
settings move above them, Execute sits with the sequence it runs, and a
status bar carries the state, a progress bar and Abort.

The window is resizable and asks Windows for per monitor dpi awareness,
which it needs to stop the text blurring on a scaled display.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The list box was the one widget the theme could not reach, so it kept the
system colours and its selection stayed the system blue.

A tree view carries the theme, and a check mark drawn with Pillow reads at
the row height where a font glyph did not. A click anywhere on a row still
toggles it, as the multiple selectmode of the list box did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The window was a control panel in front of a console the user had to keep
watching. It now carries the output itself, on a sash so it can be dragged
larger or shut.

Standard output and standard error are teed rather than replaced, so the
console still receives everything and modbuilder.cmd is unaffected. Note
that the external tools are started with subprocess and inherit the console
handle, so their own output reaches the console but not the pane.

Widgets are no longer touched from the build threads. Work is posted to a
queue that the main thread drains on a timer, which also folds the abort
polling loop into the pump and removes a thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Abort is greyed for most of a session, because the engine only holds a
process handle while the game runs. A build tool is started and waited on
without being recorded, so there is nothing to terminate, and the greyed
button looks broken rather than inapplicable.

A hint on the button says so. It is shown from a small tooltip helper,
since this version of ttkbootstrap carries none, and a disabled widget
still reports the pointer, which is the case that needed explaining.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@xezon xezon added the enhancement New feature or request label Sep 17, 2026
@xezon
xezon merged commit 90fa956 into main Sep 17, 2026
2 checks passed
@xezon
xezon deleted the update-24 branch September 17, 2026 15:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment