Skip to content

Mod Manager Initial Design - #231

Draft
foresterfx wants to merge 43 commits into
fahrenheit-crew:mainfrom
foresterfx:mod-manager-initial-design
Draft

Mod Manager Initial Design#231
foresterfx wants to merge 43 commits into
fahrenheit-crew:mainfrom
foresterfx:mod-manager-initial-design

Conversation

@foresterfx

@foresterfx foresterfx commented Jul 27, 2026

Copy link
Copy Markdown

Supports:

  • Read/Write operations on loadorder through the UI interactions
  • Launching FFX, FFX-2, FFX-2LM via menu options (debug as well)
  • Warning information given to user if binaries not found within game folder
  • Importing/Exporting of zipped directory containing mods

Enhance mod manager UI with professional styling and cross-platform support:

- Add font and icon assets, load NotoSans variable font on startup
- Implement DPI-aware window sizing based on display usable bounds with sensible clamps
- Make window borderless and install custom chrome for drag-to-move/resize behavior
- Integrate FhTheme for consistent color scheme and UI scaling
- Replace hardcoded clear color with theme background color
- Add FhAppIcon for icon texture loading and display
- Update global usings to include necessary dependencies (NativeFileDialogCore, StbImageSharp)
- Rename ModManager.UI() to FhModManagerUI.UI() for consistency
- Add comprehensive documentation comments explaining each file's responsibilities
Introduces the FhTheme system for centralized color palette management, including a FhThemeColor record for JSON serialization of user customizations, and FhElements utilities for reusable styled ImGui widgets. This provides a single source of truth for the app's visual design, allowing coherent theming across the UI with support for high-DPI scaling and user-customizable colors through the Settings modal.
Implement core mod management functionality:

- FhModScanner: scan game directory for installed mods, parse manifests, and validate load order
- FhLoadOrderEditor: read/write/modify mod load order file with normalization and atomic writes
- FhEflImporter: import loose VBF-shaped file trees as EFL mods
- FhModPackExporter/Importer: create and extract .zip mod packs with load order preservation

Both files are part of the mod manager subsystem and handle filesystem operations with proper error handling and warnings.
Adds FhAppIcon class that loads and decodes the Fahrenheit airship icon from a PNG file and creates an OpenGL texture for rendering in Dear ImGui. The texture is managed by the app outside of ImGui's texture-data system and gracefully handles loading errors without crashing the application.
Add FhWindowChrome class that restores window interaction affordances for a borderless SDL window. This includes drag-to-move and drag-edge-to-resize via SDL's hit-test callback, custom minimize/maximize/close buttons in the menu bar, and disabling DWM's automatic corner rounding on Windows so the theme's WindowRounding is the only rounding in effect.
Adds FhGameLauncher for launching FFX/FFX-2 through fhstage0.exe with validation of the Fahrenheit installation and support for debug mode, and FhShell for opening folders in the OS file browser.
Add src/modmgr/settings.cs which implements FhModManagerSettings (JSON-serializable settings for game directory, optional Fahrenheit/mods overrides, and optional theme color overrides) and FhModManagerSettingsStore. The store defines a default game path, computes SettingsPath next to the executable, and provides load/try_save methods with user-facing warnings on failure. Also includes normalize_path for canonicalizing paths and write_atomic which stages writes to a .tmp file and moves it into place to ensure atomic saves (shared with the load-order editor).
Add ui_helpers.cs with a collection of reusable helper methods for the mod manager UI, including status bar updates, window/cursor positioning, text rendering variants, and directory rescanning utilities.
Implements the menu bar UI component with menu items (Mods, Play, Settings, About), window control buttons (minimize, maximize, close), app icon rendering, and modal dialog handling for import and settings dialogs.
Add ui_mod_list.cs to render the Enabled/Disabled mod panels with their scrollable tables. Includes support for load-order arrow buttons, enable/disable checkboxes, and mod details rendering (name, version, author, path, validation status). Uses a queuing pattern to defer toggle and load-order changes until after rendering completes, avoiding mutations during list iteration.
Add ui_actions.cs containing action handlers for mod manager operations including refreshing the catalog, opening folders, exporting/importing mod lists and packs, launching the game, and applying pending mod list mutations (toggle, move, reorder).
Implement drag-to-reorder UI control for the mod manager's Enabled panel. Tracks in-progress drags in memory for responsive feedback and queues the final position for disk write on release. Includes animations for grab pop and release flash visual feedback.
Implement a status bar that displays messages at the bottom of the mod manager window. The bar automatically fades out messages after 4 seconds, with a 0.6 second fade-out duration. Supports different styling for error vs success messages and maintains a fixed height to ensure layout stability.
Implement the Settings modal for the mod manager UI with:

- Installation location configuration (game, Fahrenheit, mods directories) with live validation status icons
- Browse and open folder functionality for each location
- Editable game directory path with Enter-to-save support
- Theme color customization with 9 color pickers in a 3-column grid
- Automatic persistence of settings changes
- Reset to default theme option

The mods location row is currently hidden pending mod pack/profiles discovery and implementation.
Add UI for importing loose file trees as External File Loader mod folders. The modal allows users to select a game (FFX/FFX-2), specify a mod ID and optional name, and choose a source folder to import from.
Replace stub demo window with functional FhModManagerUI implementation. Adds static constructor to initialize settings, game directory, and mod catalog. Implements UI() method with main window frame, menu bar, header, warnings, mod lists, and status bar. Includes helper methods for rendering header and warnings sections. Class renamed from ModManager to FhModManagerUI and made partial for organization across related UI files.
Expands the README to comprehensively document the purpose and feature set of the mod manager, and adds a 'Jobs' section detailing the responsibility of each file in the project, organized by concerns (bootstrap, domain/data, and UI layers).
@EvelynTSMG EvelynTSMG added the Feature A request for a specific feature. label Jul 27, 2026
@EvelynTSMG EvelynTSMG moved this from Todo to Pending Review/Merge in Fahrenheit Jul 27, 2026
@EvelynTSMG EvelynTSMG moved this from Pending Review/Merge to In Progress in Fahrenheit Jul 27, 2026
@EvelynTSMG

Copy link
Copy Markdown
Contributor

... Could you at least tell the "AI" to be straightforward and to-the-point instead of talking as if it's trying to sell me a car?

My brain wants to explode whenever I try to extract any information out of this stars-forsaken text, so if it's not easy to read, I'm not doing so. Especially when you didn't care to write it in the first place.

Comment thread src/modmgr/main.cs Outdated
Comment thread src/modmgr/packs.cs Outdated
Comment thread src/modmgr/theme.cs Outdated
Comment on lines +7 to +9
* - FhThemeColor: a JSON-friendly RGBA color used to persist theme overrides
* in fhmodmgr.json (a plain System.Numerics.Vector4 doesn't round-trip
* through the settings store's JSON options).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your JsonSerializerOptions likely didn't IncludeFields.

Comment thread src/modmgr/theme.cs Outdated
Comment on lines +155 to +227
ImGuiStylePtr style = ImGui.GetStyle();

// Deliberately 0, unlike every other *Rounding below: the main window's
// own WindowBg fill is the one shape in this app that sits directly
// against the raw SDL window edge, with nothing else of ours behind it
// to fall back on. A rounded main window leaves small corner triangles
// that ImGui's own fill doesn't paint - what shows there instead depends
// on the OS compositor and the GL clear color, neither of which this app
// fully controls, and in practice it's shown up as the background color
// visibly poking out past the window's own rounded/bordered edge. A
// square outer window has no such gap: every pixel in it is something
// ImGui explicitly draws, full stop. Every rounded element elsewhere
// (buttons, panels, the Settings/EFL modals, frames) doesn't have this
// problem, since none of them ever sit directly against that raw edge.
style.WindowRounding = 0F;
style.ChildRounding = 6F * UiScale;
style.PopupRounding = 8F * UiScale;
style.FrameRounding = 4F * UiScale;
style.GrabRounding = 4F * UiScale;
style.ScrollbarRounding = 8F * UiScale;
style.TabRounding = 4F * UiScale;

style.WindowPadding = new Vector2(20F, 18F) * UiScale;
style.FramePadding = new Vector2(8F, 5F) * UiScale;
style.ItemSpacing = new Vector2(8F, 6F) * UiScale;
style.PopupBorderSize = 1F * UiScale;

style.Colors[(int)ImGuiCol.WindowBg] = COLOR_BACKGROUND;
style.Colors[(int)ImGuiCol.PopupBg] = COLOR_BG_RAISED;
style.Colors[(int)ImGuiCol.ChildBg] = new Vector4(0F, 0F, 0F, 0F);
style.Colors[(int)ImGuiCol.Border] = new Vector4(0.27F, 0.26F, 0.32F, 0.60F);

style.Colors[(int)ImGuiCol.FrameBg] = COLOR_FRAME_BACKGROUND;
style.Colors[(int)ImGuiCol.FrameBgHovered] = COLOR_FRAME_BACKGROUND_HOVER;
style.Colors[(int)ImGuiCol.FrameBgActive] = COLOR_FRAME_BACKGROUND_ACTIVE;

style.Colors[(int)ImGuiCol.TitleBg] = COLOR_TITLE_BAR;
style.Colors[(int)ImGuiCol.TitleBgActive] = COLOR_TITLE_BAR;
style.Colors[(int)ImGuiCol.MenuBarBg] = COLOR_TITLE_BAR;

style.Colors[(int)ImGuiCol.Button] = COLOR_ACCENT;
style.Colors[(int)ImGuiCol.ButtonHovered] = COLOR_ACCENT_HOVERED;
style.Colors[(int)ImGuiCol.ButtonActive] = COLOR_ACCENT_ACTIVE;

style.Colors[(int)ImGuiCol.CheckMark] = COLOR_ACCENT_HOVERED;
style.Colors[(int)ImGuiCol.SliderGrab] = COLOR_ACCENT;
style.Colors[(int)ImGuiCol.SliderGrabActive] = COLOR_ACCENT_ACTIVE;

style.Colors[(int)ImGuiCol.Header] = new Vector4(COLOR_ACCENT.X, COLOR_ACCENT.Y, COLOR_ACCENT.Z, 0.45F);
style.Colors[(int)ImGuiCol.HeaderHovered] = new Vector4(COLOR_ACCENT.X, COLOR_ACCENT.Y, COLOR_ACCENT.Z, 0.65F);
style.Colors[(int)ImGuiCol.HeaderActive] = new Vector4(COLOR_ACCENT.X, COLOR_ACCENT.Y, COLOR_ACCENT.Z, 0.85F);

style.Colors[(int)ImGuiCol.Separator] = new Vector4(0.30F, 0.29F, 0.36F, 1.00F);
style.Colors[(int)ImGuiCol.SeparatorHovered] = COLOR_ACCENT_HOVERED;
style.Colors[(int)ImGuiCol.SeparatorActive] = COLOR_ACCENT_ACTIVE;

// Used by the mod list's row table (see ui.cs's _render_mod_panel) - tied
// to the same tone as Separator above rather than StyleColorsDark's
// default (bluish-gray), so the table's row dividers don't clash with the
// rest of the palette.
style.Colors[(int)ImGuiCol.TableBorderLight] = new Vector4(0.30F, 0.29F, 0.36F, 0.60F);
style.Colors[(int)ImGuiCol.TableBorderStrong] = new Vector4(0.30F, 0.29F, 0.36F, 0.90F);
style.Colors[(int)ImGuiCol.TableRowBg] = new Vector4(0F, 0F, 0F, 0F);
style.Colors[(int)ImGuiCol.TableRowBgAlt] = new Vector4(COLOR_TEXT.X, COLOR_TEXT.Y, COLOR_TEXT.Z, 0.03F);

style.Colors[(int)ImGuiCol.ScrollbarBg] = COLOR_BACKGROUND;
style.Colors[(int)ImGuiCol.ScrollbarGrab] = new Vector4(0.28F, 0.27F, 0.34F, 1.00F);
style.Colors[(int)ImGuiCol.ScrollbarGrabHovered] = new Vector4(0.37F, 0.35F, 0.45F, 1.00F);
style.Colors[(int)ImGuiCol.ScrollbarGrabActive] = COLOR_ACCENT;

style.Colors[(int)ImGuiCol.Text] = COLOR_TEXT;
style.Colors[(int)ImGuiCol.TextDisabled] = COLOR_TEXT_MUTED;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once #233 merges, this should use the style defined in Fahrenheit's FhImGuiHelper. We may have to slightly refactor the helper to allow its use outside of the game process to do so. Try it- if push comes to shove we'll do it ourselves and you'll rebase against it.

Comment thread src/modmgr/theme.cs Outdated
Comment on lines +242 to +328
// Small, reusable ImGui widgets that carry FhTheme's styling automatically, so call
// sites don't each have to remember which color means "primary action" and push/pop
// it by hand.
internal static class FhElements {
// The default look (see FhTheme.apply's Button/ButtonHovered/ButtonActive) - use
// for the one action on a row/dialog that the user is most likely to want, e.g.
// "Save location", "Import".
internal static bool button_primary(string label, Vector2 size = default) {
return ImGui.Button(label, size);
}

// A small green check (valid) or red X (invalid) glyph, e.g. next to a
// location the Settings modal is showing the validity of. Hand-drawn via
// the draw list rather than a "✓"/"✗" character: the loaded font only
// covers Basic Latin + Latin-1 Supplement (see main.cs's
// io.Fonts.AddFontFromFileTTF call), so either glyph would just render as
// a blank box. Hovering shows `tooltip` if one is given - pass null/empty
// for a valid location that doesn't need explaining.
internal static void status_icon(bool is_valid, string? tooltip) {
float extent = ImGui.GetTextLineHeight();
Vector2 size = new(extent, extent);

Vector2 top_left = ImGui.GetCursorScreenPos();
Vector2 center = top_left + (size / 2F);

ImGui.Dummy(size);

ImDrawListPtr draw_list = ImGui.GetWindowDrawList();
uint color = ImGui.GetColorU32(is_valid ? FhTheme.COLOR_SUCCESS : FhTheme.COLOR_ERROR);
float glyph = extent * 0.5F;
float thickness = MathF.Max(1F, glyph * 0.3F);

if (is_valid) {
Vector2 p1 = center + new Vector2(-glyph * 0.5F, 0F);
Vector2 p2 = center + new Vector2(-glyph * 0.05F, glyph * 0.45F);
Vector2 p3 = center + new Vector2(glyph * 0.55F, -glyph * 0.5F);

draw_list.AddLine(p1, p2, color, thickness);
draw_list.AddLine(p2, p3, color, thickness);
}
else {
Vector2 half = new(glyph * 0.5F, glyph * 0.5F);

draw_list.AddLine(center - half, center + half, color, thickness);
draw_list.AddLine(
center + new Vector2(half.X, -half.Y),
center + new Vector2(-half.X, half.Y),
color,
thickness);
}

if (!string.IsNullOrWhiteSpace(tooltip) && ImGui.IsItemHovered()) {
ImGui.SetTooltip(tooltip);
}
}

// A muted variant for secondary actions that shouldn't visually compete with a
// primary button on the same row/dialog, e.g. "Browse", "Cancel", "Close".
internal static bool button_secondary(string label, Vector2 size = default) {
return _button_with_colors(
label,
size,
FhTheme.COLOR_SURFACE,
FhTheme.COLOR_SURFACE_HOVERED,
FhTheme.COLOR_SURFACE_ACTIVE);
}

// Shared by button_secondary: swaps ImGuiCol.Button/Hovered/Active for the
// duration of one ImGui.Button() call so each variant only has to name its
// three colors, not repeat the push/draw/pop dance.
private static bool _button_with_colors(
string label,
Vector2 size,
Vector4 normal_color,
Vector4 hovered_color,
Vector4 active_color) {
ImGui.PushStyleColor(ImGuiCol.Button, normal_color);
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, hovered_color);
ImGui.PushStyleColor(ImGuiCol.ButtonActive, active_color);

bool pressed = ImGui.Button(label, size);

ImGui.PopStyleColor(3);

return pressed;
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depending on @EvelynTSMG's opinion, we may want to move this up somewhere in or around the ImGuiHelper. Reusable elements shared in- and out of game seem like something we'll eventually have to get to.

Comment thread src/modmgr/launcher.cs Outdated
Comment thread src/modmgr/launcher.cs Outdated
Comment thread src/modmgr/launcher.cs Outdated
Comment thread src/modmgr/launcher.cs
Comment thread src/modmgr/launcher.cs Outdated
Comment thread src/modmgr/launcher.cs Outdated
@fkelava fkelava added this to the 1.0.0-alpha12 milestone Jul 27, 2026
Comment thread src/modmgr/icon.cs Outdated
Comment thread src/modmgr/Fahrenheit.Tools.ModManager.csproj Outdated
Replace FhLaunchTarget with FhGameId and change FhGameLauncher.launch to accept a string[] args to better support "FFX2_LASTMISSION" and "--debug".
Removed some leftover dead function calls that were left behind. Simplify most function calls to be single line (where not overwhelming). Addressing feedback regarding string concat.
Clean up formatting and make some bools have a more idiomatic written design.Simplify error messages. Refactor UI list rendering to be more compact. Small structural improvements to make the code clearer and the UI layout handling more explicit.
Removes the theme color picker UI, theme color settings storage, and theme loading logic. The mod manager will now use only the default theme instead.
Awaiting accessibility of FH Theme helper.
FhInstalledMod now holds a FhManifest instead of separate Id/Name/Version/Authors fields.
FhGameID already exists, so local definitions not needed. The import & export result types condensed into a single type since they had no unique attributes.
Replace FhLaunchResult and ImportExportResult with a single ResultsMessage record and add it to ui_helpers.cs. Update method signatures and call sites to use simple struct.
…on repetition.

Added summary tags for functions.
Local functions just makes it more readable which logic is responsible for what specifically.
Fahrenheit location can be self-derived from binary origin. If the parent directory does not contain a mods path, then check the game directory for mods and display two warning messages prompting the user to move the Fahrenheit modules.
@EvelynTSMG

Copy link
Copy Markdown
Contributor

Please click the "Resolve Conversation" button on GitHub when fulfilling a change request. It makes it much easier to scroll through the PR later. I have taken the liberty of doing so myself for the ones you reacted with 👍 to.

Comment thread src/modmgr/mods.cs
Comment on lines +50 to +57
string fahrenheit_directory = Directory.GetParent(AppContext.BaseDirectory)!.FullName;
string mods_directory = Path.Join(fahrenheit_directory, "mods");

if (!Directory.Exists(mods_directory) && Directory.Exists(Path.Join(game_directory, "fahrenheit", "mods"))) {
warnings.Add("The Fahrenheit mods directory was not found in the application directory. Checking the game directory instead.");
warnings.Add("If you installed Fahrenheit to a custom location, please move the bin directory to 'game directory/fahrenheit'");
mods_directory = Path.Join(game_directory, "fahrenheit", "mods");
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is wrong. The mod manager is emitted to bin in a normal build (and runs from there), so AppContext.BaseDirectory will be {GAME_DIR}\fahrenheit\bin, and you'll incorrectly try to find mods under {GAME_DIR}\fahrenheit\bin\mods.

Comment on lines +32 to +44
ImGui.Text("Game");
ImGui.RadioButton("Final Fantasy X##EflGame", ref _efl_import_game_index, 0);
ImGui.SameLine();
ImGui.RadioButton("Final Fantasy X-2##EflGame", ref _efl_import_game_index, 1);

ImGui.Spacing();

ImGui.Text("Mod ID");
ImGui.SetNextItemWidth(-1F);
ImGui.InputText("##EflImportModId", ref _efl_import_mod_id, MOD_ID_INPUT_LENGTH);
ImGui.Text("Mod name (optional, defaults to the ID)");
ImGui.SetNextItemWidth(-1F);
ImGui.InputText("##EflImportModName", ref _efl_import_mod_name, MOD_ID_INPUT_LENGTH);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One should also be permitted to specify an author, version, and link. All three can be text inputs as well.

Comment thread src/modmgr/packs.cs
Comment on lines +36 to +43
try {
string efl_subfolder = game == FhGameId.FFX2 ? "x2" : "x";
string efl_directory = Path.Join(mod_directory, "efl", efl_subfolder);

Directory.CreateDirectory(efl_directory);

_copy_directory(source_folder, efl_directory);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is insufficiently constrained. If the user gives the wrong folder, import will proceed regardless and the resulting mod will have no effect. An EFL mod may only contain (at its root) one or more of the following subfolders:

  • ffx_data
  • ffx_ps2
  • ffx-2_data
  • metamenu
  • version_config

Check case-insensitively.

Comment thread src/modmgr/ui_menu.cs
Comment on lines +90 to +92

Vector2 menu_cluster_max = ImGui.GetItemRectMax();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I take it this was overlooked when erasing some code. It's unused.

Comment thread src/modmgr/launcher.cs
Comment on lines +40 to +48
foreach (string required_path in new[] { stage0_path, stage1_path, game_path }) {
if (!File.Exists(required_path)) {
missing_files.Add(required_path);
}
}

if (missing_files.Count > 0) {
return new(false, $"Fahrenheit installation is incomplete.\n\nMissing files:\n{string.Join('\n', missing_files)}");
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check is well-intentioned, but a bit pointless. If fhstage1.dll or the game binary were missing, then Stage0 would surface that error. If fhstage0 were missing, the try-block beneath would catch that. Besides, quite a few more files are required for boot to properly succeed.

We can just do away with this and include a "health check" function in a follow-up PR.

Comment thread src/modmgr/mods.cs
Comment on lines +142 to +147
// guard against mod directory not existing after it was enumerated
bool directory_exists = Directory.Exists(mod_directory);

if (!directory_exists) {
return new(new FhManifest(mod_id, mod_id, "", "", "", "", [], [], FhManifestFlags.NONE), mod_directory, false, false, "The mod directory does not exist.", load_order_index);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't help. You can still get the directory deleted between this check and File.Exists(manifest_path) below. Just ditch this and take the fault at the point you try to read the manifest. "Manifest not found" does not need to clarify whether that is because the file isn't there or the entire directory was torched.

Comment thread src/modmgr/ui_actions.cs
Comment on lines +127 to +137
private static bool _take_pending<T>(ref T? pending, out T action) where T : class {
if (pending == null) {
action = null!;
return false;
}

action = pending;
pending = null;

return true;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can do away with this and replace all the usages with a snippet roughly as follows (adapt to the actual target type) - don't forget to add a global using System.Threading in usings.cs;

FhPendingModToggle? action = Interlocked.Exchange(ref _pending_mod_toggle, null);
if (action == null)
    return;

Comment thread src/modmgr/mods.cs
Comment on lines +155 to +167
try {
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(manifest_path));

JsonElement root = document.RootElement;

// Version/Authors are purely cosmetic, so they default to blank and get
// skipped by the UI; Name falls back to the mod_id
string name = _read_string(root, "Name", mod_id);
string version = _read_string(root, "Version", "0");
string authors = _read_string(root, "Authors", "Unknown");

return new(new FhManifest(mod_id, name, "", authors, version, "", [], [], FhManifestFlags.NONE), mod_directory, true, true, "", load_order_index);
}

@fkelava fkelava Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm perplexed by why this is not simply:

try {
    FhManifest manifest = JsonSerializer.Deserialize<FhManifest>(File.ReadAllText(manifest_path))
        ?? throw new Exception($"Manifest invalid or illegible: {manifest_path}");
        
    return new(manifest, // ...
}
// ... catch-block as it is

You can then override empty fields if you really care to. But I don't follow why we're creating a manifest from whole cloth when it's right there.

Comment thread src/modmgr/mods.cs
Comment on lines +176 to +188
private static string _read_string(JsonElement root, string property_name, string fallback) {
if (!root.TryGetProperty(property_name, out JsonElement property)) {
return fallback;
}

if (property.ValueKind != JsonValueKind.String) {
return fallback;
}

string? readValue = property.GetString();
return string.IsNullOrEmpty(readValue) ? fallback : readValue!;
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering the last comment about manifest deserialization, this can be removed.

Comment thread src/modmgr/settings.cs
Comment on lines +92 to +105
internal static void write_atomic(string destination, string contents) {
string temporary_path = $"{destination}.tmp";

try {
File.WriteAllText(temporary_path, contents);
File.Move(temporary_path, destination, true);
}
finally {
if (File.Exists(temporary_path)) {
File.Delete(temporary_path);
}
}
}
}

@fkelava fkelava Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem right. For one, this finally block itself can throw. Then what?

If the intended contract here is to throw on failure, then you can reduce the method to this:

string temporary_path = $"{destination}.tmp";
string backup_path    = $"{destination}.bak";

File.WriteAllText(temporary_path, contents);
File.Replace     (temporary_path, destination, backup_path);

File.Replace is the actually atomic way to do what you wanted1. As a bonus you get a backup copy.

If you want to handle throws, then wrap the latter two calls in a try with a non-throwing catch.

Footnotes

  1. https://zenn.dev/arika/articles/20251010-csharp-replace-is-atomic?locale=en

Comment thread src/modmgr/mods.cs
Comment on lines +7 to +8


Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trim instances of double empty lines anywhere they might be found to single empty lines, ex. this, L29-30 in the same file, etc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Feature A request for a specific feature.

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

3 participants