Skip to content

feat(multiplayer): install exactly the mods a server runs when joining - #239

Open
Furroxide wants to merge 6 commits into
devfrom
feat/mp-exact-installs
Open

Furroxide wants to merge 6 commits into
devfrom
feat/mp-exact-installs

Conversation

@Furroxide

@Furroxide Furroxide commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Outcome

The rest of WS6 step 6.5 of the live-multiplayer plan: a join installs exactly the mods a server runs. After consent,
the launcher downloads the server's exact id@version@sha256 archives, installs them as one change, and saves the
server's profile. The player matches nothing by hand, and nothing their own play loads changes: not their launcher
profiles, and not a game they start without the launcher, whose loader now reads the same record of server content. It
builds on #214's network clients and store and on #216's exact registry resolution (loadExactPackageCatalog,
ExactPackageCatalog.resolve, planFromCatalog). Nothing a player sees changes yet: the launcher UI (6.6) and the CLI's
join verb (6.8) drive it next.

  • MultiplayerContentHost (packages/launcher_domain/lib/src/repositories.dart): an optional capability beside
    LauncherRepository, tested with is, which LocalLauncherRepository has. It answers the join stages that need the
    game's files, and owns the cleanup of server content:

    Stage Method What it reports
    downloading downloadJoinContent JoinDownloadProgressed, JoinDownloadsCompleted; downloadFailed, integrityFailed, manifestMismatch, hostDownloadUnsupported
    installing installJoinContent JoinInstallProgressed, JoinInstallCompleted, JoinInstallUndone; installFailed with rollbackComplete, gameRunning with its ownership
    preparingProfile prepareJoinProfile JoinProfilePrepared; profileSaveFailed

    feat(launcher-data): add the multiplayer network clients and store #214's facade answers them too: answerJoin(state, install:, cancellation:, onProgress:) hands these stages to the
    content host, as it already answered resolving and fetching the descriptor, so every event the reducer takes comes
    from one place. Download and install progress reach onProgress as the reducer's own events.

  • Downloads (join_content_downloads.dart, secure_http_download.dart): each download package comes from the
    source the plan names. HTTPS goes through the new downloadHttpsToFile, which follows redirects only to public HTTPS
    URLs, as fetchHttpsBytes does, streams the body to a temporary file with its SHA-256 computed as it arrives, and
    takes progress and a CancellationToken. A local (file:) source is copied under the same rules.

    • Bounds. At most ModRegistryFormat.maxPackageBytes, and exactly the size the source states when it states one.
      An announced length past either is refused before the body is read, a body that grows past its bound as soon as it
      does, and one that ends short once it ends.
    • Integrity. The SHA-256 must be the pinned one before anything is kept. The archive's topiaforge.mod.json must
      then equal the trusted manifest the plan used; anything else is manifestMismatch, and nothing is installed.
    • Verified archives stay in the package cache by SHA-256, so a join tried again fetches nothing twice. A cancel stops
      the transfer at once and leaves no partial file; partial files a stopped process left are swept after an hour.
    • A plan that needs a package only the host serves ends as hostDownloadUnsupported before anything is fetched.
  • Exact installs (join_content_install.dart, join_install_preflight.dart, join_install_journal.dart): one join
    installs as one change, under the launch staging lease (acquireLaunchLease). It decides what to install first and
    refuses before anything changes (another build of the same version installed, the record full or unusable). Every
    package is then staged through the package installs' own stage, commit and rollback path (_stagePackageInstall),
    in one launcher-join-* folder in manager staging, with a journal. Then, in order, it records the mods new to the
    player, adds its archives to the record of server content, and moves each package into place.

    • Progress and cancellation. It reports how many packages are prepared as each is staged. A cancel before the
      order begins removes everything staged and reports JoinInstallUndone, which the reducer ends as cancelled.
    • Rollback. A failure once the order began puts back everything this join changed, and only that: packages an
      earlier join committed stay. The failure says whether the rollback completed. Each folder leaves the packages
      folder whole, in one move, before it is deleted, never half deleted in place. A folder that cannot move, as when
      an antivirus scan holds a file just moved into place, stays whole, receipt and all, and keeps its entry in the
      record and its added record, so it stays hidden until a later start takes it out. Until then no plan counts that
      folder as installed: the join downloads that version again, its install refuses with "try again" while the
      folder stays, and installs it afresh once it is out. The install also checks again, under its lease, that every
      version its plan found installed is still there as the same archive.
    • Crash safety. A process killed at any step leaves nothing the player's own play loads, and the next start, the
      next install or the next cleanup settles the journal under the lease: it undoes a partial install, or one whose
      rollback began, moving each folder out whole; keeps one whose every package is in place and whose rollback never
      began; and removes the folder. While a receipt it would judge a folder by cannot be read, or a folder cannot move
      out whole, it waits for a later start rather than drop that folder's record entry.
  • The player's state is untouched. A join never writes the manager record of a mod that already has one
    (_upsertState runs only for ids with no record). A mod new to the player gets a disabled record before its folder
    appears, and starts disabled again, in the launcher as in the game's loader, if that record is lost (a game that
    quit over the join's write, or a deleted manager state). A version installed for a mod the player already had is
    hidden from every profile that is not a server profile, from the Mods list, and from a game started without the
    launcher, so their own play never moves to a server's newer version. A version counts as server content only while
    its install receipt is still the one the join wrote.

  • The record of server content, a contract of both languages (server_content.dart, server_content_entries.dart,
    ServerContentRecord.cs): BepInEx/TopiaForge/server-content.json beside the manager state, which the launcher
    writes and both the launcher and the game's loader read.

    • Shape and bounds. Exactly schemaVersion (1), versions (each archive a join installed: id, version,
      SHA-256, the SHA-256 of its receipt, whether it is hidden, when), withheld (versions a repair keeps from the
      player's own play: id, version, receipt) and addedRecords. At most 1 MiB of UTF-8, 1,024 entries in each list, no
      member twice anywhere, no version twice in a list. A join that would pass a bound is refused, never trimmed.
    • Read whole or not at all. Anything else is damaged, a higher schemaVersion is newer, and a file that cannot
      be read is unreadable. None of the three is ever taken to mean that nothing is withheld: while one stands, the
      launcher's own view of the player's mods (the Mods list, and what a new or edited profile pins from it) freezes at
      what the manager state names, as the game's loader does. A version is withheld while its receipt has the recorded
      SHA-256, and a receipt that cannot be read withholds it, failing closed. Unpinned selection never takes a withheld
      version; an exact pin of a profile of the player's own still takes one a repair withheld, as the game's loader
      takes every exact pin, while a version a join hid is for server profiles alone. A record a repair moved aside,
      with no record written beside it, is damaged too, never missing.
    • Shared fixtures. tests/fixtures/multiplayer/server-content/cases.json (77 cases, from
      generate_server_content.py, no randomness) is replayed by both: the same documents are records, the same versions
      are withheld, the launcher writes each valid one back to its exact canonical bytes, and the largest record fits.
    • Repair, failing closed. A damaged record is never salvaged in part. At the launcher's next start, and before an
      install or a cleanup, it is replaced under the lease, with a copy kept aside. The new record keeps every entry that
      still reads and withholds each other installed version the manager state does not name. A pin of a profile of the
      player's own is no evidence, since a copy of a server profile pins the server's versions; exact pins keep loading
      what a repair withheld. A newer launcher's record is never written over. A link, a folder or an oversized file
      in the record's place is moved aside before the new record is written; if that write fails, as on a full disk,
      both languages read the copy with no record beside it as damaged, and the next start repairs it again.
    • Two launchers at once. The desktop launcher and the command line can both have the install open. Only writes,
      which hold the staging lease, put back a backup that a write stopped between its two moves left; loads read it
      where it stands, as the game's loader does, so a load can no longer move an older record over a newer one.
  • The game's loader (ModRegistry, ServerContentSelection): unpinned selection, which is a start without the
    launcher, never takes a version the record withholds, and a mod with only such versions offers nothing to it. A mod a
    join added that has lost its manager record starts disabled. A record that cannot be used freezes unpinned
    selection at what the manager state says loaded before; a mod with no manager record then does not load, and says
    to start the game from the launcher once, which repairs the record. Exact pins, which every launch the launcher makes
    writes, see every version, so server profiles are unaffected.

  • The server profile (ServerProfiles, join_server_profile.dart): mp-<sha16(serverId)>, named
    Server · <name>, inheritManagerModState: false, every package pinned, saved in one atomic profiles write. A
    re-join replaces what the join owns (the name, the inheritance, the enabled packages and their pins, and Safe Mode,
    always off) and keeps what the player set: extra launch arguments, the launch environment, the remembered launch
    selection, and the config and backup metadata. A failed save leaves the earlier profile as it was. A profile of the
    player's own under that id is never taken over.

  • Server content cleanup, by version (server_content_cleanup.dart in both packages):

    • The plan, planServerContentCleanup(install, serverProfileIds:): each version with its size, the server
      profiles that pin it, and whether it can go. A version goes only when no profile of the player's own loads or pins
      it, the manager state does not have it on, and every server profile that pins it goes too. The player's own
      installs never appear, and neither does a version whose receipt cannot be read just now.
    • Applying, applyServerContentCleanup(install, plan): exactly that plan, checked by its fingerprint, or a
      refusal (stale, gameRunning, busy, unreadable). It follows the uninstall rules, uninstallPending included.
      An added record goes only once its mod has neither a manager record nor an installed version.
  • A running game. It writes its manager state when it quits, over anything written while it ran, which would drop
    the join's disabled records; the package inbox likewise waits for the next start. So nothing installs or cleans up
    beside one. An install closes a game this launcher started only when the player's consent covered it
    (JoinConsentGiven(closeOwnedGameAcknowledged: true)), and only once every package is staged, so a join that is
    refused or cancelled leaves the game running with the player's progress in it. It then decides again what to
    install, since the game wrote its manager state as it quit. Without that consent, which stays off until the
    launcher can ask the game to quit, or for a game it did not start, the join ends as gameRunning with its ownership
    and says what to do. Downloads and the profile do not touch the game, so they go ahead while it runs.

  • The design record: docs/internal/MultiplayerLaunchContract.md sections 10 and 11.

Review round 1

An independent review of the first push. Each finding below was proven on the pushed code first, by a scratch test that
failed, and is now covered by a regression test that fails when the fix is reverted.

# Severity Finding Fix
1 Release blocker A game started without the launcher loaded a server's hidden version of the player's own mod whenever it was newer, since the loader picked the highest unpinned version. The loader reads the record through the shared contract above (ServerContentSelectionTests, ServerContentFixtureTests).
2 High Salvaging a damaged record dropped the damaged entry and so exposed its hidden version: one bad field in Alpha 2.0.0's entry made a player's unpinned profile load Alpha 2.0.0 instead of 1.0.0. Strict reads, and a repair that fails closed (multiplayer_server_content_safety_test).
3 High A receipt another program holds open without read sharing (an antivirus scan, say) made the automatic tidy-up after a re-join treat a hidden version as stale and drop its entry: afterwards every profile of the player's own loaded the server's Alpha 2.0.0. A version whose receipt cannot be read is never judged (multiplayer_server_content_safety_test).
4 High The same in the crash sweep: after a crash mid-install, the next start dropped the held version's entry and left its folder, exposing it. The sweep waits for a later start (multiplayer_join_crash_test).
5 Medium An install stopped the player's game with TerminateProcess before deciding anything, so a join refused at preflight ("installed as another build", "Nothing was installed") or cancelled while staging had already lost the player's unsaved progress. The game is closed only once everything is staged, and only when the consent covered it (multiplayer_join_running_game_test).
6 Medium The tidy-up removed a mod a join had brought in new that the player then turned on for their own play in the game, deleting its folder and its manager record, when no launcher profile of theirs inherits the manager state. What the manager state has on counts as used (multiplayer_server_content_safety_test).
7 CI "Build guides, C# and Dart references, and search" failed: dartdoc could not resolve [ServerContentRecordRead.damaged], a field of a record typedef. The reference is gone with the new reader; dartdoc reports 0 warnings for both touched packages.

The shared fixtures caught one more divergence while they were written: Dart's UTF-8 decoder drops a leading byte order
mark without a word, where the game's reader refuses one, so the launcher now looks for it in the bytes.

Review round 2

A second independent review broke every round 1 fix again (14 of round 1's mutants and an unresolved dartdoc
reference, all caught again; see Verification), then looked again at every path by which a server's version could
reach the player's own play. Each finding was proven on 96fab39, the head before this round, by a scratch test that
failed, and is now covered by a regression test that fails when the fix is reverted.

# Severity Finding Fix
1 High A rollback that could not take a just-committed folder out, as when an antivirus scan holds its assembly, still dropped its entry from the record. While the file was held, the player's default profile in the same launcher session, and a game started without the launcher, loaded the server's Alpha 2.0.0. A folder that stays keeps its entry and its added record until the next start takes it out (multiplayer_join_rollback_test: folders another program holds).
2 High A mod new to the player whose manager record was lost (a game that quit over the join's write, or a deleted manager state) was turned on by default in the player's own launcher profiles: the launcher ignored addedRecords, which the game's loader honours. The tidy-up after the next join then dropped the added record while the mod was still installed, so a game started without the launcher turned it on too. The launcher starts such a mod disabled, as the loader does, and an added record goes only once its mod has no version installed (multiplayer_server_content_safety_test: a mod a join added whose manager record is lost).
3 Medium While the record could not be used (newer, unreadable, or damaged and waiting for its repair), the launcher's Mods list showed the server's Alpha 2.0.0 and Beta 1.5.0 as the player's current versions. A new profile, turning off "follows global mod choices" and enabling a mod in a profile all pin what that list shows. The player's view freezes at what the manager state names, as the game's loader does (multiplayer_server_content_safety_test: while the record cannot be used).
4 Medium A repair counted every pin of a profile of the player's own as evidence that a version was theirs. A copy of a server profile pins the server's versions, so after a damaged record was repaired the player's default profile loaded the server's Alpha 2.0.0 instead of 1.0.0. A repair names only what the manager state names. An exact pin keeps loading what a repair withheld, as the game's loader loads every exact pin, so a profile of the player's own that pins an older version of theirs keeps working (multiplayer_server_content_safety_test: a copy of a server profile, and a version of the player's own that a profile pins).
5 Low When another server had installed a different build of the same version, the plan told the player to uninstall it in Mods, where it is not listed; doing so would have removed every version of the mod, the player's own included. A new resolution, installedForAnotherServer, says to remove that server's content in Settings; the install's own refusal says the same (multiplayer_join_install_test, multiplayer_join_content_test).

The default of closeOwnedGameAcknowledged, false, is the right one, and the guidance for it is corrected. The earlier
text told the consent sheet and topiaforge join to set it once they had warned about unsaved progress. But a game
the launcher stops outright records no clean exit, so its next start is the one-shot safe mode, which refuses the
session launch (StartupJournal, OfferLaunchSession): the join would fail after the progress was lost. Until the
graceful quit lands, both leave it false, and the player saves, quits and joins again, keeping what was downloaded.

Review round 3

A third independent review broke the fixes of rounds 1 and 2 again (all caught; see Verification), then looked at the
paths round 2 left open. Each finding was proven on 53afe68, the head before this round, by a scratch test or a probe
that failed, and is now covered by a regression test that fails when the fix is reverted.

# Severity Finding Fix
1 High A repair of a record that is a link, a folder or an oversized file moved it aside before writing the record that replaces it. When that write failed, as on a full disk, no record was left, which both languages read as nothing withheld: the player's default profile, and a game started without the launcher, loaded the server's Alpha 2.0.0 instead of 1.0.0, on every start after. Proven by making that write fail. A damaged copy with no record beside it reads as damaged in the launcher and in the game's loader, so the player's own play stays at what loaded before, and the next start repairs it again (multiplayer_server_content_store_test and multiplayer_server_content_safety_test: set aside by a repair that could not write its record; ServerContentSelectionTests: a record set aside as damaged).
2 Medium The desktop launcher and the command line share an install. A load in one put back a write's backup with a move that replaces, so landing just after the other's write moved the new record in, it replaced the new record with the older one: the entries that write had just recorded were lost while its install went on to move the hidden versions into place. Proven with a probe between the load's listing and its move. A load reads such a backup where it stands, as the game's loader does, and moves nothing; only writes, which hold the staging lease, put it back (multiplayer_server_content_store_test: a write stopped between its moves is read where it stands).
3 Medium Round 2's rollback of a held folder deleted it in place. A recursive delete takes the install receipt first and stops at the held file, so when that file sorts after the receipt (a held manifest, or an assembly named after it) the folder lost its receipt, and the next start dropped its record entry and left the rest. The Mods list then showed a broken Alpha 2.0.0, and joining the same server again said to uninstall Alpha in Mods, which deletes the player's own 1.0.0 too. The next start's sweep deleted in place the same way. The rollback and the sweep move each folder out of the packages folder whole before deleting it; one that cannot move stays whole, receipt and all, for a later start (multiplayer_join_rollback_test: a held folder is never half deleted).
4 Medium A join made while an earlier join's held folders were still in place counted them as installed and completed; once the hold ended, the next start took them out from under the new server profile, which then could not launch ("Pinned version '2.0.0' is not installed"). When every folder was held, the next start kept the failed install as finished. The rollback notes in the journal that it began, so a later start always undoes it, and a join is refused with "try again" while an earlier one is not settled (multiplayer_join_rollback_test: a join while an earlier one cannot be put back yet).
5 Low Round 2's new messages said to remove a server's content "in Settings", and the record-full refusal to clean up "in Settings", but that screen is not built yet. No app or command line path shows them today. They name no screen ("Remove that server's content, then join again"), and the record-full refusal now has a test. The damaged-record message also says to free disk space.

Round 2's other open risk is not one a player can reach: the launcher's create, duplicate and import all give a profile
a profile-<time> id, and the command line writes no profiles, so an mp-<16 hex> id without inheritance comes only
from a hand-edited profiles file. The shared fixtures' added records are now replayed by both languages at the level of
what loads: the launcher starts Arena off for exactly the two cases that list it, as the game's loader does
(multiplayer_server_content_fixture_view_test, ServerContentFixtureTests).

Review round 4

A fourth independent review broke the fixes of rounds 1 to 3 again (all 35 mutants caught; see Verification), then
drove whole joins the way the launcher UI will: a plan from loadExactPackageCatalog, the consent, then every content
stage through answerJoin and the reducer. Each finding was proven on 6a7207a, the head before this round, by a
scratch test that failed, and is now covered by a regression test that fails when the fix is reverted.

# Severity Finding Fix
1 Medium Round 3's refusal of a join while an earlier one is unsettled lived in the install, but the plan counted the earlier join's held folders as installed. Joining the same server again once the hold ended, the install's own sweep took Alpha 2.0.0 out and the join went on without it: the new server profile pinned a version that was gone, and its launch was refused ("Pinned version '2.0.0' is not installed"). Joining another server that runs only that build downloaded nothing, so it never reached the install: its profile was saved on the held folder, and the next start took the folder out from under it. The catalog a join plans from leaves out what an unsettled join's journal names, so the join downloads that version: its install refuses while the folder stays, and installs it afresh from the download cache once it is out. The install also checks again, under its lease and after its sweep, that every version its plan found installed is still there as the same archive, and otherwise ends with "join again" (multiplayer_join_replan_test).
2 Low When the game's list of mods could not be read, a join and a cleanup said to open Mods to repair it. The Mods list is empty then, and no screen repairs it. Both name BepInEx/TopiaForge/state.json and say what to do (multiplayer_join_install_test: a list of mods that cannot be read).

Checked and sound: a damaged copy that is a folder, a file, a junction or a dangling junction keeps a missing record
damaged in both languages; every writer of the record holds the staging lease; a folder move across volumes is
refused with the folder left whole, never copied and deleted; and moves and deletes past 260 characters work.

Author and player experience

  • Automatic now. One consent, then everything: downloads, checks, install, rollback, the server profile, repair of
    an install a crash interrupted, repair of a damaged record, and the tidy-up of versions no profile pins any more. A
    game started without the launcher keeps loading the player's own versions with no step from them. Mod authors do
    nothing: a mod published to a registry source is installable at its exact version.
  • Still by hand.
    • Closing the game before joining, whoever started it. For a game this launcher started, that becomes automatic once
      the launcher can ask the game to quit: stopping it outright, as it can today, loses unsaved progress and leaves the
      next start in safe mode, which refuses the session (see below). Until then the join says "Save your game and quit
      it, then join again", and the second try keeps what was downloaded.
    • A player who has the same id@version as a different build must uninstall their copy to join, because both need
      the same folder and a join never replaces it. When that build is another server's, the plan says so and asks the
      player to remove that server's content instead (installedForAnotherServer). The message names no screen, since
      the cleanup screen is not built yet; 6.6 should offer that cleanup right beside the failure.
    • Removing a whole server's content is a confirmed action in Settings (6.6 builds the screen on this plan).
    • Joining again at once after a join whose rollback could not finish, while another program still holds one of its
      files: the join says to wait a moment or close that program, then join again.
    • Packages only the host has: the host must publish them to a mod source until host-served downloads land.
    • A copy of a server profile, made with Profiles → Duplicate, pins the server's hidden versions, which only server
      profiles load, so it cannot launch and says a pinned version is not installed. The Profiles screen (6.6) should
      not offer Duplicate for a server profile, or should duplicate it onto the player's own versions.
  • Could become automatic next. Re-planning on its own when an install ends with "Your mods in this game changed
    while the join was preparing", instead of asking the player to join again; asking the player's game to quit and
    waiting, instead of stopping it, which also keeps its next start out of safe mode; host-served downloads in developer mode; installing another build of the same
    version beside the player's own, or replacing another server's build when no profile of the player's own uses it;
    and resuming an interrupted download where it stopped instead of from the start.

Deviations from the plan

  • Two new resolution classes, installedAsOtherArchive and installedForAnotherServer (6.2 lists seven). An
    id@version installed as a different archive blocks the join at checking, with the fix in plain words, instead of
    failing later at install: uninstall the player's copy, or remove the content of the other server whose hidden build
    it is.
  • Versions no profile pins are removed after each join, on their own. Only those: a version a profile of the
    player's own uses, the manager state has on, or a server profile pins, stays, and so does one whose receipt cannot be
    read just now. So the confirmed cleanup is about whole servers.
  • Closing the player's game is part of the consent. The plan lists gameRunning under launching only; installing
    can report it too. The launcher stops its game outright today, which loses unsaved progress and records no clean
    exit, so the game's next start is its one-shot safe mode, in which a session launch is refused. A join that has to
    close the player's game therefore cannot start its session until the launcher asks the game to quit gracefully, which
    the launcher hardening work plans next (its item 0). Until then the install closes it only when
    JoinConsentGiven.closeOwnedGameAcknowledged says the consent sheet said so; otherwise the join ends as
    gameRunning, owned, asking the player to save and quit.
  • New domain events, one failure and two consent fields: hostDownloadUnsupported (downloading),
    JoinInstallUndone and JoinInstallProgressed, JoinSessionState.installProgress, and
    closeOwnedGameAcknowledged on JoinConsentGiven and JoinSessionState. Each has reducer tests and is in the full
    transition matrix.
  • The record lives with the game install, not in the launcher's data folder beside feat(launcher-data): add the multiplayer network clients and store #214's store, so it moves with
    the packages it describes, the game's loader finds it, and the staging lease serializes its writes. Unlike feat(launcher-data): add the multiplayer network clients and store #214's
    store it is never salvaged in part, since the game reads it too.
  • The game-side loader change is in this pull request, because without it a join would change what a game started
    without the launcher loads. It is internal to TopiaForge.ModManager.Core; no SDK surface changes.
  • CancellationToken moved from launcher_data to launcher_domain, so the domain interface can take it.
    launcher_data re-exports it under the same names.

What later slices need

  • 6.6, the launcher UI.
    • Drive a join with LocalMultiplayerRepository(..., content: launcherRepository). In every stage that waits on
      the launcher, call answerJoin(state, install: install, cancellation: token, onProgress: (event) => ...) and reduce
      what comes back with JoinSession.reduce; reduce each progress event too. Null means the stage is not the
      launcher's, or the work was cancelled.
    • The consent sheet leaves closeOwnedGameAcknowledged false until the launcher's graceful quit lands. The
      install then ends as gameRunning, owned, with "Save your game and quit it, then join again", and the second try
      keeps what was downloaded and starts its session. Setting it today would stop the game outright, losing unsaved
      progress, and the game's next start would be its one-shot safe mode, which refuses the session. Once the graceful
      quit lands, reduce JoinConsentGiven(closeOwnedGameAcknowledged: true) after saying, when a game this launcher
      started is running, that joining closes it.
    • Progress: state.progress (bytes) while downloading, state.installProgress (packages) while installing.
    • Cancel: call token.cancel() and reduce JoinCancelRequested. Downloading ends at once. Installing ends as
      cancelled once the install answers: JoinInstallUndone before anything changed, otherwise after it finished.
    • Failures: state.outcome!.failure has code, stage and detail, which is player-facing text that says what
      to do next and names no address. Show rollbackComplete for installFailed: when false, the player must restart
      the launcher before playing. For gameRunning, gameOwnership says whether the launcher could not close its own
      game, was not allowed to, or the player must close one it did not start.
    • Settings → Multiplayer → server content: show planServerContentCleanup(install) (or serverProfileIds: for
      chosen servers): plan.versions with sizeBytes, serverProfileIds and kept, plan.profiles, and
      plan.removableBytes. Confirm, then applyServerContentCleanup(install, plan). On stale, plan again and show it;
      on gameRunning, ask the player to close the game; on busy, try again later; on unreadable, show the message,
      which says what to do. ServerProfiles.isServerProfile tells server profiles apart in Profiles.
    • A join plan blocked by installedForAnotherServer can offer that cleanup straight away: the blocker's modId,
      looked up in planServerContentCleanup(install).versions, gives the serverProfileIds of the server whose content
      is in the way, and a cleanup planned for those ids frees the folder. Its message, and the install's refusal
      when the record is full, say to remove a server's content without naming a screen, so the screen that offers the
      cleanup must ship with, or before, the join UI.
    • Profiles: do not offer Duplicate for a server profile as it is (see "Still by hand").
    • Plan from the catalog right before consent. loadExactPackageCatalog leaves out what a join that failed
      left in place, so a plan made then downloads it. A plan that downloads nothing goes from consent straight to the
      profile, outside the staging lease, so a version removed in between (an uninstall in Mods while the consent sheet
      is open) surfaces only at launch, as a pinned version that is not installed. Either plan again when the consent
      is given, or route every join through installing, which checks under its lease.
  • 6.8, topiaforge join. The same loop with the same repositories: --yes reduces JoinConsentGiven() (with
    closeOwnedGameAcknowledged false, as for the consent sheet; a game the CLI did not start is the player's to close
    anyway), and --allow-host-packages
    JoinConsentGiven(hostServedAcknowledged: true) in developer mode, though host-served downloads still end as
    hostDownloadUnsupported until that slice lands. Map each failure's stage to the stage codes (TFJOIN100-180) and
    print its detail; print progress and installProgress as they change. Before the command line opens the
    desktop launcher's data root, MultiplayerStore needs a lock across processes: today only one process uses it,
    and its writes and its backup restores are not kept apart from another process's.
  • Host-served packages (developer mode). Replace the two refusals: _downloadJoinContent ends a plan whose
    downloadRequest.hostDownloads is not empty, and _installJoinContent one with plan.hasUnverifiedPackages. Fetch
    TFD1 package <sha256> with a streaming reader (descriptor_response.dart says what it needs), under the contact
    rules checked again at download time, into _joinCacheFile(sha256) through _keepVerifiedDownload. Those packages
    have no trusted manifest, so _stage must check the archive's own manifest against the id and version the
    descriptor claims instead. The record, rollback and cleanup then treat them as they treat registry packages.
  • The launcher's graceful quit (the hardening work's item 0). When it lands, _closeOwnedGameForInstall asks the
    game to quit and waits, falling back to stopping it; the consent sheet then acknowledges closing, and its warning
    drops the lost progress.

Compatibility and security

  • Public API, schema, save and package impact.
    • launcher_domain gains MultiplayerContentHost, the server content and cleanup models (ServerContentRecord,
      ServerContentEntry, WithheldServerVersion, ServerContentStatus, ServerContentRecordRead), ServerProfiles,
      the events, failure and consent fields above, the resolutions installedAsOtherArchive and
      installedForAnotherServer, ExactInstalledArchive.serverOnly, and CancellationToken, moved from
      launcher_data, which re-exports it unchanged.
    • MultiplayerRepository.answerJoin gains optional install and onProgress parameters.
    • New files in a game install: BepInEx/TopiaForge/server-content.json (schemaVersion 1), its
      server-content.damaged-<UTC time>.json copies, and launcher-join-* and launcher-trash-* folders in manager
      staging that the next start removes.
    • C#: ModRegistry.Scan reads server-content.json, through new internal types in TopiaForge.ModManager.Core. The
      public Scan overloads keep their signatures. No SDK surface and no launch-contract change.
  • Filesystem. Installs and repairs write only under the staging lease. A join writes the manager record of a mod new
    to the player only. Cleanup removes only server content, the records joins added, records whose uninstall is
    pending, and server profiles at the revision the plan saw. The loader only reads, with sharing that never blocks the
    launcher's moves.
  • Network. Downloads go only to the source each package's plan names, over public HTTPS, or a local file source;
    redirects only to HTTPS; bounded and hashed. The host is never contacted for packages. Failures and logs name the mod
    and its source, never a URL or an address, and network errors are logged by kind only.
  • Process. An install stops a game process this launcher started, through the launcher's existing stopper, only
    when the consent covered it and only after everything is staged. The consent should not cover it until the
    launcher's graceful quit lands (see "What later slices need"), so today the default, false, is the setting to use.
  • Migration or rollback. Revert the commits. What a join installed stays installed and is listed in
    server-content.json; removing that file, and any server-content.damaged-*.json beside it, makes those versions
    the player's own, for the launcher and the game alike.

Verification

On a Windows 11 x64 machine, in a clean worktree, with the pinned Flutter 3.44.6 and its Dart 3.12.2, as CI runs each
package. Round 4 changed launcher_data (the catalog a join plans from, the install's preflight, two messages and
their tests) and the design record. Its commit, 988122c on 6a7207a, passed the Dart gates below. rebase_push.sh
then rebased the branch onto dev at 22d68d1 (#238 and #251), every commit patch-identical, and each gate below ran
again on the rebased head unless it says otherwise.

  • C# (unchanged this round; run on the rebased head, which brings feat(multiplayer): track the player intent mod code acts on #238's C# changes).
    • dotnet build TopiaForge.slnx -c Release -warnaserror: 0 warnings, 0 errors.
    • TopiaForge.ModManager.Tests: all pass, among them ServerContentFixtureTests (85 checks over the 77 fixture
      cases) and ServerContentSelectionTests (15 cases over packages the real installer installs).
      TopiaForge.ModRuntime.Tests, TopiaForge.Mods.Analyzers.Tests, TopiaForge.Mods.Multiplayer.Generators.Tests
      and TopiaForge.Mods.Multiplayer.Tests: all pass.
    • dotnet format --verify-no-changes on every C# file this branch changes: no findings.
    • bash tools/verify-csharp-release-surface.sh: 11 SDK packages built warning-free and packed, and all 7 C# release harnesses.
  • Dart. dart format --output=none --set-exit-if-changed and dart analyze --fatal-infos in
    packages/launcher_domain, packages/launcher_data and apps/topiaforge_cli: nothing to format, no issues.
    • dart test packages\launcher_domain: 2424 pass.
    • dart test packages\launcher_data: 1177 pass, 9 skipped (platform skips); 1176 on the round's commit, and fix(launcher-data): keep PowerShell progress out of the running-game probe #251
      adds one. New in round 4: multiplayer_join_replan_test (a re-join right after a held rollback, a join that
      would download nothing while an earlier one is unsettled, and an install whose planned installed versions were
      uninstalled or installed again as another build) and the unreadable list of mods in multiplayer_join_install_test.
    • dart test apps\topiaforge_cli: 514 pass, 4 skipped on the round's commit, after the Release builds CI makes. On the rebased head the
      first run passed 513, with 4 skipped and one 30-second timeout in mod add multiplayer keeps V6 and scaffolds the contract lock, a test this branch does not touch, while the suite's own template builds ran beside it; it
      passes alone in 18 s. Later runs, made while the machine was busy with unrelated work, failed only by timing
      out, mostly in the slow release-tooling tests and never in code this branch touches; nothing this branch
      changes is run by the command line.
    • dart doc --validate-links in packages/launcher_data, as the docs job runs it: 0 warnings, 0 errors.
  • Flutter. Not rerun: round 4 changed no type launcher_ui or the app uses (private members, two messages and
    tests). In round 2, flutter analyze found no issues, and flutter test passed 3, and 145 with 5 skipped.
  • Gates. The line-count audit (no non-generated Dart file over 500 lines),
    python -I .github/scripts/check_topiaforge_residue.py, python -I .github/scripts/check_readme_counts.py and
    node website/scripts/check-markdown-links.mjs (147 files): pass. python -I tests/fixtures/multiplayer/generate_server_content.py .
    reproduces cases.json byte for byte.

Mutation spot-checks

Each mutant was applied alone, the named suite run, and the file restored byte for byte.

Round 4: 6 new mutants, all killed, each by the test named; the 35 of rounds 1 to 3, run again on 6a7207a before
the fix, all killed too.
On the rebased head all 41 ran again, and all 41 were killed.

Mutant Killed by
A plan counts what an unsettled join left in place as installed multiplayer_join_replan_test: a join right after one whose rollback was held installs the held version afresh; a join that would download nothing is refused while an earlier join cannot be put back yet
An install trusts what its plan found installed the same file: an install refuses when a version its plan found installed was uninstalled since, and was installed again as another build since
The install checks that a version is there, not which build the same file: was installed again as another build since
A rollback that began counts as settled the same file's first two tests, and multiplayer_join_rollback_test: a join while an earlier one cannot be put back yet is refused
The join says to repair the list of mods in Mods again multiplayer_join_install_test: a list of mods that cannot be read stops a join and a cleanup with what to do
The cleanup says the same again the same

Round 3: 35 mutants, all killed, each by the test named. Eleven break round 3's fixes; the other 24 are round 2's,
which break the fixes of rounds 1 and 2 again.

Mutant Killed by
The rollback deletes a committed folder in place multiplayer_join_rollback_test: a held folder is never half deleted
The sweep deletes a placed folder in place the same
A join builds on an unsettled earlier join the same file: a join while an earlier one cannot be put back yet is refused
The rollback does not note that it began the same
The sweep keeps a rolled-back install whose folders stayed the same
A damaged copy with no record reads as missing (Dart) multiplayer_server_content_store_test: a damaged record set aside, as a folder and as a file; multiplayer_server_content_safety_test: set aside by a repair that could not write its record
A damaged copy with no record reads as missing (C#) ServerContentSelectionTests: a record set aside as damaged, with none beside it
A repair always sets the record aside multiplayer_server_content_store_test: a damaged record set aside ... stays damaged until a repair writes one
A load puts a backup back the same file: a write stopped between its moves is read where it stands
A join past the record's bound is not refused up front multiplayer_join_install_test: a join that would fill the record past its bound is refused
The blocker names a Settings screen again multiplayer_join_content_test: says to remove the other server's content

Round 2's 24 were all killed again, by the tests the table below names; the C# backup mutant now inverts the new block's
condition. The launcher ignoring added records is also killed by multiplayer_server_content_fixture_view_test.

Mutant Killed by
The loader keeps every version the record withholds ServerContentSelectionTests: a server's hidden version is not the player's
A record that cannot be used never freezes selection the same: a damaged record keeps what loaded before
A mod a join added starts enabled (C#) the same: a mod a join added starts disabled
An unreadable receipt exposes a version (C#) the same: an unreadable receipt withholds a version the record names
The loader reads no backup a stopped write left the same: a backup a stopped write left is read
One version twice in a list is not damage (C#) ServerContentFixtureTests: invalid duplicate-version
Damaged entries are skipped instead of damaging the record server_content_fixtures_test: the four invalid entry cases
A byte order mark is accepted (Dart) the same: invalid byte-order-mark
A repair withholds nothing multiplayer_server_content_safety_test: a damaged record is repaired failing closed, and three more
Cleanup judges a version by a receipt it cannot read the same: keeps its version through the tidy-up after a re-join
The crash sweep settles past a receipt it cannot read multiplayer_join_crash_test: the next start waits while a receipt it would judge by cannot be read
Closing the player's game needs no consent multiplayer_join_running_game_test: keeps running when the consent did not cover closing it
The player's game is closed before anything is decided the same: keeps running when the join is refused, and three more
What the manager state has on is not counted as used multiplayer_server_content_safety_test: a mod a join added that the player turned on is theirs
The player's view fails open while the record cannot be used the same: while the record cannot be used, the Mods list shows only what the manager state names
A repair takes the player's profile pins as evidence the same: a copy of a server profile does not make its versions the player's
Exact pins do not see what a repair withheld the same: a version of the player's own that a profile pins keeps loading once a repair withholds it
The launcher ignores added records the same: a mod a join added whose manager record is lost stays off
The tidy-up drops an installed mod's added record the same: keeps its added record while it is installed
A rollback drops a held folder's entry multiplayer_join_rollback_test: folders another program holds stay out of the player's own play
A rollback drops a held folder's added record the same
The catalog never marks another server's build multiplayer_join_install_test: another server's build of a version says to remove that server's content
The install says to uninstall another server's build the same
The resolution ignores serverOnly multiplayer_join_content_test: says to remove the other server's content

Round 1: 23 mutants, all killed, including the 14 above and these nine, whose tests still pass on this head: the
launcher's writer does not sort; the loader does not compare receipts; an unreadable receipt exposes a version (Dart);
a damaged record can be updated; nothing is decided again once the game quit; the SHA-256 is not checked; the archive's
manifest is not compared with the trusted one; _upsertState runs for mods that already have a record; cleanup ignores
the server profiles it keeps.

Checklist

  • I read AGENTS.md and preserved CLEAN/Bloc/Unity/TopiaForge UI boundaries.
  • I added focused regression coverage for meaningful defects.
  • I ran the relevant build, analyze, test, format, package, and line-count gates.
  • I added provenance, license text, and modification notes for new third-party material. (None added.)
  • I did not include secrets, proprietary game assemblies, generated build directories, or unrelated changes.

@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Unity PR artifacts are ready.

Artifact Link
VPM package listing Download artifact

Workflow run: Unity Source Validation #672
Commit: 49c8317

Artifacts are temporary and expire according to the workflow retention policy.

@Furroxide
Furroxide force-pushed the feat/mp-exact-installs branch 3 times, most recently from 155947a to 6a7207a Compare October 19, 2026 00:45
@Furroxide
Furroxide force-pushed the feat/mp-exact-installs branch 2 times, most recently from 59f8f0e to 4f280ef Compare October 19, 2026 03:57
A join now downloads, installs and profiles the exact archives a server
runs, with nothing matched by hand and nothing the player's own profiles
load changed.

- MultiplayerContentHost, an optional launcher-repository capability in
  launcher_domain, answers the downloading, installing and preparing-profile
  stages. LocalLauncherRepository has it, and the multiplayer facade's
  answerJoin hands those stages to it, so every join event still comes from
  one place.
- Downloads stream through secure_http with their SHA-256 computed as they
  arrive, bounded by the package cap and the size the source states, and
  checked against the trusted manifest before anything is kept.
- An install stages, records and commits as one change with a journal: a
  failure rolls back everything this join changed, a cancel before the
  commit undoes it, and the next start settles one a crash stopped.
- The manager record of a mod the player has is never touched. A mod new to
  them is recorded disabled, and a version installed for their own mod is
  seen only by server profiles.
- The server profile is saved, and a re-join updates its pins and keeps
  what the player set.
- A bounded record of server content beside the manager state, a cleanup
  plan that is applied exactly or refused, and automatic removal of
  versions no profile pins any more.
- A running game the launcher started is closed before an install; one it
  did not start ends the join with a plain message.

Signed-off-by: Furroxide <221987073+Furroxide@users.noreply.github.com>
A join's install now counts the packages it installs and reports each one
as it is staged, as the reducer's own event, so the join's progress can
show the install stage the way it shows the downloads.

- JoinInstallProgressed carries how many packages are prepared out of
  how many. The reducer keeps it while installing, and clears it when the
  step finishes or the join ends.
- installJoinContent takes onProgress, and the facade's answerJoin passes
  download and install progress through one listener, as JoinEvents.
- A listener that throws is logged, and the install goes on.

Signed-off-by: Furroxide <221987073+Furroxide@users.noreply.github.com>
The game's loader now reads the record of server content, so a game
started without the launcher never loads a version a join installed for
one of the player's own mods. The record is a contract of both languages,
held by shared fixtures both replay: read whole or not at all, with a
version field, bounds, and a fail-closed reading of anything damaged,
newer or unreadable. A record that cannot be used freezes the loader's
unpinned selection at what loaded before.

Each of these was proven on the first push and now has a regression test:
- salvaging a damaged record exposed the versions its damaged entries
  hid; the launcher now repairs it failing closed, withholding every
  installed version the player's own setup does not name;
- a receipt another program holds made the tidy-up after a join, and the
  crash sweep, drop a hidden version's entry and expose it;
- the tidy-up removed a mod the player had turned on in the game;
- an install stopped the player's game before deciding anything, so a
  refused or cancelled join lost unsaved progress; the game is now
  closed only when the consent covered it, and only once everything is
  staged;
- an unresolvable dartdoc reference failed the docs build.

Signed-off-by: Furroxide <221987073+Furroxide@users.noreply.github.com>
- A rollback that cannot take a just-committed folder out, as when an
  antivirus scan holds its assembly, keeps that folder's entry in the
  record of server content and its added record, so it stays hidden
  until the next start takes it out.
- The launcher starts a mod a join added disabled when its manager
  record is lost, as the game's loader does, and the tidy-up keeps an
  added record while its mod is still installed.
- While the record cannot be used, the launcher's view of the player's
  mods freezes at what the manager state names, as the game's loader
  does, so nothing a profile pins from the Mods list is a server's
  version.
- A repair withholds every installed version the manager state does not
  name. A pin of a profile of the player's own is no longer evidence,
  since a copy of a server profile pins the server's versions; an exact
  pin still loads a version a repair withheld, as the loader does.
- When another server installed a different build of a version, the
  plan and the install say to remove that server's content instead of
  uninstalling the player's mod (installedForAnotherServer).
- closeOwnedGameAcknowledged stays false until the launcher can ask the
  game to quit: a game stopped outright starts next in safe mode, which
  refuses the session.

Signed-off-by: Furroxide <221987073+Furroxide@users.noreply.github.com>
- A rollback, and the next start's sweep, move a join's version folder out
  of the packages folder whole before deleting it. A recursive delete in
  place took the install receipt first and stopped at a held file, so the
  next start dropped the record entry and left a broken version of the
  player's own mod, and a re-join said to uninstall it in Mods.
- The journal notes when a rollback began, so a later start undoes it even
  when every folder stayed, and a join is refused while an earlier one is
  not settled, instead of counting its held folders as installed.
- A damaged record a repair moved aside, with no record written beside it
  (a full disk, or a launcher stopped there), reads as damaged in the
  launcher and in the game's loader, never as a record that withholds
  nothing.
- Loading the record reads a stopped write's backup where it stands; only
  writes, under the staging lease, put it back, so a second launcher can no
  longer move an older record over a newer one.
- The join's messages name no screen that is not built yet, the record-full
  refusal has a test, and a damaged record says to free disk space.
- The launcher replays the shared fixtures' added records at the level of
  what loads, as the game's loader does.

Signed-off-by: Furroxide <221987073+Furroxide@users.noreply.github.com>
- The catalog a join plans from leaves out the folders a join that failed
  or stopped part way left in place, which the next sweep takes out. Such
  a folder counted as installed: a re-join right after a held rollback
  planned it as installed, the install's own sweep then moved it out, and
  the join saved a server profile pinning a version that was gone. A join
  that downloaded nothing never reached the install's refusal and did the
  same once the hold ended. The join now downloads that version, so its
  install refuses while the folder stays and installs it afresh once a
  sweep took it out.
- An install checks again, under its lease and after its sweep, that every
  version its plan found installed is still there as the same archive, and
  otherwise ends with "join again".
- The join and the cleanup no longer say to repair the game's list of mods
  in Mods, which has no such repair; they name the file and what to do.

Signed-off-by: Furroxide <221987073+Furroxide@users.noreply.github.com>
@Furroxide
Furroxide force-pushed the feat/mp-exact-installs branch from 4f280ef to 49c8317 Compare October 19, 2026 04:07

This branch has not been deployed

No deployments
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.

1 participant