Drift

Static lints that catch game-simulation non-determinism — HashMap/HashSet iteration, unseeded RNG, wall-clock reads, unordered parallelism, pointer-width fields on hashed state, non-associative float arithmetic reachable from simulation code — before they cause a lockstep/rollback-netcode desync, instead of debugging the desync after the fact.

Companion to Foldback (runtime desync detection and bisection): drift prevents what it can at build time, Foldback finds what slips through at runtime.

Pick your language

LanguageWhat you get
Rustcrates/drift-lint, a dylint lint library, 6 rules
C# / Unitybindings/csharp/Drift.Analyzers, a Roslyn analyzer, 5 rules (DRIFT0001–0005) — works in any C# project; Unity-specific rules are scoped to UnityEngine.* types; float_outside_fixed_step not ported here; published on NuGet as GameDeterminism.Analyzers, not Drift.Analyzers (see the C# quickstart for why)
C++ / Unrealbindings/unreal/drift-unreal-lint, a standalone binary over stock LLVM/Clang (no engine fork needed), full 6-rule taxonomy parity, against a project's compile_commands.json
GDScript / Godotbindings/godot/drift-godot-lint, a standalone binary over gdck-syntax (pure Rust, no engine dependency), 4 of 6 rules (hashmap_iter/usize_in_hashed_state don't apply to GDScript)

Full rule reference, one entry per rule with a real example and fix: Rule catalog.

Pick a quickstart from the sidebar for your language, or jump straight to a full step-by-step walkthrough: Rust, C# / Unity, Unreal, Godot. Something not firing the way you expect? Check the FAQ / troubleshooting page first.

License

Licensed under either of

at your option.

Rust quickstart

cargo install cargo-dylint dylint-link
cargo dylint --path crates/drift-lint --workspace

--path works against a local clone; a --git https://github.com/FelixMiddelhoff/drift install works the same way once this is pushed. Not published to crates.io — see the rule catalog for why, and cargo dylint's own docs for the full CLI.

Reachability scoping

float_outside_fixed_step is opt-in — configure it via a dylint.toml at your workspace root:

[drift-lint]
tick_reachable_roots = ["main"]

Real behavior, not obvious from the name: setting tick_reachable_roots at all scopes every rule except usize_in_hashed_state, not just float_outside_fixed_step — see the rule catalog for the full explanation.

C# / Unity quickstart

dotnet add package GameDeterminism.Analyzers

Published as GameDeterminism.Analyzers, not Drift.AnalyzersDrift is a NuGet ID prefix reserved by an unrelated company, so the underlying project (bindings/csharp/Drift.Analyzers) and the published package name differ.

Or reference the built Drift.Analyzers.dll directly as an Analyzer item in your .csproj:

<ItemGroup>
  <Analyzer Include="path/to/Drift.Analyzers.dll" />
</ItemGroup>

dotnet pack bindings/csharp/Drift.Analyzers builds the package locally in the correct analyzers/dotnet/cs/ layout that VS/Rider/VS Code auto-load on install.

5 rules (DRIFT0001–0005) — works in any C# project; Unity-specific rules are scoped to UnityEngine.* types. float_outside_fixed_step was never ported to C#/Unity — see the rule catalog for the full per-rule breakdown.

Unreal quickstart

Generate a compile_commands.json with UnrealBuildTool, then run drift-unreal-lint against it. Grab a prebuilt binary from the latest release (Linux/macOS/Windows — still needs LIBCLANG_PATH set at runtime, it links libclang dynamically rather than bundling it), or build from source:

"<EnginePath>/Engine/Binaries/DotNET/UnrealBuildTool/UnrealBuildTool.exe" \
  -Mode=GenerateClangDatabase -Project="<YourProject>.uproject" <Target> Win64 Development

LIBCLANG_PATH="<path to your LLVM install>/bin" \
  cargo run --manifest-path bindings/unreal/drift-unreal-lint/Cargo.toml -- \
  compile_commands.json [config.toml]

unseeded_rng, wallclock_read, hashmap_iter, unordered_parallelism, and usize_in_hashed_state run unconditionally. float_outside_fixed_step is opt-in — it does nothing until config.toml sets tick_reachable_roots:

tick_reachable_roots = ["AMyPawn::Tick"]
fixed_step_functions = ["UMyIntegrator::Step"]

Uses stock LLVM/Clang directly (the clang crate over libclang) — no forked compiler, no custom clang-tidy check to build.

Performance

Each translation unit is parsed in its own worker subprocess (crash isolation — a libclang OOM/segfault on one file costs only that file's findings, not the whole run). Workers run concurrently on a thread pool sized to std::thread::available_parallelism(); override with DRIFT_UNREAL_JOBS=<n> if you need to cap concurrency (e.g. to control peak memory on a large codebase).

See the rule catalog for each rule's own known limitations, and the Unreal walkthrough for a full step-by-step example against a real project.

Godot quickstart

Grab a prebuilt binary from the latest release (Linux/macOS/Windows, no toolchain needed), or build from source:

cargo run --manifest-path bindings/godot/drift-godot-lint/Cargo.toml -- <file.gd | project directory>

unseeded_rng, wallclock_read, and unordered_parallelism run unconditionally. float_outside_fixed_step runs automatically wherever _process/_physics_process is defined — no config file needed, unlike the Rust/Unreal sides (Godot's own tick entry points are fixed, well-known method names).

Pure Rust, no engine dependency (via gdck-syntax). See the rule catalog for why hashmap_iter/usize_in_hashed_state don't apply here.

Rule catalog

One entry per lint. Each rule is warn-by-default, not deny — see Suppressing a rule.

Rust (drift-lint, via dylint)

Reachability scoping (optional)

By default every rule below fires unconditionally, repo-wide. Add a dylint.toml in the target workspace root to scope rules to code actually reachable from your simulation's tick function:

[drift-lint]
tick_reachable_roots = ["tick"]        # crate-relative, no crate-name prefix — see below
fixed_step_functions = ["physics::integrate"]  # drift::float_outside_fixed_step only

tick_reachable_roots seeds a reachable-function set built from a direct, intra-crate call graph (function bodies only — dyn Trait/fn-pointer call targets aren't traversed, a deliberate under-approximation: a missed rule firing is a smaller problem for a lint tool's adoption than a wrong one). Every rule except usize_in_hashed_state (which flags a field definition, not code inside a function — reachability doesn't apply) only fires on code inside a reachable function once this is set.

Root paths are matched against TyCtxt::def_path_str, which does not include the current crate's own name — "tick" for a top-level function, "module::path::to::fn" for a nested one, never "my_crate::tick".

drift::hashmap_iter

Flags iterating a HashMap/HashSet (.iter(), .iter_mut(), .into_iter(), .keys(), .values(), .values_mut(), .drain()).

Why: iteration order isn't guaranteed stable across peers/platforms/runs. Feeding it into anything that affects simulated state (spawn order, damage application order, event dispatch) is a classic desync source.

#![allow(unused)]
fn main() {
use std::collections::HashMap;
let units: HashMap<u32, u32> = HashMap::new();
for (id, _unit) in units.iter() {
    // order of `id` here is not guaranteed the same on every peer
}
}

Fix: use a BTreeMap/BTreeSet, or collect and sort before iterating:

#![allow(unused)]
fn main() {
use std::collections::HashMap;
let units: HashMap<u32, u32> = HashMap::new();
let mut ids: Vec<_> = units.keys().collect();
ids.sort();
}

Recognizes the collect-then-sort pattern (found as a real false positive dogfooding against Foldback's hashable.rs, now fixed and reverified against that same file — zero hits): a let binding whose initializer ends in .collect() off a flagged call, immediately followed by a .sort()/.sort_by()/.sort_by_key()/.sort_unstable()/.sort_unstable_by()/.sort_unstable_by_key() on that same binding, doesn't fire:

#![allow(unused)]
fn main() {
use std::collections::HashMap;
let self_map: HashMap<u32, u32> = HashMap::new();
let mut entries: Vec<_> = self_map.iter().collect(); // not flagged — sorted right after
entries.sort_by(|a, b| a.0.cmp(b.0));
}

This is pattern-matching one known shape (same statement's binding, very next statement, same block), not real dataflow analysis — sorting a few statements later, through a helper function, or after the value moves into a field still fires. Suppress those with #[allow(drift_hashmap_iter)].

Dogfood result, real: run against veloren/veloren's common crate group (a real, shipped open-source multiplayer voxel RPG — 277 .rs files across 10 workspace crates: veloren-common, -base, -ecs, -net, -state, -systems, etc.) — 5 real findings, e.g. common/systems/src/aura.rs:176 and :180, both real HashMap/HashSet iteration inside an ECS aura-application system.

drift::unseeded_rng

Flags rand::thread_rng() and rand::random().

Why: an RNG seeded from OS entropy differs per peer and per run by construction — any simulation decision that reads from it desyncs immediately.

Fix: use an RNG constructed from an explicit, tracked seed (e.g. StdRng::seed_from_u64(tick_seed)) fed by your simulation's own deterministic seed source. Suppress with #[allow(drift_unseeded_rng)] for genuinely cosmetic randomness (particle effects, UI flourish).

Dogfood result, real: run against veloren/veloren's common crate group — 26 real findings, all rand::random(), spread across real combat-state files (leap_explosion_shockwave.rs, leap_shockwave.rs, rapid_melee.rs, shockwave.rs) — real gameplay-simulation code, not test/example files.

drift::wallclock_read

Flags std::time::SystemTime::now() and std::time::Instant::now().

Why: two peers read different wall-clock values by construction. Fine for profiling/logging, not fine for anything that affects simulated state.

Fix: use your simulation's own deterministic tick counter. Suppress with #[allow(drift_wallclock_read)] at legitimate profiling/logging call sites.

Dogfood result, real: run against veloren/veloren's common crate group — 18 real findings, including common/state/src/state.rs:966 (start: Instant::now()), a real timing field inside the simulation's own State struct.

drift::unordered_parallelism

Flags .par_iter(), .par_iter_mut(), .into_par_iter(), .par_bridge() (rayon).

Why: work items complete in scheduler-dependent order. A reduction into simulated state that isn't commutative differs between peers whose thread pools schedule differently — a desync that reproduces on one machine and not another.

Known problem: doesn't verify the chain actually ends in a non-commutative reduction — a par_iter() whose result is genuinely order-independent (e.g. .sum()) is a false positive. Suppress with #[allow(drift_unordered_parallelism)] once confirmed order-independent.

Dogfood result, real: run against veloren/veloren's common crate group — 1 real finding, common/src/store.rs:93 (self.items.par_iter_mut()).

drift::usize_in_hashed_state

Flags usize/isize fields on a struct that also derives Hash.

Why: usize/isize are pointer-width (32 vs. 64 bits). A struct hashed for a cross-peer sync check that contains one hashes differently on a 32-bit vs. 64-bit build of the same logical state — a false desync report between two otherwise-correct peers on different architectures.

Fix: use a fixed-width integer type (u32/u64/i32/i64) instead. Suppress with #[allow(drift_usize_in_hashed_state)] if the struct is only ever hashed for something width-insensitive (e.g. a HashMap key never compared across processes). Not scoped by reachability (see above) — it flags a field definition, not code inside a function.

Dogfood result, real: run against veloren/veloren's common crate group — 2 real findings: common/src/trade.rs:209 (pub struct TradeId(usize);), and common/src/comp/body/plugin.rs:58 (pub species: Species on a #[derive(Hash)] struct) — the second one confirms this rule resolves real type aliases through rustc's own type information, not just textual matching: Species is pub type Species = usize;, only visible by asking the compiler what the field's real type is, not by reading the field declaration's own spelling.

drift::float_outside_fixed_step

Flags non-associative float arithmetic (+, -, *, /, %) reachable from a tick_reachable_roots entry, unless the enclosing function is listed in fixed_step_functions.

Opt-in only, unlike every other rule here — does nothing at all unless dylint.toml configures tick_reachable_roots. Without that scoping this rule would blanket-flag nearly every float operation in a typical game codebase, which is exactly why it wasn't in the original v1 catalog — implemented once reachability scoping existed to make it viable, not before.

Why: (a + b) + c isn't guaranteed to equal a + (b + c) in floating-point — operation order, SIMD width, and compiler optimization level can all change the result. Fine inside a controlled fixed-timestep/fixed-point integrator (that's the whole point of fixed_step_functions), not fine reachable from simulation state outside one.

A chain like a + b + c is deduped to a single warning on the outermost expression, not one per operator.

[drift-lint]
tick_reachable_roots = ["tick"]
fixed_step_functions = ["physics::integrate"]

Known problem: fixed_step_functions is a blunt, whole-function exemption — it doesn't distinguish genuinely fixed-step-safe arithmetic from fragile arithmetic within the same function, or vice versa. Suppress a specific expression with #[allow(drift_float_outside_fixed_step)].

Real gap found dogfooding the Godot binding, fixed here too: compound-assignment accumulation (total += delta) is ExprKind::AssignOp, a distinct HIR node from ExprKind::Binary — the original scan only matched Binary, so float drift accumulated via +=/-=/*=//= inside a tick-reachable function went uncaught. Found via a real project (Orama-Interactive/Pixelorama)'s own _marching_ants_time_elapsed += delta inside a Godot _process; confirmed the same gap existed here and in the Unreal binding, fixed in all three. AssignOp is matched via BinOpKind::from(AssignOpKind) and is never subject to the chain-dedup check above (it can't be a chain operand — its type is ()).

Not exercised in the veloren/veloren dogfood above — this rule is opt-in and no dylint.toml/tick_reachable_roots was configured for that external target (veloren/veloren doesn't ship a dylint.toml, and adding one to a third-party project's own simulation roots is out of scope for a dogfood run), so it correctly found zero hits, not evidence of anything either way. crates/drift-lint/tests/reachability_fixture (a real cargo dylint invocation, not a ui_test) is this rule's own positive/negative-verified regression test instead.

Suppressing a rule

Every rule is a plain rustc lint under the hood — suppress the normal way:

#[allow(drift_hashmap_iter)]
fn ui_only_function() { /* ... */ }

C#/Unity (Drift.Analyzers)

A Roslyn analyzer, bindings/csharp/Drift.Analyzers — works in any C# project, not just Unity (Unity-specific rules are simply scoped to UnityEngine.* types and still just ordinary C# analysis). Install via dotnet add package GameDeterminism.Analyzers (published under that name, not Drift.AnalyzersDrift is a NuGet ID prefix reserved by an unrelated company), or by referencing the built Drift.Analyzers.dll directly as an Analyzer item.

DRIFT0001 — Dictionary/HashSet iteration order

Flags foreach over a Dictionary<TKey, TValue>/HashSet<T> (including their .Keys/.Values collections).

Same rationale as drift::hashmap_iter. Fix: use a SortedDictionary/SortedSet, or sort before iterating.

Also recognizes the non-generic System.Collections.IDictionary interface (a real gap once suspected dogfooding against Foldback's Unity binding — its FoldbackReflection.cs pattern-matches is IDictionary dict — but re-verified by reading the actual code, not just grepping for the type name: that code never bare-foreaches the dictionary, it sorts dict.Keys by string first, the same safe pattern drift::hashmap_iter's own collect-then-sort recognition covers on the Rust side — so this was never a real false negative there, just a plausible-looking one from an incomplete first read). Deliberately not extended to the generic IDictionary<TKey,TValue> interface — SortedDictionary<TKey,TValue> also implements it, so flagging the interface itself would be a new false positive on genuinely ordered code reached only through it.

DRIFT0002 — RNG seeded from OS/engine entropy

Flags new System.Random() (parameterless — seeded from Environment.TickCount) and any UnityEngine.Random.* member access except InitState/state (the deterministic-seeding mechanism itself, not a violation).

Same rationale as drift::unseeded_rng. Fix: new Random(seed), or UnityEngine.Random.InitState(seed), fed by your simulation's deterministic seed.

DRIFT0003 — Wall-clock read

Flags DateTime.Now/DateTime.UtcNow, Environment.TickCount, and UnityEngine.Time.realtimeSinceStartup.

Same rationale as drift::wallclock_read. Fix: use your simulation's own deterministic tick counter.

DRIFT0004 — Parallel iteration result order

Flags .AsParallel() (PLINQ) and Parallel.ForEach/Parallel.For (System.Threading.Tasks).

Same rationale as drift::unordered_parallelism, targeting .NET's own parallelism primitives instead of rayon. Same known problem: doesn't verify the terminal reduction is actually commutative.

DRIFT0005 — pointer-width member used in hashing

Flags nint/nuint (and the older IntPtr/UIntPtr spellings) members two ways:

  1. Declared on a record/record struct — positional parameters, fields, and properties. A C# record's compiler-generated GetHashCode/Equals are derived from every member, the closest real analogue to Rust's #[derive(Hash)].
  2. Referenced anywhere in the body of a hand-written GetHashCode() override on a plain class/struct — not full data-flow analysis (it doesn't prove the reference actually feeds the returned hash, just that a hash method touches the field at all), but real and syntactic, not guessed at. Both a block body ({ ... }) and an expression body (=> ...) are checked.

Same rationale as drift::usize_in_hashed_state: nint/nuint are pointer-width (32 vs. 64 bits), so hashing one produces a different result on a 32-bit vs. 64-bit build of the same logical state.

Known limitation: the hand-written-GetHashCode check is syntactic reference detection, not real data-flow — a field merely read inside GetHashCode (logged, asserted, whatever) but not actually folded into the hash gets flagged too. Suppress with #pragma warning disable DRIFT0005 at specific false positives.

Suppressing a C# rule

#pragma warning disable DRIFT0001
// ...
#pragma warning restore DRIFT0001

Dogfood result

Run against Foldback's real Unity binding (bindings/unity/Runtime/FoldbackReflection.cs, bindings/unity/Tests~/FoldbackSys.Tests/Program.cs) with all 5 rules: zero hits. Genuinely clean, re-verified by reading the code, not just grepping type names — see DRIFT0001 above for the one case that looked like a gap on a first pass and wasn't on a closer read.

Unreal C++ (bindings/unreal/drift-unreal-lint)

A standalone Rust binary using libclang directly (the clang crate) against a project's compile_commands.json — stock LLVM/Clang, no engine fork, no custom clang-tidy check compiled into LLVM (see drift-godot-unreal-plan.md §6 for why: the prebuilt LLVM package ships clang-c headers + libclang.lib only, not the full LibTooling headers a real clang-tidy check needs). Real UnrealBuildTool compile_commands.json entries are clang-cl.exe @file.rsp, and file.rsp itself nests a second @...Shared.rsp — both response files are expanded recursively, and --driver-mode=cl is added automatically when the original compiler was clang-cl so its MSVC-style flags (/FI, /Fo, /clang:...) parse correctly.

Build: cargo build inside bindings/unreal/drift-unreal-lint, with LIBCLANG_PATH pointing at your LLVM install's bin directory (e.g. C:\Program Files\LLVM\bin). Run: drift-unreal-lint <compile_commands.json> [config.toml]unseeded_rng, wallclock_read, hashmap_iter, unordered_parallelism, and usize_in_hashed_state always run; float_outside_fixed_step only runs once config.toml sets tick_reachable_roots.

Real, disclosed gap: unlike the Rust side's #[allow(drift_x)], there is no suppression mechanism here — no inline comment, no config-level allowlist. A confirmed false positive today means restructuring the code or not failing CI on that finding.

Two more real bugs found dogfooding against real Lyra source, past what the initial spike caught: (1) the JSON Compilation Database spec requires relative paths inside arguments/command to resolve against that entry's own directory field, not the caller's cwd — real UBT response files use relative -I../Plugins/... include paths meant to resolve against Engine/Source. Without chdir-ing into directory before each parse, every file transitively including a plugin header (e.g. Abilities/GameplayAbility.h) hit a fatal "file not found" partway through, silently truncating that TU's AST to whatever came before the failure — this was the reason an earlier version of this doc claimed a clean-but-empty dogfood result; the truncation was real, the "clean" part wasn't. (2) once real headers actually resolved, this LLVM install's own AVX512 intrinsic headers reference builtins this exact clang frontend doesn't implement (a handful of real but irrelevant errors confined to system headers) — hitting clang's default -ferror-limit=20 and aborting the whole parse before ever reaching the target file's own code. Fixed by passing -ferror-limit=0, the standard fix for exactly this in static-analysis tooling: keep going past irrelevant header noise instead of giving up on the whole TU.

Real, disclosed cost, now measured to a real conclusion, and fixed: once headers actually resolve, parsing real Unreal C++ per translation unit is genuinely expensive without a precompiled header (well known — this is also why full UE rebuilds are slow) — a single real file's own full include tree was ~20,000 function/method definitions. A full-codebase sweep of Lyra's 388 translation units was run to completion this session (two tick_reachable_roots configured together: ULyraRangedWeaponInstance::Tick, ALyraWeaponSpawner::Tick) and, on the first attempt, turned out not just slow, it crashed: ran 22m27s, then a real libclang fatal error (LLVM ERROR: out of memory / Buffer allocation failed) while parsing Plugins/UIExtension/Source/Private/Widgets/UIExtensionPointWidget.cpp, followed by a process-level segmentation fault (not a clean non-zero exit) rather than the crash-recovery path (Err(_) => continue, already in run()) absorbing it and moving on. Because findings were only printed after every TU finished (no streaming), the crash produced zero output despite most of the 388 files likely having parsed successfully before it.

Real fix: run() no longer parses every TU in one shared process. It now spawns itself once per compile_commands.json entry (--worker <index> <compile_commands.json> [config.toml], a hidden mode), and each worker parses exactly one TU in its own fresh Clang/Index, printing its findings — plus, when tick_reachable_roots is configured, its own call edges and every one of its own functions' float-chain findings (tagged by owning function, unfiltered by reachability, since a single TU can't know the whole program's call graph) — as JSON to stdout. The orchestrator aggregates every worker's output, computes the cross-TU reachable set from the aggregated edges exactly as before, and only then filters the float-chain candidates by reachability. A worker that crashes (real OOM, a genuine segfault, anything) now only costs its own file's findings — reported to stderr, not silently dropped — and the orchestrator, in a separate untouched process, always reaches the end and prints everything every other worker found.

Real result, re-run after the fix: same full 388-file sweep, same two roots — completed cleanly this time, 388/388 translation units parsed, zero worker failures (the file that previously triggered the OOM no longer appears in any crash/failure report). 250 real findings across 5 of the 6 rules (usize_in_hashed_state found zero real hits this run — a real, disclosed gap, see its own entry above, not evidence of a bug): 15 float_outside_fixed_step, 136 hashmap_iter, 4 unordered_parallelism, 13 unseeded_rng, 82 wallclock_read — spot-checked, not just counted: float_outside_fixed_step fired on both configured roots' real code (LyraRangedWeaponInstance.cpp, and a new hit in LyraWeaponSpawner.cpp:88 not seen in the earlier single-root isolated dogfood), unordered_parallelism fired on real header-internal ParallelFor/UE::Tasks::Launch usage. Real, disclosed trade-off, since fixed: on the first (sequential) fix, wall-clock went from 22m27s-before-crashing to 70m3s to a real completion — one worker process (and its own LLVM/libclang initialization) per file, run one at a time, is real overhead the old single-shared-process design didn't pay. run()'s orchestrator now runs a work-stealing thread pool of concurrent worker processes instead of spawning them one at a time — pool size defaults to std::thread::available_parallelism(), overridable via DRIFT_UNREAL_JOBS. Crash isolation is unaffected (each worker is still its own OS process; a pool just controls how many run at once), and result aggregation is order-independent (findings are sorted/deduped after collection regardless of which worker finishes first). The per-file dogfood results throughout this doc's "Unreal C++" section remain real, complete, and unaffected by any of this — each ran against an isolated single- or few-file compile database, never anywhere near this memory pressure.

Reachability scoping (required — float_outside_fixed_step only)

Same rationale as the Rust side's dylint.toml: unscoped, this would blanket-flag nearly every float operation in a typical Unreal codebase. Without a config file (or an empty one), only unseeded_rng runs.

tick_reachable_roots = ["ALyraWeaponSpawner::Tick"]
fixed_step_functions = ["UMyIntegrator::Step"]

tick_reachable_roots seeds a reachable-function set built from a real, cross-translation-unit call graph (functions matched by Namespace::Class::Method-style qualified name, edges from every CallExpr in every parsed TU). Reachability stops at any function whose definition isn't in one of the TUs actually parsed — real Engine-internals calls (a TU outside the target project's own compile_commands.json) don't extend the graph further, a real, disclosed limitation, not a silent one.

drift-unreal::unseeded_rng

Flags calls to Unreal's global RNG (FMath::Rand, RandRange, RandHelper, FRand, FRandRange, RandBool, VRand, VRandCone, VRandCone2D), seeded from OS/engine entropy unless the project explicitly tracks a seed. Same rationale as drift::unseeded_rng. Fires unconditionally, no reachability scoping needed — unlike float arithmetic, a raw call to one of these is always worth flagging.

Matched against the call's own source spelling, not the resolved declaration's qualified name — a real bug hit building this: FMath (Engine/.../UnrealMathUtility.h) is struct FMath : public FPlatformMath, and Rand/FRand are actually declared on the base (a platform-specific typedef of FGenericPlatformMath), so the resolved declaration's qualified name is not FMath::.... Tokenizing the call site directly (up to the opening () sidesteps the inheritance chain entirely.

Known limitation: scans the whole translation unit, headers included — a call inside a header (e.g. Unreal's own FRandomStream::FRand() implementation) fires too, not just application code. No attempt is made to distinguish "your project's code" from "included engine code," unlike the Rust rule which naturally only sees the current crate.

Dogfood result, real: run against LyraGameplayAbility_RangedWeapon.cpp (a real file, isolated from the full 388-entry compile database for a fast, complete-headers dogfood run once the two bugs above were fixed) — found exactly the 3 real call sites confirmed by grep beforehand (FMath::FRand() ×2, FMath::Rand() ×1) at their exact real line numbers, plus one real header-internal hit inside RandomStream.h (see limitation above, not a bug).

drift-unreal::wallclock_read

Flags calls to FPlatformTime::Seconds/Cycles/Cycles64 and FDateTime::Now/UtcNow — a value that differs per peer/run must never feed simulated state. Same rationale as drift::wallclock_read/DRIFT0003. Fires unconditionally, no reachability scoping needed, same as unseeded_rng.

Matched against the call's own source spelling, not the resolved declaration — verified, not assumed, before building: FPlatformTime (HAL/PlatformTime.h) is a platform typedef (e.g. typedef FWindowsPlatformTime FPlatformTime; on Windows), and FWindowsPlatformTime::Seconds/Cycles/Cycles64 are declared directly on that platform struct rather than inherited — so FPlatformTime::Seconds()'s resolved declaration has a different qualified name (FWindowsPlatformTime::Seconds) than the call-site text, same shape as unseeded_rng's own FMath base-class split. FDateTime::Now/UtcNow don't have this split (FDateTime declares them directly), but are matched the same way for consistency.

Known limitation: same as unseeded_rng — scans the whole translation unit, headers included, no attempt to distinguish project code from included engine code.

Real bug found dogfooding, fixed: a macro-expanded argument re-evaluates the same call-site AST node more than once at the identical file:line:column — confirmed against real Lyra source, LyraAssetManagerStartupJob.cpp's UE_LOG(..., FPlatformTime::Seconds() - JobStartTime) reported the same location 3 times before a fix. All findings are now deduped by exact (file, line, column, rule) before printing — collapses only true duplicates, leaves genuinely distinct call sites (different columns) untouched.

Dogfood result, real: run against LyraAssetManagerStartupJob.cpp/.h (isolated single-file compile database) — found exactly the 3 real FPlatformTime::Seconds() call sites grep had confirmed beforehand (.cpp lines 9 and 22, .h line 38), plus real header-internal hits inside RandomStream.h, AutomationEvent.h, StatsSystemTypes.h (see limitation above, not a bug).

drift-unreal::hashmap_iter

Flags a range-based-for or .CreateIterator()/.CreateConstIterator() call over a TMap/TSet — both are hash-backed (Containers/Map.h/Set.h, confirmed by reading the real headers, which select between TSparseSet/TCompactSet internally), not insertion-ordered by any engine guarantee. Same rationale as drift::hashmap_iter/DRIFT0001. Fires unconditionally, no reachability scoping needed. Deliberately excludes TSortedMap/TSortedSet/TMultiMap — matches the plan's own taxonomy entry, not every hash-adjacent container.

Detected by type, not call-site spelling — a real, deliberate difference from the other two unconditional rules here: a range-based-for's compiler-desugared AST (confirmed with a real -ast-dump, not assumed) exposes the synthesized auto&& __range = <container>; binding one level below the ForRangeStmt's own immediate children, so its type — the container's own type — is checked there rather than at the ForRangeStmt cursor directly. .CreateIterator()/.CreateConstIterator() calls are matched by their receiver's type the same way.

A real consequence of type-based over spelling-based detection: this rule naturally avoids the collect-then-sort false positive the Rust side's own drift::hashmap_iter had to special-case (a TArray built from a map's keys/values, sorted immediately after, then iterated) — the sorted result is a TArray, a different type than TMap/TSet, so it never matches in the first place. Verified in the fixture (tests/fixture/pawn.cpp's IterateSortedArray), not just assumed to follow from the design.

Known limitation: same as unseeded_rng/wallclock_read — scans the whole translation unit, headers included, no attempt to distinguish project code from included engine code (real UE headers like Containers/LruCache.h fire too, expected).

Dogfood result, real: run against LyraGamePhaseSubsystem.cpp and LyraTeamSubsystem.cpp (isolated two-file compile database) — found exactly the 3 real range-based-for-over-TMap call sites grep had confirmed beforehand (ActivePhaseMap at lines 124 and 147, TeamMap at line 392), plus real header-internal hits (see limitation above, not a bug).

drift-unreal::unordered_parallelism

Flags ParallelFor, ParallelForWithTaskContext, and UE::Tasks::Launch — parallel dispatch whose results, folded into simulated state without a deterministic reduction, aren't proven commutative. Same known-problem caveat as drift::unordered_parallelism/DRIFT0004, inherited rather than re-litigated. Matched by call-site spelling, same shape as unseeded_rng/wallclock_read. Fires unconditionally, no reachability scoping needed.

Deliberately excludes AsyncTask — a real decision backed by evidence, not the taxonomy-symmetry default the plan warned against: since Lyra itself has zero confirmed call sites for any of these (a real, disclosed gap — see below), real non-Lyra Engine source was checked instead. Every real AsyncTask call site found (AndroidPlatformMemory.cpp's memory warning, ConfigContext.cpp's deprecation message, IPlatformFileManagedStorageWrapper.h's background file op) was UI/logging/IO work, not simulated state — including it in v1 would flag far more non-hazards than ParallelFor would. Deferred the same way float_outside_fixed_step was itself once deferred: on real evidence against inclusion, not a guess. Revisit if a real project surfaces an AsyncTask call site that actually touches simulated state.

Real, disclosed gap: unlike the other three unconditional rules, Lyra's own source has zero confirmed call sites for any of ParallelFor/ParallelForWithTaskContext/UE::Tasks::Launch — not evidence the rule is unneeded (these are standard, documented UE parallelism primitives used throughout the wider engine and in larger real projects), just evidence Lyra isn't the dogfood target that proves this one out. Validated instead with a real positive/negative fixture (tests/fixture/pawn.cpp): RunParallel (calls ParallelFor) fires, LogAsync (calls the deliberately-excluded AsyncTask) does not.

drift-unreal::usize_in_hashed_state

Flags SIZE_T/size_t/uintptr_t/intptr_t-typed struct/class fields referenced inside a GetTypeHash overload for that struct — pointer-width types are 32-bit on Win32, 64-bit on Win64/most platforms, exactly the Rust usize/C# nint/nuint hazard drift::usize_in_hashed_state/DRIFT0005 already flag. Fires unconditionally, no reachability scoping needed.

Implementation mirrors C#'s DRIFT0005 more than the Rust side's own struct-derive-attribute scan, since Unreal has no #[derive(Hash)]/record equivalent — hashing is always a hand-written free function GetTypeHash(const T&), found via Argument-Dependent Lookup: (1) find pointer-width-typed fields, keyed by their containing struct's own qualified name; (2) for each GetTypeHash overload with exactly one parameter, resolve that parameter's pointee type's declaration to the same lookup key, and scan the function body for a member reference to one of that struct's flagged fields. Same syntactic, conservative reference-detection C#'s own version already uses (not real dataflow) — a field merely read, not actually folded into the returned hash, still gets flagged, a known, inherited limitation.

Real, disclosed gap: same as unordered_parallelism — Lyra's own LyraGame source has zero confirmed SIZE_T/GetTypeHash references (a gameplay sample isn't the kind of project that hand-rolls hash functions on pointer-width fields). The hazard is real in principle, just unconfirmed against this specific dogfood target. Validated instead with a real positive/negative fixture (tests/fixture/pawn.cpp): FUnitId::Handle (SIZE_T, referenced inside its own GetTypeHash overload) fires; FUnusedHandle::RawHandle (SIZE_T, but never referenced inside any GetTypeHash) does not.

drift-unreal::float_outside_fixed_step

Flags non-associative float/double arithmetic (+, -, *, /) reachable from a tick_reachable_roots entry, unless the enclosing function is listed in fixed_step_functions. Same rationale as the Rust/C# rule of the same name. A chain like a + b + c is deduped to one warning on the outermost expression, not one per operator — verified with a real positive/negative fixture (tests/fixture/pawn.cpp): a TickSimulate call chain with two real float-chain statements fires exactly twice (once per statement, not once per operator), and a same-shaped NotReached function never called from Tick fires zero times.

Known limitation, real not assumed: UPROPERTY/UFUNCTION/USTRUCT/etc. specifiers (EditAnywhere, Replicated, Category, ...) are completely invisible to this tool — confirmed by reading ObjectMacros.h directly, they're #define X(...) (expand to nothing) under normal compilation, only read by UnrealHeaderTool separately. This rule doesn't need that metadata (it only looks at arithmetic and call graphs), so it's unaffected — but a future rule that needed to know "is this field actually replicated" would need a different approach (parsing .generated.h output, or a real UE-aware fork like RedpointGames') than this tool's own stock-Clang path.

Dogfood result, real, against a root that actually reaches float arithmetic: ULyraRangedWeaponInstance::Tick (isolated single-file compile database, tick_reachable_roots = ["ULyraRangedWeaponInstance::Tick"]) — Tick calls UpdateSpread/UpdateMultipliers, both real, non-synthetic float arithmetic (spread-cooldown decay, movement/crouch/jump spread multipliers smoothly interpolated every tick). Found 4 real findings in LyraRangedWeaponInstance.cpp at lines 155, 177, 181, 212 (e.g. CurrentHeat - (CooldownRate * DeltaSeconds)), plus one real header-internal hit inside World.h (see the header-scanning limitation the other rules share, not repeated here). Closes the gap the earlier ALyraWeaponSpawner::Tick dogfood left open (that root legitimately found zero, since it only calls Super::Tick — a correct-but-unconvincing result on its own).

Real gap found dogfooding the Godot binding, fixed here too: compound-assignment accumulation (CurrentValue += Delta) is libclang's EntityKind::CompoundAssignOperator, a distinct node from EntityKind::BinaryOperator — the original scan only walked BinaryOperator. Fixed by also matching CompoundAssignOperator against a COMPOUND_ARITHMETIC_OPS set (+=, -=, *=, /=). See the Rust rule's own entry above for the real, non-synthetic example that surfaced this (Orama-Interactive/Pixelorama).

Godot GDScript (bindings/godot/drift-godot-lint)

A standalone Rust binary using gdck-syntax (a real, lossless, pure-Rust GDScript 4 parser — no engine dependency, no .gdextension) directly against a project's .gd files. Foundation validated by a real feasibility spike first (see drift-godot-unreal-plan.md §9 for the full spike write-up): 468/470 files of a real, substantial Godot 4 game (SlayHorizon/godot-tiny-mmo) parsed clean. A second, unrelated real project — Orama-Interactive/Pixelorama, a shipped pixel-art editor (250 .gd files, ~59.5k lines, Godot 4.7) — parsed 250/250 clean, 0 parse errors, run under DRIFT_GODOT_STATS=1 to confirm rather than assume.

Build: cargo build inside bindings/godot/drift-godot-lint — no external toolchain needed (pure Rust, unlike the Unreal binding's libclang dependency). Run: drift-godot-lint <file.gd | project directory>unseeded_rng, wallclock_read, and unordered_parallelism always run; float_outside_fixed_step runs automatically wherever _process/_physics_process is defined, no config file needed (see its own entry below for why). A directory is walked recursively, skipping .godot (Godot's own editor cache, never real project source).

v1 scope, checked against the shared taxonomy, not copy-pasted: 4 of the 6 rules port. hashmap_iter does not port — Godot's own Dictionary is insertion-ordered by engine guarantee (confirmed independently by Foldback's own reflective-hashing work), so it isn't the hazard Rust's HashMap/C#'s Dictionary/Unreal's TMap/TSet are. usize_in_hashed_state is N/A, not deferred — GDScript's int is always 64-bit, no fixed/pointer-width distinction exists to have a bug in.

Real, disclosed gap: same as the Unreal binding — no suppression mechanism exists here either, no inline comment, no config-level allowlist.

drift-godot::unseeded_rng

Flags Godot's global RNG functions (randi, randf, randi_range, randf_range, randfn, randomize) — auto-seeded from OS entropy unless a project explicitly tracks a seed via seed(value). randomize() itself is included: it explicitly reseeds from OS entropy, the opposite of a tracked seed, so calling it is exactly as real a hazard as reading the RNG directly. Same rationale as drift::unseeded_rng/DRIFT0002/drift-unreal::unseeded_rng. Fires unconditionally, no reachability scoping needed.

Matched as a bare NameRef callee, not any call whose method name matches — deliberately excludes a member-call shape like generator.randi(), which is what a project's own deterministic RNG wrapper looks like at the call site. This isn't a hypothetical distinction: the exact addon named in the plan's own demand check (§1) ships a NetworkRandomNumberGenerator class that wraps a seedable RandomNumberGenerator instance precisely to solve this hazard — its own internal generator.randi() call must not be flagged, and isn't, because it's a member call, not a bare one.

Real bug found building this: gdck-syntax's NameRef/AttributeExpr nodes carry their own leading whitespace as a child token (confirmed with a real tree dump, not assumed) — .text() on the callee node returned " randi_range", not "randi_range", so every call silently failed to match until .trim() was added. Caught before trusting a "0 findings" result, the same discipline drift-unreal-lint's own near-miss UI tests established.

Dogfood result, real: run against SlayHorizon/godot-tiny-mmo (the same real, 470-file Godot 4 game the feasibility spike used) — 20 real findings, including the exact 6 call sites the spike itself had already found by hand (gateway.gd, local_player.gd, dungeon_service.gd, weapon.gd), plus more once the full RNG_FUNCS list (the spike's own throwaway visitor only checked randi/randf/randi_range/randf_range) was applied for real.

Second dogfood target, a different genre entirely: run against Orama-Interactive/Pixelorama (a real, shipped pixel-art editor, 250 .gd files, ~59.5k lines, Godot 4.7) — 16 real findings, spot-checked: BaseDraw.gd:218 (randi() % _brush.random.size(), picking a random brush variant) and VanishingPoint.gd:7 (Color(randf(), randf(), randf(), 1), a randomized debug-handle color) are both genuine unseeded global-RNG reads, correctly located down to the column for multiple calls on the same line.

drift-godot::wallclock_read

Flags OS.get_ticks_msec, OS.get_ticks_usec, Time.get_ticks_msec, Time.get_ticks_usec, Time.get_unix_time_from_system — a value that differs per peer/run must never feed simulated state. Same rationale as drift::wallclock_read/DRIFT0003/drift-unreal::wallclock_read. Matched as a Type.method-shaped AttributeExpr callee, same shape as Unreal's own FPlatformTime::Seconds-style matching — and the same trailing-whitespace fix above applies here too. Fires unconditionally, no reachability scoping needed.

Known limitation: same as the other unconditional rules in this tool — a locally-defined method that happens to share a name with a flagged one (e.g. a project's own get_ticks_msec()) is distinguished correctly (matched by the full Type.method spelling, not the bare method name), but a project that assigns Time or OS to a local alias (var T := Time) and calls T.get_ticks_msec() would not be caught — a real, syntactic-matching limitation inherited from the same design every other spelling-based rule in this project already carries.

Dogfood result, real: run against SlayHorizon/godot-tiny-mmo — 151 real findings across client, server, and shared code (Time.get_ticks_msec/get_ticks_usec for perf/sync timing, Time.get_unix_time_from_system for chat/mail/leaderboard timestamps), a real superset of the spike's own 41 hand-found hits once get_ticks_usec was added to the real tool's list.

Second dogfood target: Orama-Interactive/Pixelorama — 5 real findings: Time.get_unix_time_from_system() (autosave/crash-recovery timestamps in Global.gd/OpenSave.gd, a debounce check in GradientEdit.gd) and one Time.get_ticks_msec() (Main.gd:244). Correctly distinguishes real Time.* calls from the codebase's own many unrelated _delta/timer-parameter uses of the word "time" — no false positives on those.

drift-godot::unordered_parallelism

Flags WorkerThreadPool.add_task/WorkerThreadPool.add_group_task — Godot's own thread-pool dispatch, whose completion order isn't guaranteed. Same known-problem caveat as drift::unordered_parallelism/DRIFT0004/drift-unreal::unordered_parallelism, inherited rather than re-litigated. Matched as a Type.method AttributeExpr callee, same shape as wallclock_read. Fires unconditionally, no reachability scoping needed.

Real, disclosed gap: both SlayHorizon/godot-tiny-mmo and Orama-Interactive/Pixelorama have zero confirmed call sites for either function — not evidence the rule is unneeded (WorkerThreadPool is a standard, documented Godot 4 parallelism primitive), just evidence neither real corpus checked so far uses it, same situation drift-unreal-lint's own unordered_parallelism was in against Lyra. Validated instead with a real positive/negative fixture: a bare WorkerThreadPool.add_task(...) call fires; an unrelated class's own same-named add_task method doesn't.

drift-godot::float_outside_fixed_step

Flags non-associative float arithmetic (+, -, *, /) reachable from _process or _physics_process — Godot's own fixed, well-known per-frame/per-physics-step entry points. Same rationale as the Rust/C#/Unreal rule of the same name, but with a real Godot-specific simplification: unlike the Rust/Unreal sides' own tick_reachable_roots config, no config file is needed here at all — every real Godot project hooks into the engine's own tick by overriding these exact method names, so reachability roots are auto-detected by name.

Real, necessary limitation, disclosed rather than hidden: GDScript is dynamically typed by default, and gdck-syntax is a syntax-only parser with no type checker — unlike the Rust/C#/Unreal sides, which all have a real compiler to ask "is this expression a float?", this tool has no way to know in general. v1's proxy is narrow and syntactic: an operand counts as float-ish only if it's (1) a Float literal (9.8), or (2) a bare name referring to a parameter or local variable in that same function explicitly typed : float. Pure untyped-variable arithmetic (var a = 1; var b = 2; return a + b, neither side typed or literal) is invisible to this rule — a real gap, not a silently-accepted one. Typed GDScript (Godot's own recommended style for anything simulation-relevant) is exactly what this catches.

A second, separate real limitation: the reachability call graph is flat and name-only (no symbol table to resolve self.foo()/obj.foo() to a specific class) — two unrelated methods sharing a name collide into one call-graph node. This widens reachability (a real false-positive risk) rather than silently dropping a real edge, the opposite trade-off from a missed edge. Real bug found dogfooding, fixed: this collision also caused the identical finding to be printed twice when two colliding names both resolved to the same underlying function (toaster.gd:154:22, confirmed in real output) — fixed with a dedup pass on (file, line, column, rule) before printing, same fix shape drift-unreal-lint needed for its own real UE_LOG macro-duplication bug.

Dogfood result, real: run against SlayHorizon/godot-tiny-mmo — 861 of 1,999 total functions resolved reachable from _process/_physics_process, 84 real findings. Spot-checked, not just counted: local_player.gd:382 (velocity = input_direction * move_speed, where move_speed is a local explicitly typed : float) fires — a real confirmation the local-type-tracking design catches real, non-synthetic code, not just the synthetic fixture. Ran in well under a second even with the whole-project call-graph construction (1,999 functions, no libclang-style parse cost here).

Second dogfood target, and a real miss it surfaced, now fixed: Orama-Interactive/Pixelorama — first run found 0 findings, but investigating why (rather than trusting a clean run) found a real false negative: Selection.gd's _process(delta: float) does _marching_ants_time_elapsed += delta, a genuine float accumulation reachable from a real tick function, invisible to this rule because += is a distinct AST shape (AssignStmt, sharing a node kind with plain x = y and every other compound-assignment operator) from the plain BinaryExpr this tool walked. Fixed by also matching AssignStmt against a COMPOUND_ARITHMETIC_OPS token set (PlusEq, MinusEq, StarEq, SlashEq — deliberately excluding StarStarEq/**= and every non-arithmetic compound op, same scope as ARITHMETIC_OPS). Re-running against the same file after the fix confirms it now fires at Selection.gd:41:2. Confirmed the same gap existed in the Rust and Unreal implementations too — see drift::float_outside_fixed_step's own entry for the cross-binding note.

A second, more serious real bug the same investigation surfaced, now fixed: at full-project scale (250 files), the fix above still produced 0 findings project-wide despite firing correctly against Selection.gd in isolation. Root cause: collect_functions_and_edges's whole-project function table keyed by bare name in a single HashMap<String, FuncInfo> — with _process/_physics_process being about as common a name as exists in a real multi-scene Godot project, every file's own definition overwrote the previous one (.insert()), so only the last one (in sorted file order) ever got scanned as a root; every other file's _process/_physics_process body was silently skipped entirely, not just its += accumulations. edges (the call graph itself) was never affected — it already unioned callees per name via .entry().or_default().extend(), not overwrite — only the root/target function table was. This was a materially worse instance of the already-disclosed "flat, name-only call graph" limitation below: that entry describes the risk only for callees (widens reachability, a false-positive risk), but for the roots themselves (the one case a real project is guaranteed to have many same-named instances of), the bug was a silent false-negative gap across nearly the whole project. Confirmed via DRIFT_GODOT_STATS=1: only 3 functions reported reachable from _process/_physics_process across all 250 files pre-fix, and a same-file single-target run found the hit a whole-project run missed. Fixed by changing funcs to HashMap<String, Vec<FuncInfo>> — every function sharing a name is now scanned, not just one; a re-run after the fix finds Selection.gd:41:2 project-wide (22 findings total, up from 21). Locked in with a real regression fixture (tests/fixture/collision/{a,b}.gd, two files each defining their own _physics_process) proving same-named functions across files no longer collide.

A third real bug found verifying the two fixes above, now fixed: writing this catalog's own walkthrough, a two-statement reproduction (a plain float chain followed by a += accumulation, both directly in one _physics_process body — not delegated to separate functions the way this catalog's own fixture originally did) surfaced a real location bug: the compound-assignment finding was reported on the wrong line — the line of whichever statement preceded it, not its own. Root cause: gdck-syntax's checkpoint-based AssignStmt construction (it opens ExprStmt, parses the lhs, then — only once it sees =/+=/etc. — retroactively reopens that span as AssignStmt at a checkpoint taken before the lhs was parsed). For any statement after the first one in a block, the Newline/Whitespace separating it from the previous statement is lexed as the next token's own leading trivia (the same "NameRef/AttributeExpr carry their own leading whitespace" quirk already documented above), and the checkpoint predates that trivia too — so a retroactively-built AssignStmt's own .range() silently swallows the previous statement's trailing newline. Confirmed directly against a real tree dump, not assumed. This only manifested for compound assignments specifically: a plain = assignment's own AssignStmt never qualifies as a finding site in the first place (Eq isn't in COMPOUND_ARITHMETIC_OPS), so the code always reported the location of its inner BinaryExpr instead, whose own range was never affected — only a compound assignment's finding, reported at the AssignStmt's own range, hit this. Fixed with a first_real_token_start helper that walks the leftmost path to the first non-trivia token and reports that instead of the node's raw range start — applied to every qualifying node, not just AssignStmt, which also corrected a smaller pre-existing one-column-off inaccuracy on ordinary BinaryExpr findings (they were pointing at the leading-whitespace character before the first identifier, not the identifier itself). Locked in with a new fixture function (combined_in_one_function, tests/fixture/player.gd) reproducing the exact two-statement shape that surfaced this.

Fixture

tests/fixture/player.gd — synthetic (small, self-contained): a bare randi_range() call fires, a member call on a project-owned RandomNumberGenerator instance doesn't; a bare Time.get_ticks_msec() call fires, a locally-defined method of the same name (and a call to it) doesn't; a bare WorkerThreadPool.add_task(...) call fires, an unrelated class's own same-named method doesn't; a _physics_process reachable float chain (velocity_y + gravity * delta, deduped to one warning) fires, a same-shaped but never-called function doesn't, and pure untyped-variable arithmetic reachable from _physics_process doesn't either; a compound-assignment accumulation (elapsed += delta) fires as its own hit; and (combined_in_one_function) a plain float chain followed by a compound assignment in the same function both fire, each at their own correct line — the exact shape that surfaced the location bug above. tests/fixture/collision/{a,b}.gd — two files each defining their own _physics_process with distinct float arithmetic, both must fire, proving same-named functions across files don't collide. Two cargo test integration tests, exact-diff assertions on all cases.

Rust walkthrough: adding drift to a project

Full step-by-step example, using the repo's own examples/minimal-rust — a real, working crate that intentionally trips every rule once, doubling as this walkthrough's running example and as a manual smoke check for the tool itself.

1. Prerequisites

  • A Rust toolchain (rustup).
  • cargo-dylint and dylint-link, the dylint framework crates/drift-lint is built on:
cargo install cargo-dylint dylint-link

Clone drift and look at the demo crate:

git clone https://github.com/FelixMiddelhoff/drift
cat drift/examples/minimal-rust/src/main.rs

2. First run: every unconditional rule at once

cd drift
cargo dylint --path crates/drift-lint -p minimal-rust

Five of the six rules — hashmap_iter, unseeded_rng, wallclock_read, unordered_parallelism, usize_in_hashed_state — need no configuration and fire immediately (real output from this exact command, not paraphrased):

warning: usize/isize field on a struct deriving Hash — width varies across platforms
  --> examples\minimal-rust\src\main.rs:20:5
   = help: use a fixed-width integer type (u32/u64/i32/i64) instead

warning: iterating a HashMap/HashSet — order is not guaranteed stable across peers
  --> examples\minimal-rust\src\main.rs:26:25
   = help: use a BTreeMap/BTreeSet, or sort the keys before iterating, if this feeds simulated state

warning: call to an OS-entropy-seeded RNG source
  --> examples\minimal-rust\src\main.rs:31:19
   = help: use an explicit, tracked seed (e.g. StdRng::seed_from_u64) fed by your simulation's deterministic seed

warning: wall-clock read — not guaranteed the same across peers
  --> examples\minimal-rust\src\main.rs:33:18
   = help: use your simulation's own deterministic tick counter if this feeds simulated state

warning: rayon parallel iteration — result order is scheduler-dependent
  --> examples\minimal-rust\src\main.rs:38:23
   = help: confirm the terminal reduction is order-independent (commutative), or suppress if already confirmed

Reading examples/minimal-rust/src/main.rs alongside the output: each warning points at exactly the line the demo crate built to trigger it — a #[derive(Hash)] struct Unit { id: usize }, a HashMap::iter(), rand::random::<u32>(), Instant::now(), and values.par_iter().sum().

3. Fixing a real finding

Take the hashmap_iter hit. Iterating a HashMap directly means two peers running the same logic can see entries in a different order — if that order ever feeds simulated state (accumulating in sequence, picking a "first" element, anything order-sensitive), that's a desync waiting to happen.

#![allow(unused)]
fn main() {
// Before — flagged: iteration order not guaranteed
let units: HashMap<u32, u32> = HashMap::new();
for (_id, _unit) in units.iter() { /* ... */ }

// After — deterministic, sorted by key first
let mut entries: Vec<_> = units.iter().collect();
entries.sort_by_key(|(id, _)| **id);
for (_id, _unit) in entries { /* ... */ }
}

drift::hashmap_iter recognizes this collect-then-sort pattern and won't re-flag it — confirmed by the rule's own test fixture, not assumed.

4. Enabling float_outside_fixed_step

The sixth rule is opt-in — it does nothing until a dylint.toml at your workspace root configures tick_reachable_roots:

# dylint.toml
[drift-lint]
tick_reachable_roots = ["main"]

This repo's own root dylint.toml sets exactly this, which is why minimal-rust's tick() function shows up as a sixth warning in the same run above:

warning: non-associative float arithmetic reachable from simulation state
  --> examples\minimal-rust\src\main.rs:46:26
46 |     let _next_position = 1.0_f32 + delta * 9.8; // drift::float_outside_fixed_step
   = help: confirm this runs under a fixed-step/fixed-point discipline, or list the enclosing function in dylint.toml's fixed_step_functions

Real, surprising behavior worth knowing before you configure this: setting tick_reachable_roots at all scopes every rule except usize_in_hashed_state to reachability from those roots, not just float_outside_fixed_step. If you point tick_reachable_roots at a narrow function, the other five rules can silently stop firing on code that isn't reachable from it — this repo's own demo roots at main specifically so every rule's own demo call site stays reachable in one pass. Pick a root that's genuinely upstream of everything you want scanned, not just your physics tick.

tick_reachable_roots = ["simulation::tick"]
fixed_step_functions = ["physics::integrate"]  # exempt a verified fixed-step integrator

5. Suppressing a specific finding

#![allow(unused)]
fn main() {
#[allow(drift_hashmap_iter)]
fn known_safe() {
    // ...
}
}

Every rule has its own attribute name (drift_unseeded_rng, drift_wallclock_read, drift_unordered_parallelism, drift_usize_in_hashed_state, drift_float_outside_fixed_step) — same pattern as any other rustc lint.

6. Wiring into CI

jobs:
  drift-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo install cargo-dylint dylint-link
      - run: cargo dylint --path crates/drift-lint --workspace

cargo dylint exits non-zero when there are findings — no extra plumbing needed to fail the job. Commit your dylint.toml (if you use tick_reachable_roots) alongside your own project so it evolves with your simulation code, same as the Unreal walkthrough's config.toml.

Real-world validation

Every rule above has also been run against veloren/veloren's common crate group — a real, shipped open-source multiplayer voxel RPG, not a toy — with genuine findings: real rand::random() calls inside combat-state code, a real Instant::now() field in the simulation's own State struct, real HashMap iteration in an ECS aura system, and a usize field caught through a type alias (pub type Species = usize;), confirming type resolution goes through rustc's own type information, not just textual matching. See each rule's own entry in the rule catalog for the exact file:line results.

Known limitations

See the rule catalog for the full, honest list per rule. The short version: reachability is a direct, intra-crate call graph only — dyn Trait/function-pointer call targets aren't resolvable via a plain HIR walk, so they don't add an edge. This is a deliberate under-approximation (a missed rule firing is judged less damaging to trust in the tool than a wrong one) — see the rule catalog for why.

C# / Unity walkthrough: adding drift to a project

Full step-by-step example: taking a real C# or Unity project from zero to GameDeterminism.Analyzers flagging real code, using a small multiplayer-relevant class as the running example.

1. Prerequisites

  • Any C# project (.csproj) targeting a framework the analyzer supports — it's a plain Roslyn analyzer, so it works identically in a Unity project or a standalone .NET project.
  • .NET SDK (for building/testing) — Unity users don't need this separately, Unity ships its own Roslyn pipeline.

2. Install

dotnet add package GameDeterminism.Analyzers

Published as GameDeterminism.Analyzers, not Drift.Analyzers — see the C# quickstart for why. In Unity, add the same <PackageReference> to your .csproj (via NuGetForUnity, or manually if you manage .csproj files directly), or reference the built DLL:

<ItemGroup>
  <Analyzer Include="path/to/Drift.Analyzers.dll" />
</ItemGroup>

No project restart needed — Roslyn analyzers run inline as you build (and in most IDEs, as you type).

3. First build: every rule fires unconditionally

Unlike the Rust/Unreal sides, none of the 5 C# rules need reachability configuration — there's no opt-in step here at all, and no float_outside_fixed_step equivalent (never ported to C#/Unity). Build your project and real findings show up as ordinary build warnings:

Dictionary<int, PlayerState> players = new();
foreach (var kvp in players) // DRIFT0001
{
    ApplyState(kvp.Value);
}
DRIFT0001: Iterating a Dictionary<int, PlayerState> — order is not guaranteed stable across peers

4. Fixing a real finding

Two peers running this same loop can process players in a different order — if ApplyState has any order-dependent side effect (accumulating into shared state, picking a "first" match), that's a desync waiting to happen.

// Before — flagged
foreach (var kvp in players) { ApplyState(kvp.Value); }

// After — deterministic order
foreach (var kvp in players.OrderBy(p => p.Key)) { ApplyState(kvp.Value); }

Sorting by key before iterating is recognized as the fix — same rationale as the Rust/Unreal/Godot sides' own hashmap_iter.

5. The other four rules, with real examples

var roll = UnityEngine.Random.Range(1, 7); // DRIFT0002: OS/engine-entropy-seeded RNG
var now = DateTime.Now;                    // DRIFT0003: wall-clock read
Parallel.ForEach(units, u => u.Tick());    // DRIFT0004: parallel iteration, result order not guaranteed
public record PlayerId(nint Handle); // DRIFT0005: nint is pointer-width — 32 vs 64 bits

Fixes, in order: UnityEngine.Random.InitState(seed) fed by a deterministic seed (or a tracked System.Random(seed) outside Unity); replace the wall-clock read with your simulation's own tick counter; confirm Parallel.ForEach's reduction is actually order-independent, or don't parallelize that step; replace nint/nuint in anything hashed with a fixed-width int/long.

6. Suppressing a specific finding

#pragma warning disable DRIFT0001
foreach (var kvp in players) { /* verified safe, e.g. read-only debug logging */ }
#pragma warning restore DRIFT0001

7. Wiring into CI

Nothing special needed — analyzer warnings surface on any normal dotnet build/msbuild. To make them fail the build (rather than just show as warnings) in CI, promote them in your .csproj or Directory.Build.props:

<PropertyGroup>
  <WarningsAsErrors>DRIFT0001;DRIFT0002;DRIFT0003;DRIFT0004;DRIFT0005</WarningsAsErrors>
</PropertyGroup>
jobs:
  drift-analyzers:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
      - run: dotnet build YourProject.csproj

Known limitations

See the rule catalog for the full per-rule breakdown. The short version: DRIFT0001 doesn't extend to the generic IDictionary<TKey,TValue> interface (deliberate — SortedDictionary<TKey,TValue> also implements it, so flagging the interface would be a new false positive on genuinely ordered code), and DRIFT0005's hand-written-GetHashCode check is syntactic reference detection, not real data-flow — a field merely read inside GetHashCode but not actually folded into the hash still gets flagged.

Unreal walkthrough: adding drift to a project

Full step-by-step example: taking a real Unreal C++ project from zero to a drift-unreal-lint run wired into CI, using a small AMyPawn-style class with a Tick function as the running example. Every command below is real — this is the same path used to dogfood this tool against Lyra (see the rule catalog for those results).

1. Prerequisites

  • An Unreal Engine C++ project (Epic Games Launcher build or source build) with a .uproject file.
  • LLVM/Clang installed separately from the engine's own bundled compiler — drift-unreal-lint uses stock libclang directly, not UBT's compiler. On Windows, winget install LLVM.LLVM is enough; you only need libclang.dll/libclang.lib, not a full LLVM toolchain build.
  • A Rust toolchain (rustup) to build drift-unreal-lint itself — it isn't published as a prebuilt binary yet.

Clone drift alongside (or inside) your engine workspace:

git clone https://github.com/FelixMiddelhoff/drift

2. Generate a compile database

drift-unreal-lint reads a standard JSON Compilation Database — the same format clangd/clang-tidy use. UnrealBuildTool generates one directly:

"<EnginePath>/Engine/Binaries/DotNET/UnrealBuildTool/UnrealBuildTool.exe" \
  -Mode=GenerateClangDatabase -Project="<YourProject>.uproject" <Target> Win64 Development

Replace <Target> with your project's editor target (e.g. MyProjectEditor). This writes compile_commands.json at your project root, one entry per translation unit UBT knows how to build.

3. Build drift-unreal-lint

cd drift/bindings/unreal/drift-unreal-lint
cargo build --release

Point LIBCLANG_PATH at your LLVM install's bin directory so the clang crate can find libclang.dll/.so/.dylib — both for this build step (not strictly required to build, only to run) and for every run below:

export LIBCLANG_PATH="C:/Program Files/LLVM/bin"   # adjust for your platform/install

4. First run: unconditional rules only

Five of the six rules — unseeded_rng, wallclock_read, hashmap_iter, unordered_parallelism, usize_in_hashed_state — need no configuration and run the moment you point the tool at a compile database:

./target/release/drift-unreal-lint <YourProject>/compile_commands.json

Example output against a project with a raw FMath::FRand() call and a TMap iterated in a range-based for:

Source/MyProject/MyPawn.cpp:42:9: warning: FRand() reads Unreal's global RNG, which is not seeded deterministically by default; differs per peer/run [drift-unreal::unseeded_rng]
Source/MyProject/InventoryComponent.cpp:88:5: warning: iterating a TMap/TSet — order is not guaranteed stable across peers [drift-unreal::hashmap_iter]

The process exits 1 if there are any findings, 0 if clean — plug it straight into a CI gate on that alone.

5. Fixing a real finding

Take the unseeded_rng hit above. FMath::FRand() reads Unreal's process-global RNG, seeded from OS entropy by default — two peers in a lockstep session get different values from the same call. Fix: use an explicitly-seeded FRandomStream that's part of your replicated/synchronized simulation state instead:

// Before — flagged
float Roll = FMath::FRand();

// After — deterministic, seed is part of simulation state
float Roll = SimRandomStream.FRand();

Re-run drift-unreal-lint — that finding is gone; SimRandomStream.FRand() isn't one of the flagged call spellings (FMath::*), because it's no longer reading the shared global state.

6. Enabling float_outside_fixed_step

The sixth rule, float_outside_fixed_step, is opt-in: it flags float/double arithmetic (+ - * /) reachable from your simulation's tick functions, because IEEE-754 float addition/multiplication isn't associative — the same expression can produce a different bit pattern on different platforms/compilers depending on evaluation order, a real desync source in a lockstep sim. Unscoped, this would flag nearly every float operation in a typical Unreal codebase, so it does nothing until you tell it where your simulation's fixed-tick entry points are.

Create config.toml next to (or anywhere, and pass its path explicitly) your compile database:

tick_reachable_roots = ["AMyPawn::Tick"]
fixed_step_functions = ["UMyIntegrator::Step"]
  • tick_reachable_roots: the functions where your fixed-step simulation begins. drift-unreal-lint builds a call graph from every translation unit and does a reachability walk from these roots — any float arithmetic in a function reachable from here gets flagged.
  • fixed_step_functions: an exemption list — functions you've already verified use a deterministic, fixed-order integrator (e.g. a Step(float FixedDelta) you've audited) and don't want re-flagged.

Run again with the config:

./target/release/drift-unreal-lint <YourProject>/compile_commands.json config.toml
Source/MyProject/MyPawn.cpp:57:21: warning: float arithmetic reachable from tick-reachable code; non-associative reordering can desync across platforms [drift-unreal::float_outside_fixed_step]

That's AMyPawn::Tick calling into a helper that does NewPosition = Position + Velocity * DeltaTime — worth checking whether DeltaTime here is your fixed simulation step or the engine's variable frame delta (a common real bug: mixing DeltaSeconds from Tick(float DeltaSeconds) into simulated state instead of a fixed timestep accumulator).

7. Performance on a full codebase

Each translation unit parses in its own worker subprocess — real cost: a full 388-file Lyra sweep hit a genuine libclang out-of-memory crash on one file before this existed, which used to take the whole run down with it (see the rule catalog for the full story). Now a crashed worker only costs that one file's findings.

Workers run concurrently by default (sized to your CPU's available_parallelism). On a memory-constrained machine, cap it:

DRIFT_UNREAL_JOBS=4 ./target/release/drift-unreal-lint <YourProject>/compile_commands.json config.toml

Set DRIFT_UNREAL_STATS=1 to print a summary (translation units parsed, call edges, float candidates found) to stderr — useful to confirm a full sweep actually completed before trusting a "zero findings" result.

8. Wiring into CI

A minimal GitHub Actions job — adjust the LLVM install step for your runner OS:

jobs:
  drift-unreal:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - name: Install LLVM
        run: choco install llvm -y
      - name: Build drift-unreal-lint
        run: cargo build --release --manifest-path drift/bindings/unreal/drift-unreal-lint/Cargo.toml
      - name: Generate compile database
        run: |
          "<EnginePath>/Engine/Binaries/DotNET/UnrealBuildTool/UnrealBuildTool.exe" `
            -Mode=GenerateClangDatabase -Project="MyProject.uproject" MyProjectEditor Win64 Development
      - name: Run drift-unreal-lint
        env:
          LIBCLANG_PATH: "C:/Program Files/LLVM/bin"
        run: drift/bindings/unreal/drift-unreal-lint/target/release/drift-unreal-lint.exe compile_commands.json drift-config.toml

The non-zero exit code on any finding fails the job — no extra plumbing needed. Commit drift-config.toml (your tick_reachable_roots/fixed_step_functions) to your project's own repo so it evolves alongside your simulation code.

Known limitations

See the rule catalog for the full, honest list per rule — worth reading before relying on a clean run as proof of correctness. The short version: call-site matching is syntactic (spelling-based, not full symbol resolution) for unseeded_rng/wallclock_read/unordered_parallelism, and the reachability call graph is built per-translation-unit then merged, which can miss edges through virtual dispatch or function pointers.

Godot walkthrough: adding drift to a project

Full step-by-step example: taking a real Godot 4 GDScript project from zero to a drift-godot-lint run, including the fix for a real bug this tool's own dogfooding surfaced (see the rule catalog for the full story) — this walkthrough uses that same real-world case as its running example.

1. Get the tool

# prebuilt (no toolchain needed)
# grab the binary for your platform from the latest release:
# https://github.com/FelixMiddelhoff/drift/releases/latest

# or build from source
git clone https://github.com/FelixMiddelhoff/drift
cargo build --release --manifest-path drift/bindings/godot/drift-godot-lint/Cargo.toml

Pure Rust, no engine dependency (via gdck-syntax) — no Godot install needed to run it, only to run your project.

2. First run: three rules fire unconditionally

drift-godot-lint <file.gd | project directory>

unseeded_rng, wallclock_read, and unordered_parallelism need no configuration. Against a script like this:

func roll_damage() -> int:
    return randi_range(1, 6)

func log_event() -> void:
    var now := Time.get_ticks_msec()
player.gd:2:11: warning: randi_range() reads or reseeds Godot's global RNG, which is not deterministically tracked by default; differs per peer/run [drift-godot::unseeded_rng]
player.gd:5:15: warning: Time.get_ticks_msec() reads wallclock/OS time, which differs per peer/run; do not fold it into simulated state [drift-godot::wallclock_read]

3. Fixing a real finding

Godot's global RNG is auto-seeded from OS entropy — two peers in a lockstep session get different results from the same bare call. Fix: route through a project-owned, explicitly-seeded RandomNumberGenerator instance instead:

# Before — flagged
func roll_damage() -> int:
    return randi_range(1, 6)

# After — deterministic, seed is part of tracked simulation state
var seeded_rng := RandomNumberGenerator.new()

func roll_damage() -> int:
    return seeded_rng.randi_range(1, 6)

unseeded_rng only matches a bare call (randi_range(...)) — a member call on your own RNG instance (seeded_rng.randi_range(...)) is a different shape and never fires, so this fix is recognized, not just visually similar.

4. float_outside_fixed_step — no config needed

The one reachability-scoped rule auto-detects Godot's own fixed tick entry points by name — _process/_physics_process — no config file, unlike the Rust/Unreal sides' tick_reachable_roots:

func _physics_process(delta: float) -> void:
    velocity_y = velocity_y + gravity * delta   # fires — reachable, float arithmetic
    elapsed_time += delta                        # fires too — compound assignment, same rule

Both non-associative-arithmetic shapes fire: plain binary arithmetic (a + b * c) and compound-assignment accumulation (x += y) are both matched.

Real limitation, disclosed not hidden: GDScript is dynamically typed by default, and this tool has no type checker — an operand only counts as float-ish if it's a float literal or a variable/parameter explicitly typed : float in that same function. Pure untyped arithmetic (var c = a + b, neither side typed or literal) is invisible to this rule even when reachable.

5. Running against a real, multi-file project

This is the case that matters most in practice, and where a real bug in this tool was found and fixed: _process/_physics_process are about the most common function names in any real Godot project (nearly every scene script defines one). Point the tool at your project root, not just one file:

drift-godot-lint path/to/your/project
DRIFT_GODOT_STATS=1 drift-godot-lint path/to/your/project  # prints a parse/function/finding summary to stderr

DRIFT_GODOT_STATS=1 is worth running once on a new project — it confirms every file actually parsed (0 parse errors) and reports how many function bodies were resolved reachable, so a suspiciously clean run is something you can verify, not just trust.

Known limitations

See the rule catalog for the full, honest list. The short version: call-graph edges are resolved by bare function name (no symbol table), which can widen reachability across two unrelated same-named functions — a false-positive risk, not a false-negative one, and this tool's own real dogfooding (including the multi-file collision bug above) is the reason that trade-off is documented instead of assumed safe.

FAQ / troubleshooting

Real questions with real answers — every claim here is checked against the actual source or a real run, not written from memory. If something here turns out stale, open an issue.

General

A rule didn't fire on code I expected it to flag — is that a bug?

Usually not — check these first, in order:

  1. Is the rule reachability-scoped, and did you configure it? float_outside_fixed_step is opt-in on the Rust and Unreal sides (needs tick_reachable_roots in dylint.toml/config.toml) — without it, that one rule does nothing at all, silently. Godot auto-detects _process/_physics_process by name instead, so this doesn't apply there.
  2. On Rust specifically: configuring tick_reachable_roots at all scopes every rule except usize_in_hashed_state, not just float_outside_fixed_step — a real, easy-to-miss behavior. If your root is too narrow, the other five rules can stop firing on code that isn't reachable from it. See the Rust walkthrough for the full explanation.
  3. Did you run against the whole project, not just one file? On Godot specifically, _process/_physics_process bodies are only counted if the tool actually walked that file — run with DRIFT_GODOT_STATS=1 (see below) to confirm every file parsed and how many functions were resolved reachable, rather than trusting a clean run.
  4. Type-inference limits (Godot only): GDScript is dynamically typed, and drift-godot-lint has no type checker — float_outside_fixed_step only recognizes a variable as float-ish if it's a literal or explicitly typed : float in the same function. Untyped arithmetic (var c = a + b, neither side typed) is invisible to it. This is real and permanent, not a bug — see the rule catalog for the exact rule.
  5. Syntactic matching, not full symbol resolution (Unreal/Godot): unseeded_rng/wallclock_read/unordered_parallelism match by call-site spelling (FMath::Rand, Time.get_ticks_msec, etc.), not resolved declarations. An aliased import or an unusual call shape can slip past. The Rust and C# sides use real compiler type information instead and don't have this limitation.

The tool reported zero findings — is that trustworthy?

Don't just trust a clean run — verify it actually processed your code:

  • Godot: run with DRIFT_GODOT_STATS=1 — it prints files scanned, parse errors, function count, and reachable-function count to stderr. Zero findings alongside 0 parse errors and a plausible function count is real evidence; zero findings with parse errors or a suspiciously low function count means the tool silently skipped something.
  • Unreal: run with DRIFT_UNREAL_STATS=1 — prints N/M translation units parsed. If N < M, some files failed to parse (a real, disclosed cost of parsing real Unreal C++ without a precompiled header) and their findings are missing, not absent.
  • Rust: cargo dylint fails loudly on a compile error rather than silently skipping files, so a clean exit is stronger evidence here than on the other two.

How do I suppress a specific finding?

Depends on the binding — and this is genuinely uneven, not a design choice:

  • Rust: #[allow(drift_hashmap_iter)] (or the matching attribute name for any other rule) on the item.
  • C# / Unity: #pragma warning disable DRIFT0001 / #pragma warning restore DRIFT0001.
  • Unreal / Godot: no suppression mechanism exists yet — no comment-based ignore, no config-level allowlist. If you have a confirmed false positive, your only options today are to restructure the code so it doesn't match the rule's pattern, or accept the finding and don't fail your CI build on it. This is a real, disclosed gap, not a hidden one — worth an issue if it's blocking you.

Rust (drift-lint)

Install it: cargo install dylint-link (alongside cargo-dylint). crates/drift-lint's own .cargo/config.toml points rustc at dylint-link as the linker — without it on PATH, even an unrelated dependency's build script fails to link.

My dylint.toml config isn't being picked up

Root paths are matched against TyCtxt::def_path_str, which is crate-relative — it never includes your crate's own name. tick_reachable_roots = ["my_crate::tick"] silently matches nothing; use ["tick"] for a top-level function or ["module::path::tick"] for a nested one. Confirmed the hard way this session — a first attempt with a crate-name prefix produced zero reachable functions with no error at all.

Building crates/drift-lint is slow / seems to hang on a clean checkout

Its dylint_driver build script does a full, uncached clone of rust-lang/rust-clippy on every clean build (needed to extract symbol data for recent nightlies) — a real operational fragility, not a bug in this project. Swatinem/rust-cache (already used in this repo's own CI) caches target/ across runs so this only bites on a genuinely clean cache.

Do I need to publish this to crates.io to use it?

No, and you can't — drift-lint depends on clippy_utils via git (rust-lang/rust-clippy), which is itself never published to crates.io (its own README says it provides no stability guarantees). crates.io rejects any crate with an unversioned git dependency, confirmed via a real cargo publish --dry-run. Install via cargo dylint --path <clone> or --git https://github.com/FelixMiddelhoff/drift instead — see the Rust quickstart.

C# / Unity (Drift.Analyzers / GameDeterminism.Analyzers)

Why is the NuGet package name different from the project name?

Drift is a NuGet ID prefix reserved by an unrelated company — confirmed via nuget.org's own upload UI, which returned "The package ID is reserved" on every version pushed under Drift.Analyzers. Published as GameDeterminism.Analyzers instead; the underlying project (bindings/csharp/Drift.Analyzers) and DLL filename are unchanged. See the C# quickstart.

The analyzer isn't showing up in my IDE after installing

Roslyn analyzers need to be referenced in the correct NuGet layout (analyzers/dotnet/cs/) to auto-load — the published package does this correctly. If you're referencing the DLL directly instead of via NuGet, double-check the <Analyzer Include="..."> item group, not <Reference> or <PackageReference> styles meant for ordinary libraries.

Unreal (drift-unreal-lint)

LIBCLANG_PATH errors, or the tool can't find libclang

Point it at your LLVM install's bin directory explicitly — the clang crate doesn't reliably find a system libclang on its own across platforms. Needed both to build the tool and to run it (a prebuilt release binary still needs this set at runtime; it links libclang dynamically, doesn't bundle it).

A file's findings seem to be missing entirely

Run with DRIFT_UNREAL_STATS=1 and check N/M translation units parsed — a real libclang crash (OOM, a genuine parse failure) costs that one file's findings, reported to stderr as a warning, not silently dropped from the count. This was a real bug found dogfooding a 388-file sweep (see the rule catalog) and is now crash-isolated per file via a worker-subprocess architecture, but a crash still means that one file contributes nothing.

The sweep is slow against a large codebase

Workers run concurrently by default (a thread pool sized to available_parallelism()). Cap it with DRIFT_UNREAL_JOBS=<n> if you need to control peak memory instead — each worker is a full libclang parse, which is genuinely expensive without a precompiled header.

Godot (drift-godot-lint)

The tool found the same finding twice at the same location

Should be fixed — a real duplicate-finding bug (flat, name-only call graph resolving two same-named functions to one, scanning the same body twice) was found and fixed with a dedup pass. If you still see this, it's a regression worth reporting with the exact command and file.

A finding is reported on the wrong line

Should also be fixed — a real bug where a compound-assignment (x += y) finding got attributed to the previous statement's line whenever it wasn't the first statement in its function (a gdck-syntax parser checkpoint quirk). Fixed and locked in with a regression test. If you see a location that clearly doesn't match the reported rule, it's worth reporting with the exact file.

hashmap_iter/usize_in_hashed_state don't exist for Godot — why?

Not deferred, genuinely N/A: Godot's Dictionary is insertion-ordered by engine guarantee (unlike Rust's HashMap or C#'s Dictionary), so iteration-order desync isn't a real hazard there. GDScript's int is always 64-bit — there's no fixed/pointer-width distinction for usize_in_hashed_state to have a bug in.

Contributing to Drift

Building and testing

cargo build --workspace
cargo test --workspace

Or, with just installed, run the same gate CI runs:

just check

This runs cargo fmt --check, cargo clippy -D warnings, and cargo test --workspace — matching the required CI job. Run it locally before pushing.

crates/drift-lint (its own detached nightly workspace, needs cargo-dylint/dylint-link), bindings/csharp/Drift.Analyzers (dotnet test), bindings/unreal/drift-unreal-lint (its own detached workspace, needs LIBCLANG_PATH pointing at a real LLVM install), and bindings/godot/drift-godot-lint (its own detached workspace, pure Rust, no external toolchain needed) each have their own test command — see each directory's own tests and the CI workflow (.github/workflows/ci.yml) for the exact invocations.

Toolchain

The Rust version is pinned in rust-toolchain.toml. rustup will pick it up automatically; you don't need to install it separately.

Rule-authoring changes

Any change to a lint rule (crates/drift-lint, bindings/csharp/Drift.Analyzers, bindings/unreal/drift-unreal-lint, or bindings/godot/drift-godot-lint) is validated against that binding's own fixture corpus — a rule's false-positive/false-negative behavior is the actual product, not an implementation detail, so a rule change without an updated or added fixture won't be merged. See drift-planning/drift-plan.md §7 (validation strategy) for why this matters more here than in a typical project.

Submitting a pull request

  1. Open an issue first for anything non-trivial (a new rule, a change to the reachability-tagging API, a new suppression mechanism) — cheap to discuss before code exists, expensive to discuss after.
  2. Keep PRs scoped to one change. One new rule (implementation + fixtures + docs/rule-catalog.md entry) per PR is the expected shape.
  3. Fill in the PR template's changelog-relevant-description checkbox — it feeds the changelog automation.
  4. CI must pass (fmt, clippy, tests) before merge.
  5. Every commit needs a Signed-off-by: trailer (git commit -s) — a Developer Certificate of Origin, not a copyright assignment: you're attesting you have the right to submit the code under this project's license, and you keep your own copyright. Enforced by the DCO CI check. Forgot on an existing commit? git commit --amend -s (or git rebase --signoff <base> for a range).

Documentation

docs/rule-catalog.md is the actual product surface most users read — a new or changed rule isn't done until its catalog entry (what it flags, why, a real example, the fix) is written or updated in the same PR.

Code of Conduct

This project follows the Contributor Covenant.

Security Policy

Reporting a vulnerability

Please use GitHub's private vulnerability reporting (Security tab → "Report a vulnerability") rather than a public issue — it opens a private advisory only the maintainer can see until a fix is ready.

If you'd rather not use GitHub, DM @FelixMiddelhoff on GitHub.

Please include:

  • What you found and why it's a security issue, not just a bug.
  • Steps to reproduce, or a minimal input triggering it.
  • The affected crate/binding and commit or version.

Scope

Drift is a build-time/CI static-analysis tool, not something that runs in a trust boundary by design — it doesn't execute the code it analyzes, only parses/lints it. The main thing worth reporting here is a crash, hang, or resource-exhaustion issue in one of the lint engines (drift-lint, Drift.Analyzers, drift-unreal-lint, or drift-godot-lint) triggered by adversarial or pathological source input — a tool that's supposed to run in CI shouldn't be able to hang a CI job or crash on legitimate-but-unusual code.

Out of scope: issues that require an attacker to already have local code execution on the machine running drift, and vulnerabilities in third-party dependencies without a demonstrated impact on drift itself (report those upstream — Dependabot already tracks known-vulnerable dependencies here).

Supported versions

Pre-1.0 (0.x), not yet published to crates.io — fixes land on main and the latest commit is the only supported one. This section will be replaced with a real version table once there's a tagged release history to support.

Response

This is currently a solo-maintained project — no formal SLA, but security reports get priority over routine issues. Expect an initial response within a few days.