Burnsie.Common — Developer Guide
How to build LSPDFR/RPH plugins on the Burnsie.Common foundation, and how to integrate with PanicButton. Written for plugin authors; architecture rationale lives in ARCHITECTURE.md, API ground truth in research/. Deeper per-feature reference docs (one page per module) live in features/.
1. Setup
Reference the assemblies (both ship beside your plugin in plugins\LSPDFR\):
<ItemGroup>
<PackageReference Include="RagePluginHook" Version="1.124.0" ExcludeAssets="runtime" />
<Reference Include="Burnsie.Common"> <HintPath>libs\Burnsie.Common.dll</HintPath> <Private>false</Private></Reference>
<Reference Include="Burnsie.Common.Lspdfr"> <HintPath>libs\Burnsie.Common.Lspdfr.dll</HintPath> <Private>false</Private></Reference>
<Reference Include="LSPD First Response"> <HintPath>libs\LSPD First Response.dll</HintPath> <Private>false</Private></Reference>
</ItemGroup>
Burnsie.Common needs only RPH — usable from any RPH plugin. Burnsie.Common.Lspdfr adds the
LSPDFR-facing services. Target net48, x64.
2. The plugin skeleton (lifecycle done right)
LSPDFR gives you three entry points and zero duty queries. The pattern below is the distilled,
correct shape — DutyStateService caches duty from the event, one Session object owns all
mutable state, and teardown is a single path:
public class Main : LSPD_First_Response.Mod.API.Plugin
{
private static Log _log;
private static DutyStateService _duty;
private static MySession _session; // ALL your mutable state lives here
public override void Initialize() => Startup(); // subscribe + log. NOTHING else.
public override void InitializeAgain() => Startup(); // reload-safe because Startup is idempotent
private static void Startup()
{
_log = new Log("MyPlugin");
_duty?.Detach(); // unsubscribe-then-subscribe = idempotent
_duty = new DutyStateService(_log);
_duty.DutyStarted += () => GameFiber.StartNew(() => { _session = new MySession(_log); _session.Start(); });
_duty.DutyEnded += () => GameFiber.StartNew(() => { _session?.End(); _session = null; });
_duty.Attach();
}
public override void Finally() // must be safe even if never on duty
{
_session?.End();
_session = null;
_duty?.Detach();
}
}
Rules this encodes (violate them and you get the classic plugin bugs — see research/v1-audit.md for the gallery):
Initialize()runs before the world exists. Subscribe and log — nothing game-facing.- Duty events arrive on LSPDFR's fiber — copy state and
GameFiber.StartNewimmediately. - Everything you start must stop in one place, and that place must run on off-duty, reload and unload.
3. Module tour with examples
Logging
var log = new Log("MyPlugin"); // → "[MyPlugin] ..." in RagePluginHook.log
log.DebugEnabled = config.DebugLogging;
log.Info("loaded"); log.Warn("odd"); log.Error("failed", ex);
Configuration (schema-driven — file, values and docs from one table)
var schema = new ConfigSchema("plugins/LSPDFR/MyPlugin.ini", log);
var cooldown = schema.Int ("Settings", "CooldownSeconds", 30, 5, 300, "Seconds between uses.");
var hotkey = schema.Enum("Keybindings", "Key", Keys.F11, "Activation key.");
schema.EnsureFileExists(); // writes a fully-commented default INI on first run
schema.Load(); // typed reads, clamped to declared ranges, warnings on bad values
int seconds = cooldown.Value;
string markdown = schema.ToMarkdown(); // paste into your README — zero doc drift
Keybinds (suppression + composite modifiers handled)
var bind = new Keybind(Keys.B, Keys.LControlKey); // Keys.Control matches either Ctrl
if (bind.IsJustPressed()) { /* edge-fired; auto-suppressed when console/pause/typing */ }
bind.FriendlyName(); // "Ctrl+B" for help text
Fibers (exception fences + cooperative cancellation)
var flag = new CancellationFlag();
var fibers = new FiberRegistry(log);
fibers.StartLoop("MyPlugin.Watch", 250, flag, () => { /* runs every 250ms, exceptions logged */ });
fibers.StartOnce("MyPlugin.OneShot", () => { /* fenced one-off */ });
// teardown:
flag.Cancel(); // loops exit at next iteration
fibers.AbortAll(); // backstop
GameFiber.Sleep = real time. GameFiber.Wait = scaled by Game.TimeScale — only use it when
you want slow-motion to stretch your delay. Timers: Game.TickCount or DateTime.UtcNow,
never Game.GameTime.
Notifications
var notifier = new Notifier(log);
notifier.Start(fibers, flag); // rate-limited queue drain
notifier.Show(Palette.Dispatch("unit en route to " + Palette.Key(area)));
notifier.ShowBrandedNow("~b~MyPlugin", "~s~subtitle", "body"); // immediate, from a fiber
notifier.SetLiveStatus("~b~STATUS", "", "3 units on scene"); // update-in-place toast
Palette implements the community color standard (~b~ police/dispatch, ~r~ danger,
~y~ keys/objectives, ~o~ warnings, ~g~ success — color the words, not the sentence).
Audio
var wav = new WavPlayer(log);
wav.Preload("plugins/LSPDFR/MyPlugin/audio/alert.wav"); // off-thread; call at duty start
wav.Play("plugins/LSPDFR/MyPlugin/audio/alert.wav", repeats: 3, gapMs: 450); // never blocks
FrontendSounds.RadioSquelchOpen(); // fail-soft native beeps
RPH has no managed sound API (verified) — this is the supported path for custom audio.
Scanner audio (LSPDFR layer — crash-proof by construction)
var scanner = new ScannerAudio(log);
scanner.Initialize(); // enumerates lspdfr\audio\scanner on THIS install
scanner.Start(fibers, flag); // queue fiber gated on GetIsAudioEngineBusy (busy = dropped!)
string phrase = scanner.Compose( // tokens that don't exist here are dropped, not crashed
scanner.FirstAvailable("ATTENTION_ALL_UNITS", "AI_OFFICER_REQUEST_BACKUP"),
"WE_HAVE", "CRIME_OFFICER_IN_NEED_OF_ASSISTANCE",
"IN_OR_ON_POSITION", // placeholder LSPDFR substitutes with zone audio
"UNITS_RESPOND_CODE_03");
scanner.Enqueue(phrase, playerPos); // positional variant fills IN_OR_ON_POSITION
Backup dispatch with real tracking
var dispatcher = new BackupDispatcher(log);
dispatcher.UnitArrived += u => log.Info(u.Type + " on scene");
dispatcher.BeginSession(fibers, flag, new DispatchOptions {
PositionProvider = () => Game.LocalPlayer.Character.Position,
ContinueEscalation = () => StillInDanger(), // gate between waves
});
var waves = new List<Wave> {
new Wave { Label = "patrol", DelaySeconds = 0,
Units = { new UnitOrder { Type = EBackupUnitType.LocalUnit } } },
};
int created = dispatcher.DispatchWaves(waves); // honest count of spawned units
// ... later:
dispatcher.EndSession(fadeBlips: true);
The dispatcher keeps the Vehicle from every RequestBackup and correlates
Events.OnBackupUnitCreated by request position/time — that event fires for all backup in the
game, so blind adoption would hijack the player's own backup menu.
Entity lifetime
var bag = new EntityBag(log);
bag.Keep(myPed); bag.Keep(myBlip);
// exactly one of, on every exit path:
bag.ReleaseAll(); // happy path: blips deleted, entities dismissed to ambient
bag.DeleteAll(); // abort path: hard delete
Safe natives
Anything not in the verified RPH surface goes through SafeNatives — pinned hashes, one failure
disables that native for the session, never throws:
SafeNatives.AnimPostFxPlay("DeathFailMPIn", 0, looped: true);
SafeNatives.PadShake(400, 200);
SafeNatives.ApplyPedDamagePack(ped, "SCR_Torture", 0f, 1f);
Diagnostics
var report = new DependencyReport(log);
report.CheckFile("RAGE Plugin Hook", "RAGEPluginHook.exe", new Version(1, 124), hardRequirement: true);
if (report.HasBlockingFailure) { /* disable features, tell the user */ }
// version parsing is System.Version — never float (RPH 1.124 < 1.98 as floats was a real bug)
new UpdateChecker(log).CheckDaily("you/your-repo", myVersion, "plugins/LSPDFR/MyPlugin/update.stamp",
tag => notifier.Show("Update " + tag + " available")); // off-thread, 5s timeout, daily
// Same rules, but against the official LCPDFR downloads API. Three noteworthy outcomes:
// a plain update notice, a "major release — your build may now be unstable" warning, and
// a BETA warning when the running version is *newer* than the public release.
// result.Message is a ready-made toast string; "up to date" never reaches the callback.
new LcpdfrVersionChecker(log).CheckDaily(12345 /* your file id */, myVersion,
"plugins/LSPDFR/MyPlugin/lcpdfr-version.stamp",
result => notifier.Show(result.Message));
// Or check Burnsie.Common's own download page (file id 54637):
new LcpdfrVersionChecker(log).CheckLibraryDaily(
"plugins/LSPDFR/MyPlugin/burnsie-common-version.stamp",
result => notifier.Show(result.Message));
4. The PanicButton API
PanicButton.API.PanicProvider — stable across 1.x:
| Member | Behavior |
|---|---|
bool IsPanicActive | Incident running right now. |
bool TriggerPanic() | Start a panic as if the chord was pressed. False when off duty / cooling down / active / disabled by user config. |
bool TriggerOfficerDown() | Start (or escalate the running incident to) officer-down. |
void CancelPanic() | Code 4 the active incident. |
event Action PanicStarted / PanicEnded | Fired on PanicButton's fibers — return fast, never block. |
Consume via the soft-dependency pattern so your plugin works without PanicButton installed:
isolate every PanicProvider reference in one non-inlined method and catch
FileNotFoundException at the call site (full sample in the README).
Respect the user: external triggers obey their cooldown and their
[Integration] AllowExternalTriggers setting — a false return is an answer, not an error.
5. Ground rules recap (the ten commandments)
- Natives and entities only on GameFibers.
- Yield every loop iteration;
Sleepfor pacing,Waitonly for deliberate time-scaling. - LSPDFR event handlers: copy args, hand off, return.
- Re-validate every entity with
.Exists()after any yield. - Timers on
TickCount/UtcNow, neverGameTime. - Blocking IO off-thread, always (
OffThread.Run). - Everything you set, you restore — on off-duty, reload and unload.
- Versions are
System.Version, never floats. - Degrade per-subsystem: one broken asset/native never takes the plugin down.
- Log decisions, not noise — and prefix every line.