Skip to content

Restore compatibility with current versions of the game - #12

Open
bitrotlab wants to merge 12 commits into
Majiir:masterfrom
bitrotlab:fix/current-game-compatibility
Open

Restore compatibility with current versions of the game#12
bitrotlab wants to merge 12 commits into
Majiir:masterfrom
bitrotlab:fix/current-game-compatibility

Conversation

@bitrotlab

Copy link
Copy Markdown

Restore compatibility with current versions of the game

Fixes #11.

The problem

The 0.4.0 release ships a complete, pre-patched BBI.Unity.Game.dll — the whole game assembly as
it existed in November 2018 — and the install instructions say to overwrite the game's own copy.
On any later build that replaces years of game code, which is why the main menu misbehaves and
the transition into a match fails.

The actual modification inside that 1.85 MB file is three IL instructions at the end of
ShipbreakersMain.ResetEntityManager:

newobj  instance void Subsystem.AttributeLoader::.ctor()
ldsfld  class BBI.Core.Data.EntityTypeCollection BBI.Unity.Game.ShipbreakersMain::sEntityTypes
call    instance void Subsystem.AttributeLoader::LoadAttributes(class BBI.Core.Data.EntityTypeCollection)

Everything else is just the 2018 game. Checked against a current install, that assembly references
19 types and 51 members that no longer exist, concentrated in BBI.Steam, BBI.Spark and
BBI.Unity.Game.Data — the lobby, leaderboard, stats and DLC layers, rewritten when the game
moved to Epic Online Services. Those throw as soon as the affected code is JIT-compiled, which is
exactly where the reported symptoms appear.

Subsystem.dll itself was never broken. Against a current install it resolves every type and
member reference it uses, all 21 wrapper classes still satisfy the game interfaces they implement,
and every wrapper copy-constructor still copies 100% of its interface's properties. The 2018
sources compile against current game assemblies with no errors and no warnings. Only the delivery
mechanism was broken.

The fix

SubsystemPatcher applies those three instructions to whatever BBI.Unity.Game.dll the user
actually has, rather than replacing the file. It keeps working across game updates — you re-run it
after each one.

It backs the original up before writing, restores it byte-for-byte, is idempotent, and refuses to
run against an assembly whose references do not resolve against the rest of the install, which is
what an old pre-patched copy looks like.

The rewrite was verified rather than assumed:

  • Comparing an opcode-and-operand fingerprint of all 15,907 members before and after, only
    ResetEntityManager differs: +3 instructions, its 2 exception handlers and 9 locals intact.
  • All 26,312 lines of custom attributes and metadata flags are byte-identical — 109
    [Serializable] types, 10 [NonSerialized] fields, class layout, security declarations.
  • No embedded resources, exported types, module references or P/Invoke exist to lose; ILOnly
    and the 32-bit architecture flag are preserved.
  • --restore reproduces the original file's checksum exactly.

What else is in here

build/Subsystem.Sdk.csproj — the existing project is legacy-format with no HintPaths, so it
only ever built where Visual Studio happened to resolve the game assemblies. This compiles the
same sources with nothing but the .NET SDK, on any OS, referencing the assemblies from the game
install. The original project file is left in place.

EntityTypeDumper — writes Data/Subsystem.entities.log on every game start: every entity
type the game loaded, its components, and for weapons the ranges, auto-fire flags and
per-target-class modifiers. The only published name list is the 2018 gist, and names have changed
since. It is written before the patch is read, so it is still produced when patch.json is
missing or malformed.

SubsystemLint — validates a patch.json against the shapes Subsystem deserializes and the
names the install actually loaded. This matters more than it sounds: LitJson's ignore_extra_keys
is never assigned and defaults to false, so one unknown key makes the whole document throw
and nothing is patched at all. A single typo silently disables the entire file, with one line in
the player log as the only clue.

CI and release workflows — CI builds both tools. The mod assembly cannot be built on CI at all,
since it references assemblies that ship with the game and cannot be redistributed, so CI instead
checks that its build guard still fires. The release workflow runs on v* tags and publishes the
tools as portable framework-dependent builds (~600 KB, runs on every OS) packaged with the docs,
the mod sources and the build project, so users can extract it and build Subsystem.dll against
their own installation.

Documentationdocs/compatibility.md records what broke and why so it does not have to be
rediscovered; docs/patch-reference.md covers every patchable property and the non-obvious rules;
docs/finding-names-and-values.md covers discovering names for a given install;
docs/troubleshooting.md is symptom-first; docs/building.md covers the build and release
process. The install instructions, dead source links, and an example referencing an entity the
game no longer has are all fixed.

Compatibility

No behavioural change to existing patch files. The hook site, the instructions injected and the
timing are identical to 0.4.0, so an existing patch.json behaves exactly as before.

Testing

Verified against Steam build 12339551 on Linux under Proton: patch, verify, re-patch, restore
round-trip, rejection of a mismatched assembly, and in-game confirmation that attribute changes
apply and are reported in Subsystem.log.

About this contribution

I built this because I wanted to play the game with my own tweaks and the old install method no
longer worked. I am not volunteering to maintain Subsystem, and nothing here should be read as a
commitment to support it — I work on the parts I happen to be curious about, when I feel like it.

Take it, change it, or ignore it. I am happy to answer questions on this PR and to fix anything
you want changed before merging, but please do not route issues or future game breakage to me by
default.

That is also why docs/ is as thorough as it is. Everything I worked out — the hook, the failure
mode, the verification, the traps in the patch format — is written down specifically so the next
person does not have to ask me, or rediscover it from scratch the way I did.

bitrotlab added 12 commits July 31, 2026 20:53
The 0.4.0 release shipped a complete pre-patched BBI.Unity.Game.dll built against
the November 2018 game, and the install instructions told users to overwrite the
game's own copy. On any later build that replaces the game assembly wholesale,
which is why the menu misbehaves and entering a match fails.

The modification itself is three IL instructions at the end of
ShipbreakersMain.ResetEntityManager. This tool applies those instructions to
whatever BBI.Unity.Game.dll the user actually has, using Mono.Cecil, so it keeps
working across game updates instead of pinning one build.

It backs the original up before writing, restores it byte-for-byte, is idempotent,
and refuses to run against an assembly whose references do not resolve against the
rest of the install - which is what an old pre-patched copy looks like.
…start

Entity and component names are the keys patch.json is written against, and the
only published list dates from 2018. Names have changed since, campaign and
multiplayer variants differ, and names scraped from asset files are unreliable
because the build compresses them.

AttributeLoader now writes Data/Subsystem.entities.log listing every entity type
the game loaded, its components, and for weapons the ranges, auto-fire flags and
per-target-class modifiers. It is written before the patch is read and in its own
try/catch, so it is still produced when patch.json is missing or malformed - which
is when it is most useful.
Subsystem swallows most mistakes. The costly one is an unknown property: LitJson's
ignore_extra_keys is never assigned and so defaults to false, meaning a single
unrecognised key makes the whole document throw, and AttributeLoader turns that
into one line in the player log with nothing patched at all. A typo therefore
silently disables the entire file.

The linter derives the expected shapes from Subsystem.dll itself rather than a
hand-maintained schema, and checks names against Subsystem.entities.log when the
game has written one. It reports unknown keys, wrong value types, invalid enum
values, non-integer list keys, and unknown entity and component names, with
spelling suggestions.
Subsystem/Subsystem.csproj is a legacy-format project whose <Reference> items
carry no HintPaths, so it only ever built on a machine where Visual Studio
resolved BBI.Core, BBI.Game.Data and UnityEngine on its own.

This project compiles the same sources with nothing but the .NET SDK, on Linux,
macOS or Windows, referencing the assemblies directly from the game install. It
locates a Steam install automatically, including Flatpak Steam, and fails with a
clear message rather than a wall of unresolved-type errors when it cannot.

The original project file is left in place for reference.
CI builds both tools on pushes and pull requests. The mod assembly cannot be built
on CI at all - it references assemblies that ship with the game and cannot be
redistributed - so CI instead checks that its build guard still fires when pointed
at a missing game folder.

The release workflow runs on v* tags and publishes both tools as portable
framework-dependent builds, one set of files that runs on every OS at around 600 KB
rather than the 67 MB a self-contained build costs per platform. It packages them
with the docs, the mod sources and the build project, so a user can extract the
archive and build Subsystem.dll against their own installation.
Records what actually broke so the next person does not have to rediscover it: the
three-instruction hook, the 19 types and 51 members the 2018 assembly references
that no longer exist after the move to Epic Online Services, and the verification
that rewriting the assembly changes nothing else.

Adds a patch.json reference covering every patchable property and the rules that
are not obvious - an unknown key discarding the whole file, lists being keyed by
index rather than name, unit stats only applying to newly spawned units, and the
stat card showing buffed values.

Adds a guide to finding entity, component and weapon names for a given install,
and a symptom-first troubleshooting page. Replaces the install instructions, which
no longer work, and fixes the dead source links and an example that referenced an
entity the game no longer has.
applyListPatch ordered its entries with OrderBy(x => x.Key), an ordinal
string sort, so "10" sorts between "1" and "2". A list with more than ten
entries therefore reached index 10 while only two entries had been built,
tripped the non-consecutive-index check, and stopped -- silently discarding
every remaining entry and leaving only "ERROR: Non-consecutive index" in
Subsystem.log.

No entity in the game data has more than ten weapon modifiers or veterancy
levels, so this was unreachable until patches started carrying longer lists.

Sort by the parsed integer instead. A non-integer key still aborts the list,
as before.
The Entities section patches the shared entity type templates, so a change
necessarily reaches every player fielding that unit. The engine already
keeps a per-commander copy of each buffable entity type and modifies it with
attribute buffs -- that is how a research upgrade gives one player better
tanks. This exposes the same mechanism from patch.json:

  "Commanders": { "1": { "EntityTypeBuffs": { "C_HAC": { "Buffs": {
      "0": { "Attribute": "Unit_MaxHealth", "Mode": "Set", "Value": 6000 } } } } } }

Buffs are saved with the game and restored by Sim.OnLoad, so they survive
loading a save. Patching the per-commander copies directly would not: OnLoad
calls ResetUserSpecificEntityTypes and rebuilds them. The trade is reach --
only Buff.CategoryAndID attributes are expressible, not all of patch.json.

A second hook carries this, injected after SimController.PostLoadInit inside
the OnSceneLoadComplete state machine. It has to be that late: per-commander
copies do not exist until Sim's constructor runs, and on a save load OnLoad
discards and rebuilds them. The hook site is located by finding the state
machine that calls PostLoadInit rather than by its compiler-generated name.

Because loading a save re-runs the hook over buffs the game has already
restored, a buff that is already present is not applied twice; otherwise Add
and AddPercent would compound on every load.

EntityTypeBuffExtensions is internal to BBI.Game, so it is reached by
reflection. Mirroring its eleven component categories here would have to be
kept in step with the game; calling its own code does not.

Also:

- EntityTypeDumper records each weapon's loadout ID. Buffs key on
  WeaponBinding.WeaponID, which is not the weapon component name the
  Entities section uses -- C_HAC_Weapon_G2G is "DEFAULT" -- and a wrong one
  matches nothing without complaint.
- SubsystemLint validates the new section and reports indexed lists whose
  keys are not 0..n-1, which AttributeLoader truncates at the first gap.
- SubsystemPatcher reports the two hooks separately, so an install patched
  by an older version is recognised as incomplete rather than up to date.
patch.example.json now covers both sections and explains the traps in place:
indexed lists with no gaps, integer-only buff values, weapon buffs keyed on
the loadout ID rather than the component name, and range bands that a buff
cannot create.

Comments are safe to ship in it. The LitJson in BBI.Core skips // and /* */,
checked by parsing the file with the game's own JsonMapper.

It rejects a trailing comma, though, and SubsystemLint was passing those:
JsonDocumentOptions had AllowTrailingCommas = true, so the linter would
report a clean bill of health on a file the game throws out whole. Match the
game instead.
AbilityClass alone does not say what an ability does -- the behaviour lives
in whichever sub-attribute is populated. Passive self-repair, for instance,
is ApplyStatusEffect plus autocast-on-spawn, pointing at a status effect
whose modifier carries the heal rate and tick.

Print the type, the autocast and toggle flags, cooldown and warmup, the
repair weapon ID, and for each status effect applied its lifetime, duration
and any health-over-time modifier. That is the whole chain from an ability to
the number of hitpoints per second it is worth, read off the install rather
than guessed at.
TargetingType Passive is what UnitManager.ActivatePassiveAbilities gates on
when it fires abilities as a unit spawns, so it is the difference between an
ability that works by itself and one waiting for a button.
Buffs can only tune an ability a unit already has, so there was no way to
give a unit self-repair. AddAbilities copies a whole AbilityAttributes onto a
commander's entity type copies:

  "AddAbilities": { "C_": { "UseAsPrefix": true,
      "Abilities": { "0": { "From": "Ability_C_Battlecruiser_Regen" } } } }

Abilities are entity types in their own right, so the donor is named the same
way everything else is. Matching is shared with EntityTypeBuffs.

Only an ability whose TargetingType is Passive runs by itself --
UnitManager.ActivatePassiveAbilities fires those as a unit spawns, with no
button. Anything else is added and never triggered, which the log calls out
rather than leaving it to be discovered in a mission.

SkipIfSelfHealing, on by default, leaves alone any unit that already
regenerates, decided by walking its abilities for a healing health-over-time
modifier rather than by a hard-coded list of names.

The ability is added already wrapped in BuffedAbilityAttributes.
MakeAllTypesBuffableForCommander wraps every ability on every commander copy
and flags the category as buffed, after which AddAbilityBuffs casts each
ability to BuffedAbilityAttributes unchecked. A bare one there throws
InvalidCastException the next time anything buffs an ability on that unit --
including the game itself, through a research upgrade. Reproduced against the
real assemblies: bare throws, wrapped does not.

Two limits, both in the docs. A unit gets its abilities when it spawns, so a
grant reaches newly built units rather than the fleet already on the field.
And unlike buffs this is not saved with the game; it is re-applied on every
mission load, so it is written to be idempotent.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Release 0.4.0 dll files no longer compatible with latest (steam) version of HW DoK.

1 participant