Repository navigation
Conversation
|
Unity PR artifacts are ready.
Workflow run: Unity Source Validation #672 Artifacts are temporary and expire according to the workflow retention policy. |
Furroxide
force-pushed
the
feat/mp-exact-installs
branch
3 times, most recently
from
October 19, 2026 00:45
155947a to
6a7207a
Compare
5 tasks done
Furroxide
force-pushed
the
feat/mp-exact-installs
branch
2 times, most recently
from
October 19, 2026 03:57
59f8f0e to
4f280ef
Compare
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
force-pushed
the
feat/mp-exact-installs
branch
from
October 19, 2026 04:07
4f280ef to
49c8317
Compare
This branch has not been deployed
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.
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@sha256archives, installs them as one change, and saves theserver'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'sjoinverb (6.8) drive it next.MultiplayerContentHost(packages/launcher_domain/lib/src/repositories.dart): an optional capability besideLauncherRepository, tested withis, whichLocalLauncherRepositoryhas. It answers the join stages that need thegame's files, and owns the cleanup of server content:
downloadJoinContentJoinDownloadProgressed,JoinDownloadsCompleted;downloadFailed,integrityFailed,manifestMismatch,hostDownloadUnsupportedinstallJoinContentJoinInstallProgressed,JoinInstallCompleted,JoinInstallUndone;installFailedwithrollbackComplete,gameRunningwith its ownershipprepareJoinProfileJoinProfilePrepared;profileSaveFailedfeat(launcher-data): add the multiplayer network clients and store #214's facade answers them too:
answerJoin(state, install:, cancellation:, onProgress:)hands these stages to thecontent 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
onProgressas the reducer's own events.Downloads (
join_content_downloads.dart,secure_http_download.dart): eachdownloadpackage comes from thesource the plan names. HTTPS goes through the new
downloadHttpsToFile, which follows redirects only to public HTTPSURLs, as
fetchHttpsBytesdoes, streams the body to a temporary file with its SHA-256 computed as it arrives, andtakes progress and a
CancellationToken. A local (file:) source is copied under the same rules.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.
topiaforge.mod.jsonmustthen equal the trusted manifest the plan used; anything else is
manifestMismatch, and nothing is installed.the transfer at once and leaves no partial file; partial files a stopped process left are swept after an hour.
hostDownloadUnsupportedbefore anything is fetched.Exact installs (
join_content_install.dart,join_install_preflight.dart,join_install_journal.dart): one joininstalls as one change, under the launch staging lease (
acquireLaunchLease). It decides what to install first andrefuses 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 theplayer, adds its archives to the record of server content, and moves each package into place.
order begins removes everything staged and reports
JoinInstallUndone, which the reducer ends as cancelled.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.
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
(
_upsertStateruns only for ids with no record). A mod new to the player gets a disabled record before its folderappears, 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.jsonbeside the manager state, which the launcherwrites and both the launcher and the game's loader read.
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 theplayer's own play: id, version, receipt) and
addedRecords. At most 1 MiB of UTF-8, 1,024 entries in each list, nomember twice anywhere, no version twice in a list. A join that would pass a bound is refused, never trimmed.
schemaVersionis newer, and a file that cannotbe 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.
tests/fixtures/multiplayer/server-content/cases.json(77 cases, fromgenerate_server_content.py, no randomness) is replayed by both: the same documents are records, the same versionsare withheld, the launcher writes each valid one back to its exact canonical bytes, and the largest record fits.
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.
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 thelauncher, 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)>, namedServer · <name>,inheritManagerModState: false, every package pinned, saved in one atomic profiles write. Are-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.dartin both packages):planServerContentCleanup(install, serverProfileIds:): each version with its size, the serverprofiles 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.
applyServerContentCleanup(install, plan): exactly that plan, checked by its fingerprint, or arefusal (
stale,gameRunning,busy,unreadable). It follows the uninstall rules,uninstallPendingincluded.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 isrefused 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
gameRunningwith its ownershipand 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.mdsections 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.
ServerContentSelectionTests,ServerContentFixtureTests).multiplayer_server_content_safety_test).multiplayer_server_content_safety_test).multiplayer_join_crash_test).TerminateProcessbefore 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.multiplayer_join_running_game_test).multiplayer_server_content_safety_test).[ServerContentRecordRead.damaged], a field of a record typedef.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.
multiplayer_join_rollback_test: folders another program holds).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.multiplayer_server_content_safety_test: a mod a join added whose manager record is lost).multiplayer_server_content_safety_test: while the record cannot be used).multiplayer_server_content_safety_test: a copy of a server profile, and a version of the player's own that a profile pins).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 earliertext told the consent sheet and
topiaforge jointo set it once they had warned about unsaved progress. But a gamethe 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 thegraceful 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.
multiplayer_server_content_store_testandmultiplayer_server_content_safety_test: set aside by a repair that could not write its record;ServerContentSelectionTests: a record set aside as damaged).multiplayer_server_content_store_test: a write stopped between its moves is read where it stands).multiplayer_join_rollback_test: a held folder is never half deleted).multiplayer_join_rollback_test: a join while an earlier one cannot be put back yet).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 anmp-<16 hex>id without inheritance comes onlyfrom 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 contentstage through
answerJoinand the reducer. Each finding was proven on 6a7207a, the head before this round, by ascratch test that failed, and is now covered by a regression test that fails when the fix is reverted.
multiplayer_join_replan_test).BepInEx/TopiaForge/state.jsonand 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
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.
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.
id@versionas a different build must uninstall their copy to join, because both needthe 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, sincethe cleanup screen is not built yet; 6.6 should offer that cleanup right beside the failure.
files: the join says to wait a moment or close that program, then join again.
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.
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
installedAsOtherArchiveandinstalledForAnotherServer(6.2 lists seven). Anid@versioninstalled as a different archive blocks the join at checking, with the fix in plain words, instead offailing later at install: uninstall the player's copy, or remove the content of the other server whose hidden build
it is.
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.
gameRunningunder launching only; installingcan 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.closeOwnedGameAcknowledgedsays the consent sheet said so; otherwise the join ends asgameRunning, owned, asking the player to save and quit.hostDownloadUnsupported(downloading),JoinInstallUndoneandJoinInstallProgressed,JoinSessionState.installProgress, andcloseOwnedGameAcknowledgedonJoinConsentGivenandJoinSessionState. Each has reducer tests and is in the fulltransition matrix.
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.
without the launcher loads. It is internal to
TopiaForge.ModManager.Core; no SDK surface changes.CancellationTokenmoved fromlauncher_datatolauncher_domain, so the domain interface can take it.launcher_datare-exports it under the same names.What later slices need
LocalMultiplayerRepository(..., content: launcherRepository). In every stage that waits onthe launcher, call
answerJoin(state, install: install, cancellation: token, onProgress: (event) => ...)and reducewhat comes back with
JoinSession.reduce; reduce each progress event too. Null means the stage is not thelauncher's, or the work was cancelled.
closeOwnedGameAcknowledgedfalse until the launcher's graceful quit lands. Theinstall then ends as
gameRunning, owned, with "Save your game and quit it, then join again", and the second trykeeps 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 launcherstarted is running, that joining closes it.
state.progress(bytes) while downloading,state.installProgress(packages) while installing.token.cancel()and reduceJoinCancelRequested. Downloading ends at once. Installing ends ascancelled once the install answers:
JoinInstallUndonebefore anything changed, otherwise after it finished.state.outcome!.failurehascode,stageanddetail, which is player-facing text that says whatto do next and names no address. Show
rollbackCompleteforinstallFailed: when false, the player must restartthe launcher before playing. For
gameRunning,gameOwnershipsays whether the launcher could not close its owngame, was not allowed to, or the player must close one it did not start.
planServerContentCleanup(install)(orserverProfileIds:forchosen servers):
plan.versionswithsizeBytes,serverProfileIdsandkept,plan.profiles, andplan.removableBytes. Confirm, thenapplyServerContentCleanup(install, plan). Onstale, plan again and show it;on
gameRunning, ask the player to close the game; onbusy, try again later; onunreadable, show the message,which says what to do.
ServerProfiles.isServerProfiletells server profiles apart in Profiles.installedForAnotherServercan offer that cleanup straight away: the blocker'smodId,looked up in
planServerContentCleanup(install).versions, gives theserverProfileIdsof the server whose contentis 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.
loadExactPackageCatalogleaves out what a join that failedleft 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.
topiaforge join. The same loop with the same repositories:--yesreducesJoinConsentGiven()(withcloseOwnedGameAcknowledgedfalse, as for the consent sheet; a game the CLI did not start is the player's to closeanyway), and
--allow-host-packagesJoinConsentGiven(hostServedAcknowledged: true)in developer mode, though host-served downloads still end ashostDownloadUnsupporteduntil that slice lands. Map each failure'sstageto the stage codes (TFJOIN100-180) andprint its
detail; printprogressandinstallProgressas they change. Before the command line opens thedesktop launcher's data root,
MultiplayerStoreneeds 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.
_downloadJoinContentends a plan whosedownloadRequest.hostDownloadsis not empty, and_installJoinContentone withplan.hasUnverifiedPackages. FetchTFD1 package <sha256>with a streaming reader (descriptor_response.dartsays what it needs), under the contactrules checked again at download time, into
_joinCacheFile(sha256)through_keepVerifiedDownload. Those packageshave no trusted manifest, so
_stagemust check the archive's own manifest against the id and version thedescriptor claims instead. The record, rollback and cleanup then treat them as they treat registry packages.
_closeOwnedGameForInstallasks thegame 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
launcher_domaingainsMultiplayerContentHost, the server content and cleanup models (ServerContentRecord,ServerContentEntry,WithheldServerVersion,ServerContentStatus,ServerContentRecordRead),ServerProfiles,the events, failure and consent fields above, the resolutions
installedAsOtherArchiveandinstalledForAnotherServer,ExactInstalledArchive.serverOnly, andCancellationToken, moved fromlauncher_data, which re-exports it unchanged.MultiplayerRepository.answerJoingains optionalinstallandonProgressparameters.BepInEx/TopiaForge/server-content.json(schemaVersion 1), itsserver-content.damaged-<UTC time>.jsoncopies, andlauncher-join-*andlauncher-trash-*folders in managerstaging that the next start removes.
ModRegistry.Scanreadsserver-content.json, through new internal types inTopiaForge.ModManager.Core. Thepublic
Scanoverloads keep their signatures. No SDK surface and no launch-contract change.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.
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.
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.
server-content.json; removing that file, and anyserver-content.damaged-*.jsonbeside it, makes those versionsthe 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 andtheir tests) and the design record. Its commit, 988122c on 6a7207a, passed the Dart gates below.
rebase_push.shthen rebased the branch onto
devat 22d68d1 (#238 and #251), every commit patch-identical, and each gate below ranagain on the rebased head unless it says otherwise.
dotnet build TopiaForge.slnx -c Release -warnaserror: 0 warnings, 0 errors.TopiaForge.ModManager.Tests: all pass, among themServerContentFixtureTests(85 checks over the 77 fixturecases) and
ServerContentSelectionTests(15 cases over packages the real installer installs).TopiaForge.ModRuntime.Tests,TopiaForge.Mods.Analyzers.Tests,TopiaForge.Mods.Multiplayer.Generators.Testsand
TopiaForge.Mods.Multiplayer.Tests: all pass.dotnet format --verify-no-changeson 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 format --output=none --set-exit-if-changedanddart analyze --fatal-infosinpackages/launcher_domain,packages/launcher_dataandapps/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 #251adds one. New in round 4:
multiplayer_join_replan_test(a re-join right after a held rollback, a join thatwould 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 thefirst 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; itpasses 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-linksinpackages/launcher_data, as the docs job runs it: 0 warnings, 0 errors.launcher_uior the app uses (private members, two messages andtests). In round 2,
flutter analyzefound no issues, andflutter testpassed 3, and 145 with 5 skipped.python -I .github/scripts/check_topiaforge_residue.py,python -I .github/scripts/check_readme_counts.pyandnode website/scripts/check-markdown-links.mjs(147 files): pass.python -I tests/fixtures/multiplayer/generate_server_content.py .reproduces
cases.jsonbyte 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.
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 yetmultiplayer_join_rollback_test: a join while an earlier one cannot be put back yet is refusedmultiplayer_join_install_test: a list of mods that cannot be read stops a join and a cleanup with what to doRound 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.
multiplayer_join_rollback_test: a held folder is never half deletedmultiplayer_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 recordServerContentSelectionTests: a record set aside as damaged, with none beside itmultiplayer_server_content_store_test: a damaged record set aside ... stays damaged until a repair writes onemultiplayer_join_install_test: a join that would fill the record past its bound is refusedmultiplayer_join_content_test: says to remove the other server's contentRound 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.ServerContentSelectionTests: a server's hidden version is not the player'sServerContentFixtureTests: invalid duplicate-versionserver_content_fixtures_test: the four invalid entry casesmultiplayer_server_content_safety_test: a damaged record is repaired failing closed, and three moremultiplayer_join_crash_test: the next start waits while a receipt it would judge by cannot be readmultiplayer_join_running_game_test: keeps running when the consent did not cover closing itmultiplayer_server_content_safety_test: a mod a join added that the player turned on is theirsmultiplayer_join_rollback_test: folders another program holds stay out of the player's own playmultiplayer_join_install_test: another server's build of a version says to remove that server's contentserverOnlymultiplayer_join_content_test: says to remove the other server's contentRound 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;
_upsertStateruns for mods that already have a record; cleanup ignoresthe server profiles it keeps.
Checklist
AGENTS.mdand preserved CLEAN/Bloc/Unity/TopiaForge UI boundaries.