Restore compatibility with current versions of the game - #12
Open
bitrotlab wants to merge 12 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 asit 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: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.SparkandBBI.Unity.Game.Data— the lobby, leaderboard, stats and DLC layers, rewritten when the gamemoved to Epic Online Services. Those throw as soon as the affected code is JIT-compiled, which is
exactly where the reported symptoms appear.
Subsystem.dllitself was never broken. Against a current install it resolves every type andmember 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
SubsystemPatcherapplies those three instructions to whateverBBI.Unity.Game.dllthe useractually 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:
ResetEntityManagerdiffers: +3 instructions, its 2 exception handlers and 9 locals intact.[Serializable]types, 10[NonSerialized]fields, class layout, security declarations.ILOnlyand the 32-bit architecture flag are preserved.
--restorereproduces the original file's checksum exactly.What else is in here
build/Subsystem.Sdk.csproj— the existing project is legacy-format with noHintPaths, so itonly 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— writesData/Subsystem.entities.logon every game start: every entitytype 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.jsonismissing or malformed.
SubsystemLint— validates apatch.jsonagainst the shapes Subsystem deserializes and thenames the install actually loaded. This matters more than it sounds: LitJson's
ignore_extra_keysis never assigned and defaults to
false, so one unknown key makes the whole document throwand 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 thetools 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.dllagainsttheir own installation.
Documentation —
docs/compatibility.mdrecords what broke and why so it does not have to berediscovered;
docs/patch-reference.mdcovers every patchable property and the non-obvious rules;docs/finding-names-and-values.mdcovers discovering names for a given install;docs/troubleshooting.mdis symptom-first;docs/building.mdcovers the build and releaseprocess. 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.jsonbehaves 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 failuremode, 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.