27 KiB
Lightless Sync Development Guidelines
This document outlines the coding standards, architectural patterns, and best practices for contributing to the Lightless Sync project.
Table of Contents
- Project Structure
- Coding Standards
- Architecture Patterns
- Dependency Injection
- Mediator Pattern
- Service Development
- UI Development
- Dalamud Integration
- Performance Considerations
- Testing
- Common Patterns
Project Structure
Directory Organization
LightlessSync/
├── Changelog/ # Version history and update notes
├── FileCache/ # File caching and management
├── Interop/ # Game interop and IPC with other plugins
│ └── Ipc/ # IPC callers for external plugins
├── LightlessConfiguration/ # Configuration management
├── PlayerData/ # Player data handling and pairs
│ ├── Factories/
│ ├── Handlers/
│ ├── Pairs/
│ └── Services/
├── Services/ # Core business logic services
│ ├── CharaData/ # Character data management
│ ├── Events/ # Event aggregation
│ ├── Mediator/ # Mediator pattern implementation
│ └── ServerConfiguration/
├── UI/ # ImGui-based user interfaces
│ ├── Components/ # Reusable UI components
│ ├── Handlers/ # UI event handlers
│ └── Models/ # UI data models
├── Utils/ # Utility classes
└── WebAPI/ # SignalR and HTTP client logic
├── Files/ # File transfer operations
└── SignalR/ # SignalR hub connections
File Organization Rules
- One primary class per file: File name must match the primary type name
- Partial classes: Use for large classes (e.g.,
CharaDataManager.cs,CharaDataManager.Upload.cs) - Nested types: Keep in the same file unless they grow large enough to warrant separation
- Interfaces: Prefix with
I(e.g.,IIpcCaller,IMediatorSubscriber)
Coding Standards
General C# Conventions
Follow the .editorconfig settings:
# Indentation
indent_size = 4
tab_width = 4
end_of_line = crlf
# Naming
- Interfaces: IPascalCase (begins with I)
- Types: PascalCase
- Methods: PascalCase
- Properties: PascalCase
- Private fields: _camelCase (underscore prefix)
- Local variables: camelCase
- Parameters: camelCase
- Constants: PascalCase or UPPER_CASE
Code Style Preferences
Namespaces
// Use block-scoped namespaces (not file-scoped)
namespace LightlessSync.Services;
public class MyService
{
// ...
}
Braces
// Always use braces for control statements
if (condition)
{
DoSomething();
}
// Even for single-line statements
if (condition)
{
return;
}
Expression-Bodied Members
// Prefer expression bodies for properties and accessors
public string Name { get; init; } = string.Empty;
public bool IsEnabled => _config.Current.Enabled;
// Avoid for methods, constructors, and operators
public void DoWork()
{
// Full method body
}
Object Initialization
// Prefer object initializers
var notification = new LightlessNotification
{
Id = "example",
Title = "Example",
Message = "This is an example",
Type = NotificationType.Info
};
// Prefer collection initializers
var list = new List<string> { "item1", "item2", "item3" };
Null Handling
// Prefer null coalescing
var value = possiblyNull ?? defaultValue;
// Prefer null propagation
var length = text?.Length ?? 0;
// Prefer pattern matching for null checks
if (value is null)
{
return;
}
// Use nullable reference types
public string? OptionalValue { get; set; }
public string RequiredValue { get; set; } = string.Empty;
Modern C# Features
// Use target-typed new expressions when type is apparent
List<string> items = new();
Dictionary<string, int> map = new();
// Use primary constructors sparingly (prefer traditional constructors for clarity)
// AVOID: public class MyService(ILogger logger) : IService
// Use record types for DTOs and immutable data
public record UserData(string Name, string Id);
public record struct PairRequestEntry(string HashedCid, string MessageTemplate, DateTime ReceivedAt);
// Use pattern matching
if (obj is MyType { Property: "value" } typedObj)
{
// Use typedObj
}
Architecture Patterns
Mediator Pattern (Core Communication)
The Lightless Sync uses a custom mediator pattern for decoupled communication between components.
Key Components
- LightlessMediator: Central message bus
- MessageBase: Base class for all messages
- IMediatorSubscriber: Interface for subscribers
- MediatorSubscriberBase: Base class with auto-cleanup
Creating Messages
// In Services/Mediator/Messages.cs
public record MyCustomMessage(string Data, int Value) : MessageBase;
// For messages that need to stay on the same thread
public record SynchronousMessage : MessageBase
{
public override bool KeepThreadContext => true;
}
Subscribing to Messages
public class MyService : DisposableMediatorSubscriberBase, IHostedService
{
public MyService(ILogger<MyService> logger, LightlessMediator mediator)
: base(logger, mediator)
{
}
public Task StartAsync(CancellationToken cancellationToken)
{
// Subscribe to messages
Mediator.Subscribe<MyCustomMessage>(this, HandleMyMessage);
Mediator.Subscribe<AnotherMessage>(this, HandleAnotherMessage);
return Task.CompletedTask;
}
private void HandleMyMessage(MyCustomMessage message)
{
Logger.LogDebug("Received: {Data}, {Value}", message.Data, message.Value);
// Handle the message
}
public Task StopAsync(CancellationToken cancellationToken)
{
// Cleanup is automatic with DisposableMediatorSubscriberBase
return Task.CompletedTask;
}
}
Publishing Messages
// Publish to all subscribers
Mediator.Publish(new MyCustomMessage("test", 42));
// Publish from non-mediator classes
_mediator.Publish(new NotificationMessage(
"Title",
"Message",
NotificationType.Info));
Message Guidelines
- Use records for messages (immutable and concise)
- Keep messages simple: Only carry data, no logic
- Name clearly:
<Action><Subject>Message(e.g.,PairRequestReceivedMessage) - Thread-safe data: Messages are processed asynchronously unless
KeepThreadContext = true - Avoid circular dependencies: Messages should flow in one direction
Dependency Injection
Service Registration (Plugin.cs)
// In Plugin.cs constructor:
.ConfigureServices(collection =>
{
// Singleton services (shared state, long-lived)
collection.AddSingleton<LightlessMediator>();
collection.AddSingleton<ApiController>();
collection.AddSingleton<PairManager>();
// Scoped services (per-resolution scope)
collection.AddScoped<UiService>();
collection.AddScoped<CacheMonitor>();
// Hosted services (background services with lifecycle)
collection.AddHostedService(p => p.GetRequiredService<LightlessMediator>());
collection.AddHostedService(p => p.GetRequiredService<NotificationService>());
// Lazy dependencies (avoid circular dependencies)
collection.AddSingleton(s => new Lazy<ApiController>(() => s.GetRequiredService<ApiController>()));
// Factory pattern
collection.AddSingleton<GameObjectHandlerFactory>();
})
Service Patterns
Standard Service
public class MyService
{
private readonly ILogger<MyService> _logger;
private readonly SomeDependency _dependency;
public MyService(
ILogger<MyService> logger,
SomeDependency dependency)
{
_logger = logger;
_dependency = dependency;
}
public void DoWork()
{
_logger.LogInformation("Working...");
_dependency.PerformAction();
}
}
Hosted Service (Background Service)
public class MyHostedService : IHostedService, IMediatorSubscriber
{
private readonly ILogger<MyHostedService> _logger;
private readonly LightlessMediator _mediator;
public LightlessMediator Mediator => _mediator;
public MyHostedService(
ILogger<MyHostedService> logger,
LightlessMediator mediator)
{
_logger = logger;
_mediator = mediator;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting {Service}", nameof(MyHostedService));
// Subscribe to mediator messages
_mediator.Subscribe<SomeMessage>(this, HandleSomeMessage);
// Initialize resources
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping {Service}", nameof(MyHostedService));
// Cleanup
_mediator.UnsubscribeAll(this);
return Task.CompletedTask;
}
private void HandleSomeMessage(SomeMessage msg)
{
// Handle message
}
}
Mediator Subscriber Service
public sealed class MyService : DisposableMediatorSubscriberBase
{
private readonly SomeDependency _dependency;
public MyService(
ILogger<MyService> logger,
LightlessMediator mediator,
SomeDependency dependency)
: base(logger, mediator)
{
_dependency = dependency;
// Subscribe in constructor or in separate Init method
Mediator.Subscribe<SomeMessage>(this, HandleMessage);
}
private void HandleMessage(SomeMessage msg)
{
Logger.LogDebug("Handling message: {Msg}", msg);
// Process message
}
// Dispose is handled by base class
}
Service Development
Service Responsibilities
Services should follow Single Responsibility Principle:
- NotificationService: Handles all in-game notifications
- BroadcastService: Manages Lightfinder broadcast state
- PairRequestService: Manages incoming pair requests
- DalamudUtilService: Utility methods for Dalamud framework operations
Service Guidelines
- Logging: Always use
ILogger<T>with appropriate log levels - Error Handling: Wrap risky operations in try-catch, log exceptions
- Async/Await: Use
ConfigureAwait(false)for non-UI operations - Thread Safety: Use locks,
ConcurrentDictionary, orSemaphoreSlimfor shared state - Disposal: Implement
IDisposableor inherit fromDisposableMediatorSubscriberBase
Example Service Template
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Services;
public sealed class TemplateService : DisposableMediatorSubscriberBase, IHostedService
{
private readonly ILogger<TemplateService> _logger;
private readonly SomeDependency _dependency;
private readonly SemaphoreSlim _lock = new(1);
public TemplateService(
ILogger<TemplateService> logger,
LightlessMediator mediator,
SomeDependency dependency)
: base(logger, mediator)
{
_logger = logger;
_dependency = dependency;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting {Service}", nameof(TemplateService));
Mediator.Subscribe<SomeMessage>(this, HandleSomeMessage);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping {Service}", nameof(TemplateService));
_lock.Dispose();
return Task.CompletedTask;
}
private async void HandleSomeMessage(SomeMessage msg)
{
await _lock.WaitAsync().ConfigureAwait(false);
try
{
_logger.LogDebug("Processing message: {Msg}", msg);
// Do work
await _dependency.ProcessAsync(msg).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process message");
}
finally
{
_lock.Release();
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_lock?.Dispose();
}
base.Dispose(disposing);
}
}
UI Development
UI Base Classes
All UI windows inherit from WindowMediatorSubscriberBase:
public class MyWindow : WindowMediatorSubscriberBase
{
private readonly UiSharedService _uiShared;
public MyWindow(
ILogger<MyWindow> logger,
LightlessMediator mediator,
UiSharedService uiShared,
PerformanceCollectorService performanceCollector)
: base(logger, mediator, "My Window Title", performanceCollector)
{
_uiShared = uiShared;
// Window configuration
Flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse;
SizeConstraints = new WindowSizeConstraints
{
MinimumSize = new Vector2(600, 400),
MaximumSize = new Vector2(800, 600)
};
}
protected override void DrawInternal()
{
// ImGui drawing code here
ImGui.Text("Hello, World!");
}
}
UI Guidelines
- Separation of Concerns: UI should only handle rendering and user input
- Use UiSharedService: Centralized UI utilities (icons, buttons, styling)
- Performance: Use
PerformanceCollectorServiceto track rendering performance - Disposal: Windows are scoped; don't store state that needs to persist
- Mediator: Use mediator to communicate with services
- ImGui Best Practices:
- Use
ImRaiifor push/pop operations - Always pair
Begin/Endcalls - Use
ImGuiHelpers.ScaledDummy()for responsive spacing - Check
IsOpenproperty for visibility
- Use
ImGui Patterns
// Using ImRaii for automatic cleanup
using (ImRaii.PushColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")))
using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 6f))
{
if (ImGui.Button("Styled Button"))
{
// Handle click
}
}
// Tab bars
using (var tabBar = ImRaii.TabBar("###my_tabs"))
{
if (!tabBar) return;
using (var tab1 = ImRaii.TabItem("Tab 1"))
{
if (tab1)
{
ImGui.Text("Tab 1 content");
}
}
using (var tab2 = ImRaii.TabItem("Tab 2"))
{
if (tab2)
{
ImGui.Text("Tab 2 content");
}
}
}
// Child windows for scrolling regions
using (var child = ImRaii.Child("###content", new Vector2(0, -40), false,
ImGuiWindowFlags.AlwaysVerticalScrollbar))
{
if (!child) return;
// Scrollable content here
}
Dalamud Integration
Dalamud Services
Dalamud provides various services accessible through constructor injection:
// Common Dalamud services:
IClientState clientState // Player and world state
IFramework framework // Game framework events
IObjectTable objectTable // Game objects (players, NPCs, etc.)
IChatGui chatGui // Chat window access
ICommandManager commandManager // Command registration
IDataManager gameData // Game data sheets
IPluginLog pluginLog // Logging
INotificationManager notificationManager // Toast notifications
IGameGui gameGui // Game UI access
ITextureProvider textureProvider // Icon/texture loading
Framework Threading
CRITICAL: Dalamud operates on the game's main thread.
// Safe: Already on framework thread
private void OnFrameworkUpdate(IFramework framework)
{
// Can safely access game state
var player = _clientState.LocalPlayer;
}
// Unsafe: Called from background thread
private async Task BackgroundWork()
{
// DO NOT access game state here!
// Use DalamudUtilService to run on framework thread
await _dalamudUtil.RunOnFrameworkThread(() =>
{
// Now safe to access game state
var player = _clientState.LocalPlayer;
});
}
Game Object Access
// Always check for null
var player = _clientState.LocalPlayer;
if (player == null)
{
return;
}
// Iterating game objects
foreach (var obj in _objectTable)
{
if (obj is IPlayerCharacter pc)
{
// Handle player character
}
}
// Finding specific objects
var target = _targetManager.Target;
if (target is IPlayerCharacter targetPlayer)
{
// Handle targeted player
}
IPC with Other Plugins
Use the IPC pattern for communicating with other Dalamud plugins:
public sealed class IpcCallerExample : DisposableMediatorSubscriberBase, IIpcCaller
{
private readonly IDalamudPluginInterface _pi;
private readonly ICallGateSubscriber<int> _apiVersion;
public string Name => "ExamplePlugin";
public bool Available { get; private set; }
public IpcCallerExample(
ILogger<IpcCallerExample> logger,
IDalamudPluginInterface pluginInterface,
LightlessMediator mediator)
: base(logger, mediator)
{
_pi = pluginInterface;
try
{
_apiVersion = _pi.GetIpcSubscriber<int>("ExamplePlugin.GetApiVersion");
CheckAvailability();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to initialize IPC for ExamplePlugin");
}
}
private void CheckAvailability()
{
try
{
var version = _apiVersion.InvokeFunc();
Available = version >= 1;
Logger.LogInformation("ExamplePlugin available, version: {Version}", version);
}
catch
{
Available = false;
}
}
public void DoSomething()
{
if (!Available)
{
Logger.LogWarning("ExamplePlugin not available");
return;
}
// Call IPC methods
}
}
Performance Considerations
Framework Updates
The game runs at ~60 FPS. Avoid heavy operations in framework updates:
Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, OnTick);
private void OnTick(PriorityFrameworkUpdateMessage _)
{
// Throttle expensive operations
if ((DateTime.UtcNow - _lastCheck).TotalSeconds < 1)
{
return;
}
_lastCheck = DateTime.UtcNow;
// Do lightweight work only
}
Async Operations
// Good: Non-blocking async
public async Task<Data> FetchDataAsync()
{
var result = await _httpClient.GetAsync(url).ConfigureAwait(false);
return await result.Content.ReadFromJsonAsync<Data>().ConfigureAwait(false);
}
// Bad: Blocking async
public Data FetchDataBlocking()
{
return FetchDataAsync().GetAwaiter().GetResult(); // Blocks thread!
}
Collection Performance
// Good: Thread-safe concurrent collections
private readonly ConcurrentDictionary<string, Data> _cache = new();
// Good: Lock-protected regular collections
private readonly List<Item> _items = new();
private readonly object _itemsLock = new();
public void AddItem(Item item)
{
lock (_itemsLock)
{
_items.Add(item);
}
}
// Good: SemaphoreSlim for async locks
private readonly SemaphoreSlim _asyncLock = new(1);
public async Task ProcessAsync()
{
await _asyncLock.WaitAsync().ConfigureAwait(false);
try
{
// Critical section
}
finally
{
_asyncLock.Release();
}
}
Memory Management
// Dispose pattern for unmanaged resources
public class ResourceManager : IDisposable
{
private bool _disposed;
private readonly SemaphoreSlim _semaphore = new(1);
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// Dispose managed resources
_semaphore?.Dispose();
}
// Free unmanaged resources
_disposed = true;
}
}
Testing
Unit Testing
[TestClass]
public class MyServiceTests
{
private Mock<ILogger<MyService>> _mockLogger;
private Mock<LightlessMediator> _mockMediator;
private MyService _service;
[TestInitialize]
public void Setup()
{
_mockLogger = new Mock<ILogger<MyService>>();
_mockMediator = new Mock<LightlessMediator>();
_service = new MyService(_mockLogger.Object, _mockMediator.Object);
}
[TestMethod]
public void DoWork_WithValidInput_ReturnsExpectedResult()
{
// Arrange
var input = "test";
// Act
var result = _service.DoWork(input);
// Assert
Assert.AreEqual("expected", result);
}
}
Integration Testing
Test with real Dalamud services in a controlled environment:
// Create test harness that mimics Dalamud environment
public class DalamudTestHarness
{
public IClientState ClientState { get; }
public IFramework Framework { get; }
// ... other services
public void SimulateFrameworkUpdate()
{
// Trigger framework update event
}
}
Common Patterns
Notification Pattern
// Simple notification
Mediator.Publish(new NotificationMessage(
"Title",
"Message body",
NotificationType.Info));
// Rich notification with actions
var notification = new LightlessNotification
{
Id = "unique_id",
Title = "Action Required",
Message = "Do you want to proceed?",
Type = NotificationType.Warning,
Duration = TimeSpan.FromSeconds(10),
Actions = new List<LightlessNotificationAction>
{
new()
{
Id = "confirm",
Label = "Confirm",
Icon = FontAwesomeIcon.Check,
Color = UIColors.Get("LightlessGreen"),
IsPrimary = true,
OnClick = (n) =>
{
_logger.LogInformation("User confirmed");
DoAction();
n.IsDismissed = true;
}
},
new()
{
Id = "cancel",
Label = "Cancel",
Icon = FontAwesomeIcon.Times,
OnClick = (n) => n.IsDismissed = true
}
}
};
Mediator.Publish(new LightlessNotificationMessage(notification));
Configuration Pattern
public class MyConfigService : ConfigurationServiceBase<MyConfig>
{
public MyConfigService(string configDirectory)
: base(Path.Combine(configDirectory, "myconfig.json"))
{
}
}
// Usage
_configService.Current.SomeSetting = newValue;
_configService.Save();
Factory Pattern
public class GameObjectHandlerFactory
{
private readonly DalamudUtilService _dalamudUtil;
private readonly LightlessMediator _mediator;
public GameObjectHandlerFactory(
DalamudUtilService dalamudUtil,
LightlessMediator mediator)
{
_dalamudUtil = dalamudUtil;
_mediator = mediator;
}
public GameObjectHandler Create(IntPtr address, string identifier)
{
return new GameObjectHandler(
_dalamudUtil,
_mediator,
() => address,
identifier);
}
}
Error Handling
Logging Guidelines
// Debug: Verbose information for debugging
_logger.LogDebug("Processing request {RequestId}", requestId);
// Information: General informational messages
_logger.LogInformation("User {UserId} connected", userId);
// Warning: Recoverable issues
_logger.LogWarning("Cache miss for {Key}, will fetch from server", key);
// Error: Errors that need attention
_logger.LogError(ex, "Failed to process message {MessageId}", messageId);
// Critical: Application-breaking errors
_logger.LogCritical(ex, "Database connection failed");
Exception Handling
public async Task<Result> DoWorkAsync()
{
try
{
// Risky operation
return await PerformOperationAsync().ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Network error during operation");
// Show user-friendly notification
Mediator.Publish(new NotificationMessage(
"Network Error",
"Failed to connect to server. Please check your connection.",
NotificationType.Error));
return Result.Failure;
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error during operation");
throw; // Re-throw unexpected exceptions
}
}
Best Practices Summary
DO ✅
- Use
DisposableMediatorSubscriberBasefor services that subscribe to mediator - Use
IHostedServicefor background services - Always inject
ILogger<T>for logging - Use
ConfigureAwait(false)for async operations not requiring UI thread - Dispose of resources properly (SemaphoreSlim, HttpClient, etc.)
- Check for null when accessing Dalamud game objects
- Use thread-safe collections for shared state
- Keep UI logic separate from business logic
- Use mediator for cross-component communication
- Write unit tests for business logic
- Document public APIs with XML comments
DON'T ❌
- Don't access game objects from background threads without
RunOnFrameworkThread - Don't block async methods with
.Resultor.Wait() - Don't store UI state in services (services are often singletons)
- Don't use
async voidexcept for event handlers - Don't catch
Exceptionwithout re-throwing or logging - Don't create circular dependencies between services
- Don't perform heavy operations in framework updates
- Don't forget to unsubscribe from mediator messages
- Don't hardcode file paths (use
IDalamudPluginInterface.ConfigDirectory) - Don't use primary constructors (prefer traditional for clarity)
Code Review Checklist
Before submitting a pull request, ensure:
- Code follows naming conventions
- All new services are registered in
Plugin.cs - Mediator messages are defined in
Messages.cs - Logging is appropriate and informative
- Thread safety is considered for shared state
- Disposable resources are properly disposed
- Async operations use
ConfigureAwait(false) - UI code is separated from business logic
- No direct game object access from background threads
- Error handling is comprehensive
- Performance impact is considered
- Comments explain "why", not "what"
- No compiler warnings
- Tests pass (if applicable)
Resources
Happy Coding! 🚀