클릭 몇 번으로 관리하는 크로스플랫폼 데스크탑 앱WheelWizard(휠 위저드)는 하나의 특정 게임 생태계를 위한 전용 런처다. 여기서 게임은 마리오카트 Wii이고, 생태계는 팬 커뮤니티가 만든 커스텀 트랙 배포판 Retro Rewind 및 GameBanana에 올라오는 각종 모드들이다. 이 모든 걸 손으로 하려면 에뮬레이터를 깔고, 모드 파일을 정확한 폴더에 배치하고, 업데이트를 직접 추적해야 한다. WheelWizard는 그 과정을 버튼 몇 개로 압축한다.
기능을 나열하면 이렇다 — Dolphin 에뮬레이터 자동 설치, Retro Rewind 자동 업데이트, GameBanana 모드 브라우저(앱 안에서 검색·설치), 모드 우선순위 관리, Mii 편집기(3D 렌더링 포함), 온라인 방(room) 실시간 조회(지금 누가 플레이 중인지), 친구 코드 관리, 그리고 앱 자체의 자동 업데이트. 이 모든 게 Windows·Linux·macOS에서 단일 코드베이스로 돈다.
Steam이 게임을 설치·패치·실행하고 친구가 뭘 하는지 보여주듯, WheelWizard는 마리오카트 Wii + Retro Rewind라는 하나의 대상에 대해 똑같은 일을 한다. 에뮬레이터를 깔아 주고, 모드팩을 최신으로 유지하고, 스토어(GameBanana)에서 모드를 받아 설치하고, 지금 온라인에 몇 명이 있는지 보여준다.
차이는 규모가 아니라 대상이 하나로 고정됐다는 점이다. 덕분에 "게임 파일이 어디 있어야 하고, 모드는 어느 폴더에, 어떤 순서로 로드돼야 하는가" 같은 도메인 지식이 앱 안에 통째로 녹아 있다. 사용자는 그 복잡함을 몰라도 된다.
중요한 선 긋기: WheelWizard는 관리 도구일 뿐, 닌텐도의 게임 파일을 배포하지 않는다. 사용자는 자신이 소유한 마리오카트 Wii의 디스크 이미지를 직접 제공해야 하고, Retro Rewind는 별도 팬 커뮤니티(제작자 ZPL)의 결과물이다. 이 문서는 저작권·에뮬레이션의 법적 논의가 아니라, "이 오픈소스 앱의 코드에서 무엇을 배울 수 있는가"에 초점을 둔다.
비슷한 모드 로더는 예전부터 있었지만, WheelWizard가 TrendShift Daily 1위까지 오른 데는 네 가지가 겹친다. 번거로운 수동 설치를 없앴고, 세 OS를 하나로 지원하며, 코드 품질이 상용 앱급이고, 앱 안에서 Mii를 3D로 그리는 기술적 볼거리까지 있다.
Retro Rewind를 수동으로 돌리려면 Dolphin을 받아 설정하고, 게임 파일을 인식시키고, 모드 파일을 정확한 폴더에 넣고, 매번 새 버전을 확인해야 한다. WheelWizard는 Dolphin 설치 → 게임 경로 지정 → 실행을 앱이 대신하고, 실행 순간 필요한 모드를 준비(PrepareModsForLaunch)한 뒤 알아서 Retro Rewind 최신 버전으로 맞춘다. 이 "귀찮음의 제거"가 커뮤니티에서 빠르게 퍼진 1차 이유다.
과거 이런 도구는 대개 Windows 전용(WPF/WinForms)이었다. WheelWizard는 Avalonia를 써서 Windows·Linux·macOS를 단일 C# 코드베이스로 지원하고, Linux는 Flatpak 패키지까지 낸다. 빌드 설정도 Debug/Release × Windows/Linux/macOS로 갈려 있어, 리눅스 유저(에뮬레이션 커뮤니티의 큰 축)를 그대로 끌어안았다.
보통 게임 유틸은 한 파일에 다 몰아넣기 마련인데, 이 저장소는 기능별 모듈 + 의존성 주입 + 결과(Result) 패턴 + 파일시스템 추상화 + 단위 테스트 + 17개 언어 국제화를 갖췄다. 323개 C# 파일이 Features/·Services/·Views/·Shared/로 깔끔히 나뉜다. 그래서 "게임 도구"라기보다 잘 설계된 Avalonia 데스크탑 앱을 통째로 읽는 교재로서 개발자들의 별을 받았다.
단순 관리 도구를 넘어, WheelWizard는 커스텀 Mii 편집기를 품고 있고, Wii의 Mii 캐릭터를 앱 내부에서 3D로 렌더링한다. 이건 닌텐도의 얼굴 라이브러리 FFL을 네이티브 interop으로 끌어와 구현한 것(오픈소스 FFL-Testing 포팅). C# 앱이 네이티브 C/C++ 라이브러리를 불러 3D를 그리는, 흔치 않은 볼거리다.
| 방식 | 설치·관리 | WheelWizard와의 차이 |
|---|---|---|
| 수동 설정 | Dolphin·게임·모드를 손으로 배치 | 매 업데이트 추적·폴더 관리가 전부 사용자 몫 |
| Windows 전용 로더 | 바이너리 하나로 실행 | macOS·Linux 미지원, 확장·유지보수 구조 빈약 |
| WheelWizard | 설치·모드·업데이트·프로필·온라인 통합 | 크로스플랫폼 + 모듈형 아키텍처 + 3D Mii + i18n |
순수 C# / .NET 8(net8.0) 프로젝트다. 스택은 "화면을 그리는 UI 층", "네트워크·파일·실행을 다루는 서비스 층", "세 OS로 빌드·배포하는 플랫폼 층" 세 겹으로 볼 수 있다. 눈여겨볼 점은, 이 앱이 프레임워크의 최신 실전 패턴(DI·Result·IO 추상화·리질리언스)을 게임 유틸리티에 그대로 적용했다는 것 — 그래서 스택 표 하나하나가 곧 학습 항목이다.
| 구성요소 | 기술 / 버전 | 역할 |
|---|---|---|
| 언어·런타임 | C# · .NET 8 (SDK 8.0.124) | 단일 언어로 로직·UI·네이티브 interop까지. Nullable·ImplicitUsings 활성. |
| UI 프레임워크 | Avalonia 11.2.5 | 크로스플랫폼 XAML UI. Fluent 테마 + Inter 폰트, 컴파일된 바인딩 기본. MVVM 패턴. |
| UI 부가 | Avalonia.HtmlRenderer · Diagnostics | 앱 내 HTML 렌더(패치노트 등), 디버그 빌드 진단 도구. |
| 화면 표면 | AXAML 103개 | Views/Pages(Home·Rooms·Mods·Mii·Settings…) + Popups + 재사용 Components. |
| 패키지 | 역할 |
|---|---|
| Microsoft.Extensions.DependencyInjection | 의존성 주입 컨테이너. ServiceCollection으로 모든 기능을 조립(ValidateOnBuild로 시작 시 검증). |
| Refit.HttpClientFactory | 타입세이프 HTTP. [Get(...)] 어트리뷰트가 붙은 인터페이스가 곧 API 클라이언트(GameBanana·GitHub·Retro Rewind). |
| Microsoft.Extensions.Http.Resilience | Polly 기반 리질리언스 — 네트워크 호출 재시도·타임아웃·서킷브레이커. |
| Serilog (+ Console/File Sinks) | 구조적 로깅. Avalonia의 자체 로거로도 브리지(AvaloniaLoggerAdapter). |
| System.IO.Abstractions · Testably.Abstractions | 파일·시간·랜덤을 인터페이스로 추상화(IFileSystem·ITimeSystem·IRandomSystem) → 테스트 가능. |
| SharpCompress | 모드 아카이브(zip·7z 등) 압축 해제 — 모드 설치의 실체. |
| YamlDotNet · ini-parser | YAML(17개 언어 번역 파일) 파싱 · INI(Dolphin 설정·모드 메타) 읽고 쓰기. |
| Semver · Microsoft.Extensions.Caching.Memory | 시맨틱 버전 비교(업데이트 판단) · 메모리 캐시(API 응답 등). |
| 구성요소 | 내용 |
|---|---|
| 멀티 구성 빌드 | Debug/Release × Windows/Linux/macOS 6종. RID(win-x64·linux-x64·osx-arm64)별 상수(WINDOWS/LINUX/MACOS) 분기. |
| 빌드 스크립트 | build-win.bat·build-linux.bat·build-linux-arm64.bat·build-mac.sh·build-everything.bat. |
| Linux 통합 | Tmds.DBus.Protocol(D-Bus), Flatpak 매니페스트, Flatpak 샌드박스 감지 로직. |
| 코드 스타일·품질 | CSharpier(포매터) · Roslyn 분석기 · .editorconfig(18KB) · WheelWizard.Test(테스트 17파일). |
| 네이티브 Mii 3D | FFL 라이브러리 interop(FflNativeInterop·NativeMiiRenderer) + 임베디드 3D 바디 모델(.rmdl). |
패키지 목록만 봐도 "이 앱은 테스트 가능성과 크로스플랫폼을 1순위로 뒀다"는 게 읽힌다. 파일시스템조차 IFileSystem으로 추상화한 것은, 실제 디스크 없이도 모드 설치 로직을 테스트하기 위함이다. Refit·Polly·Serilog·DI는 전형적인 "회사 백엔드"의 도구인데, 그걸 게임 런처에 통째로 가져와 팬 프로젝트를 상용 앱 골격으로 끌어올렸다.
이 앱의 뼈대는 세 가지 결정으로 요약된다 — (1) 모든 기능을 DI 컴포지션 루트에서 조립하고, (2) 에러를 예외가 아니라 OperationResult 값으로 흘리며, (3) 화면(View)과 로직(Feature/Service)을 MVVM으로 가른다. 아래가 시작부터 게임 실행까지의 전체 흐름이다.
SetupExtensions.cs)모든 의존성은 한 곳에서 조립된다. 각 기능은 자기만의 AddXxx() 확장 메서드를 갖고, 컴포지션 루트는 그걸 순서대로 부르기만 한다. 새 기능을 추가할 때 확장 메서드 하나만 등록하면 되므로, 결합도가 낮고 경계가 뚜렷하다.
public static void AddWheelWizardServices(this IServiceCollection services)
{
services.AddDolphinInstaller();
services.AddSettings();
services.AddGameBanana();
services.AddWiiManagement();
services.AddMods(); // 기능마다 자기 등록 확장을 가짐
// …
// 파일·시간·랜덤을 "진짜 구현"으로 바인딩 (테스트에선 가짜로 교체)
services.AddSingleton<IFileSystem, RealFileSystem>();
services.AddSingleton<ITimeSystem, RealTimeSystem>();
// 제네릭 API 호출기 — 어떤 Refit 인터페이스든 감싼다
services.AddTransient(typeof(IApiCaller<>), typeof(ApiCaller<>));
}
// 예: Mods 기능의 자기 등록 (ModsExtensions.cs)
public static IServiceCollection AddMods(this IServiceCollection services)
{
services.AddSingleton<IModInstallationService, ModInstallationService>();
services.AddSingleton<IModManager, ModManager>();
services.AddSingleton<IModsLaunchService, ModsLaunchService>();
return services;
}
이 앱은 실패를 try/catch로 여기저기 던지는 대신, OperationResult / OperationResult<T>라는 반환값으로 표현한다. 성공이면 값을, 실패면 Error를 담아 돌려주고, 호출자는 IsSuccess로 분기한다. 모드 로딩·API 호출·게임 실행 등 실패가 정상 시나리오인 곳에서 코드가 훨씬 예측 가능해진다.
// 모드 로딩: 각 단계가 실패를 값으로 반환 → 조기 리턴으로 흐름이 평평해진다
public async Task<OperationResult<ObservableCollection<Mod>>> LoadModsAsync()
{
var dir = FileHelper.EnsureDirectory(_modsFolderPath);
if (dir.IsFailure) return dir.Error; // 던지지 않고 에러를 넘김
var iniFiles = FileHelper.FindFilesByExtension(_modsFolderPath, "*.ini");
if (iniFiles.IsFailure) return iniFiles.Error;
// … 성공 경로만 아래로 계속
}
// 예외가 필요한 경계는 TryCatch가 값으로 변환해 준다
OperationResult<T> r = OperationResult.TryCatch(() => RiskyCall());
ApiCaller.cs)GameBanana 모드 검색 같은 외부 API는 Refit 인터페이스로 선언만 하면 구현이 자동 생성된다. 그리고 모든 호출은 제네릭 IApiCaller<TApi>를 통과하는데, 이게 표현식 트리(Expression)로 "어떤 메서드를 불렀는지"를 문자열로 뽑아 로그에 남기고, 예외를 OperationResult로 감싼다. 네트워크 실패가 앱을 죽이지 않고 값으로 흐른다.
// 선언만으로 API 클라이언트가 된다 (IGameBananaApi.cs)
public interface IGameBananaApi {
[Get("/Util/Search/Results")]
Task<GameBananaSearchResults> GetModSearchResults(
[AliasAs("_sSearchString")] string q, [AliasAs("_idGameRow")] int gameId, …);
}
// 호출은 제네릭 호출기를 통해 — 실패해도 예외가 아니라 OperationResult
var result = await apiCaller.CallApiAsync(api => api.GetModSearchResults("rainbow", 5709, …));
if (result.IsSuccess) Render(result.Value); // 실패 시 result.Error 로 처리
온라인 방(room)에서 공정성을 지키려고, WheelWizard는 Dolphin을 실행할 때 --config=Dolphin.Core.EnableCheats=False·Achievements.Enabled=False를 붙이고, 이미 떠 있는 Dolphin 인스턴스를 먼저 종료한다. 또 게임 파일이 없으면 실행 버튼 단계에서 OperationResult 실패로 막아, "빈 실행"으로 인한 오류를 사전에 차단한다. 이런 도메인 규칙이 런처 코드에 명시적으로 박혀 있다.
코드는 수평(횡단)이 아니라 수직(기능)으로 갈려 있다. 즉 "모든 서비스", "모든 화면"으로 뭉치기보다, Features/ 아래에 기능별 폴더(모드·GameBanana·Mii렌더·자동업데이트…)를 두고, 그 안에 로직과 DI 등록을 함께 담았다. 아래가 본체 프로젝트(WheelWizard/)의 지도다.
구조가 드러내는 설계 의도는 분명하다. 기능을 추가하면 Features/에 폴더 하나가 늘고, 그 폴더의 AddXxx()를 컴포지션 루트에 한 줄 추가하면 끝이다. 화면(Views)은 로직을 모른 채 서비스 인터페이스에만 의존하고, 실패 처리는 Shared/OperationResult 하나로 통일된다. "어디를 고쳐야 하는가"가 폴더 경계에 그대로 보인다.
WheelWizard는 "요즘 잘 만든 데스크탑 앱이 안에서 뭘 하는가"를 통째로 보여주는 교재다. UI, DI, 에러 처리, 테스트, 네이티브 interop, 국제화, 크로스플랫폼 배포까지 한 저장소에 다 들어 있다. 계층별로 배울 것을 정리하면 이렇다.
WPF/XAML 계열 문법이지만 Windows·Linux·macOS를 하나의 코드로 낸다. 컴파일된 바인딩, Fluent 테마, 재사용 컴포넌트, 디자인 토큰(Styles/Constants·Icons)까지 — "데스크탑 UI를 어떻게 구조화하는가"의 좋은 실물이다.
"의존성을 한 곳에서 조립하고(컴포지션 루트), 기능마다 자기 등록 확장을 갖는다"는 패턴은 규모가 커지는 앱을 지탱하는 핵심 기술이다. ValidateOnBuild로 시작 시 그래프를 검증하는 것까지, 실전 DI의 정석을 볼 수 있다.
OperationResult<T>로 "성공/실패를 반환값으로" 다루는 함수형 스타일. 실패가 흔한 I/O·네트워크 경계에서 코드가 어떻게 평평하고 예측 가능해지는지, 언제 TryCatch로 예외를 값으로 바꾸는지를 익힌다.
IFileSystem(System.IO.Abstractions)으로 디스크를 추상화하면, 실제 파일 없이도 "모드 설치" 로직을 검증할 수 있다. "부작용을 인터페이스 뒤로 밀어 테스트 가능하게 만드는" 설계 감각을 실물로 배운다.
인터페이스 선언만으로 API 클라이언트가 되는 Refit, 그리고 재시도·타임아웃을 붙이는 Http.Resilience(Polly). 외부 API를 "문자열 URL 조립" 없이 타입 안전하게 다루는 현대적 방식이다.
Mii 3D 렌더러는 C#이 네이티브 FFL 라이브러리를 P/Invoke로 부르고, 임베디드 3D 모델(.rmdl)을 로드해 그린다. 관리 코드(C#)와 비관리 코드(C/C++)의 경계를 넘는, 흔히 접하기 힘든 실전 예제다.
17개 언어를 YAML로 관리하고 번역 함수까지 테스트한다. 또 플랫폼별 자동 업데이트(Windows/Linux/Fallback), 멀티 RID 빌드, Flatpak 패키징 — "만든 걸 세 OS에 어떻게 실제로 배포하는가"의 전 과정을 담고 있다.
거창한 사양은 필요 없다. 사용에는 일반 데스크탑(Windows/Linux/macOS)과 사용자가 소유한 게임 파일이면 되고, 개발에는 .NET 8 SDK와 편집기만 있으면 빌드가 된다.
| 구분 | 요구 / 참고 |
|---|---|
| OS(사용) | Windows · Linux(Flatpak 포함) · macOS(Apple Silicon osx-arm64 기본) |
| 전제(사용) | 본인 소유의 마리오카트 Wii 디스크 이미지 + Dolphin(앱이 설치 지원). 게임 파일은 앱이 배포하지 않음. |
| SDK(개발) | .NET 8 SDK (global.json: 8.0.124, latestFeature 롤포워드) |
| 편집기(개발) | JetBrains Rider · Visual Studio · VS Code(.vscode 포함). Avalonia 미리보기 지원 |
| 빌드 | 플랫폼별 스크립트(build-win.bat·build-mac.sh…) 또는 dotnet build -c Release-Linux |
| 포터블/경로 | WW_BASEDIR 환경변수로 작업 폴더 지정 · portable-ww.txt로 포터블 모드 |
서명되지 않은 새 실행 파일은 백신이 "바이러스/트로이목마"로 잘못 표시하는 일이 흔하다. 제작진은 이를 오탐으로 설명하며 (1) 최신 버전 사용, (2) Microsoft에 파일 제출, (3) 직접 소스로 빌드, (4) VirusTotal 검증을 권한다. 코드 서명 도입도 검토 중이다. "왜 백신이 새 오픈소스 바이너리를 막는가"라는, 배포자가 반드시 겪는 문제를 그대로 보여주는 사례다.
git clone 후 dotnet build -c Release-Linux(또는 build-win.bat)로 빌드하고 실행해 본다. 디버그 빌드에선 Avalonia Diagnostics(F12류)로 UI 트리를 뜯어본다. 목표: .NET 8 + Avalonia 앱이 어떻게 시작되는지(진입점 → DI → 창)를 체감.
Resources/Languages/의 en.yml을 복제해 몇 개 키를 번역하고, WheelWizard.Test의 TranslationFunctionsTests를 돌려 본다. 배우는 것: 번역이 코드로 어떻게 로드·검증되는지, 리소스가 어떻게 임베드되는지.
Feature 폴더 + 자기 등록 AddXxx() 만들기사소한 서비스(예: 하드코딩 문구를 반환하는 ITipService) 하나를 Features/에 만들고, AddTips() 확장을 SetupExtensions.cs에 등록한 뒤 페이지에서 주입받아 표시한다. 배우는 것: 컴포지션 루트에 기능을 꽂는 실제 절차(= 이 앱 확장 방식의 최소 형태).
OperationResult로 감싸기아무 공개 REST API를 [Get(...)] Refit 인터페이스로 선언하고, IApiCaller<T>로 호출해 성공/실패를 OperationResult로 분기해 화면에 그린다. 네트워크를 끊어 실패 경로도 확인. 배우는 것: 타입세이프 HTTP + 값-기반 에러 처리의 결합.
Features/MiiRendering/Services/FflNativeInterop.cs·NativeMiiRenderer.cs를 읽어 "C#이 네이티브 라이브러리를 어떻게 부르는지" 분석한 뒤, 간단한 C 라이브러리를 만들어 [DllImport]로 호출하는 30줄짜리 샘플을 짠다. 배우는 것: 관리/비관리 경계, 마샬링, 임베디드 리소스 로딩.
| 주차 | 주제 | 무엇을 · 왜 |
|---|---|---|
| 1주 | C# · .NET 8 기초 | 비동기(async/Task), 인터페이스, 제네릭, 프로젝트/빌드 구성. 앱을 빌드해 보는 토대. |
| 2주 | Avalonia · MVVM | AXAML, 데이터 바인딩, 스타일/리소스, Pages·Popups 구조. UI를 로직과 분리하는 법. |
| 3주 | 의존성 주입 · 컴포지션 루트 | 서비스 수명(Singleton/Transient), AddXxx() 모듈화, ValidateOnBuild. 앱 확장의 핵심. |
| 4주 | Result 패턴 · 테스트 | OperationResult로 에러를 값처럼, IFileSystem로 부작용 추상화, xUnit로 검증. |
| 5주 | Refit · 리질리언스 · 로깅 | 타입세이프 HTTP, Polly 재시도, Serilog 구조적 로깅. 외부 세계와 안전하게 대화. |
| 6주 | 네이티브 interop · 배포 | P/Invoke·마샬링, 멀티 RID 빌드, Flatpak·자동 업데이트. 만든 걸 세 OS로 내보내기. |
처음부터 Mii 3D 렌더러(네이티브 interop)에 뛰어들면 정작 이 앱의 뼈대(Avalonia + DI + Result)를 건너뛰게 된다. 1~4주에 "화면–서비스가 어떻게 조립되고 에러가 어떻게 흐르는가"를 손으로 재현하면 이 저장소의 대부분이 이해된다. interop과 크로스플랫폼 배포는 그 위에 얹는 고급 주제로 마지막에 다루는 순서가 가장 단단하다.
Views/가 그렇게 구성된다.SetupExtensions.AddWheelWizardServices().IsSuccess면 값을, 아니면 Error를 담는다. 실패가 흔한 I/O·네트워크에서 흐름을 예측 가능하게 만든다.[Get(...)] 같은 어트리뷰트만 붙이면 HTTP 클라이언트 구현을 자동 생성해 주는 라이브러리. 문자열 URL 조립 없이 타입 안전하게 API를 호출한다.[DllImport]로 부르는 메커니즘. 마샬링은 두 세계 사이에서 데이터 형식을 변환하는 것. Mii 3D 렌더러가 이걸로 FFL 라이브러리를 쓴다..rmdl은 렌더링에 쓰는 임베디드 3D 바디 모델 파일이다.win-x64·linux-x64·osx-arm64처럼 빌드 대상 플랫폼을 가리키는 식별자. Flatpak은 리눅스용 샌드박스 패키지 포맷으로, WheelWizard가 리눅스 배포에 사용한다.UrlProtocolManager가 등록을 담당한다.