Burnsies HideoutDocsBurnsie.Common

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):

  1. Initialize() runs before the world exists. Subscribe and log — nothing game-facing.
  2. Duty events arrive on LSPDFR's fiber — copy state and GameFiber.StartNew immediately.
  3. 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:

MemberBehavior
bool IsPanicActiveIncident 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 / PanicEndedFired 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)

  1. Natives and entities only on GameFibers.
  2. Yield every loop iteration; Sleep for pacing, Wait only for deliberate time-scaling.
  3. LSPDFR event handlers: copy args, hand off, return.
  4. Re-validate every entity with .Exists() after any yield.
  5. Timers on TickCount/UtcNow, never GameTime.
  6. Blocking IO off-thread, always (OffThread.Run).
  7. Everything you set, you restore — on off-duty, reload and unload.
  8. Versions are System.Version, never floats.
  9. Degrade per-subsystem: one broken asset/native never takes the plugin down.
  10. Log decisions, not noise — and prefix every line.