36 Commits
Author SHA1 Message Date
stefankoelle 7d9a73ebf6 moonweb.yml 2026-08-14 23:54:35 +02:00
stefankoelle 0cc389ff5d demo.sh update 2026-08-11 21:14:06 +02:00
stefankoelle 305588da8f help box sorted 2026-08-11 21:07:08 +02:00
stefankoelle bc39039c3c help box updated 2026-08-11 21:05:19 +02:00
stefankoelle d5d7cfb7c4 Fix help screen display
- Use symbols for cursor arrows (↑ / ↓, →, ←)
- Combine ESC/q into single line
- Simplify help box to grey background only
2026-08-11 21:02:53 +02:00
stefankoelle 12586262cb help box 2026-08-11 21:00:46 +02:00
stefankoelle 9c65dafdcf release v0.9.7
- Update AGENTS.md: cleanup redundant policy content
- Clean BUGS.md: remove all resolved bugs/issues
- Delete PLAN.md: completed document
- Fix InputController.cs: redraw menu after Hatari with cache invalidation
- ProgramHelpers.cs: add convenience ShowConsoleMessage overload
2026-08-11 20:44:33 +02:00
stefankoelle 03f7e5eed4 hatari running message redraw screen 2026-08-11 20:21:21 +02:00
stefankoelle 6e32223eef AGENTS.md 2026-08-11 20:17:53 +02:00
stefankoelle 2bb2b09c1d remove PLAN.md 2026-08-11 20:12:40 +02:00
stefankoelle bc9bb8e862 fix bugs 2026-08-11 20:11:23 +02:00
stefankoelle 4c7592582c release v0.9.6
## Summary

### New Features
- Improved Hatari launch configuration with {cfg} placeholder validation in args template
- Enhanced path traversal protection in overlay directory browser
- Added support for more joystick configurations
- Improved demo setup with folders G-Z

### Fixes
- Fixed hatari config file path handling to use bundled config by default
- Improved favorites service with positional constructor for better initialization
- Fixed Modal display logic for Hatari launches
- Enhanced error handling and validation throughout
- Fixed various typos and code style issues
- Improved menu rendering with better handling of console dimensions

### Documentation
- Updated README with Hatari setup instructions
- Updated AGENTS.md with enhanced release process
2026-08-11 20:08:06 +02:00
stefankoelle 5e2ab6581c README.md 2026-08-11 20:05:05 +02:00
stefankoelle e839c01a1e fix create release 2026-08-11 20:02:42 +02:00
stefankoelle 0117ba90c3 model center and wait for hatari 2026-08-11 19:55:44 +02:00
stefankoelle 9649910df6 demo.sh 2026-08-11 19:34:12 +02:00
stefankoelle 7971385beb cleanup 2026-08-11 19:31:18 +02:00
stefankoelle 410da6a98b Merge remote-tracking branch 'origin/main' 2026-08-11 19:30:56 +02:00
stefankoelle dd17b60784 launcher fixes - hatari config fixes 2026-08-11 19:30:35 +02:00
stefankoelle 5d8db1347b release v0.9.5 2026-08-11 19:30:33 +02:00
stefankoelle 7b40cbc331 launcher fixes - hatari config fixes 2026-08-11 19:22:53 +02:00
stefankoelle 4660cc6be8 release v0.9.5\n\nThis release updates the Marcer GameDVD Launcher with the following changes:\n\n#### Bug Fixes\n- Fix bundled config path resolution using Directory.GetCurrentDirectory() for better cross-platform compatibility\n\n#### Features\n- Add bundled Hatari configuration file for automatic fallback\n- Make Hatari.ConfigFile optional with automatic fallback to bundled config\n\n#### Code Improvements\n- Centralize configuration handling for better maintainability\n- Improve documentation and comments throughout\n\nThe launcher now provides improved configuration handling and bundled support for Hatari. 2026-08-11 14:51:40 +02:00
stefankoelle 148c2bb484 fix: use Directory.GetCurrentDirectory() for bundled config path 2026-08-11 14:45:27 +02:00
stefankoelle 607d0df28c release v0.9.4
### Changes

This release updates the Marcer GameDVD Launcher with the following changes:

#### Bug Fixes
- Fix the  placeholder handling when empty - now properly uses the bundled config file
- Add validation for  in constructor
- Fix  bounds checking on console resize
- Fix  key binding documentation in AGENTS.md

#### Features
- Add bundled Hatari configuration file ()
- Make  optional with automatic fallback to bundled config
- Update documentation to reflect new optional config behavior
- Add  key support (alias for Enter)

#### Documentation
- Update AGENTS.md module overview to include new RightArrow key binding
- Update README.md to clarify optional  with bundled fallback
- Create  documentation file with known issues and fixes

#### Code Improvements
- Centralize magic strings and constants
- Add  helper for console height management
- Make color scheme fully configurable via JSON
- Improve Win32 constants documentation

The launcher now provides a more robust and configurable experience with proper error handling and improved documentation.
2026-08-11 14:37:28 +02:00
stefankoelle 4b3321e812 fix 2026-08-11 14:31:30 +02:00
stefankoelle 6a4be2e397 fix 2026-08-11 14:30:55 +02:00
stefankoelle 61a8ae8457 fix 2026-08-11 14:30:49 +02:00
stefankoelle 5995a96516 fix 2026-08-11 14:30:42 +02:00
stefankoelle e704261ea4 provide hatari config with launcher 2026-08-11 14:30:27 +02:00
stefankoelle 1634ce806c hatari config 2026-08-11 14:14:55 +02:00
stefankoelle 292b642d76 some fixes 2026-08-11 14:14:12 +02:00
stefankoelle e8c43a9d52 release v0.9.3
Marcer GameDVD Launcher v0.9.3 - Major refactoring with configurable color scheme and centralized code organization

- Refactor: Decompose LauncherApp ~300-line class into separate InputController for key handling and ReloadGameEntries; Lifecycle & Main-loop remain in AppHost
- Centralize hardcoded magic strings/constants: FavoritesRootName, DefaultFileName, DefaultTitle, scroll fractions, AvailableLines helper; remove redundant ArgsTemplate fallback
- Make color scheme configurable via 'Colors' section in launcher.config.json with Enum.TryParse<ConsoleColor> + default fallback
- Update MenuRenderer: remove dead maxRow logic, unify cache sizing via EnsureCache
- Document intentional error swallowing in FavoritesService.Save/UIErrorService
- Update README + AGENTS.md with configurable color docs
- Update launcher.config.example.json with new Colors section
- Update README.md with /create-release command
2026-08-11 12:26:58 +02:00
stefankoelle 7f4df30ae7 docs: check off Magische Strings parent item 2026-08-11 01:34:20 +02:00
stefankoelle 7a75c635f6 docs: mark all PLAN.md items complete in checklist 2026-08-11 01:33:54 +02:00
stefankoelle 62968dbcd1 refactor: decompose LauncherApp, centralize magic strings/constants, configurable colors
- Extract InputController from AppHost (key handling + ReloadGameEntries)
- Centralize hardcoded values as code constants (non-configurable):
  FavoritesRootName, DefaultFileName, DefaultTitle, scroll fractions,
  AvailableLines helper, removed redundant ArgsTemplate fallback
- Make color scheme configurable via 'Colors' section in launcher.config.json
  with Enum.TryParse + default fallback; MenuRenderer uses constructor injection
- Clean up MenuRenderer: remove dead maxRow logic, unify cache sizing via EnsureCache
- Document intentional error swallowing in FavoritesService.Save/UIErrorService
- Update README + AGENTS.md with configurable color docs
- Remove redundant ArgsTemplate fallback (config is single source of truth)
2026-08-11 01:33:33 +02:00
stefankoelle 7dca82baa8 /create-release in README.md 2026-08-11 00:54:10 +02:00
21 changed files with 1062 additions and 510 deletions
+6
View File
@@ -0,0 +1,6 @@
title: "Marcer GameDVD Launcher"
emoji: "🕹️"
category: code
subcategory: "Retro"
status: active
stack: [C#, .NET, Console]
+11 -6
View File
@@ -14,22 +14,27 @@ Create a release tag and push it to origin. The GitHub Action will automatically
- Run `git status --porcelain`
- If any output, warn the user and stop (commit first)
3. Analyze changes since last release:
- Run `git log --oneline $(git describe --tags --abbrev=0 HEAD)..HEAD` to list all commits
3. Find the previous tag dynamically:
- Run `git describe --tags --abbrev=0 HEAD` to find the most recent tag before HEAD
- If no previous tag exists (first release), note this and skip to step 5
- Report the previous tag to the user (e.g. "Previous tag found: v0.9.5")
4. Analyze changes since the previous tag:
- Run `git log --oneline <PREV_TAG>..HEAD` to list all commits between the previous tag and HEAD
- Read the changed files to understand context
- Write a concise, well-structured release summary in English with:
- A one-line overview
- Bullet points for each notable change (features, fixes, breaking changes)
- Keep it developer-friendly, no fluff
4. Create release commit with the summary as message:
5. Create release commit with the summary as message:
- Run `git commit --allow-empty -m "release v$ARGUMENTS\n\n<summary>"`
- The commit message IS the release notes — the GitHub Action picks it up automatically
5. Create annotated tag on that commit:
6. Create annotated tag on that commit:
- Run `git tag -a v$ARGUMENTS -m "Release v$ARGUMENTS"`
6. Push commit and tag:
7. Push commit and tag:
- Run `git push origin main --tags` (or current branch)
7. Confirm success with the version number
8. Confirm success with the version number
+27 -33
View File
@@ -6,16 +6,16 @@ applyTo: '**'
The implementation is split into focused modules (files) under the `src/MarcerGameDvdLauncher/` folder. Keep this section up to date when files are added, removed or responsibilities change.
- MarcerGameDvdLauncher/Program.cs: Minimal entry point. Sets console title and starts the application by creating `LauncherApp`.
- MarcerGameDvdLauncher/LauncherApp.cs: Application lifecycle host — loads configuration, initializes components and runs the main directory navigation loop (contains `AppHost` internal class).
- MarcerGameDvdLauncher/AppConfiguration.cs: POCO configuration classes (`AppConfig`, `AppHatariConfig`) used to deserialize `launcher.config.json`.
- MarcerGameDvdLauncher/ProgramHelpers.cs: Small shared helpers (resolve relative paths, centralized console message helper) used across modules.
- MarcerGameDvdLauncher/Program.cs: Minimal entry point. Sets the console title (via `DefaultTitle` constant) and starts the application by creating `LauncherApp`.
- MarcerGameDvdLauncher/LauncherApp.cs: Application lifecycle host — loads configuration, initializes components and runs the main directory navigation loop (contains `AppHost` internal class). Key-handling logic is delegated to `InputController`.
- MarcerGameDvdLauncher/AppConfiguration.cs: POCO configuration classes (`AppConfig`, `AppHatariConfig`, `AppColorConfig`) used to deserialize `launcher.config.json`; color values resolved via `Enum.TryParse<ConsoleColor>` with default fallback.
- MarcerGameDvdLauncher/ProgramHelpers.cs: Small shared helpers (resolve relative paths, centralized console message helper, input buffer flushing) used across modules.
- MarcerGameDvdLauncher/OverlayDirectoryBrowser.cs: Filesystem overlay and browsing logic — merges root and patch directories, enumerates folders and ZIPs, protects against path traversal and ensures navigation cannot leave the configured roots.
- MarcerGameDvdLauncher/NavigationController.cs: Encapsulates selection, scrolling and relative-path navigation logic (cursor, page up/down, per-directory remembered selection/state).
- MarcerGameDvdLauncher/MenuRenderer.cs: Console rendering logic — efficient per-line redraw, double-buffering, color selection according to overlay rules, and the help box overlay.
- MarcerGameDvdLauncher/HatariLauncher.cs: Responsible for validating the Hatari executable and starting Hatari with the configured argument template (replaces `{cfg}` and `{zip}`).
- MarcerGameDvdLauncher/FavoritesService.cs: Manages the favorites/bookmark system — toggling favorites on ZIPs, persisting them to `favorites.txt`, and providing the virtual `Favorites` folder view.
- MarcerGameDvdLauncher/DirectoryService.cs: Filesystem service layer — enumerates directories and ZIPs, resolves paths, and provides the underlying I/O operations used by OverlayDirectoryBrowser.
- MarcerGameDvdLauncher/NavigationController.cs: Encapsulates selection, scrolling and relative-path navigation logic (cursor, page up/down, per-directory remembered selection/state); uses named scroll-fraction constants.
- MarcerGameDvdLauncher/MenuRenderer.cs: Console rendering logic — efficient per-line redraw, double-buffering, configurable color selection via injected `AppColorConfig`, and the help box overlay.
- MarcerGameDvdLauncher/InputController.cs: Handles key events (arrow keys, Enter, Backspace, PageUp/Down, `*`, `?`, ESC, RightArrow) and the associated navigation/drawing logic; owns `ReloadGameEntries` and the virtual `Favorites` folder integration.
- MarcerGameDvdLauncher/HatariLauncher.cs: Responsible for validating the Hatari executable and starting Hatari with the configured argument template (replaces `{cfg}` and `{zip}`). Exposes `DefaultConfigFile` constant (`MarcerGameDvd-Hatari.cfg`).
- MarcerGameDvdLauncher/FavoritesService.cs: Manages the favorites/bookmark system — toggling favorites on ZIPs, persisting them to `favorites.txt` (via `DefaultFileName` constant), and providing the virtual `Favorites` folder view (via `FavoritesRootName` constant).
- MarcerGameDvdLauncher/UIErrorService.cs: Centralized UI error presentation using the console message helper.
Note: This overview is intentionally concise. For behavioral changes (navigation, color scheme, launch command or config schema), update this file (AGENTS.md) and README.md as required by project policy.
@@ -32,15 +32,6 @@ The Launcher cannot be executed or tested via `scripts/start.cmd` from this envi
4. The GitHub Action handles the rest (build, ZIP, release notes, GitHub Release).
- Local release artifacts in `release/` are optional and no longer required for the release process.
Additional policy:
- README.md must be written in English. Any functional change that affects usage, configuration, or behavior MUST update README.md in English immediately after the change. If there are consequential changes to developer-facing policies, build steps, or requirements, AGENTS.md must be updated as well.
Developer note: Visual Studio Solution
- A Visual Studio solution file exists at `src/marcer-gamedvd-launcher.sln`. Developers may open this solution in Visual Studio to work on the project, debug and build from the IDE. The solution references `MarcerGameDvdLauncher\MarcerGameDvdLauncher.csproj` and includes Debug and Release configurations. Use `scripts/build.cmd` (Windows) or `scripts/build.sh` (Linux/macOS) and `scripts/start.cmd` (Windows) or `scripts/start.sh` (Linux/macOS) for consistent command-line builds/releases as described elsewhere in this document.
With this, it is ensured that binary/release files never end up in git, and the release process is always traceable and performed exclusively manually in the web interface.
# Requirements for the Marcer GameDVD Launcher (AGENTS.md)
## Basic Function / Purpose
@@ -55,14 +46,14 @@ The console launcher is meant for browsing a games directory and can launch ZIP
- Only display subfolders and ZIP files; ignore other file types and hidden files.
- Navigation and control exclusively with these keys:
- Arrow down/up: scroll by single entries
- Enter: open folder or launch ZIP with Hatari
- Backspace: jump to parent directory (never outside root)
- Enter / RightArrow: open folder or launch ZIP with Hatari
- Backspace / LeftArrow: jump to parent directory (never outside root)
- ESC: exit the program
- PageUp/PageDown: jump by one page up/down through the file list
- `?`: show a help box with key bindings
- The file list always shows exactly as many lines as fit the screen ALWAYS **one line less** than the console height (`Console.WindowHeight - 1`). This avoids overflow at the bottom and ensures the selection never enters the non-visible area.
Rationale: writing to the very last console line can cause the Windows console to auto-scroll or produce visual jumps when the cursor reaches the bottom row. Reserving one line prevents unintended scrolling/flicker and keeps the selection cursor strictly within the visible area.
Maintenance: when changing rendering or navigation logic, always compute the displayed page size as `availableLines = Console.WindowHeight - 1` and keep this value consistent across MenuRenderer, NavigationController and any other code that references the console height.
Maintenance: when changing rendering or navigation logic, always compute the displayed page size as `availableLines = ProgramHelpers.AvailableLines` (which resolves to `Console.WindowHeight - 1`) and keep this value consistent across MenuRenderer, NavigationController and any other code that references the console height.
- There is no information line/path display.
- Cursor and scroll logic:
- The selection cursor must always remain in the visible area.
@@ -71,6 +62,7 @@ The console launcher is meant for browsing a games directory and can launch ZIP
### Launching a Game
- If Enter is pressed on a ZIP, Hatari is called with the configured executable, config file, and the full path to the ZIP file as arguments.
- `Hatari.ConfigFile` is optional. When empty, the bundled `MarcerGameDvd-Hatari.cfg` (shipped with the launcher) is used automatically.
### Error Handling & Edge Cases
- Paths with special characters or spaces must work robustly.
@@ -78,7 +70,7 @@ The console launcher is meant for browsing a games directory and can launch ZIP
- Empty directories must be displayed correctly (or reported correctly).
- In the root directory, Backspace must have no effect (no error, do not leave the program).
- Navigation (Backspace, Enter, etc.) must remain robust even for very deep or large directory trees.
- Hatari.Executable is validated during startup: the path is resolved (relative to the EXE directory when applicable) and must point to an existing file. If validation fails the program must present a clear error and exit.
- Hatari.Executable is validated during startup: the path is resolved (relative to the EXE directory when applicable) and must point to an existing file. If validation fails the program must present a clear error and exit.
### Miscellaneous
- Build and start scripts (`scripts/build.cmd` / `scripts/build.sh` / `scripts/start.cmd` / `scripts/start.sh`) are present and must be used.
@@ -86,9 +78,8 @@ The console launcher is meant for browsing a games directory and can launch ZIP
- After making any code changes that affect behavior or touch source files, run the platform build script (`scripts/build.cmd` on Windows, `scripts/build.sh` on Linux/macOS) and ensure the build completes successfully before committing. Additionally, perform a manual functional test on a Windows, Linux, or macOS machine prior to pushing a release.
- The console window can have any number of lines; display/navigation must adapt dynamically.
- For every release, a Release Notes file must be maintained that summarizes all changes, bugfixes, and new features in that version; Release Notes must be provided with the release asset.
- **IMPORTANT:** With any functional change to the launcher, BOTH this file (AGENTS.md) AND the README.md must always be updated and kept current. Immediately after, a successful build must be executed. This is mandatory for all development on the project.
- README.md must be written in English. Any functional change that affects usage, configuration, or behavior MUST update README.md in English immediately after the change. If there are consequential changes to developer-facing policies, build steps, or requirements, AGENTS.md must be updated as well.
- **IMPORTANT:** With any functional change to the launcher, BOTH this file (AGENTS.md) AND the README.md must always be updated and kept current. Immediately after, a successful build must be executed. This is mandatory for all development on the project.
---
@@ -114,15 +105,18 @@ Each entry is displayed with a left label indicating its layer status:
- **`[ROOT]`**: Entry exists only in main (root) layer
- **`[PTCH]`**: Entry exists only in patch layer
Color mapping:
- Folder in both layers: **ConsoleColor.Yellow** (`[BOTH]`)
- Folder only in patch layer: **ConsoleColor.DarkYellow** (`[PTCH]`)
- Folder only in main layer: **ConsoleColor.Gray** (`[ROOT]`)
- ZIP in both layers: **ConsoleColor.Green** (`[BOTH]`)
- ZIP only in main layer: **ConsoleColor.DarkGreen** (`[ROOT]`)
- ZIP only in patch layer: **ConsoleColor.Magenta** (`[PTCH]`)
Color mapping (now configurable via the `Colors` section in `launcher.config.json`; defaults shown below):
- Folder in both layers: **Yellow** (`[BOTH]`)`FolderBoth`
- Folder only in patch layer: **DarkYellow** (`[PTCH]`)`FolderPatchOnly`
- Folder only in main layer: **Gray** (`[ROOT]`)`FolderRootOnly`
- ZIP in both layers: **Green** (`[BOTH]`)`ZipBoth`
- ZIP only in main layer: **DarkGreen** (`[ROOT]`)`ZipRootOnly`
- ZIP only in patch layer: **Magenta** (`[PTCH]`)`ZipPatchOnly`
- Selected entry foreground: **Black**`SelectedForeground`
- Selected entry background: **DarkCyan**`SelectedBackground`
- Virtual entry (Favorites pseudo-folder): **White**`VirtualEntry`
Note: The ConsoleColor mapping above is authoritative for the application. If you change color values in code (MenuRenderer/GetColorForEntry), update this section to keep documentation and implementation in sync.
Note: Color values are resolved in `MenuRenderer` from the injected `AppColorConfig` (populated in `LauncherApp.LoadConfiguration` via `Enum.TryParse<ConsoleColor>` with default fallback). Invalid or missing values fall back to the defaults above. If you change default color values in `AppColorConfig`, update this section and the README color tables to keep documentation and implementation in sync.
### Navigation
- Navigation is always based strictly on the **relative path from root** and is consistent on all levels (Backspace always moves up one level, Enter always moves one level deeper, regardless of which layer).
+46
View File
@@ -0,0 +1,46 @@
# Known Bugs & Issues
## Critical Bugs
_No open critical bugs._
## Medium Bugs
_No open medium bugs._
## Documentation Inconsistencies
_No open documentation inconsistencies._
## Code Quality / Minor
_No open code quality issues._
---
## Fixed / Non-Issues (Verified)
| Item | Status | Notes |
|------|--------|-------|
| `demo.sh` location | ✅ OK | Exists at repo root, README link correct |
| Magic strings centralized | ✅ Done | `FavoritesRootName`, `DefaultFileName`, `DefaultTitle` |
| Scroll fractions as constants | ✅ Done | `BottomScrollFraction`, `TopScrollFraction` |
| `AvailableLines` helper | ✅ Done | `ProgramHelpers.AvailableLines` |
| Colors configurable | ✅ Done | `Colors` section in config |
| Win32 constants documented | ✅ Done | Marked "intentionally hardcoded" |
| `Hatari.ConfigFile` optional with bundled fallback | ✅ Done | Empty ConfigFile falls back to bundled `MarcerGameDvd-Hatari.cfg` |
| `FavoritesService.Save()` swallow comment | ✅ Done | Explains intentional behavior |
| `UIErrorService` no-rethrow doc | ✅ Done | Explains design decision |
| ArgsTemplate `{cfg}` empty ConfigFile fallback | ✅ Done | `HatariLauncher.Launch()` falls back to bundled config |
| `MenuRenderer.RedrawEntry()` resize bounds check | ✅ Done | Row validated against `availableLines` + `Console.WindowHeight`, catch in `WriteConsoleLine` |
| `HatariLauncher` ArgsTemplate validation | ✅ Done | Constructor validates both `{zip}` and `{cfg}` placeholders |
| ZIP launch file existence check | ✅ Done | `File.Exists` defense-in-depth in `InputController` |
| `RightArrow`/`LeftArrow` in AGENTS.md | ✅ Done | Added to key bindings |
| `OverlayDirectoryBrowser` path traversal | ✅ Non-issue | `..` segments filtered at entry, `IsSubPathOf` as additional safety net |
| `ShowConsoleMessage` params overload | ✅ Done | Added convenience overload with flat argument list |
---
## Priority Recommendation
All identified issues have been resolved. See the Fixed table above for details.
-97
View File
@@ -1,97 +0,0 @@
# PLAN
Ziel: Das Repo aufräumen (Doku, Code, Config)
## 1. Code-Qualität Schwächen beheben (harmlos, rein refaktoriell)
- [ ] **LauncherApp.cs zerlegen:** Die ~300-Zeilen-Klasse in separate Klassen aufteilen
(z.B. `InputController`/`KeyHandler` für den switch-Block, Lifecycle bleibt in LauncherApp).
Ziel: keine Funktionsänderung, nur Struktur.
- [ ] **Magische Strings entfernen / zentralisieren (nur im Code, siehe 2c):**
- [ ] Virtueller Ordnername `"Favorites"` (LauncherApp.cs) → gemeinsame Konstante (nicht konfigurierbar).
- [ ] Dateiname `"favorites.txt"` (LauncherApp.cs) → Konstante in FavoritesService (nicht konfigurierbar).
- [ ] Console-Titel `"Marcer GameDVD Launcher"` (Program.cs) → Konstante (nicht konfigurierbar).
- [ ] Default-ArgsTemplate `-c "{cfg}" --disk-a "{zip}"` (LauncherApp.cs) → redundanten Fallback
entfernen; Config liefert den Template (ist schon in der example definiert, Validation `{zip}` existiert).
- [ ] **Scroll-Trigger magische Zahlen** (NavigationController.cs: `2/3`, `1/3`) in benannte Konstanten
(z.B. `BottomScrollFraction = 2f/3f`) mit Kommentar aufzählen.
- [ ] Redundanz beseitigen: `RedrawEntry` hat unnötige `maxRow`-Logik; `EnsureCacheForRow` ungenau →
klarer formulieren.
- [ ] **Fehler-Schlucken besprechen:** `FavoritesService.Save()` (catch leer) und `UIErrorService`
entweder Kommentar ergänzen („bewusst still") oder Rückgabewert einführen.
- [ ] (Optional) Testprojekt hinzufügen für NavigationController & OverlayDirectoryBrowser
vorab mit Nutzer klären, da Policy bisher keine Tests vorsieht.
---
## 2. Hardcoded-Werte Aufgaben
### 2a. Colorschema konfigurierbar machen (entschieden)
- Aktuell: `MenuRenderer.cs` (`GetColorForEntry`) und README/agents-Doku (6 Entry-Typen +
Auswahl-Schema schwarz/blau).
- Ziel: Farben (Foreground pro Entry-Typ + Selection-Farben) in `launcher.config.json` konfigurierbar
machen (**Entscheidung: ja, konfigurierbar**), mit Default-Fallback auf heutige Werte.
- [ ] `AppConfig` erweitern: neuen Abschnitt z.B. `"Colors"` hinzufügen:
- [ ] POCO `AppColorConfig` mit `ConsoleColor`-Werten (als String, z.B. `"Yellow"`):
- FolderBoth, FolderPatchOnly, FolderRootOnly,
ZipBoth, ZipRootOnly, ZipPatchOnly,
SelectedForeground, SelectedBackground, VirtualEntry(vorab optional).
- [ ] Deserialisierung per `Enum.TryParse<ConsoleColor>` + Default-Fallback.
- [ ] `MenuRenderer` bekommt optional passende `AppColorConfig` (Konstruktor-Injection);
`GetColorForEntry`/`GetColors` nutzen Config statt Konstanten.
- [ ] Falls Config-Werte fehlen → heutiges Verhalten beibehalten (fallback).
- [ ] Doku synchronisieren: README + AGENTS.md Farbtabellen auf Config-Felder verlinken.
- [ ] Beispielwerte ins `launcher.config.example.json` aufnehmen.
### 2b. Benutzer-Config (User-Config) entschieden
Ziel: die echte lokale Config gehört NICHT ins Repo, sondern bleibt lokal.
- [ ] **Entscheidung getroffen (Variante 1):** `launcher.config.json` bleibt im Build-Output/EXE-Ordner
(lokal, gitignored). Die `launcher.config.example.json` wird beim Release mitgeliefert und der
Nutzer passt sie sich manuell an → kopieren zu `launcher.config.json`.
- [ ] KEINE zusätzliche Suchreihenfolge (`%APPDATA%`, `~/.config`) implementieren.
- [ ] KEIN CLI-Parameter `--config` einführen.
- [ ] `.gitignore` entsprechend ergänzen (echte `launcher.config.json` + `favorites.txt` werden nie committet).
- [ ] README: Abschnitt „Configuration" beschreibt nur das manuelle Kopieren der example.
### 2c. Platzhalterwerte zentralisieren (sehr detailliert)
**Was bedeutet das Problem?**
Einige Werte stehen direkt („hartkodiert") im Code statt an einer zentralen Stelle. Bei Wiederverwendung
oder Änderung des Verhaltens sucht man sie an mehreren Stellen im Quelltext — und es passieren schnell
Inkonsistenzen. Die folgenden Werte sind betroffen:
| # | Wert | Aktuelle Stelle | Was es tut | Geplanter Fix |
|---|------|-----------------|------------|---------------|
| 1 | `"Favorites"` | `LauncherApp.cs` (2×) | Virtueller Ordnername, der dem Nutzer als Eintrag an der Wurzel angezeigt wird. Wenn zwei voneinander abweichende Literale im Codestand bleiben, funktioniert Enter/Anzeige dieses Ordners nicht mehr. | Eine einzige Konstante `const string FavoritesRootName = "Favorites";` (z.B. in `FavoritesService`); beide Verwendungen darauf zurückführen. **Nur Code-Konstante, nicht konfigurierbar.** |
| 2 | `"favorites.txt"` | `LauncherApp.cs` | Dateiname der Favoriten-Persistenz (liegt im Patch-Dir oder neben EXE). | Konstante im `FavoritesService` (z.B. `public const string DefaultFileName = "favorites.txt";`); Aufrufer nutzen diese statt Literal. **Nur Code-Konstante, nicht konfigurierbar.** |
| 3 | `"Marcer GameDVD Launcher"` | `Program.cs` (`Console.Title`) | Konsolen-Titel, der im Fenstertitel erscheint. | Konstante `const string DefaultTitle = "Marcer GameDVD Launcher";` (Nutzer-visible, aber **nicht konfigurierbar** — fixer Anzeige-Titel). |
| 4 | Default-ArgsTemplate `-c "{cfg}" --disk-a "{zip}"` | `LauncherApp.cs` | Redundanter Fallback, falls `Hatari.ArgsTemplate` in der Config nicht gesetzt ist. Ist bereits in der example definiert → das Hardcode ist nur ein Sicherheitsnetz. | Redundanz entfernen: Fallback streichen und erzwingen, dass die Config den Template liefert (Validation `{zip}` existiert schon). **Config-Feld bleibt die einzige Quelle.** |
| 5 | Scroll-Anteile `2/3` und `1/3` | `NavigationController.cs` | Bestimmt, ab welcher relativen Position im sichtbaren Fenster automatisch gescrollt wird (Cursor bei 2/3 unten → scrollen, bei 1/3 oben → zurückscrollen). | Benannte Konstanten `BottomScrollTriggerFraction = 2f/3f` und `TopScrollTriggerFraction = 1f/3f` mit Kommentar; Logik bleibt identisch. **Nur Code-Konstanten, nicht konfigurierbar.** |
| 6 | `WindowHeight - 1` / `WindowWidth` | `LauncherApp.cs` / `MenuRenderer.cs` | Die Liste ist immer eine Zeile weniger als die Konsolenhöhe (Policy, verhindert Auto-Scroll am Fensterrand). Wird an mehreren Stellen erneut berechnet. | Zentrale Helfer-Berechnung (z.B. statische Methode/Property `AvailableLines`), damit die Policy an genau einer Stelle kodiert ist; Logik unverändert (Resize-Handling bleibt). **Nur Code, nicht konfigurierbar.** |
| 7 | `STD_INPUT_HANDLE = -10`, `VK_RETURN = 0x0D` | `ProgramHelpers.cs` | Win32-Konstanten für FlushConsoleInputBuffer bzw. GetAsyncKeyState. Diese sind per Definition konstant. | KEINE Änderung nötig — nur per Kommentar als „bewusst hartkodiert" markieren (Win32-API-Konstanten). |
**Entscheidung:** Alle o.g. Werte werden **nur im Code zentralisiert** (Konstanten/Helfer).
Sie werden **NICHT** als Konfigurationsfelder in `launcher.config.json` angeboten — es gibt keine
externe Anpassungsmöglichkeit dafür.
**Verbindliche To-dos für 2c:**
- [ ] Konstanten/Helfer einführen für: Favorites-Ordnername, `favorites.txt`, Konsolen-Titel,
Scroll-Anteile, `AvailableLines`-Helfer.
- [ ] Default-ArgsTemplate-Fallback entfernen; Config muss `ArgsTemplate` immer liefern.
- [ ] Alle vorkommenden String-Literale auf die neuen Konstanten zurückführen (kein doppeltes `"Favorites"` mehr).
- [ ] Kommentare ergänzen, die erklären, WARUM der Wert fest ist (z.B. `WindowHeight-1` als Policy).
- [ ] Win32-Konstanten (Punkt 7) NICHT anfassen, nur per Kommentar als „bewusst hartkodiert" markieren.
- [ ] README/agents nicht um diese rein implementativen Werte erweitern (keine Config-Felder dokumentieren);
Doku bleibt bei nutzersichtbaren, konfigurierbaren Werten (Farben, Titel, RootDir/Patch/Hatari).
---
## 4. Abschlusskriterien
- [ ] Commit mit aussagekräftiger Message (nur echter Autor, kein Co-Author).
- [ ] Nach Doku- und Code-Änderungen: `scripts/build.cmd` (Windows) bzw. `scripts/build.sh` (Linux) läuft fehlerfrei.
+77 -6
View File
@@ -16,7 +16,9 @@ Built for the [Marcer GameDVD](https://www.facebook.com/groups/360493904888475/)
### Color Scheme
| Entry | Label | Color | Meaning |
Colors are configurable via the `Colors` section in `launcher.config.json` (see [Configuration](#-configuration)). Defaults are shown below:
| Entry | Label | Default Color | Meaning |
|---|---|---|---|
| Folder in both layers | `[BOTH]` | Yellow | Exists in main + patch |
| Patch-only folder | `[PTCH]` | DarkYellow | Only in patch layer |
@@ -24,6 +26,8 @@ Built for the [Marcer GameDVD](https://www.facebook.com/groups/360493904888475/)
| ZIP in both layers | `[BOTH]` | Green | Exists in main + patch |
| Main-only ZIP | `[ROOT]` | DarkGreen | Only in main layer |
| Patch-only ZIP | `[PTCH]` | Magenta | Only in patch layer |
| Selected entry | — | Black on DarkCyan | Highlighted entry |
| Virtual entry (Favorites) | — | White | Pseudo-folder |
---
@@ -42,9 +46,33 @@ Download the ZIP for your platform from the [Releases](https://github.com/anomal
### 💻 System Requirements
- **.NET Runtime 10** or later ([download](https://dotnet.microsoft.com/download/dotnet/10.0))
- **Hatari Emulator** with a working configuration file
- Windows: native Hatari
- Linux / macOS: Hatari via Wine or native build
- **Hatari Emulator** (Windows native, or Linux/macOS via Wine or native build) — a Hatari configuration file (`MarcerGameDvd-Hatari.cfg`) is bundled with the launcher and used automatically when `Hatari.ConfigFile` is empty.
- **TOS 1.04** ROM image (`TOS.IMG`) — required by Hatari to boot the Atari ST system.
### 🕹️ Setting Up Hatari
Before using the launcher, you need to install Hatari and provide a TOS ROM:
1. **Download Hatari** from the official site: [https://hatari.tuxfamily.org/download.html](https://hatari.tuxfamily.org/download.html)
- **Windows:** Download and extract the latest Windows binary.
- **Linux:** Install via your package manager (e.g. `sudo apt install hatari`) or build from source.
- **macOS:** Download the macOS build or install via Homebrew: `brew install hatari`.
2. **Get TOS 1.04** (also known as TOS UK or TOS 1.04):
- TOS is copyrighted Atari ROM software — you must own a legal copy.
- Place the ROM file as `TOS.IMG` in the same directory as the launcher executable (or the Hatari working directory).
- The bundled `MarcerGameDvd-Hatari.cfg` is preconfigured to look for `TOS.IMG` in the current directory.
3. **Configure the path** in `launcher.config.json`:
```json
"Hatari": {
"Executable": "C:\\Tools\\hatari\\hatari.exe",
"ConfigFile": "",
"ArgsTemplate": "-c \"{cfg}\" --disk-a \"{zip}\""
}
```
- Set `Hatari.Executable` to the full path of your `hatari` (or `hatari.exe`) binary.
- Leave `Hatari.ConfigFile` empty to use the bundled configuration automatically.
### ⚡ Quick Start
@@ -67,8 +95,19 @@ The launcher reads `launcher.config.json` from the same directory as the executa
"PatchDirectory": "C:\\Games\\Hatari\\PATCH",
"Hatari": {
"Executable": "C:\\Tools\\hatari\\hatari.exe",
"ConfigFile": "C:\\Tools\\hatari\\hatari-st.cfg",
"ConfigFile": "",
"ArgsTemplate": "-c \"{cfg}\" --disk-a \"{zip}\""
},
"Colors": {
"FolderBoth": "Yellow",
"FolderPatchOnly": "DarkYellow",
"FolderRootOnly": "Gray",
"ZipBoth": "Green",
"ZipRootOnly": "DarkGreen",
"ZipPatchOnly": "Magenta",
"SelectedForeground": "Black",
"SelectedBackground": "DarkCyan",
"VirtualEntry": "White"
}
}
```
@@ -78,8 +117,30 @@ The launcher reads `launcher.config.json` from the same directory as the executa
| `RootDirectory` | ✅ | Game root folder. Navigation never leaves this directory. |
| `PatchDirectory` | ❌ | Optional overlay/patch directory merged at runtime. |
| `Hatari.Executable` | ✅ | Path to the Hatari executable. Validated at startup. |
| `Hatari.ConfigFile` | | Path to the Hatari configuration file. |
| `Hatari.ConfigFile` | | Path to a Hatari configuration file. If empty, the bundled `MarcerGameDvd-Hatari.cfg` (shipped with the launcher) is used automatically. |
| `Hatari.ArgsTemplate` | ✅ | Argument template. Must contain `{zip}`, optionally `{cfg}`. |
| `Colors` | ❌ | Optional color overrides. See [Color Scheme](#-color-scheme) below. Missing or invalid values fall back to defaults. |
**Notes:**
- Relative paths are resolved relative to the executable's directory.
- Include quotes around `{cfg}` and `{zip}` in the template if your paths contain spaces.
- After editing `launcher.config.json`, restart the application.
### 🎨 Color Scheme
Colors are fully configurable via the `Colors` section of `launcher.config.json`. Each field accepts a [ConsoleColor](https://learn.microsoft.com/dotnet/api/system.consolecolor) name (case-insensitive). Omitting the entire `Colors` section — or any individual field — falls back to the built-in defaults:
| Field | Default | Applies to |
|---|---|---|
| `FolderBoth` | Yellow | Folders in both layers |
| `FolderPatchOnly` | DarkYellow | Folders in patch only |
| `FolderRootOnly` | Gray | Folders in root only |
| `ZipBoth` | Green | ZIPs in both layers |
| `ZipRootOnly` | DarkGreen | ZIPs in root only |
| `ZipPatchOnly` | Magenta | ZIPs in patch only |
| `SelectedForeground` | Black | Foreground for the highlighted entry |
| `SelectedBackground` | DarkCyan | Background for the highlighted entry |
| `VirtualEntry` | White | Virtual entries (e.g. the Favorites pseudo-folder) |
**Notes:**
- Relative paths are resolved relative to the executable's directory.
@@ -183,6 +244,16 @@ This builds the project, generates a structured `.demo/` directory with sample f
Releases are automated via GitHub Actions (`.github/workflows/release.yml`).
**To create a release:**
You can use the `/create-release` command (via opencode) for a fully automated flow:
```text
/create-release 1.0.0
```
This command validates the version, checks for uncommitted changes, generates a release summary from recent commits, creates a release commit with the summary as the commit message, tags it, and pushes — all in one step. The GitHub Action then builds platform ZIPs and creates the GitHub Release.
**Manual release:**
1. Ensure `README.md` and `AGENTS.md` are up to date.
2. Commit all changes.
3. Tag and push:
+9 -3
View File
@@ -13,7 +13,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DEMO_DIR="$SCRIPT_DIR/.demo"
ROOT_DIR="$DEMO_DIR/root"
PATCH_DIR="$DEMO_DIR/patch"
CONFIG_FILE="$SCRIPT_DIR/launcher.config.json"
CONFIG_FILE="$DEMO_DIR/launcher.config.json"
# --- Step 1: Build ---
echo "=== Building MarcerGameDvdLauncher ==="
@@ -83,6 +83,11 @@ fake_zip "$ROOT_DIR/E/Racing/Pole Position.zip"
fake_zip "$ROOT_DIR/E/Racing/Out Run.zip"
fake_zip "$ROOT_DIR/E/Racing/Daytona USA.zip"
# === Folders G-Z: empty placeholder folders ===
for letter in {G..Z}; do
mkdir -p "$ROOT_DIR/$letter"
done
# --- PATCH layer (overlay/additions) ---
echo "Creating patch layer..."
@@ -160,7 +165,8 @@ echo "=== Writing launcher.config.json ==="
HATARI_FAKE="$DEMO_DIR/hatari.exe"
cat > "$HATARI_FAKE" <<'HATEXEC'
#!/bin/bash
echo "[DEMO] Hatari would launch with: $@"
echo "[DEMO] Hatari would launch with: $0 $@"
read -n 1 -r -p $'Press any key to continue... '
HATEXEC
chmod +x "$HATARI_FAKE"
@@ -171,7 +177,7 @@ cat > "$CONFIG_FILE" <<EOF
"Hatari": {
"Executable": "$HATARI_FAKE",
"ConfigFile": "",
"ArgsTemplate": "{zip}"
"ArgsTemplate": "-c \"{cfg}\" --disk-a \"{zip}\""
}
}
EOF
@@ -4,6 +4,25 @@
namespace MarcerGameDvdLauncher
{
// Configuration POCOs separated into their own file for clarity
/// <summary>
/// Color configuration for the menu renderer.
/// All properties have default values matching the original hardcoded scheme,
/// so omitting any value (or the entire "Colors" section) preserves existing behaviour.
/// </summary>
public class AppColorConfig
{
public ConsoleColor FolderBoth { get; set; } = ConsoleColor.Yellow;
public ConsoleColor FolderPatchOnly { get; set; } = ConsoleColor.DarkYellow;
public ConsoleColor FolderRootOnly { get; set; } = ConsoleColor.Gray;
public ConsoleColor ZipBoth { get; set; } = ConsoleColor.Green;
public ConsoleColor ZipRootOnly { get; set; } = ConsoleColor.DarkGreen;
public ConsoleColor ZipPatchOnly { get; set; } = ConsoleColor.Magenta;
public ConsoleColor SelectedForeground { get; set; } = ConsoleColor.Black;
public ConsoleColor SelectedBackground { get; set; } = ConsoleColor.DarkCyan;
public ConsoleColor VirtualEntry { get; set; } = ConsoleColor.White;
}
public class AppHatariConfig
{
public string? Executable { get; set; }
@@ -16,5 +35,9 @@ namespace MarcerGameDvdLauncher
public string? RootDirectory { get; set; }
public string? PatchDirectory { get; set; }
public AppHatariConfig? Hatari { get; set; }
// Ignored during JSON deserialization — parsed manually in LoadConfiguration so that
// invalid color strings fall back to defaults instead of throwing.
[System.Text.Json.Serialization.JsonIgnore]
public AppColorConfig? Colors { get; set; }
}
}
+17 -11
View File
@@ -4,17 +4,20 @@
namespace MarcerGameDvdLauncher
{
// Manages loading, saving and querying favorite ZIP paths.
public class FavoritesService
public class FavoritesService(string filePath)
{
private readonly string _filePath;
// Virtual folder name displayed at the root when favorites exist.
// Not configurable — fixed UI element.
public const string FavoritesRootName = "Favorites";
// Filename used for persisting favorites to disk.
// Not configurable — fixed persistence file.
public const string DefaultFileName = "favorites.txt";
private readonly string _filePath = filePath ?? throw new ArgumentNullException(nameof(filePath));
// Use a SortedSet so favorites are kept in sorted order in memory.
private SortedSet<string> _favorites = new(StringComparer.OrdinalIgnoreCase);
public FavoritesService(string filePath)
{
_filePath = filePath ?? throw new ArgumentNullException(nameof(filePath));
}
// Load favorites from disk (no-op if file missing)
public void Load()
{
@@ -23,7 +26,7 @@ namespace MarcerGameDvdLauncher
var lines = File.ReadAllLines(_filePath, System.Text.Encoding.UTF8);
foreach (var l in lines)
{
var t = l?.Trim();
var t = l.Trim();
if (string.IsNullOrEmpty(t)) continue;
try
{
@@ -63,14 +66,13 @@ namespace MarcerGameDvdLauncher
if (string.IsNullOrWhiteSpace(path)) throw new ArgumentNullException(nameof(path));
var full = Path.GetFullPath(path);
bool added;
if (_favorites.Contains(full))
if (!_favorites.Add(full))
{
_favorites.Remove(full);
added = false;
}
else
{
_favorites.Add(full);
added = true;
}
Save();
@@ -87,7 +89,11 @@ namespace MarcerGameDvdLauncher
}
catch
{
// Let callers surface errors (we swallow here to avoid throwing on write failures during UI operations)
// Swallowed intentionally ("bewusst still"): a persistence failure
// (e.g. disk full, read-only directory) must not crash or interrupt
// the UI. The in-memory state is still updated so the user sees
// immediate feedback; only the on-disk write is lost. On next
// application start the favorites reflect the last successful save.
}
}
}
+19 -7
View File
@@ -5,6 +5,11 @@ namespace MarcerGameDvdLauncher
{
public class HatariLauncher
{
// The Hatari configuration file that is shipped with the launcher.
// When Hatari.ConfigFile is not set in launcher.config.json, this file
// (resolved relative to the executable directory) is used automatically.
private const string DEFAULT_CONFIG_FILE = "MarcerGameDvd-Hatari.cfg";
private readonly string _exePath;
private readonly string _cfgPath;
private readonly string _argsTemplate;
@@ -18,6 +23,12 @@ namespace MarcerGameDvdLauncher
if (!File.Exists(exePath))
throw new ArgumentException($"Hatari executable not found: {exePath}", nameof(exePath));
// Validate argsTemplate
if (string.IsNullOrWhiteSpace(argsTemplate) || !argsTemplate.Contains("{zip}"))
throw new ArgumentException("Hatari.ArgsTemplate must contain the {zip} placeholder.", nameof(argsTemplate));
if (string.IsNullOrWhiteSpace(argsTemplate) || !argsTemplate.Contains("{cfg}"))
throw new ArgumentException("Hatari.ArgsTemplate must contain the {cfg} placeholder.", nameof(argsTemplate));
_exePath = exePath;
_cfgPath = cfgPath;
_argsTemplate = argsTemplate;
@@ -33,20 +44,21 @@ namespace MarcerGameDvdLauncher
throw new ArgumentException("ZIP archive path must not be empty.", nameof(zipFilePath));
try
{
string args = _argsTemplate.Replace("{cfg}", _cfgPath).Replace("{zip}", zipFilePath);
var args = _argsTemplate;
args = args.Replace("{cfg}", !string.IsNullOrWhiteSpace(_cfgPath) ? _cfgPath : Path.Combine(Directory.GetCurrentDirectory(),DEFAULT_CONFIG_FILE));
args = args.Replace("{zip}", zipFilePath);
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = _exePath,
Arguments = args,
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(_exePath) ?? string.Empty
WorkingDirectory = Directory.GetCurrentDirectory()
};
System.Diagnostics.Process.Start(psi);
// Show a modal indicating the emulator was started and wait until
// the user releases the Return key before clearing the modal. This
// prevents accidental key repeats from triggering other actions.
ProgramHelpers.ShowModalUntilReturnReleased("Hatari started. Release Return to continue...");
var process = System.Diagnostics.Process.Start(psi);
// Show a modal indicating the emulator is running and wait until
// the process has exited before clearing the modal.
ProgramHelpers.ShowModalUntilProcessExited(process, "Hatari is running...");
}
catch (Exception ex)
{
@@ -0,0 +1,245 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
namespace MarcerGameDvdLauncher
{
/// <summary>
/// Handles user input (key events) and the associated navigation / drawing logic.
/// Extracted from AppHost so that LauncherApp stays focused on lifecycle management.
/// No functional change - all key-handling behaviour is preserved exactly.
/// </summary>
internal class InputController(
OverlayDirectoryBrowser directoryBrowser,
MenuRenderer menuRenderer,
NavigationController navigationController,
HatariLauncher hatariLauncher,
FavoritesService favoritesService,
UIErrorService errorService)
{
private List<GameEntry> _gameEntries = new();
private bool IsFavorite(GameEntry e)
{
return e.Kind == EntryKind.Zip && favoritesService.IsFavorite(e.InPatch ? e.PatchPath : e.RootPath);
}
private void DrawMenu(int availableLines)
{
menuRenderer.DrawMenu(_gameEntries, navigationController.ScrollOffset,
navigationController.SelectedIndex, availableLines, IsFavorite);
}
/// <summary>
/// Updates scroll offset and redraws the full menu. Called on initial load and console resize.
/// </summary>
public void RefreshView(int availableLines)
{
navigationController.UpdateScrollOffset(_gameEntries.Count, availableLines);
DrawMenu(availableLines);
}
/// <summary>
/// Loads entries for the current directory (or the virtual Favorites folder).
/// </summary>
public void ReloadGameEntries()
{
try
{
// If we are at the virtual Favorites folder, produce the flat list from the favorites service
if (string.Equals(navigationController.CurrentRelativePath, FavoritesService.FavoritesRootName, StringComparison.OrdinalIgnoreCase))
{
var favs = favoritesService.GetAll();
_gameEntries = new List<GameEntry>();
foreach (var p in favs)
{
_gameEntries.Add(new GameEntry
{
Name = Path.GetFileName(p),
Kind = EntryKind.Zip,
InRoot = true,
InPatch = false,
RootPath = p,
PatchPath = string.Empty,
IsVirtual = false
});
}
navigationController.SetEntriesCount(_gameEntries.Count);
return;
}
// Otherwise use the overlay directory browser for normal folders
_gameEntries = directoryBrowser.GetEntries(navigationController.CurrentRelativePath);
// If we are at the root and there are favorites, prepend a virtual Favorites folder
if (string.IsNullOrEmpty(navigationController.CurrentRelativePath) && favoritesService.HasFavorites())
{
var virtualEntry = new GameEntry
{
Name = FavoritesService.FavoritesRootName,
Kind = EntryKind.Directory,
InRoot = true,
InPatch = false,
RootPath = string.Empty,
PatchPath = string.Empty,
IsVirtual = true
};
_gameEntries.Insert(0, virtualEntry);
}
navigationController.SetEntriesCount(_gameEntries.Count);
}
catch (Exception ex)
{
// Show the error to the user and continue with an empty list
errorService.ShowError(ex.Message);
_gameEntries = new List<GameEntry>();
navigationController.SetEntriesCount(0);
}
}
/// <summary>
/// Handles a key press. Returns true if the application should exit.
/// </summary>
public bool HandleKey(ConsoleKeyInfo key, int availableLines)
{
if (key.KeyChar == '?')
{
menuRenderer.ShowHelpBox(availableLines);
Console.ReadKey(intercept: true);
menuRenderer.InvalidateCache();
DrawMenu(availableLines);
ProgramHelpers.FlushInputBuffer();
return false;
}
switch (key.Key)
{
case ConsoleKey.UpArrow:
{
int previousSelectedIndex = navigationController.SelectedIndex;
bool didScroll = navigationController.MoveUp(_gameEntries, availableLines);
if (didScroll)
{
DrawMenu(availableLines);
}
else
{
menuRenderer.RedrawEntry(_gameEntries, previousSelectedIndex, previousSelectedIndex - navigationController.ScrollOffset, false, availableLines, IsFavorite);
menuRenderer.RedrawEntry(_gameEntries, navigationController.SelectedIndex, navigationController.SelectedIndex - navigationController.ScrollOffset, true, availableLines, IsFavorite);
}
// flush input to avoid key repeat
ProgramHelpers.FlushInputBuffer();
}
break;
case ConsoleKey.DownArrow:
{
int previousSelectedIndex = navigationController.SelectedIndex;
bool didScroll = navigationController.MoveDown(_gameEntries, availableLines);
if (didScroll)
{
DrawMenu(availableLines);
}
else
{
menuRenderer.RedrawEntry(_gameEntries, previousSelectedIndex, previousSelectedIndex - navigationController.ScrollOffset, false, availableLines, IsFavorite);
menuRenderer.RedrawEntry(_gameEntries, navigationController.SelectedIndex, navigationController.SelectedIndex - navigationController.ScrollOffset, true, availableLines, IsFavorite);
}
ProgramHelpers.FlushInputBuffer();
}
break;
case ConsoleKey.Enter:
case ConsoleKey.RightArrow:
{
var oldRelativePath = navigationController.CurrentRelativePath;
var isDirectory = _gameEntries.Count > 0 && _gameEntries[navigationController.SelectedIndex].Kind == EntryKind.Directory;
navigationController.HandleEnter(_gameEntries);
if (isDirectory && oldRelativePath != navigationController.CurrentRelativePath)
{
ReloadGameEntries();
navigationController.UpdateScrollOffset(_gameEntries.Count, availableLines);
}
DrawMenu(availableLines);
// Only start a ZIP if NOT switching to a directory
if (!isDirectory && _gameEntries.Count > 0 && _gameEntries[navigationController.SelectedIndex].Kind == EntryKind.Zip)
{
string zipToLaunch = _gameEntries[navigationController.SelectedIndex].InPatch ? _gameEntries[navigationController.SelectedIndex].PatchPath : _gameEntries[navigationController.SelectedIndex].RootPath;
if (!File.Exists(zipToLaunch))
{
errorService.ShowError($"ZIP file not found: {zipToLaunch}");
DrawMenu(availableLines);
ProgramHelpers.FlushInputBuffer();
break;
}
try
{
hatariLauncher.Launch(zipToLaunch);
}
catch (Exception ex)
{
errorService.ShowError(ex.Message);
}
// Redraw menu after Hatari has exited.
// Invalidate the cache because the modal overwrote
// a console row that the cache still considers valid.
menuRenderer.InvalidateCache();
DrawMenu(availableLines);
}
// flush input to avoid leftover key events after an enter/navigation
ProgramHelpers.FlushInputBuffer();
}
break;
case ConsoleKey.Backspace:
case ConsoleKey.LeftArrow:
{
navigationController.GoUpDirectory();
ReloadGameEntries();
navigationController.UpdateScrollOffset(_gameEntries.Count, availableLines);
DrawMenu(availableLines);
ProgramHelpers.FlushInputBuffer();
}
break;
case ConsoleKey.PageDown:
{
navigationController.PageDown(_gameEntries, availableLines);
DrawMenu(availableLines);
ProgramHelpers.FlushInputBuffer();
}
break;
case ConsoleKey.PageUp:
{
navigationController.PageUp(_gameEntries, availableLines);
DrawMenu(availableLines);
ProgramHelpers.FlushInputBuffer();
}
break;
case ConsoleKey.Multiply:
case ConsoleKey.Oem8:
{
// Toggle favorite for selected ZIP (handles numpad * and some layouts)
if (_gameEntries.Count > 0 && _gameEntries[navigationController.SelectedIndex].Kind == EntryKind.Zip)
{
var ge = _gameEntries[navigationController.SelectedIndex];
string path = ge.InPatch ? ge.PatchPath : ge.RootPath;
try
{
favoritesService.Toggle(path);
}
catch (Exception ex)
{
errorService.ShowError("Failed to toggle favorite: " + ex.Message);
}
// Redraw the whole menu so the '*' marker updates immediately
DrawMenu(availableLines);
}
ProgramHelpers.FlushInputBuffer();
}
break;
case ConsoleKey.Escape:
case ConsoleKey.Q:
return true;
}
return false;
}
}
}
+114 -218
View File
@@ -63,251 +63,147 @@ namespace MarcerGameDvdLauncher
if (!File.Exists(cfg.Hatari.Executable))
throw new InvalidOperationException($"Hatari executable not found: {cfg.Hatari.Executable}");
// Also validate the Hatari config file (if provided)
if (!string.IsNullOrWhiteSpace(cfg.Hatari.ConfigFile) && !File.Exists(cfg.Hatari.ConfigFile))
// Validate the Hatari config file (either user-specified or bundled fallback)
if (!string.IsNullOrEmpty(cfg.Hatari.ConfigFile) && !File.Exists(cfg.Hatari.ConfigFile))
throw new InvalidOperationException($"Hatari configuration file not found: {cfg.Hatari.ConfigFile}");
if (string.IsNullOrWhiteSpace(cfg.Hatari.ArgsTemplate) || !cfg.Hatari.ArgsTemplate.Contains("{cfg}"))
throw new InvalidOperationException("Hatari.ArgsTemplate must contain the {cfg} placeholder.");
if (string.IsNullOrWhiteSpace(cfg.Hatari.ArgsTemplate) || !cfg.Hatari.ArgsTemplate.Contains("{zip}"))
throw new InvalidOperationException("Hatari.ArgsTemplate must contain the {zip} placeholder.");
// Parse Colors section manually so invalid values fall back to defaults
// instead of crashing the deserialization.
cfg.Colors = ParseAppColors(json);
return cfg;
}
// Parses the optional "Colors" JSON section into an AppColorConfig.
// Each field is resolved with Enum.TryParse<ConsoleColor>; unparseable or
// missing values silently fall back to the defaults defined in AppColorConfig.
private static AppColorConfig ParseAppColors(string json)
{
var colors = new AppColorConfig();
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("Colors", out var colorsEl) && colorsEl.ValueKind == System.Text.Json.JsonValueKind.Object)
{
ParseColorField(colorsEl, "FolderBoth", v => colors.FolderBoth = v);
ParseColorField(colorsEl, "FolderPatchOnly", v => colors.FolderPatchOnly = v);
ParseColorField(colorsEl, "FolderRootOnly", v => colors.FolderRootOnly = v);
ParseColorField(colorsEl, "ZipBoth", v => colors.ZipBoth = v);
ParseColorField(colorsEl, "ZipRootOnly", v => colors.ZipRootOnly = v);
ParseColorField(colorsEl, "ZipPatchOnly", v => colors.ZipPatchOnly = v);
ParseColorField(colorsEl, "SelectedForeground", v => colors.SelectedForeground = v);
ParseColorField(colorsEl, "SelectedBackground", v => colors.SelectedBackground = v);
ParseColorField(colorsEl, "VirtualEntry", v => colors.VirtualEntry = v);
}
}
catch
{
// On any JSON error, fall back to default colors (already set above)
}
return colors;
}
private static void ParseColorField(System.Text.Json.JsonElement colorsEl, string name, Action<ConsoleColor> setter)
{
if (colorsEl.TryGetProperty(name, out var prop) && prop.ValueKind == System.Text.Json.JsonValueKind.String)
{
var str = prop.GetString();
if (Enum.TryParse<ConsoleColor>(str ?? string.Empty, ignoreCase: true, out var parsed))
setter(parsed);
// Invalid color names are silently ignored — defaults are preserved
}
}
}
// Internal host that keeps state previously stored in Program.cs
// Internal host that keeps state and lifecycle management for the application.
// Key-handling logic has been extracted into InputController; this class
// focuses on component wiring, initialization and the main loop (resize detection + key polling).
internal class AppHost(AppConfig cfg)
{
OverlayDirectoryBrowser? _directoryBrowser;
MenuRenderer _menuRenderer = new MenuRenderer();
NavigationController _navigationController = new NavigationController();
List<GameEntry> _gameEntries = new List<GameEntry>();
HatariLauncher? _hatariLauncher;
readonly UIErrorService _errorService = new UIErrorService();
FavoritesService? _favoritesService;
private InputController? _inputController;
private MenuRenderer? _menuRenderer;
private HatariLauncher? _hatariLauncher;
private int _currentAvailableLines;
private int _currentWidth;
public void InitializeComponents()
public void InitializeComponents()
{
var directoryBrowser = new OverlayDirectoryBrowser(cfg.RootDirectory ?? string.Empty, cfg.PatchDirectory ?? string.Empty);
// Initialize favorites service. Use PatchDirectory if present, otherwise exe dir fallback.
string favPath;
if (!string.IsNullOrWhiteSpace(cfg.PatchDirectory))
{
_directoryBrowser = new OverlayDirectoryBrowser(cfg.RootDirectory ?? string.Empty, cfg.PatchDirectory ?? string.Empty);
// Initialize favorites service. Use PatchDirectory if present, otherwise exe dir fallback.
string favPath;
if (!string.IsNullOrWhiteSpace(cfg.PatchDirectory))
{
favPath = Path.Combine(cfg.PatchDirectory!, "favorites.txt");
}
else
{
favPath = Path.Combine(AppContext.BaseDirectory, "favorites.txt");
}
_favoritesService = new FavoritesService(favPath);
try { _favoritesService.Load(); } catch { /* ignore load errors */ }
try
{
_hatariLauncher = new HatariLauncher(cfg.Hatari?.Executable ?? throw new InvalidOperationException("Hatari.Executable not configured"), cfg.Hatari?.ConfigFile ?? string.Empty, cfg.Hatari?.ArgsTemplate ?? "-c \"{cfg}\" --disk-a \"{zip}\"");
}
catch (Exception ex)
{
ProgramHelpers.ShowConsoleMessage(["Hatari initialization error: " + ex.Message, "Press any key to exit."
], ConsoleColor.Red);
Environment.Exit(1);
}
Console.CursorVisible = false;
favPath = Path.Combine(cfg.PatchDirectory!, FavoritesService.DefaultFileName);
}
else
{
favPath = Path.Combine(AppContext.BaseDirectory, FavoritesService.DefaultFileName);
}
var favoritesService = new FavoritesService(favPath);
try { favoritesService.Load(); } catch { /* ignore load errors */ }
var errorService = new UIErrorService();
try
{
// ArgsTemplate is validated in LoadConfiguration — it must always contain {zip}.
// No hardcoded fallback is needed; the config file is the single source of truth.
_hatariLauncher = new HatariLauncher(cfg.Hatari?.Executable ?? throw new InvalidOperationException("Hatari.Executable not configured"), cfg.Hatari?.ConfigFile ?? string.Empty, cfg.Hatari?.ArgsTemplate ?? string.Empty);
}
catch (Exception ex)
{
ProgramHelpers.ShowConsoleMessage(["Hatari initialization error: " + ex.Message, "Press any key to exit."
], ConsoleColor.Red);
Environment.Exit(1);
}
_menuRenderer = new MenuRenderer(cfg.Colors);
var navigationController = new NavigationController();
_inputController = new InputController(directoryBrowser, _menuRenderer, navigationController,
_hatariLauncher!, favoritesService, errorService);
Console.CursorVisible = false;
}
public void RunDirectoryNavigation()
{
_currentAvailableLines = ProgramHelpers.AvailableLines;
_currentWidth = Console.WindowWidth;
_inputController!.ReloadGameEntries();
_inputController.RefreshView(_currentAvailableLines);
bool exitRequested = false;
ReloadGameEntries();
int currentAvailableLines = Console.WindowHeight - 1;
int currentWidth = Console.WindowWidth;
_navigationController.UpdateScrollOffset(_gameEntries.Count, currentAvailableLines);
var isFav = new Func<GameEntry, bool>(e => _favoritesService?.IsFavorite(e.Kind == EntryKind.Zip ? (e.InPatch ? e.PatchPath : e.RootPath) ?? string.Empty : string.Empty) ?? false);
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
while (!exitRequested)
{
// Reloads are performed explicitly when entering or leaving directories (Enter/Backspace)
// Reloads are performed explicitly by InputController when entering/leaving directories
// Do NOT hit the filesystem here on every loop iteration.
// detect a change in console height and/or width and redraw immediately
int latestAvailableLines = Console.WindowHeight - 1;
int latestAvailableLines = ProgramHelpers.AvailableLines;
int latestWidth = Console.WindowWidth;
if (latestAvailableLines != currentAvailableLines || latestWidth != currentWidth)
{
currentAvailableLines = latestAvailableLines;
currentWidth = latestWidth;
_navigationController.UpdateScrollOffset(_gameEntries.Count, currentAvailableLines);
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
}
// Only block if there's actually a key; otherwise allow resize detection
if (!Console.KeyAvailable)
{
Thread.Sleep(50);
continue;
}
var key = Console.ReadKey(intercept: true);
if (key.KeyChar == '?')
{
_menuRenderer.ShowHelpBox(currentAvailableLines);
Console.ReadKey(intercept: true);
_menuRenderer.InvalidateCache();
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
ProgramHelpers.FlushInputBuffer();
continue;
}
switch (key.Key)
{
case ConsoleKey.UpArrow:
int previousSelectedIndexUp = _navigationController.SelectedIndex;
bool didScrollUp = _navigationController.MoveUp(_gameEntries, currentAvailableLines);
if (didScrollUp)
{
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
}
else
{
_menuRenderer.RedrawEntry(_gameEntries, previousSelectedIndexUp, previousSelectedIndexUp - _navigationController.ScrollOffset, false, currentAvailableLines, isFav);
_menuRenderer.RedrawEntry(_gameEntries, _navigationController.SelectedIndex, _navigationController.SelectedIndex - _navigationController.ScrollOffset, true, currentAvailableLines, isFav);
}
// flush input to avoid key repeat
ProgramHelpers.FlushInputBuffer();
break;
case ConsoleKey.DownArrow:
int previousSelectedIndexDown = _navigationController.SelectedIndex;
bool didScrollDown = _navigationController.MoveDown(_gameEntries, currentAvailableLines);
if (didScrollDown)
{
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
}
else
{
_menuRenderer.RedrawEntry(_gameEntries, previousSelectedIndexDown, previousSelectedIndexDown - _navigationController.ScrollOffset, false, currentAvailableLines, isFav);
_menuRenderer.RedrawEntry(_gameEntries, _navigationController.SelectedIndex, _navigationController.SelectedIndex - _navigationController.ScrollOffset, true, currentAvailableLines, isFav);
}
ProgramHelpers.FlushInputBuffer();
break;
case ConsoleKey.Enter:
case ConsoleKey.RightArrow:
var oldRelativePath = _navigationController.CurrentRelativePath;
var isDirectory = _gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Directory;
_navigationController.HandleEnter(_gameEntries);
if (isDirectory && oldRelativePath != _navigationController.CurrentRelativePath) {
ReloadGameEntries();
_navigationController.UpdateScrollOffset(_gameEntries.Count, currentAvailableLines);
}
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
// Only start a ZIP if NOT switching to a directory
if (!isDirectory && _gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Zip)
{
string zipToLaunch = _gameEntries[_navigationController.SelectedIndex].InPatch ? _gameEntries[_navigationController.SelectedIndex].PatchPath : _gameEntries[_navigationController.SelectedIndex].RootPath;
try
{
_hatariLauncher!.Launch(zipToLaunch);
}
catch (Exception ex)
{
_errorService.ShowError(ex.Message);
}
}
// flush input to avoid leftover key events after an enter/navigation
ProgramHelpers.FlushInputBuffer();
break;
case ConsoleKey.Backspace:
case ConsoleKey.LeftArrow:
_navigationController.GoUpDirectory();
ReloadGameEntries();
_navigationController.UpdateScrollOffset(_gameEntries.Count, currentAvailableLines);
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
ProgramHelpers.FlushInputBuffer();
break;
case ConsoleKey.PageDown:
_navigationController.PageDown(_gameEntries, currentAvailableLines);
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
ProgramHelpers.FlushInputBuffer();
break;
case ConsoleKey.PageUp:
_navigationController.PageUp(_gameEntries, currentAvailableLines);
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
ProgramHelpers.FlushInputBuffer();
break;
case ConsoleKey.Multiply:
case ConsoleKey.Oem8:
// Toggle favorite for selected ZIP (handles numpad * and some layouts)
if (_gameEntries.Count > 0 && _gameEntries[_navigationController.SelectedIndex].Kind == EntryKind.Zip)
{
var ge = _gameEntries[_navigationController.SelectedIndex];
string path = ge.InPatch ? ge.PatchPath : ge.RootPath;
try
{
_favoritesService?.Toggle(path);
}
catch (Exception ex)
{
_errorService.ShowError("Failed to toggle favorite: " + ex.Message);
}
// Redraw the whole menu so the '*' marker updates immediately
_menuRenderer.DrawMenu(_gameEntries, _navigationController.ScrollOffset, _navigationController.SelectedIndex, currentAvailableLines, isFav);
}
ProgramHelpers.FlushInputBuffer();
break;
case ConsoleKey.Escape:
case ConsoleKey.Q:
exitRequested = true;
break;
}
}
}
void ReloadGameEntries()
{
try
if (latestAvailableLines != _currentAvailableLines || latestWidth != _currentWidth)
{
// If we are at the virtual Favorites folder, produce the flat list from the favorites service
if (string.Equals(_navigationController.CurrentRelativePath, "Favorites", StringComparison.OrdinalIgnoreCase))
{
var favs = _favoritesService?.GetAll() ?? new List<string>();
_gameEntries = favs.Select(p => new GameEntry
{
Name = Path.GetFileName(p),
Kind = EntryKind.Zip,
InRoot = true,
InPatch = false,
RootPath = p,
PatchPath = string.Empty,
IsVirtual = false
}).ToList();
_navigationController.SetEntriesCount(_gameEntries.Count);
return;
}
// Otherwise use the overlay directory browser for normal folders
_gameEntries = _directoryBrowser!.GetEntries(_navigationController.CurrentRelativePath);
// If we are at the root and there are favorites, prepend a virtual "Favorites" folder
if (string.IsNullOrEmpty(_navigationController.CurrentRelativePath) && (_favoritesService?.HasFavorites() ?? false))
{
var virtualEntry = new GameEntry
{
Name = "Favorites",
Kind = EntryKind.Directory,
InRoot = true,
InPatch = false,
RootPath = string.Empty,
PatchPath = string.Empty,
IsVirtual = true
};
// insert at the beginning
_gameEntries.Insert(0, virtualEntry);
}
_navigationController.SetEntriesCount(_gameEntries.Count);
_currentAvailableLines = latestAvailableLines;
_currentWidth = latestWidth;
_inputController.RefreshView(_currentAvailableLines);
}
catch (Exception ex)
// Only block if there's actually a key; otherwise allow resize detection
if (!Console.KeyAvailable)
{
// Show the error to the user and continue with an empty list
_errorService.ShowError(ex.Message);
_gameEntries = new List<GameEntry>();
_navigationController.SetEntriesCount(0);
Thread.Sleep(50);
continue;
}
var key = Console.ReadKey(intercept: true);
exitRequested = _inputController.HandleKey(key, _currentAvailableLines);
}
}
}
}
@@ -0,0 +1,339 @@
[Log]
sLogFileName = stderr
sTraceFileName = stderr
nTextLogLevel = 3
nAlertDlgLogLevel = 1
bConfirmQuit = TRUE
bNatFeats = FALSE
bConsoleWindow = FALSE
[Debugger]
nNumberBase = 10
nSymbolLines = -1
nMemdumpLines = -1
nFindLines = -1
nDisasmLines = -1
nBacktraceLines = 0
nExceptionDebugMask = 1073741830
nDisasmOptions = 7
nMemConvLocale = FALSE
bDisasmUAE = TRUE
nSymbolsAutoLoad = 1
bMatchAllSymbols = FALSE
[Screen]
nMonitorType = 1
nFrameSkips = 5
bFullScreen = FALSE
bKeepResolution = FALSE
bResizable = FALSE
bAllowOverscan = TRUE
nSpec512Threshold = 1
bAspectCorrect = TRUE
bUseExtVdiResolutions = FALSE
nVdiWidth = 1264
nVdiHeight = 912
nVdiColors = 1
bMouseWarp = TRUE
bShowStatusbar = TRUE
bShowDriveLed = FALSE
bCrop = FALSE
bForceMax = FALSE
nMaxWidth = 1280
nMaxHeight = 1024
nZoomFactor = 3
bUseSdlRenderer = FALSE
ScreenShotFormat = 2
szScreenShotDir =
bUseVsync = FALSE
[Joystick0]
nJoystickMode = 0
bEnableAutoFire = FALSE
bEnableJumpOnFire2 = TRUE
nJoyId = 0
nJoyBut1Index = 0
nJoyBut2Index = 1
nJoyBut3Index = 2
kUp = Up
kDown = Down
kLeft = Left
kRight = Right
kFire = Right Ctrl
[Joystick1]
nJoystickMode = 1
bEnableAutoFire = FALSE
bEnableJumpOnFire2 = TRUE
nJoyId = 0
nJoyBut1Index = 0
nJoyBut2Index = 1
nJoyBut3Index = 2
kUp = Up
kDown = Down
kLeft = Left
kRight = Right
kFire = Right Ctrl
[Joystick2]
nJoystickMode = 0
bEnableAutoFire = FALSE
bEnableJumpOnFire2 = TRUE
nJoyId = 0
nJoyBut1Index = 0
nJoyBut2Index = 1
nJoyBut3Index = 2
kUp = Up
kDown = Down
kLeft = Left
kRight = Right
kFire = Right Ctrl
kButtonB = B
kButtonC = C
kButtonOption = O
kButtonPause = P
kButtonStar = +
kButtonHash = #
kButton0 = 0
kButton1 = 1
kButton2 = 2
kButton3 = 3
kButton4 = 4
kButton5 = 5
kButton6 = 6
kButton7 = 7
kButton8 = 8
kButton9 = 9
[Joystick3]
nJoystickMode = 0
bEnableAutoFire = FALSE
bEnableJumpOnFire2 = TRUE
nJoyId = 0
nJoyBut1Index = 0
nJoyBut2Index = 1
nJoyBut3Index = 2
kUp = Up
kDown = Down
kLeft = Left
kRight = Right
kFire = Right Ctrl
kButtonB =
kButtonC =
kButtonOption =
kButtonPause =
kButtonStar =
kButtonHash =
kButton0 =
kButton1 =
kButton2 =
kButton3 =
kButton4 =
kButton5 =
kButton6 =
kButton7 =
kButton8 =
kButton9 =
[Joystick4]
nJoystickMode = 0
bEnableAutoFire = FALSE
bEnableJumpOnFire2 = TRUE
nJoyId = 0
nJoyBut1Index = 0
nJoyBut2Index = 1
nJoyBut3Index = 2
kUp = Up
kDown = Down
kLeft = Left
kRight = Right
kFire = Right Ctrl
[Joystick5]
nJoystickMode = 0
bEnableAutoFire = FALSE
bEnableJumpOnFire2 = TRUE
nJoyId = 0
nJoyBut1Index = 0
nJoyBut2Index = 1
nJoyBut3Index = 2
kUp = Up
kDown = Down
kLeft = Left
kRight = Right
kFire = Right Ctrl
[Keyboard]
bFastForwardKeyRepeat = TRUE
nKeymapType = 0
nCountryCode = -1
nKbdLayout = -1
nLanguage = -1
szMappingFileName =
[KeyShortcutsWithMod]
kOptions = O
kFullScreen = F
kBorders = B
kMouseMode = M
kColdReset = C
kWarmReset = R
kScreenShot = G
kBossKey = I
kCursorEmu = J
kFastForward = X
kRecAnim = A
kRecSound = Y
kSound = S
kPause =
kDebugger = Pause
kQuit = Q
kLoadMem = L
kSaveMem = K
kInsertDiskA = D
kSwitchJoy0 = F1
kSwitchJoy1 = F2
kSwitchPadA = F3
kSwitchPadB = F4
[KeyShortcutsWithoutMod]
kOptions = F12
kFullScreen = F11
kBorders =
kMouseMode =
kColdReset =
kWarmReset =
kScreenShot =
kBossKey =
kCursorEmu =
kFastForward =
kRecAnim =
kRecSound =
kSound =
kPause = Pause
kDebugger =
kQuit =
kLoadMem =
kSaveMem =
kInsertDiskA =
kSwitchJoy0 =
kSwitchJoy1 =
kSwitchPadA =
kSwitchPadB =
[Sound]
bEnableMicrophone = TRUE
bEnableSound = TRUE
bEnableSoundSync = FALSE
nPlaybackFreq = 44100
nSdlAudioBufferSize = 0
szYMCaptureFileName =
YmVolumeMixing = 2
[Memory]
nMemorySize = 1024
nTTRamSize = 28672
bAutoSave = FALSE
szMemoryCaptureFileName =
szAutoSaveFileName =
[Floppy]
bAutoInsertDiskB = FALSE
FastFloppy = FALSE
EnableDriveA = TRUE
DriveA_NumberOfHeads = 2
EnableDriveB = TRUE
DriveB_NumberOfHeads = 2
nWriteProtection = 0
szDiskAZipPath =
szDiskAFileName =
szDiskBZipPath =
szDiskBFileName =
szDiskImageDirectory =
[HardDisk]
nGemdosDrive = 0
bBootFromHardDisk = FALSE
bUseHardDiskDirectory = FALSE
szHardDiskDirectory = R:
nGemdosCase = 0
nWriteProtection = 0
bFilenameConversion = FALSE
bGemdosHostTime = FALSE
[ACSI]
bUseDevice0 = FALSE
sDeviceFile0 =
nBlockSize0 = 512
nAcsiVersion0 = 1
[SCSI]
[IDE]
[ROM]
szTosImageFileName = TOS.IMG
bPatchTos = TRUE
szCartridgeImageFileName =
[LILO]
Args = root=/dev/ram video=atafb:vga16 load_ramdisk=1
Kernel =
Symbols =
Ramdisk =
HaltOnReboot = TRUE
KernelToFastRam = TRUE
RamdiskToFastRam = TRUE
[RS232]
bEnableRS232 = FALSE
szOutFileName =
szInFileName =
EnableSccA = FALSE
SccAOutFileName =
SccAInFileName =
EnableSccALan = FALSE
SccALanOutFileName =
SccALanInFileName =
EnableSccB = FALSE
SccBOutFileName =
SccBInFileName =
[Printer]
bEnablePrinting = FALSE
szPrintToFileName =
[Midi]
bEnableMidi = FALSE
sMidiInFileName =
sMidiOutFileName =
sMidiInPortName = Off
sMidiOutPortName = Off
[System]
nCpuLevel = 0
nCpuFreq = 8
bCompatibleCpu = TRUE
nModelType = 0
bBlitter = FALSE
nDSPType = 0
nRtcYear = 0
bPatchTimerD = FALSE
bFastBoot = FALSE
bFastForward = FALSE
bAddressSpace24 = TRUE
bCycleExactCpu = TRUE
bCpuDataCache = TRUE
n_FPUType = 0
bSoftFloatFPU = FALSE
bMMU = TRUE
VideoTiming = 3
[Video]
AviRecordVcodec = 2
AviRecordFps = 0
AviRecordFile =
@@ -10,10 +10,13 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ItemGroup>
<Content Include="launcher.config.example.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="MarcerGameDvd-Hatari.cfg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="../../LICENSE">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>LICENSE</Link>
+56 -95
View File
@@ -3,7 +3,7 @@
namespace MarcerGameDvdLauncher
{
public class MenuRenderer
public class MenuRenderer(AppColorConfig? colors = null)
{
// Simple line-based double buffer to avoid full Clear() flicker.
// cachedBuffer holds the last rendered text and colors for each visible row.
@@ -11,6 +11,9 @@ namespace MarcerGameDvdLauncher
private LineState[] _cachedBuffer = Array.Empty<LineState>();
private int _cachedWidth = -1;
// Color configuration (injected; defaults to built-in scheme if null)
private readonly AppColorConfig _colors = colors ?? new AppColorConfig();
// availableLines is provided per-draw so the renderer adapts to console resizes
public void DrawMenu(List<GameEntry> entries, int scrollOffset, int selectedIndex, int availableLines, Func<GameEntry, bool>? isFavorite = null)
{
@@ -43,11 +46,8 @@ namespace MarcerGameDvdLauncher
}
// Diff & write only changed lines
// Use the caller-provided availableLines (which should be Console.WindowHeight - 1)
int maxRow = Math.Max(0, availableLines - 1);
for (int row = 0; row < availableLines; row++)
{
if (row > maxRow) break;
var newLine = newBuffer[row];
var oldLine = _cachedBuffer[row];
if (oldLine.Text != newLine.Text || oldLine.Fg != newLine.Fg || oldLine.Bg != newLine.Bg)
@@ -63,26 +63,16 @@ namespace MarcerGameDvdLauncher
{
if (entryIdx < 0 || entryIdx >= entries.Count) return;
availableLines = Math.Max(1, availableLines);
int maxRow = Math.Max(0, availableLines - 1);
if (row < 0 || row > maxRow) return;
// row is a visual row within the visible window; validate against availableLines
if (row < 0 || row >= availableLines) return;
int width = Console.WindowWidth;
// ensure cache is valid for current width/height and the target row
EnsureCacheForRow(width, Math.Min(availableLines, Math.Max(1, _cachedBuffer.Length == 0 ? 1 : _cachedBuffer.Length)));
// Ensure the cache matches the current dimensions (same as DrawMenu)
EnsureCache(width, availableLines);
var e = entries[entryIdx];
var (fg, bg) = GetColors(e, selected);
string text = BuildLineText(e, width, isFavorite?.Invoke(e) ?? false);
ConsoleColor fg, bg;
if (selected)
{
bg = ConsoleColor.DarkCyan;
fg = ConsoleColor.Black;
}
else
{
bg = ConsoleColor.Black;
fg = GetColorForEntry(e);
}
var newLine = new LineState { Text = text, Fg = fg, Bg = bg };
// If cache differs, write
@@ -98,7 +88,11 @@ namespace MarcerGameDvdLauncher
else
{
// out of cache bounds - attempt a direct write
WriteConsoleLine(row, newLine);
// Validate row against current console height to prevent ArgumentOutOfRangeException
if (row < Console.WindowHeight)
{
WriteConsoleLine(row, newLine);
}
}
}
@@ -113,18 +107,6 @@ namespace MarcerGameDvdLauncher
}
}
// Ensures the cached buffer has at least requiredRows entries and matches width.
private void EnsureCacheForRow(int width, int requiredRows)
{
if (_cachedBuffer.Length < requiredRows || _cachedWidth != width)
{
int newLen = Math.Max(requiredRows, 1);
_cachedBuffer = new LineState[newLen];
for (int i = 0; i < newLen; i++) _cachedBuffer[i].Text = null!;
_cachedWidth = width;
}
}
// Write a line to the console using the centralized logic (handles concurrent resizes safely)
private void WriteConsoleLine(int row, LineState newLine)
{
@@ -151,55 +133,34 @@ namespace MarcerGameDvdLauncher
_cachedBuffer[i].Text = null!;
}
// Renders a centered, bordered help box with key bindings inside the
// Renders a centered help box with key bindings inside the
// available console area. The caller is responsible for waiting on a
// key and redrawing the menu afterwards.
// key and redrawing the menu afterward.
public void ShowHelpBox(int availableLines)
{
try
var width = Console.WindowWidth;
var helpLines = GetHelpLines();
var boxHeight = Math.Min(helpLines.Length + 2, Math.Max(3, availableLines));
var boxWidth = Math.Max(1, width);
var topRow = Math.Max(0, (availableLines - boxHeight) / 2);
Console.BackgroundColor = ConsoleColor.DarkGray;
Console.ForegroundColor = ConsoleColor.White;
// Fill the box area without borders for cleaner appearance
for (var i = 0; i < boxHeight; i++)
{
int width = Console.WindowWidth;
string[] helpLines = GetHelpLines();
int boxHeight = Math.Min(helpLines.Length + 2, Math.Max(3, availableLines));
int boxWidth = Math.Max(1, width);
int topRow = Math.Max(0, (availableLines - boxHeight) / 2);
Console.BackgroundColor = ConsoleColor.DarkGray;
Console.ForegroundColor = ConsoleColor.White;
string topBorder = "+" + new string('-', Math.Max(0, boxWidth - 2)) + "+";
Console.SetCursorPosition(0, topRow);
Console.Write(topBorder);
for (int i = 0; i < boxHeight - 2; i++)
var row = topRow + i;
if (row < Console.WindowHeight)
{
int row = topRow + 1 + i;
string content;
if (i < helpLines.Length)
{
content = PadToWidth(helpLines[i], boxWidth - 2);
}
else
{
content = new string(' ', Math.Max(0, boxWidth - 2));
}
Console.SetCursorPosition(0, row);
Console.Write("|" + content + "|");
var content = i == 0 || i == boxHeight - 1 || i >= helpLines.Length + 2
? new string(' ', boxWidth)
: PadToWidth(helpLines[i - 1], boxWidth);
Console.Write(content);
}
int bottomRow = topRow + boxHeight - 1;
if (bottomRow < Console.WindowHeight)
{
string bottomBorder = "+" + new string('-', Math.Max(0, boxWidth - 2)) + "+";
Console.SetCursorPosition(0, bottomRow);
Console.Write(bottomBorder);
}
Console.ResetColor();
}
catch
{
}
Console.ResetColor();
}
private static string[] GetHelpLines()
@@ -207,14 +168,13 @@ namespace MarcerGameDvdLauncher
return [
" Help — Key Bindings",
" ",
" ↑ / ↓ Move selection up / down",
" Enter / → Open folder / launch ZIP with Hatari",
" / BS Go up one directory (never exceeds root)",
" ESC / Q Exit the program",
" PgUp Jump one page up",
" PgDn Jump one page down",
" * Toggle favorite on selected ZIP",
" ? Show this help",
" ↑ / ↓ Move selection up / down",
" PgUp / PgDn Jump one page up / down",
" Enter / Open folder / launch ZIP with Hatari",
" * Toggle favorite on selected ZIP",
" ← / Backspace Go up one directory (never exceeds root)",
" ? Show this help",
" ESC / q Exit the program",
" ",
" Navigation is strictly limited to RootDirectory.",
" The overlay shows both root and patch layers combined.",
@@ -229,12 +189,13 @@ namespace MarcerGameDvdLauncher
return text + new string(' ', width - text.Length);
}
// Returns foreground and background colors for an entry depending on selection state
// Returns foreground and background colors for an entry depending on selection state.
// Selected colors and entry colors come from the injected AppColorConfig.
private (ConsoleColor fg, ConsoleColor bg) GetColors(GameEntry e, bool selected)
{
if (selected)
{
return (ConsoleColor.Black, ConsoleColor.DarkCyan);
return (_colors.SelectedForeground, _colors.SelectedBackground);
}
else
{
@@ -247,7 +208,7 @@ namespace MarcerGameDvdLauncher
{
if (width <= 0) return string.Empty;
string label = GetLabel(e, isFavorite);
var label = GetLabel(e, isFavorite);
// If the console width is smaller than the label, truncate the label
if (width <= label.Length)
@@ -255,7 +216,7 @@ namespace MarcerGameDvdLauncher
return label.Substring(0, width);
}
int maxNameLen = width - label.Length; // space left for name
var maxNameLen = width - label.Length; // space left for name
string displayName;
if (e.Name.Length <= maxNameLen)
{
@@ -269,7 +230,7 @@ namespace MarcerGameDvdLauncher
displayName = e.Name.Substring(0, Math.Max(0, maxNameLen));
}
int padding = Math.Max(0, width - label.Length - displayName.Length);
var padding = Math.Max(0, width - label.Length - displayName.Length);
var result = label + displayName + new string(' ', padding);
// Ensure exact width (defensive): truncate or pad if needed
if (result.Length > width) return result.Substring(0, width);
@@ -285,7 +246,7 @@ namespace MarcerGameDvdLauncher
{
// Layer label (7 chars)
string layer;
if (e.InRoot && e.InPatch) layer = "[BOTH] ";
if (e is {InRoot: true, InPatch: true}) layer = "[BOTH] ";
else if (e.InPatch) layer = "[PTCH] ";
else if (e.InRoot) layer = "[ROOT] ";
else layer = " ";
@@ -306,20 +267,20 @@ namespace MarcerGameDvdLauncher
private ConsoleColor GetColorForEntry(GameEntry e)
{
// Virtual entries (like the Favorites pseudo-folder) should be white
if (e.IsVirtual) return ConsoleColor.White;
// Virtual entries (like the Favorites pseudo-folder) use the configured VirtualEntry color
if (e.IsVirtual) return _colors.VirtualEntry;
if (e.Kind == EntryKind.Directory)
{
if (e.InRoot && e.InPatch) return ConsoleColor.Yellow; // Both layers
if (e.InPatch && !e.InRoot) return ConsoleColor.DarkYellow; // Only patch
if (e.InRoot && !e.InPatch) return ConsoleColor.Gray; // Only root
if (e is {InRoot: true, InPatch: true}) return _colors.FolderBoth; // Both layers
if (e is {InPatch: true, InRoot: false}) return _colors.FolderPatchOnly; // Only patch
if (e is {InRoot: true, InPatch: false}) return _colors.FolderRootOnly; // Only root
}
else if (e.Kind == EntryKind.Zip)
{
if (e.InRoot && e.InPatch) return ConsoleColor.Green; // Both layers
if (e.InRoot && !e.InPatch) return ConsoleColor.DarkGreen; // Only root
if (e.InPatch && !e.InRoot) return ConsoleColor.Magenta; // Only patch
if (e is {InRoot: true, InPatch: true}) return _colors.ZipBoth; // Both layers
if (e is { InRoot: true, InPatch: false }) return _colors.ZipRootOnly; // Only root
if (e is {InPatch: true, InRoot: false}) return _colors.ZipPatchOnly; // Only patch
}
return ConsoleColor.DarkGray;
}
@@ -5,8 +5,14 @@ namespace MarcerGameDvdLauncher
{
public class NavigationController
{
public int SelectedIndex { get; private set; } = 0;
public int ScrollOffset { get; private set; } = 0;
// Scroll fractions: when the selection cursor reaches 2/3 of the visible window
// height from the top, the list scrolls down; when it reaches 1/3, it scrolls up.
// Not configurable — these ratios are the established navigation behaviour.
private const double BOTTOM_SCROLL_FRACTION = 2.0 / 3.0;
private const double TOP_SCROLL_FRACTION = 1.0 / 3.0;
public int SelectedIndex { get; private set; }
public int ScrollOffset { get; private set; }
public string CurrentRelativePath { get; private set; } = "";
// Stores the last selection & scroll position for each directory
private readonly Dictionary<string, (int sel, int scroll)> _lastSelections = new();
@@ -99,12 +105,12 @@ namespace MarcerGameDvdLauncher
if (availableLines < 1) availableLines = 1;
if (entryCount <= availableLines) { ScrollOffset = 0; return; }
if (SelectedIndex == 0) { ScrollOffset = 0; return; }
int bottomScrollTrigger = ScrollOffset + (int)(availableLines * 2 / 3.0);
int topScrollTrigger = ScrollOffset + (int)(availableLines * 1 / 3.0);
int bottomScrollTrigger = ScrollOffset + (int)(availableLines * BOTTOM_SCROLL_FRACTION);
int topScrollTrigger = ScrollOffset + (int)(availableLines * TOP_SCROLL_FRACTION);
if (SelectedIndex >= bottomScrollTrigger && (ScrollOffset + availableLines) < entryCount)
ScrollOffset = SelectedIndex - (int)(availableLines * 2 / 3.0);
ScrollOffset = SelectedIndex - (int)(availableLines * BOTTOM_SCROLL_FRACTION);
else if (SelectedIndex < topScrollTrigger && ScrollOffset > 0)
ScrollOffset = SelectedIndex - (int)(availableLines * 1 / 3.0);
ScrollOffset = SelectedIndex - (int)(availableLines * TOP_SCROLL_FRACTION);
if (ScrollOffset < 0) ScrollOffset = 0;
if (ScrollOffset > entryCount - availableLines)
ScrollOffset = entryCount - availableLines;
@@ -19,16 +19,16 @@ namespace MarcerGameDvdLauncher
public class OverlayDirectoryBrowser(string root, string patch)
{
public List<GameEntry> GetEntries(string currentRelativePath)
public List<GameEntry> GetEntries(string? currentRelativePath)
{
try
{
// Normalize and protect against path traversal or absolute paths in the relative path
string rel = currentRelativePath ?? string.Empty;
var rel = currentRelativePath ?? string.Empty;
// Remove any leading directory separators
rel = rel.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
// If the relative path is rooted or contains parent directory segments, reset to root
if (Path.IsPathRooted(rel) || rel.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries).Any(p => p == ".."))
if (Path.IsPathRooted(rel) || rel.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries).Any(p => p == ".."))
{
rel = string.Empty;
}
+4 -1
View File
@@ -5,9 +5,12 @@ namespace MarcerGameDvdLauncher
{
class Program
{
// Console window title. Not configurable — fixed application display name.
private const string DEFAULT_TITLE = "Marcer GameDVD Launcher";
static void Main(string[] args)
{
Console.Title = "Marcer GameDVD Launcher";
Console.Title = DEFAULT_TITLE;
var app = new LauncherApp();
app.Run();
}
+34 -22
View File
@@ -1,7 +1,6 @@
// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
using System;
using System.Runtime.InteropServices;
namespace MarcerGameDvdLauncher
@@ -9,6 +8,11 @@ namespace MarcerGameDvdLauncher
// Small helpers refactored into their own file to keep Program.cs focused.
internal static class ProgramHelpers
{
// The console window height minus one. The last row is reserved to prevent
// auto-scroll / flicker when the cursor reaches the bottom row (project policy).
// Centralized here so the policy lives in exactly one place.
public static int AvailableLines => Math.Max(0, Console.WindowHeight - 1);
// Flushes the console input buffer to avoid processing leftover key events
// Uses Win32 FlushConsoleInputBuffer on the standard input handle. On non-Windows
// environments this will be a no-op.
@@ -31,52 +35,54 @@ namespace MarcerGameDvdLauncher
}
}
// Win32 API constants — intentionally hardcoded (bewusst hartkodiert).
// These are defined by the Windows API and do not change.
private const int STD_INPUT_HANDLE = -10;
[System.Runtime.InteropServices.DllImport("kernel32.dll")]
[DllImport("kernel32.dll")]
private static extern IntPtr GetStdHandle(int nStdHandle);
[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool FlushConsoleInputBuffer(IntPtr hConsoleInput);
// P/Invoke to query key state (used to detect physical key release)
// Win32 virtual-key code — intentionally hardcoded (bewusst hartkodiert).
private const int VK_RETURN = 0x0D;
[DllImport("user32.dll")]
private static extern short GetAsyncKeyState(int vKey);
// Shows a simple modal message on the reserved last console line and
// blocks until the Return key is physically released. The message is
// then removed and the method returns. This is designed to be a
// lightweight modal for the console UI and uses the last console line
// which the application reserves for transient messages.
public static void ShowModalUntilReturnReleased(string message)
// Shows a simple modal message in the center of the console and
// blocks until the given process has exited. The message is then
// removed and the method returns.
public static void ShowModalUntilProcessExited(System.Diagnostics.Process? process, string? message)
{
try
{
int lastRow = Math.Max(0, Console.WindowHeight - 1);
int width = Console.WindowWidth;
string text = message ?? string.Empty;
if (text.Length > width) text = text.Substring(0, Math.Max(0, width - 3)) + "...";
int padding = Math.Max(0, width - text.Length);
string line = text + new string(' ', padding);
int height = Console.WindowHeight;
int centerRow = height / 2;
var text = message ?? string.Empty;
if (text.Length > width - 4) text = text.Substring(0, Math.Max(0, width - 7)) + "...";
int leftPad = Math.Max(0, (width - text.Length) / 2);
var line = new string(' ', leftPad) + text;
Console.BackgroundColor = ConsoleColor.DarkGray;
Console.ForegroundColor = ConsoleColor.White;
try { Console.SetCursorPosition(0, lastRow); } catch { }
try { Console.Write(line); } catch { }
try { Console.SetCursorPosition(0, centerRow); } catch { }
try { Console.Write(line.PadRight(width)); } catch { }
Console.ResetColor();
// Wait until Return key is not pressed
// GetAsyncKeyState returns a short where the high-order bit is set when key is down
while ((GetAsyncKeyState(VK_RETURN) & 0x8000) != 0)
// Wait until the external process has exited
while (process != null && !process.HasExited)
{
Thread.Sleep(10);
Thread.Sleep(100);
}
// Clear the line
try { Console.SetCursorPosition(0, lastRow); } catch { }
try { Console.SetCursorPosition(0, centerRow); } catch { }
try { Console.Write(new string(' ', width)); } catch { }
}
catch
@@ -114,5 +120,11 @@ namespace MarcerGameDvdLauncher
}
if (waitForKey) Console.ReadKey(intercept: true);
}
// Convenience overload: flate argument list instead of explicit array.
public static void ShowConsoleMessage(params string[] lines)
{
ShowConsoleMessage(lines, ConsoleColor.Gray, clear: true, waitForKey: true);
}
}
}
@@ -5,6 +5,10 @@ namespace MarcerGameDvdLauncher;
/// <summary>
/// Centralized service for error and user message output in the console UI.
/// Errors are presented to the user via <see cref="ProgramHelpers.ShowConsoleMessage"/>
/// and are not rethrown — the caller's context does not allow for meaningful error
/// recovery, so the application stays in the navigation loop after the user dismisses
/// the message.
/// </summary>
public class UIErrorService
{
@@ -3,7 +3,18 @@
"PatchDirectory": "C:\\Games\\Hatari\\PATCH",
"Hatari": {
"Executable": "C:\\Tools\\hatari\\hatari.exe",
"ConfigFile": "C:\\Tools\\hatari\\hatari-st.cfg",
"ConfigFile": "",
"ArgsTemplate": "-c \"{cfg}\" --disk-a \"{zip}\""
},
"Colors": {
"FolderBoth": "Yellow",
"FolderPatchOnly": "DarkYellow",
"FolderRootOnly": "Gray",
"ZipBoth": "Green",
"ZipRootOnly": "DarkGreen",
"ZipPatchOnly": "Magenta",
"SelectedForeground": "Black",
"SelectedBackground": "DarkCyan",
"VirtualEntry": "White"
}
}