Skip to content

feature(mods): Mod Manager UI, Rescan and Hot Reload reflect deleted mods#236

Open
Zorkats wants to merge 4 commits into
JRickey:mainfrom
Zorkats:feat/mod-manager-phase1
Open

feature(mods): Mod Manager UI, Rescan and Hot Reload reflect deleted mods#236
Zorkats wants to merge 4 commits into
JRickey:mainfrom
Zorkats:feat/mod-manager-phase1

Conversation

@Zorkats

@Zorkats Zorkats commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

What

Currently, Battleship does have a menu for Hi-Res Texture Packs and more,
but not a proper Mod menu where people can see what Mods they have installed,
or even reload or scan for new mods. This fixes that.
One issue that I had during development of this mod menu was that
the Mods menu's Rescan and Hot Reload buttons did not pick up a mod
folder deleted at runtime — the mod kept showing LOADED and Rescan still
listed it.

Why

MountModsDir (port/port.cpp) only ever adds archives (skips
already-mounted, AddArchives new). Nothing unmounts. So a deleted mod folder
stays mounted in the ArchiveManager for the session:

  • Hot Reload unloads → re-mounts (no-op) → recompiles the still-mounted
    archive → LOADED again.
  • Rescan re-reads the mounted archives → the deleted mod is still there.

Fix

Add UnmountMissingMods() (port/port.cpp): for each mounted archive carrying a
manifest.json whose on-disk path no longer exists, call
ArchiveManager::RemoveArchive. The base game + shader archives (no manifest)
are never touched.

Wire it in (port/gui/PortMenu.cpp):

  • Hot Reload (DoHotReload): after UnloadAll, before re-mount/recompile —
    so a deleted mod is dropped instead of recompiled.
  • Rescan (RefreshModList): sync mounts to disk (drop gone, pick up new),
    then re-snapshot. New mods appear as not loaded until a Hot Reload compiles
    them; this only refreshes the inventory.

Test plan

Load with a mod, delete its folder while running, press Rescan (then Hot
Reload
). It should drop from the list. Drop a new folder in and Rescan — it
should appear (not loaded), then load on Hot Reload.

Notes

  • Compiles and boots clean (mods still load — no regression); the unmount path
    itself needs a button click to exercise, hence the manual test plan.
  • Tested with the Player Tint mod I left in the previous Documentation PR I made, works flawlessly with Hot Reload (characters "un-tinted" in real time).
  • Uses ArchiveManager::RemoveArchive, the unmount primitive flagged in the Mod
    Manager design note as the main Phase-2 dependency.
  • One thing I did not test (because they aren't many mods for Battleship yet) is that what would happen with a mod with the size of Smash Remix? But that can be solved on the long run.

Zorkats added 4 commits June 16, 2026 18:33
Phase 1 of the in-game Mod Manager (docs/mod_manager_menu_plan.md):
turn the Mods panel's single Hot Reload button into a read-only list of
installed mods with their manifest metadata and load state.

New port/mods/ModRegistry — the model behind the panel. A pure, no-throw
Snapshot() walks the engine's mounted archives (ArchiveManager), treats any
archive carrying a manifest.json as a mod (same rule MountModsDir uses, so
the base game + shader archives are excluded), reads name/version/author/
description from Archive::GetManifest(), and marks each Loaded when its
manifest name is in ScriptLoader::GetLoadersInDependencyOrder(). Results are
ordered loaded-first then case-insensitive by name for a stable list.

PortMenu: the Mods panel now renders an action bar (Hot Reload / Rescan /
Open Mods Folder), a name/author filter, status-badged mod cards (LOADED /
not loaded / manifest issue) with an archive-path tooltip, and a footer
count. The list is cached and refreshed on first view, Rescan, and after a
Hot Reload rather than re-scanned every frame.

No enable/disable yet (Phase 2). Additive and behavior-preserving: with no
mods the panel just reports an empty folder. Model/view are separated so
discovery stays UI-independent.

Verification: ModRegistry compiles clean under clang -std=c++20 -Wall
-Wextra against the libultraship API, and its logic was exercised by an
edge-case harness (base-archive exclusion, null subsystems, unnamed
manifest fallback, loaded matching, case-insensitive ordering). A full
in-tree build was not run here (submodules not checked out in this
checkout); the libultraship calls are verified against pinned SHA
be6415584d.
PortShutdown relied on ~Context (via sContext.reset()) to run
ScriptLoader::UnloadAll, but by then the Context's EventSystem is already
being destroyed. A mod that REGISTER_LISTENERs in ModInit and
UNREGISTER_LISTENERs in ModExit then crashes on exit:
UnloadAll -> ModExit -> UNREGISTER_LISTENER -> Context::GetEventSystem
dereferences a dead shared_ptr<EventSystem> (access violation in
_Ptr_base<EventSystem>::_Incref).

Unload the scripts explicitly in PortShutdown, while the EventSystem is still
alive, right after the hooks are torn down. The later ~Context UnloadAll then
finds nothing loaded.

Verified: graceful close (WM_CLOSE) with a listener-registering mod loaded now
logs the mod's ModExit ("[PlayerTint] exit OK") and exits with no crash dump,
where before it produced one on every exit.
MountModsDir only ever adds archives, so a mod folder/.o2r deleted at runtime
stayed mounted in the ArchiveManager for the session: Hot Reload recompiled it
from the still-mounted VFS (still LOADED) and Rescan still listed it.

Add UnmountMissingMods() (port.cpp): for each mounted archive carrying a
manifest.json whose on-disk path no longer exists, ArchiveManager::RemoveArchive.
The base game + shader archives (no manifest) are untouched. Wire it into Hot
Reload (after UnloadAll, before re-mount/recompile) and Rescan (sync mounts to
disk, then snapshot).

Verified in-game: deleting a loaded mod's folder and pressing Rescan/Hot Reload
now drops it from the list.
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