You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
On a DeployCompile game, lmm deploy reports per-mod success for mods that individually deployed nothing, and never mentions the one artifact that actually reached the game directory. The output asserts something untrue about where files went.
❯ lmm -g icarus deploy
Deploying 2 mod(s) using symlink...
✓ Donovan's More Points
✓ laanp-Combined_QOL
Deployed: 2
Both ✓ lines are mods that linked zero files. The real artifact — a single profile-level zzz_LMM_Merged_P.pak — is never named. using symlink compounds it: the link method is real, but it applies to the merged pak's single deployment, not to either listed mod.
Why it happens
DeployProfile's loop calls installer.Install per mod, then unconditionally does result.Deployed++ and emits DeployDeployed (internal/core/flows.go:1856-1859).
Installer.Install resolves deployableFiles and loops over it (internal/core/installer.go:43-82). Zero files → zero iterations → return nil. Success, indistinguishable from a real deployment.
For an exmodz mod deployableFiles narrows to claimed manifest members once a retained source exists (internal/core/deployable.go:75-83), and an exmodz entry has none. internal/core/verify.go:314-318 already documents the invariant: "a DeployCompile game's .exmodz mod is ingested as validate+retain ONLY - it has zero deployment members of its own by design."
The merged pak is built after the loop by syncMergedPak (internal/core/flows.go:1885), which emits only warnings — there is no positive progress event for it, so the CLI currently has nothing to render even if it wanted to.
So Deployed: 2 counts two no-op installs and omits the only real deployment.
A compile profile is three-way, not two-way
A blanket "compiled" label would be its own lie. The classes:
Class
Individually deployed?
Label
exmodz, or a pak that converted
No — the merged pak carries it
merged
pak that failed conversion, or convert_paks: false
Yes, raw
deployed raw
ordinary loose-file mod in the same profile
Yes
deployed
The gate separating class 1 from class 2 is participating := game.ConvertPaks && mod.ConvertPaks && !failed (internal/core/merged_pak.go:424).
Data already available — no new computation
Service.MergedPakOutcomes(game, profile) ([]MergedFingerprintEntry, bool) (internal/core/merged_pak.go:620) — public, already consumed by verify and status; per-mod Kind / Converted / FailReason.
core.HasRetainedCompileSource(...) (internal/core/verify_helpers.go:26) — public; exactly the "merge participant with no members of its own" predicate.
enabledMergeSources — cheap (one dir read plus small stats; already the sync fast-path cost) and yields kind + participation without the merge having run yet.
Decided approach: option (b) — classify before the loop, correct in the footer
Settled 2026-08-08. Do not re-litigate.
Per-mod ✓ lines print during the loop, but syncMergedPak runs after it — so at ✓ time we know a mod will merge, not whether a pak's conversion succeeded. Option (b) resolves that as follows:
Classify each mod before the deploy loop using enabledMergeSources, and label merge participants inline as (merged).
Let the existing post-sync conversion-failure warning carry the correction for the one optimistic case (a pak labeled (merged) that then fails conversion and falls back to raw).
Add a post-sync footer that names the artifact and reports participant counts. This is where accuracy lands, since it runs after syncMergedPak and can report raw fallbacks and conversion failures correctly.
Exact wording is not prescribed here — the requirement is that the per-mod lines stop reading as individual deployments and the merged artifact is named.
Rejected alternatives, recorded so they aren't rediscovered:
Read the stored fingerprint before the loop — wrong on first deploy and after any input change (the fingerprint describes the previous sync, not this one).
Buffer per-mod lines and flush them after sync — fully accurate, but breaks the deliberate "print at the exact point of occurrence" contract documented at cmd/lmm/deploy.go:136-144, and loses live feedback on slow deploys.
Keep Deployed: N counting merge participants
They are deployed, via the merged pak; the labels carry the nuance. Redefining the count is a larger behavioral break than changing the prose around it, and Deployed:/Failed: is load-bearing in existing tests.
Scope
New DeployPhase value(s): a merge-complete event carrying participant count and artifact name. The enum is explicitly designed to be extended rather than forked (internal/core/flows.go:518-521).
DeployProgress gains a per-mod classification field, set on DeployDeployed.
Emit the merge event after syncMergedPak succeeds (internal/core/flows.go:1885-1894) — today only its warnings surface. Preserve the invariant documented at cmd/lmm/deploy.go:136-141 that every Warnings/Notes entry has exactly one corresponding event, so nothing double-prints.
Render in cmd/lmm/deploy.go: header (:133), per-mod line (:187-188), summary (:215-219).
TUI parity (standing directive): the TUI is currently worse off — coreProvider.DeployProfile passes nil for progress (internal/tui/service_core.go:810), so it has no event stream at all and shows only Deployed 2 mod(s). It needs the counts from the result, not from events, which likely means a small additive DeployResult field rather than event handling. Note the TUI's status line is one row and already collapses multi-warning outcomes (TUI: multi-warning outcomes collapse to "(N warnings)" with no way to read them — Icarus asset conflicts are lost #253), so the merge readout must fit the Message rather than ride along as a warning.
Risk: low
Additive, and gated to DeployMode == DeployCompile — non-compile output is untouched.
deploy has no --json, so there is no output contract to version.
Every existing deploy test uses a non-compile (symlink) game and asserts via Contains (cmd/lmm/deploy_test.go:164, :167), so no existing test needs to change.
Main cost is new coverage on the compile path, where fixtures already exist in internal/core/service_icarus_compile_test.go and cmd/lmm/*_compile_test.go.
Acceptance
On a compile game, deploy output names the merged artifact and does not present merge participants as individually deployed.
A pak that fell back to raw deploy (conversion failed, or convert_paks: false) is distinguishable in the output from a merged participant.
An ordinary loose-file mod in a compile profile still reads as a normal individual deployment.
Non-compile game output is byte-identical to today.
The TUI reports the merge too, within its one-row status line.
Tests cover all three mod classes on the compile path, plus a conversion-failure case where the inline (merged) label is corrected by the footer/warning.
Summary
On a
DeployCompilegame,lmm deployreports per-mod success for mods that individually deployed nothing, and never mentions the one artifact that actually reached the game directory. The output asserts something untrue about where files went.Both
✓lines are mods that linked zero files. The real artifact — a single profile-levelzzz_LMM_Merged_P.pak— is never named.using symlinkcompounds it: the link method is real, but it applies to the merged pak's single deployment, not to either listed mod.Why it happens
DeployProfile's loop callsinstaller.Installper mod, then unconditionally doesresult.Deployed++and emitsDeployDeployed(internal/core/flows.go:1856-1859).Installer.InstallresolvesdeployableFilesand loops over it (internal/core/installer.go:43-82). Zero files → zero iterations →return nil. Success, indistinguishable from a real deployment.deployableFilesnarrows to claimed manifest members once a retained source exists (internal/core/deployable.go:75-83), and an exmodz entry has none.internal/core/verify.go:314-318already documents the invariant: "a DeployCompile game's.exmodzmod is ingested as validate+retain ONLY - it has zero deployment members of its own by design."syncMergedPak(internal/core/flows.go:1885), which emits only warnings — there is no positive progress event for it, so the CLI currently has nothing to render even if it wanted to.So
Deployed: 2counts two no-op installs and omits the only real deployment.A compile profile is three-way, not two-way
A blanket "compiled" label would be its own lie. The classes:
convert_paks: falseThe gate separating class 1 from class 2 is
participating := game.ConvertPaks && mod.ConvertPaks && !failed(internal/core/merged_pak.go:424).Data already available — no new computation
Service.MergedPakOutcomes(game, profile) ([]MergedFingerprintEntry, bool)(internal/core/merged_pak.go:620) — public, already consumed by verify and status; per-modKind/Converted/FailReason.core.HasRetainedCompileSource(...)(internal/core/verify_helpers.go:26) — public; exactly the "merge participant with no members of its own" predicate.enabledMergeSources— cheap (one dir read plus small stats; already the sync fast-path cost) and yields kind + participation without the merge having run yet.Decided approach: option (b) — classify before the loop, correct in the footer
Settled 2026-08-08. Do not re-litigate.
Per-mod
✓lines print during the loop, butsyncMergedPakruns after it — so at✓time we know a mod will merge, not whether a pak's conversion succeeded. Option (b) resolves that as follows:enabledMergeSources, and label merge participants inline as(merged).(merged)that then fails conversion and falls back to raw).syncMergedPakand can report raw fallbacks and conversion failures correctly.Target output:
Exact wording is not prescribed here — the requirement is that the per-mod lines stop reading as individual deployments and the merged artifact is named.
Rejected alternatives, recorded so they aren't rediscovered:
cmd/lmm/deploy.go:136-144, and loses live feedback on slow deploys.Keep
Deployed: Ncounting merge participantsThey are deployed, via the merged pak; the labels carry the nuance. Redefining the count is a larger behavioral break than changing the prose around it, and
Deployed:/Failed:is load-bearing in existing tests.Scope
DeployPhasevalue(s): a merge-complete event carrying participant count and artifact name. The enum is explicitly designed to be extended rather than forked (internal/core/flows.go:518-521).DeployProgressgains a per-mod classification field, set onDeployDeployed.syncMergedPaksucceeds (internal/core/flows.go:1885-1894) — today only its warnings surface. Preserve the invariant documented atcmd/lmm/deploy.go:136-141that everyWarnings/Notesentry has exactly one corresponding event, so nothing double-prints.cmd/lmm/deploy.go: header (:133), per-mod line (:187-188), summary (:215-219).coreProvider.DeployProfilepassesnilfor progress (internal/tui/service_core.go:810), so it has no event stream at all and shows onlyDeployed 2 mod(s). It needs the counts from the result, not from events, which likely means a small additiveDeployResultfield rather than event handling. Note the TUI's status line is one row and already collapses multi-warning outcomes (TUI: multi-warning outcomes collapse to "(N warnings)" with no way to read them — Icarus asset conflicts are lost #253), so the merge readout must fit theMessagerather than ride along as a warning.Risk: low
DeployMode == DeployCompile— non-compile output is untouched.deployhas no--json, so there is no output contract to version.Contains(cmd/lmm/deploy_test.go:164,:167), so no existing test needs to change.internal/core/service_icarus_compile_test.goandcmd/lmm/*_compile_test.go.Acceptance
convert_paks: false) is distinguishable in the output from a merged participant.(merged)label is corrected by the footer/warning.