Skip to content

Releases: WingedGuardian/x4-ai-toolkit

v4.0.0 — X4 AI Assistant Toolkit

Choose a tag to compare

@WingedGuardian WingedGuardian released this 21 Oct 19:45

v4.0.0 turns the X4 Foundations Claude Code Modding Toolkit into the X4 AI Assistant Toolkit: X4-specific analysis tools, engine knowledge and guided workflows for working with your own game installation.

Claude Code and Codex are first-class integrations. OpenCode support is best effort and CLI only. Other coding agents can use the knowledge and tools; guard protection requires an adapter that passes the included conformance check.

What changes in 4.0

  • Choose your agent. Instructions, skills, guards and settings are generated from a shared source into the files each supported agent reads.
  • Verified save preservation. With guards active, recognized changes to one or two existing saves require permanent, verified preservation first. Bulk access requires qualified human approval. Opaque programs naming saves require review; unresolved scope is refused.
  • Check which protections are ready. x4doctor checks prerequisites, wiring and guard self-tests, and distinguishes failures, unknowns and steps only the user can complete.
  • Optional reference protection. x4refguard can protect the unpacked reference folder independently of agent hooks. Existing reference trees require an explicit protection step; elevated processes remain outside this guarantee.
  • More precise mod interaction reports. x4compat evaluates edits at their load position, identifies overwritten additions and separates qualified dependency overwrites into advisories.
  • Find Nexus mods by name. x4modlist search marks candidates as have, have? or new against the active registry and reports incomplete coverage.
  • Keep useful evidence while managing output. Retention tooling uses recoverable removal and protects qualified backups, baselines and linked evidence. Personal notes in KNOWLEDGEBASE.local.md survive upgrades.

The toolkit also includes validation, merged-data provenance, log triage, save inspection, numeric comparisons, script-reference tracing, semantic version comparisons and optional corpus/live queries. See the README for what each tool can help you do.

Selected fixes

  • Cross-mod patches at nested DLC paths are included in the effective store.
  • Hook-only deployments resolve their configured shared runtime instead of raising ValueError for unknown tools.
  • Save/profile approval ordering preserves saves before returning a separate confirmation.
  • Restoring an older backup preserves the current destination first.
  • Interrupted or malformed Steam responses report unavailable information rather than a false absence.
  • Cleanup preserves known-good baselines and linked evidence, and isolated test scratch avoids pruning other projects' runs.
  • Selector/reference, expression-checker, guard parsing and install/upgrade issues found during review are corrected.

The full changelog records the complete changes, evidence and known limitations.

Upgrading to 4.0

  1. Read the upgrade notes, then rerun the installer with --over-existing (-OverExisting in PowerShell).
  2. The paths configuration moves to x4-paths.env in the toolkit root.
  3. An edited legacy CLAUDE.md is preserved as X4-NOTES.pre-4.0.md, but that copy is no longer loaded. Move the personal instructions you want into KNOWLEDGEBASE.local.md. A customized AGENTS.md is preserved separately.
  4. Codex users: trust the folder, review the hooks in /hooks, then run x4doctor. Look for the X4 GUARDS LIVE session banner.
  5. Rebuild the effective store and other derived indexes before relying on their results. Check whether an existing reference folder needs OS protection applied.

For a fresh installation, follow the setup guide and the included SETUP_PROMPT.txt. On Windows, extract to a short path.

Validation and support

The complete Windows/Ubuntu CI run 39541688798 passed on b9bab53: Windows 6,262 passed / 60 skipped; Ubuntu 5,904 / 330 on both main and cold-clone checks, and 5,847 / 379 on the cold archive. All mutation and fuzz gates passed. The final executable code is identical; the subsequent resource-policy wording and release metadata are checked separately and independently reviewed. The final generated release metadata passed 16 targeted checks with no skips; both required identifier scans passed. This is scoped verification of release metadata, not a claim of full CI on the final tag.

Windows is the primary development environment. Codex on Linux/macOS and reference OS protection there are best effort, not device-tested. OpenCode host operation has not been verified. The entire optional live channel is experimental: use a throwaway save. Guards do not sandbox arbitrary programs; keep independent backups and read the coverage limitations.

Independent fan project. No Egosoft game data, AI model or subscription is bundled.

Release asset

  • Tag: v4.0.0 at e20a7382ad44d3ad80fa70f09230a360d157207e.
  • Archive: X4.Foundations.AI.Assistant.Toolkit-v4.0.0.zip.
  • Size: 10,666,173 bytes.
  • SHA-256: ae030cbb5ce07296485a1c2cdc61294ec015c0ec56a8276fd1d7eba7ff2e7437.
  • Packaging verification: all 791 shipped files match the tag's tracked member set and committed objects; 37 export-ignored maintainer files are excluded.

v3.3.1 — CI and tests only

Choose a tag to compare

@WingedGuardian WingedGuardian released this 29 Sep 20:45

A CI-and-tests patch: no tool changes behaviour. If you are on v3.3.0 your tools work the same; take this for a green, honest test suite.

v3.3.0's own CI went red on all three jobs. None was a defect in a tool — each was in how the release was tested:

  • The history identifier scan checked nothing on a tagged release commit. Its range started at git describe --tags, which on the release commit is the new tag, so the range was empty and the scan correctly refused to certify it. It now starts from the last tag before HEAD.
  • 16 new tests of gate logic never ran on a machine with no X4. They imported a gate that refuses at import without an install, so on CI they skipped — pushing the job over the skip ceiling (69 > 56) that exists to catch exactly that. They now run against a hermetic fake install; Windows CI skips 50, Ubuntu 63, both under their ceilings.
  • Two tests assumed Windows: one expected Set-Acl (a Windows-only cmdlet) to be denied on Linux, where the guard correctly fails closed to ask; one faked os.name = "nt" for the whole process, which crashed Python's path handling on Linux.
  • A provenance-floor test could never reach the floor it names (a stale-store refusal fired first); it now does, and disabling the floor turns it red.

A fresh release review of this range found a leak in the new fake-install helper (it could leave fake paths behind for the rest of a test run when collected first) — fixed before this tag.

CI on the released commit: all three jobs green — Windows 2,919 passed / 50 skipped, Ubuntu 2,886 passed / 63 skipped, cold clone 2,878 passed, no personal data in any of the published commits.

Full detail: CHANGELOG.

v3.3.0 — load order follows the engine

Choose a tag to compare

@WingedGuardian WingedGuardian released this 29 Sep 02:05

Load order now follows the engine — and that can change which mod wins a collision. Every tool that decides a winner (x4compat, x4effective, the effective store, BaseX x4eff, Tier B) used the community rule "alphabetical, dependencies forced earlier". Measured against the engine's own debug.txt it was wrong: it inverted 685 of 5,134 engine-logged pairs. The rule it now implements inverts 0, and an in-game probe (throwaway mods launched to the main menu, three rounds) confirmed the parts the log could not show.

⚠ A zzz_ folder name does not make a mod load last

The engine walks extension folders in case-insensitive UPPERCASE order (_ sorts after letters) in repeated passes: a mod whose dependencies have not loaded yet waits for a later pass. So an overlay named zzz_… loads last only within its own pass — every mod that waits on a dependency loads after it. To make an overlay win against a mod, give it an optional="true" <dependency> on that mod: measured to order exactly like a required one, and to never block loading, even when the mod it names is uninstalled or disabled.

Also measured and now modelled: a missing REQUIRED dependency, a dependency cycle, or a required dependency on a disabled mod means the mod does not load (and every tool now says so, naming the reason); the profile entry decides whether a mod is enabled, the manifest is only the default; DLC load before mods; mods in the profile's extensions\ folder load after every game-root mod, and a dependency never resolves between the game root and the profile root. The Steam Workshop root is unmeasured and disclosed as such whenever it is in play.

The PowerShell tool is guarded — and a release review closed its gaps

Before this release the safety hooks had no matcher for PowerShell or NotebookEdit, so every guard could be bypassed by choosing PowerShell. PowerShell commands are now parsed with PowerShell's own parser and judged by the same rule set as Bash. The pre-release review then found real bypasses in that new front-end (a delete reached through a pipeline or a variable, pwsh -cwa / stdin hosts, cmd /R, ^-escaped verbs, cd /d, an unquoted path with spaces, .NET writers, scriptblocks) — all closed, test-first, and measured against 50,061 historical commands before shipping so they add no friction. Bare system python on toolkit code is now a deny with the uv run form to use instead. Hook timeouts went from 5 s to 30 s (a guard that times out does not block).

A zero from BaseX is certified only over what the query addressed

ask.py used to print "NEGATIVE CONFIRMED over 10970 of 10970 documents" for a query that filtered itself down to 9 of them. A query that can narrow the documents it addresses (by document identity, position, node order, or an unreadable route) still runs and reports its hits, but its zero is withheld (rc 4, naming why). An unreadable mod catalog no longer lets coverage read "complete".

Every tool says what it left out

A mod the engine will not load is excluded from the model and named, with the reason, by every tool that models the running game — previously 16 of 20 call sites dropped it silently. x4compat, x4stats and x4similar now scan every configured extensions root, not just the game root.

Also

  • Freshness: an artifact goes STALE for every change that moves its answer (mods moving between roots, the reference tree, the engine code); a machine with no X4 installed no longer crashes x4xref.
  • x4diff sees element text; x4modlist detects updates from the Nexus file date with a 3-day grace window and never lets a network outage overwrite what it knew; x4validate, x4effective, x4debug, x4save, x4live and ~30 gates got correctness fixes — a finding now always outranks a refusal.
  • Installing from a git checkout copies only tracked files (one checkout carried 2.3 GB of local BaseX databases).
  • A Windows TMP longer than 260 characters hung the hook test suite for hours; fixed and banned by a test.

How this release was checked

A full release review: 9 fresh reviewers read all 116 changed production files in full; every finding (1 critical, 9 important, and every minor and pre-existing item they raised) fixed test-first, then re-verified. Full suite 2,961 passed, 0 failed (3 skipped: symlink tests that need a privilege this machine lacks); a cold clone with no X4 installed 2,878 passed, 0 failed, every CLI refusing cleanly; the release zip is the tagged commit, member for member.

Known, and stated rather than hidden

  • The instrument_hygiene gate is red: it measures the maintainer's own command habits across session transcripts, not the toolkit.
  • mutation_probe rewrites source in place and is never part of a gate run; it was not re-run for this release.
  • Issue #3 (one unexplained, non-reproducing cross_tool failure) is still open.
  • Still unmeasured, and disclosed at run time where it applies: the Workshop root; case-sensitivity of dependency ids; where a game-root mod that waits a pass lands relative to profile-root mods; non-NTFS filesystems.

Full detail: CHANGELOG · defect register: BLIND-SPOTS F128–F145 · audit record: tools/x4validate/AUDIT-2026-09-24.md.

v3.2.0 — the live channel can write, and a guard whose coverage was a coincidence

Choose a tag to compare

@WingedGuardian WingedGuardian released this 21 Sep 14:14

The toolkit can now ask the running game questions, and — for the first time — tell it to hold still. x4live pause / unpause / pausestate are the first verbs that change a live game, and they exist because a measurement taken while the simulation runs is a measurement of a moving target: a faction gains tens of objects a minute.

Upgrade for the guard fix if for nothing else

A recursive text search rooted above your 60 GB reference\ tree reached no rule at all, and this release is what broke it. The scope rules matched a search rooted AT reference\, or at the toolkit or game root — never at an ancestor. On a layout where reference\ sits inside what used to be the toolkit root, that ancestor was covered only by coincidence; retiring the dev repo moved the root and the coverage vanished silently.

Measured through the deployed hook, before the fix:

cd <parent-of-reference> && grep -rl "x" --include="*.md" .    ALLOW   <-- the hole
same shape at the toolkit root                                  deny
rooted at reference/ itself                                     deny
rooted at the game root                                         deny

Three controls that still denied is what makes it a hole rather than a rule change — the guard worked everywhere its authors had looked. No test could fail: the fixture set the toolkit root to the parent of reference\, so it encoded the same coincidence. It could vary the roots' values, never their relationship.

It was found by hook_false_positives, which replays every historical command through the real hook — and that gate had been red and unobserved, because it is slow and was not in the previous release's gate sweep. The suite stayed green throughout. (F127)

BaseX was quietly answering a different question under Git Bash

A query typed as an argument was rewritten by MSYS before Python ever saw it — //ware arriving as /ware — and the empty result was then certified as a negative finding. If you ran a corpus query from Git Bash and concluded "X does not exist", that conclusion may have been manufactured by your shell.

xq now takes --file, an argument query's zero is refused under Git Bash rather than reported, and every argument result prints the query as received so you can see what the tool actually ran. Found by a cold agent following the documentation's own example.

One repository

The development checkout is retired and archived; the public repo is now the only tree. The blind-spots register (F1–F127), the 2026-08 audit, four gates and the next-id script used to exist only in the private copy. They ship here now.

Weapon DPS is still an honest gap, deliberately

It was promoted to a computed field during this cycle and demoted again before shipping. A live sweep of the whole population found the per-channel derivation disagreeing with the engine on 20 of 345 weapon/turret macros — including one that derives 0.0 where the engine reports 30,315, which would have read as "this weapon does no damage" rather than as a defect.

So the tool still says "we cannot compute this". No shipped release has ever emitted a weapon dps, and this one does not either — nothing regresses. The work is kept and under test; re-promotion is two lines once the 20 are explained. The aggregation rule was corrected on the way and is exact (345 of 345), which is why the remaining gap is attributed to the per-channel inputs rather than to the sum.

x4live, in full

  • Write verbs (pause, unpause, pausestate): the write vocabulary is a lookup table on both sides, every write reply leads with a write=yes advisory enforced at the reply choke point, the result is the engine's read-back rather than the verb's intention, and unpause undoes only a pause this channel made. query refuses both names, so its free-text passthrough is not a second door.
  • A hard request-size cap (MAX_REQUEST_BYTES, 1900). Measured live: a request over ~1997 bytes tears the game-side pipe read down, and that teardown was followed by a crash whose minidump faults in X4's own UI event dispatch. The cap sits below the measured ceiling and refuses before the write.
  • The FFI verbs ship disabled by default (X4_LIVE_ALLOW_FFI=1 to re-enable) — precautionary after that crash, reversible, and explicitly not a claim that FFI caused it.
  • A "nothing connected" refusal that measures the cause instead of listing possibilities. The helper arms on game load, so at the main menu its marker is legitimately absent — and the old message sent you to check an install that was fine.

Verified at the tagged tree

suite 2,393 passed / 3 skipped / 0 failed in ONE invocation (26:16) · cold clone 2,209 / 65 / 0 over COMMITTED blobs, with all 10 configuration-dependent CLIs refusing exit 2 and no traceback · mutation_probe alone 54/54 killed, 0 survivors, 0 hangs, 0 unmeasured, tree verified restored · hook probes 169/0/0 · hook units 459 · hook_false_positives green (no drift, 22,718 shared commands, 0 changed) after it found F127 · gates: budget, routing 11/11, deploy parity 37/37, register_rederivation 80 entries / 72 naming a check · identifier scans clean over the tree and all 42 commits · build-release.sh --selftest still reproduces the v3.0.0 asset byte for byte · CI green on all three jobs, read per job · release review over v3.1.1..HEAD: 112 commits, 134 files, +25,272 / −2,663.

The 3 suite skips are one test file needing a privilege this account lacks (symlink creation, WinError 1314). A skip is counted and named, never folded into the pass count.

Two things stay open and are named rather than hidden: instrument_hygiene is red on the machine that produced this release, reporting command-hygiene regressions in my own sessions — the baseline was deliberately not re-recorded to clear it. And issue #3, one unexplained non-reproducing failure from the v3.1.0 sweep, has not reproduced in 30+ runs since.

Full detail in CHANGELOG.md.

v3.1.1 — the release that was red, and the leg that could not say so

Choose a tag to compare

@WingedGuardian WingedGuardian released this 10 Sep 02:14

v3.1.0 was tagged, released and verified byte-for-byte while both CI legs were red on the released commit. This is the close-out, and each defect in it was hidden by the one before.

Upgrade for the guard fix if for nothing else

verb_unresolved — the rule v3.1.0 added to close a total bypass of all three hard blocks — shipped with no test case of its own. Given one, the fuzzer found a live hole in seconds: a command name with a substitution embedded in a path, aimed at your game folder, came back ALLOW. The plain spelling was correctly blocked. The rule tested startswith, so a substitution anywhere but the first character escaped it.

install.ps1 was 100% dead on Linux and macOS

Both methods, 21 tests red. Find-GitBash built its candidate array with an eager Join-Path on $env:LOCALAPPDATA, which is $null off Windows. The if ($base) guard on the very next line is correct and one level too late — the array is built first. It protected the loop variable, not the expression that produced it. Two more of the same class fixed alongside it.

If you are on Linux or macOS, install.ps1 from v3.1.0 cannot have worked. install.sh was never affected.

The tooling that was supposed to catch all this

  • The differential fuzzer, which v3.1.0's notes name as evidence you can run, exited 2 for everyone — an orphaned control anchor. It also masked every CI step after it for an entire release. Anchors are now scoped to the rule they test.
  • The skip ceiling then failed a leg with 1,631 passed and 0 failed, while Windows sat at exactly 59 of 59. All 71 skips were classified before it was raised.
  • The bundle verifier refused a correct bundle, calling 14 deliberately-excluded files missing.

The reason none of it was visible

ubuntu-latest was experimental: true, and continue-on-error is keyed on that flag. So the run concludes success with that job red, and even gh run watch --exit-status returns 0. Every defect above hid there.

That flag is now false. Two Linux-only checks — --method global in bash, and the cold-clone verification — are enforced for the first time.

Verified at the tagged tree

suite 1,704 / 14 skipped · cold clone CLEAN 1,653 / 65 · gates 28 of 28 attempted passed, buckets summing 29 of 29 · mutation_probe alone 48/48 killed, 0 survivors · fuzz-guard rc 0 over 2,675 mutants with its control live and rediscovering 126 bypasses, 22 of 24 rules seed-covered · hook probes 160/0/0 · hook units 444 · E2E 20/0 · 72 installer tests · --selftest still reproduces the v3.0.0 asset byte for byte · release review: no CRITICAL, no IMPORTANT in-arc · CI green on every job, read per job, with the Linux leg finally able to fail a build.

Full detail in CHANGELOG.md.

v3.1.0 — the instruments that could not report what they were built to find

Choose a tag to compare

@WingedGuardian WingedGuardian released this 09 Sep 05:57

⚠ Read this first if you installed a pre-release build

If you ever ran an installer from this repo with --over-existing, check
.claude/backups/ before anything else.
An upgrade deleted the destination's
recovery store silently, at exit 0. The prune list is dual-purpose — paths in it are
skipped on the way in and rm -rf'd from the destination on the way out — and the
entry added to stop the source machine's backups travelling inherited that second,
destructive meaning. On the machine where it was found, that directory held 982
files, 60 MB and 33 named known-good snapshots
. Fixed in this release.

Three installer criticals, all introduced by this arc

  • --dry-run performed a REAL install whenever the toolkit was already at the
    destination — both installers, both methods, printing === install complete ===. A
    new "nothing to change" fast path returned above the dry-run gate, so the guard was
    correct, present, and unreachable.
  • The source machine's secrets travelled. x4-paths.env.bak-* and .tmp* hold
    X4_NEXUS_KEY by construction and were copied to the destination.
  • --method global rewrote the user's own files inside shipped skill directories,
    with the only backup deleted on the success path.

Also: the recovery command both installers printed from a refused install (x4lock.py unlock) exits 2 — it needs a path or --all. The remedy failed at the moment you were
least able to tell a broken instruction from a broken tool.

The theme: five gates with no floor under their verdict

Every one reported success over a population it never examined — All properties hold
over three checks that never ran, No per-mod regression over an empty
intersection, a completion sentinel over an empty directory, a header-only artifact
from an empty input, and a three-channel audit whose third channel was uncounted. Each
is now a refusal or an rc 3.

The generalisation: a count incremented before the step it is meant to measure
cannot report that step failing.

mutation_probe could not run at all — it shared a 120s timeout with the targeted
runs while the suite had grown to 665s, so it returned its non-answer and refused. It
was the one gate of 29 left unrun, and it was found only because the runner prints
NOT ATTEMPTED and makes its buckets sum to the roster.

Other measured fixes

  • An empty root list was digested as a world, so an artifact that enumerated zero
    mods
    reported FRESH.
  • A malformed document made x4stats print "candidate introduces/changes no wares" at
    rc 0 — walking around the guard this arc added against exactly that sentence.
  • Nine tests that could not fail, four of them holding an already-fixed defect in place.
  • x4effective dump printed an incomplete tree as a clean one; it now discloses dropped
    overlays and returns 3.

Verification

Suite 1702 passed / 14 skipped / 0 failed · verify-cold 1651 / 65, COLD RUN CLEAN with the roots asserted unresolved · 160 hook probes · gates 28 of 29
attempted, 28 passed
· mutation_probe run alone, 48/48 killed, 0 survivors ·
all four derived artifacts agreeing on both fingerprint axes.

Full detail, with the measurements and the denominators, is in CHANGELOG.md.

v3.0.0 — the guard was walkable, and the toolkit got a live channel to the running game

Choose a tag to compare

@WingedGuardian WingedGuardian released this 05 Sep 23:38

Three things make this a major version: the Bash guard was rebuilt around a single parse pass, the toolkit gained a live channel to the running game, and it now ships a game extension of its own. The first changes how every command you run is checked; the other two change what the toolkit can see.

⚠ protect-bash.sh now requires Python. Without it the guard asks rather than silently allowing.


The guard was walkable, and the thing built to prove otherwise could not reach it

case $string in *) rm -rf "<game>";; esac was ALLOW, while the bare rm -rf "<game>" was correctly denied. One line was doing a substring search where it meant token membership, so the cut landed inside the word string; the segment's verb came out as ary, and every verb-keyed rule missed at once — all three hard blocks included.

That was live in both hook copies while 996 fuzz mutants, 82 mutation probes, 23 predicate checks and 388 unit tests were green.

The reason is the part worth keeping. fuzz-guard.py states one invariant in its own docstring — the dangerous operand is byte-identical in every mutant. It had a second, unstated one: every compound-command mutator was a fixed string, always a one-character word and a one-character label. The word was unreachable by construction, exactly as the operand is. An instrument's stated invariant is its blind spot, and so is the one it doesn't state.

11 parameter-varied mutators later: 1,140 mutants, 80 bypasses on the first run, 0 now. Three heredoc markers and two more case-arm spellings were found by those mutators and by nothing else.

Rule precedence was decided by line number

ask exited, so every deny written below it was unreachable and severity was decided by position in the file. Three findings that looked unrelated were this one defect: 300 trivial command substitutions turned the game-delete hard block into an ask; rm <file> && git add -A asked where git add -A alone denies; and the save-directory warning was unreachable in the default layout.

Precedence is now by value: deny > ask > advise. Re-priced per item over 13,503 real commands with the prediction written first — 8 changed, all tightening, 0 loosening, including two deploy commands running a recursive delete inside the game install that had been silent allows.

Also: one git -c key=value silenced the entire destructive-git rule, in the rule whose own docstring records that git ignores the read-only attribute — so nothing else was watching.

The effective tree was discarding 99 base entries

The additive directories (libraries/, index/, t/) are paths inside the extension that owns a document. Two of three call sites pass a vpath that still carries extensions/<owner>/, and extensions/ego_dlc_boron/libraries/rooms.xml does not start with libraries/ — so the union branch was skipped and the base document was thrown away.

vpath base entries effective, before
ego_dlc_boron/libraries/rooms.xml 19 2
ego_dlc_boron/libraries/roomgroups.xml 19 2
ego_dlc_split/libraries/rooms.xml 18 2

This is a user-facing data defect, not a reporting one: anything asking "what rooms exist" got the wrong answer.

Your files got a layer that does not depend on anyone being careful

A mod registry — 258 hand-triaged entries — was emptied during a review round, the fourth irreplaceable file lost in twelve days. Forensics across every transcript found that none of the four was a decision: each was a command that ran, resolved to a live path, and met nothing in the way.

  • x4lock marks the irreplaceable files read-only. Measured over 14 write primitives, each paired with a control: 11 blocked, including all four real incidents. The three it does not block are documented rather than glossed.
  • x4canary refuses on an emptied, deleted or halved file, and exits 2 when it cannot check. Wired to run on session start, so it does not depend on anyone remembering.
  • The registry save is now atomic and refuses to shrink.

⚠ An ACL-based first attempt locked the machine out of two of its own files and was withdrawn entirely — icacls W includes SYNCHRONIZE, so a write-deny denies reading, and the deny includes the permission needed to remove itself. The read-only attribute cannot do either.

Installing over an existing installation now requires DIRECTION, not approval

Not a prompt you can click through: an explicit --over-existing. The refusal names what it found and what would be replaced.

Instruments that could not report what they were built to find

A long tail, all one shape — a step that narrows the data and reports success anyway:

  • the oracle enforced half its contract, and the half it dropped was the narrowing half
  • unparseable documents counted as verbatim while the same screen said they were not
  • a NUL byte removed a file from the control-byte sweep — and NUL is in the class that sweep hunts
  • a zero-coverage baseline stayed green forever once recorded
  • the lockstep gate read its population from the working tree its own docstring promises it never reads
  • the reference freshness survey was blind below depth 2
  • three tests reported PASSED without asserting, and one guard was satisfied by the comment that mentioned the flag

Also

The mod's reply cap bounded the answer rather than the work — 40,492 engine calls in one frame to show 489 rows. blockinput=true froze all player input for 12 seconds on every game load. A mixed-case Index/ made all 8 DLC layers REPLACE instead of union, taking 4,733 entries down to 17. A scalar sel= crashed, or reported the length of a string as a node count.


Verification for this release: suite 1382 passed / 14 skipped · 138 hook probes · 393 parser unit tests · 1,140 fuzz mutants with 0 bypasses and a control proving the fuzzer can still fail · engine oracle 241/241 = 100%, 0 false OK · both installers dry-run from a cold git archive extract writing nothing · cold-clone suite green with every X4_* cleared and the roots asserted unresolved.

Full detail, with the measurements and the denominators, is in CHANGELOG.md.

v2.9.0 — the patch that patches another mod is finally checked

Choose a tag to compare

@WingedGuardian WingedGuardian released this 28 Aug 04:26

A cross-mod script patch was validated by nothing at all. X4 mods patch each other by mirroring
the target's path inside their own folder — <mymod>/extensions/<target>/md/foo.xml. Both halves of
the schema pass checked direct children only, so every one of those files fell straight through.
Measured on a real 125-mod install: 16 such files across 8 mods, every one of them live.

They are now validated through the document the engine actually builds — target plus patch,
merged — rather than the patch file, which is a <diff> the engine never parses on its own. That
also dissolves the depth problem instead of fighting it: after merging, there is no nesting left.

The interesting part is what it refuses to tell you. Validating the merged document alone would
blame a mod for its target's pre-existing schema violations. MEASURED: 182 findings, of which 167
(91.8%) belong to the target, not the patcher
— a mod named moreroomsforships fails md.xsd's
capital-letter pattern because its own author named it that, and the patching mod can neither cause
nor fix it. Reporting those would flood, and a check that floods trains you to ignore it. So both
sides are validated and diffed per finding: 15 introduced, 1 fixed.

fixed is reported as well as introduced, because a check that can only go one way cannot be
trusted to go the other.

A file that could not be checked never reads as one that came back clean, and the two reasons are
kept apart — because one of them is a real defect:

  • target mod is not installed — the engine no-ops the patch too; nothing wrong.
  • target is installed but does not supply that file — a stale patch that silently never applies.
    One live instance found: a mod patching md/spawnclaymore.xml in a target that ships seven md
    files, none of them that one. The engine logs nothing for this.

x4effective dump stopped reporting a wrong question as a missing answer. Addressing a mod's
document by its path on disk — extensions/<mod>/md/foo.xml — printed no effective content, which
reads as not in the live tree when the truth was right file, wrong form: documents are keyed by
the vpath the engine builds, and for a DLC that literal path is the vpath while for a mod it is
not. 1,713 of 3,257 addressable paths are mod-owned, so this covered a third of the surface. It
now resolves the path and says so out loud, because resolving it silently would trade one confident
wrong answer for another. A genuine absence still exits 1.


Known limit, stated because it is easier to trust a tool that names its edges: the attribution diff
compares the target alone against the target plus this mod. A finding that appears only in
combination with a third mod's patch is outside what it can see.

v2.8.0 — read the save, start the JVM, and say which source won

Choose a tag to compare

@WingedGuardian WingedGuardian released this 27 Aug 15:31

x4save reads a savegame and says what it would silently lose — the save is the only artifact the
engine wrote, so it answers two questions no manifest can: which extensions are baked into the save,
and what content it references that the live tree no longer defines.

CI had never once started a JVM. The BaseX jar has shipped vendored since 2.6.0 and all 24 of its
tests mocked java. CI now installs Temurin 17 and runs a real collection() query against the real
jar — proven able to fail three ways. setup.sh also probes for Java, which README.md had been
promising as a prerequisite without anything checking it.

Provenance now says which source WON, not which INTRODUCED it. A root <replace sel="//macros">
swaps a whole document, so base contributes no chain entry at all — and that is VRO's dominant
idiom, 848 of them. A single-entry chain therefore read as "this mod introduced this value" when it
usually means "this mod re-supplied what vanilla already had."

MEASURED: of 35,423 single-op root-replace attributes, 23,182 (65.4%) also exist in vanilla
with the chain hiding it; only 39 vpaths are genuinely mod-added. who-sets and dump --chain
now disclose when base+DLC also supply the vpath, and stay silent where there is nothing to disclaim.

Also fixed: a store could be stamped with a fingerprint describing a world it was never built from;
a guard that caught a vanishing CLI but not a new one; and a line-ending pin that was declared but
never checked against the bytes it governs — 14 of 155 pinned files still held CRLF, including two that
bash sources, where it is functionally fatal rather than cosmetic.

Under the hood, mutation coverage went from one module to six — 15 mutants, killed 15/15. Every
detector it names is now provably killable rather than assumed so.

Full detail in CHANGELOG.md; every finding is recorded individually in docs/TRUST.md's register
with a measured cost.

v2.7.0 — the port is proven, and the tools say which world they describe

Choose a tag to compare

@WingedGuardian WingedGuardian released this 27 Aug 00:13

Fixed — five tests failed on any machine with no game installed (F63)

_effective._ext_root() returns _registry.GAME_EXTENSIONS, guarded by except AttributeError.
That guard never fires — the attribute exists and its value is None when nothing is
configured. _freshness.fingerprint() then refused, correctly (it must never guess an extensions
root and hash whatever directory you are standing in), but it could not tell "the caller forgot"
from "the caller looked and there is none".

Every fresh clone and every CI runner is the second case. Both now have spellings:
fingerprint(config) still raises; fingerprint(config, extensions=None) records the content
axis as UNKNOWN.

⚠ And UNKNOWN never reads as fresh. None == None, so a naive comparison would have made two
unknown content axes match and report FRESH — an artifact that cannot say which world it describes,
declaring that it still describes it. compare() now says freshness could not be established,
which is neither fresh nor known-stale.

_freshness.py is deliberately not among the sources the engine fingerprint hashes, so this changes
no artifact's fingerprint and nothing needs rebuilding.

Fixed — the mutation gate ran a narrower scope than it reported (F62)

gates/mutation_probe.py drops a missing test path from a mutant's scope before invoking pytest.
Filtering a vanished path is right; doing it silently is not — and it was happening inside the
one gate built to detect vacuous assertions.

MEASURED: this gate's _registry.py scope lists four test files. In one checkout only three exist,
so it ran 3 of 4 and printed the same killed line as a full scope.

Two more in the same function. An empty scope returned the verdict "hang" — an absence
rendered as a timeout that was never measured, which the caller then "confirms" by re-running,
against a file that is still not there. And the test meant to catch all of this,
test_every_target_has_tests_that_exist, asserted any(... exists ...): its NAME promises every
file, its ASSERTION accepted one, so three of four present passed.

Now: a partial scope names what it dropped; an empty scope returns a new verdict noscope with
its own bucket, excluded from the killed count and failing the gate; an empty BASELINE scope returns
rc 2 (cannot run), never rc 1 (has findings). The check is mp_scope_gaps(), which returns the
named gaps rather than a boolean.

The asymmetry is the proof: it is GREEN against a complete tree and RED against an incomplete one,
naming the exact missing file. Six tests written first and each watched fail for the right reason —
one printing killed 1/1 for a mutant that was never challenged. Probe re-run afterwards:
11/11 killed, 0 survivors, 0 hangs, 0 unmeasured.

Changed — docs/QA-PROCESS.md: how to prove a port

Rule 2 said to prove a port with diff -rq. MEASURED on a real pair of trees, that reports 52
differences where 12 are real
— the other 40 are line endings, from a working tree checked out
before an LF pin, while both repositories store identical bytes. A proof that buries findings under
non-findings teaches you to skip it. The rule now says to compare committed blobs.

Fixed — a mutating gate could not put the tree back if it died (F59)

gates/mutation_probe.py breaks a source file on purpose, runs the tests, and restores it. The
restore lived in a finally: — and finally does not run on SIGKILL. A killed probe therefore
left a deliberately-broken source on disk, and because that file is tracked, git status shows
an ordinary modification. v2.5.0 shipped exactly that way once: ambiguous-sel detection
silently disabled in a public release.

Not hypothetical. A stop command that reported success and did not stop left three gate sweeps
racing, so "never run a mutating gate against a tree in use" can be broken by accident.

Reproduced rather than simulated — launched the probe, polled until a target really differed from
HEAD, hard-killed it at t=7.5 s. It left if len(new_children) != 1 and False: on disk. Re-running
refused with rc 2; --recover restored it and named the mutated file.

Byte-for-byte pristine copies plus a .mutation-probe-active marker written before the first
mutation, so recovery never depends on the thing that failed. Recovery is explicit, never automatic:
auto-restore would need pid liveness, and on Windows os.kill(pid, 0) maps to TerminateProcess.

Added — the mutation window is visible to every tool, and refuses writers

During a window every CLI answers from broken code with nothing on screen saying so. Reads now get a
banner; writes refuse with rc 2. The asymmetry is the point: a wrong read can be re-taken
once the ~70 s window closes, but a poisoned artifact outlives it and is trusted afterwards.

The banner rides on the decorator every entry point already carries, so all 9 CLIs are covered by
construction. The refusal sits at the two stamping sites, not at argument parsing, so no other
entry path can slip past it. ⚠ The marker resolves from __file__, never the CWD — these tools are
normally run from the game directory, where a CWD-relative lookup would report all-clear from the one
place it matters most.

Changed — mutation coverage 1 file → 3, and a hang is its own verdict (F54)

_merge (6 mutants), _registry (3), _compat (2) — 11/11 killed. Every mutant inverts a guard
whose failure has a measured cost, so a contrived mutant nothing kills cannot pad the number.
Per-mutant timeout 1800 s → 120 s, and HUNG is now distinct from both killed and survived: a
mutant that hangs is a finding about the suite, not a pass.

Changed — a stale verdict says how far it moved

Both freshness axes now quote [stored -> now]. "Moved how far, and by whose change?" is the first
question on hitting a stale artifact, and answering it previously needed a hand-written script.

Fixed — a capability that existed, was correct, and was never reached (F58)

x4effective dump --chain answers whether a vpath exists in the live tree, who supplies it, and
whether they supply or merely patch it (vro:full vs base, ego_dlc_x:diff). A gap was filed
against it anyway, and a hand-rolled base-only substitute labelled 65 of 241 vpaths as
"paths Egosoft renamed" — they are mod-supplied files that actually win those paths, so the error
pointed toward deleting them.

Fixed with a routing-table row, and deliberately not a new helper: the one added for this exact
trap the day before had zero callers. Also deliberately not a lint — the failure was in a
throwaway script, which no linter covers. MEASURED alongside: 21 of 30 CLI subcommands appear in
no routing surface.

Fixed — the freshness content axis was blind to a mod's files changing (F57)

_freshness.hash_content stat'd only each mod folder's content.xml. The ordinary
overlay-deploy workflow — edit a file, copy it in, manifest untouched — moved nothing, so every
artifact reported FRESH while describing a different tree.

MEASURED: 72 of 121 non-DLC mods (59.5%) have at least one file newer than their own manifest,
and the old code returned the identical digest 03122df005f47fbd before and after a file-only edit.
This is a false FRESH — it fails in the unsafe direction, unlike F53's false STALE.

The axis now also hashes a per-mod tree_sha over sorted (relpath, mtime, size) for
.xml/.cat/.dat/.lua. Cost on a live 129-mod install: 0.10 s / 2,170 files.

⚠ The suffix filter is correctness, not speed: 8 mods would otherwise move the fingerprint on
README/CHANGES/LICENCE churn alone.

⚠ Scope limit: (mtime, size), not a content hash. An edit changing neither is invisible; a
same-size edit reads as touched rather than content. Manifests are hashed.

Added — x4modlist changed / x4modlist snapshot (F56)

The digest said THAT the world moved, never WHAT. Localising one changed mod took nine investigative
steps and three false leads, and only worked because it happened to bump its version.

content_detail() now returns the per-folder vector, hash_content() folds it through a single
_fold(), and the vector is persisted beside the digest (190 KB) so a move is localised by
diffing rather than by forensics.

x4modlist changed [--since store|xref|latest|<path>] [--files] [--usn]
x4modlist snapshot [--label <name>]
  • Triples are hashed as a set, never max(mtime) — the incident mod had back-dated its 26
    files, which is exactly what defeats a max and what made find -newermt useless.
  • A baseline with no vector raises NoBaseline and exits 3 — a non-answer, never "no change".
    Reading is optional, so no existing artifact flips to UNKNOWN.
  • --usn needs Administrator (Microsoft's documented requirement for change-journal reads) and
    degrades to a message rather than failing the command.

Fixed

  • F43 — the content axis could not see a mod being enabled or disabled in-game, because toggling
    rewrites only the PROFILE manifest. Now carried per mod, joined by manifest id, never folder
    name
    , defaulting to enabled for a mod the profile has never seen. Verified against the live
    profile: it finds exactly one disabled mod, matching independently-recorded ground truth.
  • F52 — check_effective_schema's note went silent about files it skipped as soon as it
    validated anything. The effective-schema: {checked} prefix is preserved; gates/schema_sweep.py
    parses it positionally.
  • F46 (swept) — fingerprint() fell back to Path("") = Path("."), hashing the current
    directory. It now refuses. All four production callers already passed the root explicitly.
  • F53 — the working tree is renormalised, so the untouched ENGINE_SOURCES are byte-identical to
    HEAD and this tree hashes like a fresh checko...
Read more