Context: A per-module adversarial audit (19 confirmed fixes). The reusable lessons:
- Client-only distance/authority gates are not gates.
v-inventory:givetrusted the client's proximity check → cross-map transfers. Every server callback/net event that acts on a target must re-derive proximity/permission server-side (mirrorsearchPlayer/openStash). - Ignoring a mutation's return value loses data.
AddItemreturns false when full; v-clothing unequip/equip discarded it and cleared the worn slot anyway → destroyed the item. Always branch on the boolean before mutating dependent state. - Two-step money must be atomic across the DB. The offline bank transfer credited the recipient's DB row immediately but only debited the sender in memory → a crash duplicated money. Wrap both sides in
MySQL.transaction.await. - Re-validate on every action, not just on open. Trunk/drop/stash proximity (and job/gang access) was checked only at open; the move callback now re-checks each move.
- Client-triggered "on death/respawn" resets need a server death signal. Gate
onRespawnon aDeadset fed bybaseevents:onPlayerDied, cleared on respawn. - Shared globals: only clear what you set. The stress blur called
ClearTimecycleModifier()unconditionally, wiping other resources' modifiers; track a local flag and clear only on the set→unset edge. - A synchronous latch must be set BEFORE the first
await(selectCharacter re-entrancy), same as the existingcreatingguard.
Context: Every inventory move from the player grid errored: main.lua:341: attempt to call a nil value (global 'maybeUnequip'), so drag-drop did nothing.
Root cause: maybeUnequip is a local function defined ~line 396, but the v-inventory:move callback calls it ~line 343 - before the local exists in scope, so the call resolved to a nil global and the move callback errored for every player move.
Fix: Forward-declare local maybeUnequip near the top, and define it later with function maybeUnequip(...) (assigning the forward local) so it's in scope at the call site.
Prevention: In Lua a local function is only visible after its definition - forward-declare any helper used earlier in the file (local f up top, function f() … end later).
Context: The tattoo/appearance editor rendered untranslated keys instead of labels.
Root cause: The locale files were listed under a locales { … } block in the fxmanifest. locales is not a script directive - FiveM never executed those files, so Locales.fr/en never got the app.* keys and t() returned the raw key. (Data-driven text worked; only t()-translated text was raw.)
Fix: Load the locale files as shared_scripts (after @v-core/locale/shared.lua, before config.lua).
Prevention: Locale Lua must be loaded via shared_scripts/client_scripts/server_scripts - never a bare locales {} list. Raw keys in a NUI while data renders → the locale table isn't populated (manifest not executing the files).
Context: Ground drops opened as secondary containers; adversarial review of v-inventory.
Error: The openStash drop branch checked only that the id matched ^drop:%d+$ and existed - no proximity check (the trunk/glovebox branch had a 5 m gate). Drop ids come from an incrementing counter, so a modded client could fire openStash('drop:1',…), drop:2, … and drain every ground drop on the map remotely; the per-move re-check only runs for search, so it wasn't distance-gated on move either.
Fix: The drop branch now checks the player's ped is within 5 m of the drop's server-stored coords before opening.
Prevention: Every openable container branch must apply the same server-side proximity gate; never rely on an id being "unguessable" (sequential ids are trivially enumerable).
Context: Durability derived from the client-reported ammo delta.
Error: A modded client could under-report / not report ammo, so spent = old - new stayed ≤ 0 and durability never dropped - the weapon never wore or jammed.
Fix: Wear now comes from the engine's weaponDamageEvent (trusted sender); the ammo net event is a pure magazine sync.
Prevention: Never derive an authoritative stat from a client-sent value at a security boundary; use an engine-raised event whose actor the server controls.
Context: Money laundering: a launderer that pays a 0.65 rate for marked_bills (price 1).
Error: Selling 1 marked bill removed the item, then total = floor(1*1*0.65) = 0 hit the if total <= 0 then resolve(false) guard before any payout/refund - the bill was destroyed for nothing, with no notification. Caught by an adversarial review workflow (3 agents independently).
Root cause: The total <= 0 guard sat after RemoveItem. Any sale whose floor(price*amount*rate) rounds to 0 fell into the post-removal, no-refund window.
Fix: Compute total and run the <= 0 guard before removing anything; notify (shop.too_small). Order is now compute → validate ownership → remove → pay.
Prevention: In any "take then pay" flow, validate the payout amount before the irreversible removal. Never place a value guard after the item has been consumed.
Context: Selling stackable items; adversarial review.
Error: RemoveItem matched a single stack with amount >= wanted; a quantity split across several stacks was wrongly rejected though the total sufficed.
Fix: Sum every stack of the item and, if the total is enough, remove spanning stacks.
Prevention: "Remove N of item X" must consider the item's total across all stacks, not one stack.
Context: New gathering module; adversarial review.
Error: The server cooldown (2 s) was shorter than the client harvest animation (3.5–4.5 s), so a scripted client firing the harvest callback directly could gather ~2× faster.
Root cause: The pacing that limited legit players lived client-side (the animation wait); the server had only a flat 2 s cooldown.
Fix: Gate server-side on the resource's own time via GetGameTimer() (ms).
Prevention: Never let a client-side timer be the only rate limit on a server action; enforce the real interval server-side.
Context: Store buying had no server-side location/job check.
Error: Any client could buy any shop's catalogue (incl. a job-locked police armory) from anywhere on the map; RemoveMoney's result was also ignored after AddItem (latent free-item dupe).
Fix: canUseShop verifies the player is at a Config.Locations entry mapping to the shop id and holds shops.job if set; amount clamped server-side; item label guarded; RemoveMoney checked with the item refunded on failure.
Prevention: Every money/item server callback must re-derive proximity + authorization server-side and check the return of each mutation, refunding on failure.
Context: v-spawn ran a "black-out guard" thread at resource start - while not spawnReady do DoScreenFadeOut(0) ... end - to hide the default spawnmanager ped before the custom spawn took over.
Error: On connect the player got infinite loading, then (after a partial fix) saw the default world for a frame and got stuck on a black screen. The server log showed the join but never playerReady/needCharacter.
Root cause: The default spawnmanager waits for IsScreenFadedIn() before firing playerSpawned (its spawnPlayer coroutine fades in and loops until the screen is in). The guard re-faded-OUT every frame, so IsScreenFadedIn() was never true → spawnmanager hung → playerSpawned never fired → v-core never got playerReady → needCharacter never sent → the creator never opened → the native loadscreen never dismissed. An earlier variant of the guard also FreezeEntityPosition'd the pre-spawn ped, which independently blocked the spawn.
Fix: Removed the black-out guard entirely. The spawnmanager now completes its fade-in and fires playerSpawned normally; startCreator / the onPlayerLoaded handler fade out and take over immediately after. The brief flash of the default spawn point is accepted; the real bug (falling into the void) stays fixed in switchSpawn (freeze + stream collision + ground-Z before unfreeze).
Prevention: Never hold the screen faded-out (or freeze the player ped) before the first playerSpawned. The default spawn flow needs IsScreenFadedIn() to be reachable. Do all custom fading/freezing AFTER playerSpawned (i.e. inside needCharacter / onPlayerLoaded), never in a pre-spawn loop.
Context: Every NUI menu routed its focus through a shared helper, exports['v-core']:OpenMenu(), which called SetNuiFocus(true, true) inside v-core.
Error: The panels rendered, but the player had no cursor and no keyboard capture - the UI was impossible to use. v-hud (F7) and v-spawn were unaffected.
Root cause: SET_NUI_FOCUS is scoped to the calling resource. In code/components/nui-resources/src/ResourceUIScripting.cpp the handler fetches the caller's ResourceUI, returns early when resourceUI->HasFrame() is false, and otherwise posts {"type":"focusFrame","frameName":"<caller resource name>"}. v-core declares no ui_page, so HasFrame() was false and the call was a silent no-op - no error, no log line. Even with a frame it would have focused v-core's own page, never the module's. The two modules that still called SetNuiFocus locally (v-hud, v-spawn) kept working, which is exactly the pattern that made the bug look module-specific.
Fix: v-core/client/focus.lua no longer touches SetNuiFocus; it only keeps the reference-counted LocalPlayer.state.nuiOpen bookkeeping, renamed to MenuOpened() / MenuClosed() (+ IsAnyMenuOpen()). Each owning resource now calls SetNuiFocus itself, immediately next to its MenuOpened() / MenuClosed() report.
Prevention: Never call SetNuiFocus, SendNUIMessage, RegisterNUICallback or SetNuiFocusKeepInput from a resource that does not declare the ui_page. They are all resolved against the calling resource's own NUI frame. A shared helper may own bookkeeping (statebags, ref-counting), never the native itself. When a native silently does nothing, check whether it is resource-scoped before assuming the arguments are wrong.
Context: Extracting oxmysql.zip / menuv.zip directly into resources/[standalone] with PowerShell Expand-Archive.
Error: New-Item : Il existe déjà un élément avec le nom spécifié ...[standalone] - extraction aborted.
Root cause: FiveM resource group folders use square brackets ([standalone]). PowerShell treats [ ] in -DestinationPath as wildcard/character-class globs, so the literal path is misresolved.
Fix: Extract into a bracket-free temp directory, then move the contents into the bracketed folder with the Bash tool (which handles brackets when the path is quoted).
Prevention: Never pass a bracketed path to PowerShell path parameters that glob. Use a temp dir + mv, or [System.IO.Compression.ZipFile]::ExtractToDirectory with a literal path, or the Bash tool for any file op touching [...] folders.
Context: First real in-game test of the joined experience (loadscreen → language → character creation → HUD). A browser preview validated each NUI in isolation but not the integrated in-game render. Symptoms reported in-game:
- Oversized opaque "black boxes" behind widgets (the compass box was far wider than its content).
- Character-creation menus unusable: couldn't type in inputs, couldn't tell where to click.
- Default GTA HUD (health/armor near minimap, default cash) shown alongside the custom HUD.
- Accent rendered purple instead of orange. Root causes (audited):
- Native GTA HUD never hidden (only cash added afterwards).
- No routing-bucket isolation for creation → simultaneous new players would share the world.
- CEF-sensitive CSS:
width: max-contenton fixed widgets, reliance onbackdrop-filter/mask-image, and cross-resourcecfx-nui-v-ui/theme.cssloading - brittle in-game. - Risk of a Lua error in
v-spawnstartCreatoraborting beforeDoScreenFadeIn(black screen). Fix: Hide native HUD; isolate creation in a private routing bucket (SetPlayerRoutingBucket); replacemax-contentwidget widths with explicit widths; inline the theme locally per resource (drop cross-resource load); guard the creation flow so the screen always fades back in. Prevention: Never ship a NUI/interface change without an in-game smoke test. Prefer explicit sizes and self-contained CSS overmax-contentand cross-resource asset loads. Always guaranteeDoScreenFadeInruns (wrap risky natives, add a fail-safe).
Context: Admin launched the F9 clothing scan (v-clothing); each captured screenshot (base64 data URI, ~200-500 KB) was sent to the server with TriggerServerEvent('v-clothing:server:saveThumb', ...) in a tight loop.
Error: Client kicked from the server during the scan (FiveM reliable network event overflow protection).
Root cause: FiveM hard-limits reliable net event payload volume per client; shipping hundreds of large base64 blobs over TriggerServerEvent trips the overflow guard and the server drops the player.
Fix: Replaced the net-event upload with an HTTP pipeline: the NUI downscales each capture to a 384px square jpeg (canvas, ~4-40 KB) and POSTs it to a SetHttpHandler endpoint (http://<server>/v-clothing/upload) authenticated by a one-shot scan token; net events now carry only tiny progress/done signals.
Prevention: NEVER send images/blobs (> a few KB) through TriggerServerEvent/TriggerClientEvent. Use the resource HTTP handler (SetHttpHandler + NUI fetch or screenshot-basic upload) for any bulk payload, with token auth and a server-side size guard.
Context: v-hud custom minimap. The GTA:O green (health) + blue (armour) bars kept rendering under the minimap even after (a) repositioning, (b) the QBCore squaremap texture swap, and (c) an opaque NUI cover strip.
Error: Bars still visible; the square texture reshapes the map but does NOT remove the bars; the NUI cover misaligned because the native map (bottom-aligned) and the CEF frame (top-left) used different coordinate systems (drag also went the wrong way).
Root cause: The bars are drawn by the minimap scaleform itself, not a hideable HUD component or a clippable map region - no amount of repositioning/masking removes them at the source.
Fix: Verified CFX method - call the minimap scaleform's SETUP_HEALTH_ARMOUR with GOLF mode (param 3 = no bars) every frame:
local mm = RequestScaleformMovie('minimap'); BeginScaleformMovieMethod(mm,'SETUP_HEALTH_ARMOUR'); ScaleformMovieMethodAddParamInt(3); EndScaleformMovieMethod()
Also unify native map + NUI frame on the SAME top-left coordinate space so drag direction and any overlay line up.
Prevention: For minimap/HUD scaleform elements, look for the scaleform METHOD that controls them (SETUP_HEALTH_ARMOUR, etc.) instead of trying to mask/clip. When overlaying CEF on a native element, use ONE coordinate system for both. Verify the technique against a known source before shipping instead of guessing offsets blind.
Context: Added a resizable minimap. Enlarging it distorted the map (stretched imagery) and the player blip drifted off-centre.
Error: The minimap sizeX/sizeY used a wrong aspect ratio (0.160 x 0.178) and were scaled from there; the game renders the map assuming a fixed ratio, so any other ratio stretches the content and de-centres the blip.
Root cause: GTA's minimap expects the frontend.xml default ratio sizeX:sizeY = 0.150 : 0.188888. Deviating from it distorts the map; the effect grows with size.
Fix: Use baseW=0.150, baseH=0.188888 as the base and scale BOTH by the same size factor (ratio preserved) -> undistorted, blip centred at any size. Verified default from frontend.xml via CFX docs.
Prevention: When resizing the native minimap, always preserve the 0.150:0.188888 ratio (scale uniformly). Never set sizeX/sizeY independently.
[2026-07-10 - session] - Minimap drag wrong place + HUD not hidden in pause (definitive)
Context: Draggable minimap landed in the wrong spot; HUD stayed drawn over the pause menu. Fixed after a multi-agent research pass (CFX cookbook, Dalrae1/MinimapPositionFiveM, qb-hud).
Error 1 (drag): The NUI frame was placed with raw vw/vh; the native map with SetMinimapComponentPosition('L','B'). The two diverge because SetMinimapComponentPosition works in SAFE-ZONE space (GetSafeZoneSize, per player) + aspect letterboxing - NOT raw screen fractions. So screen_top = 1 - posY - sizeY is wrong.
Fix 1: Native map = source of truth (component posX/posY/scale, qb square layout). Read its TRUE screen rect via SetScriptGfxAlign('L','B') + GetScriptGfxPosition (the engine's exact inverse - already applies safezone+aspect) and SLAVE the NUI frame to that pixel rect. Drag reports pixel deltas -> 1:1 component delta (posX += dx/resX, posY -= dy/resY). Never hand-roll safezone math.
Error 2 (pause): Only the NUI was hidden; DisplayRadar stayed true and a 1.5s re-assert loop + a per-frame scaleform loop forced the radar back on. IsPauseMenuActive() alone also misses the open/close transition and faded/switch screens.
Fix 2: A single hudHidden flag gates the minimap loop (DisplayRadar(false)); robust condition set = IsPauseMenuActive or GetPauseMenuState()~=0 or IsScreenFadedOut/FadingOut or IsPlayerSwitchInProgress or GetIsLoadingScreenActive or IsHudHidden or LocalPlayer.state.nuiOpen. Poll at 50ms, send on change.
Prevention: For native minimap position/size use the gfx round-trip, never raw fractions. For "hide HUD in menus" gate the NATIVE radar too (not just NUI), use GetPauseMenuState for transitions, and route all menu SetNuiFocus through a ref-counted v-core OpenMenu/CloseMenu that sets LocalPlayer.state.nuiOpen.
Context: Global "EMBER" NUI restyle (v-ui/theme.css + 12 module style.css). backdrop-filter: blur() was layered onto the glass panels inside @supports (backdrop-filter: blur(2px)) blocks, assumed to be safe progressive enhancement.
Error: In game, every blurred element rendered as a solid black rectangle - black bar behind the v-hud vitals rings, giant black box behind the inventory panels (user report + screenshots).
Root cause: FiveM's CEF parses and reports support for backdrop-filter (so @supports passes) but renders it as an opaque black box. The @supports guard is therefore useless as protection. This exact trap was already documented in the 2026-07-10 ERROR_LOG entry ("CEF-sensitive CSS … reliance on backdrop-filter/mask-image … brittle in-game") and in pre-EMBER RULES.md ("renders as an opaque black box in CEF 103 anyway") - and was reintroduced anyway on the assumption that "newer CEF" fixed it.
Fix: Deleted all 19 @supports (backdrop-filter…) blocks across 10 stylesheets (theme.css, v-hud ×6, v-inventory ×4, v-notify, v-loadscreen, v-admin, v-target, v-clothing ×2, v-spawn). The near-opaque --v-panel* gradient fills - always declared outside the blocks - now do all the work; no visual regression beyond losing blur. Docs re-synced: RULES.md §3.5 forbids backdrop-filter again, ARCHITECTURE.md (v-ui) and CHANGELOG amended.
Prevention: backdrop-filter is FORBIDDEN in this project - not "behind @supports", not "as an enhancement": CEF's support detection lies. Trust ERROR_LOG/RULES entries over assumptions about newer CEF builds, and smoke-test any CEF-sensitive CSS in-game before rolling it out framework-wide.
Context: framework-wide audit of config completeness in v-garages
Error: Config.StoreMaxDamage = true was declared with a comment promising a burning wreck could not be parked. No code anywhere read the value, so a destroyed vehicle could be stored and retrieved fully repaired - the garage was a free repair shop.
Root cause: the config entry was written at the same time as the comment describing the intent, and the enforcement was never added. Nothing in the toolchain flags a config key that is declared but never read.
Fix: enforced in the v-garages:store callback using the server-side entity health natives (engine, body, petrol tank), and exposed as an admin setting.
Prevention: a config key or a registered setting that no code path reads is worse than no config: it lies to the operator. Grep every new Config.X and every { key = 'x' } for a matching read before considering the module done.
Context: adding a retrieval fee to public garages
Error: on a failed vehicle spawn, v-garages:take refunded only g.fee and only when g.type == 'impound'. My first patch put the new public-garage fee inside the impound branch, where it was unreachable.
Root cause: the fee was computed inside a type-specific branch instead of once above it, so every new fee source needed its own duplicated refund.
Fix: the fee is computed once before the branch and refunded unconditionally when the spawn fails.
Prevention: when adding a second source of a charge, move the charge out of the branch first; never add a parallel one.
Context: adding limit errors to v-banking
Error: v-banking/html/app.js mapped res.error === 'target' to one string and everything else to "insufficient funds". A transfer refused for exceeding the maximum told the player the opposite of the truth.
Root cause: the handler was written when only two error codes existed and used an inline ternary instead of a lookup.
Prevention: map server error codes through t('prefix.err_' + code) with a fallback, never a ternary chain - the fallback then degrades gracefully instead of lying.
Context: Adding the "Phone apps" editor subtab to the admin panel with a Python patch script.
Error: UnicodeEncodeError: 'utf-8' codec can't encode characters ... surrogates not allowed on io.open(p, 'w').write(s). The next grep showed the file at 0 lines: 1350 lines gone.
Root cause: Two compounding mistakes. The file contains an emoji outside the BMP, and reading it without errors='surrogatepass' produced lone surrogates that the encoder then refused on the way out. Worse, open(path, 'w') truncates the target before the encoder ever runs, so the failure destroyed the file it was supposed to edit.
Fix: git checkout -- restored it, then the patch was rewritten to read and write with errors='surrogatepass' and to write to path + '.tmp' followed by os.replace.
Prevention: Never write a patched file in place. Write to a temp path and os.replace only after the write returns. Any in-place open(p, 'w') on a file that already exists is a destructive operation waiting for its first exception.
Context: Re-running a multi-section patch script after its first section failed on a wrong anchor.
Error: The first failure aborted the whole script before the sdk.js section ran. When re-running, that section was replaced with print('sdk: already applied') from memory of the run order — but it had never executed. The Bleeter/Snapmatic/Hush tiles were missing and every one rendered as a grey dot, straight into the delivered preview.
Root cause: Assuming a section had applied because a LATER check (node parse) passed. Parsing proves syntax, not presence.
Fix: Re-ran the section with its own guard (assert 'bleet:' not in s), verified with grep before rebuilding the preview.
Prevention: A skipped section must be justified by a grep for its own marker in the target file, never by recalling the run order. Every section of a multi-part patch carries its own idempotence guard so re-running the whole script is always safe.
Context: Porting a fix from an external, more advanced line of the phone (fivem-autres/v-phone) that had already isolated and resolved the same defect.
Error: v-phone/html/index.html and apps/example/index.html declared <meta name="color-scheme" content="light dark">. On a machine whose OS is in dark mode, that opts the frame into the browser's own dark handling, and CEF paints an opaque canvas BENEATH the document. In a transparent NUI page that canvas is a grey sheet over the entire game, present from the moment v-phone starts, before any player loads.
Root cause: The paint happens below the CSS box, so html, body { background: transparent } cannot stop it and the file looks correct. Computed styles and DOM audits are both blind to frame-level paint, so the usual checks report nothing.
Fix: Removed the meta from both HTML files (index.html and the drop-in app template that propagates it), and pinned html { color-scheme: normal; } as the first rule in style.css so it wins over any theme or stray tag. Boot clean, 48 resources, no v-phone errors.
Prevention: Never conclude "this page cannot paint" from computed styles or the DOM alone. A NUI page must not declare color-scheme on the top-level document; if a native control needs a scheme hint, scope it to an opaque element (e.g. body.inframe), never the transparent shell.
[2026-08-28 20:25] — The phone could open into nothing: an anti-iframe guard dropped Lua's own message
Context: Porting a fix from the external, more advanced phone line (fivem-autres/v-phone), which had already isolated the same latent defect.
Error: v-phone/html/app.js opened its Lua->page message handler with if (e.source && e.source !== window) return;, a guard meant to stop an app iframe impersonating Lua. It assumes a CEF host message always arrives with a null source. On some FiveM builds SendNUIMessage carries a non-null source, so that guard discards every host message at the first line and the phone opens into nothing, with no error to find.
Root cause: the guard fails by returning, not raising, so it is invisible to logs. The offline preview cannot catch it either: a synthetic MessageEvent has a null source, so it always passes the guard, while a real build may not.
Fix: reject a message only when its source is genuinely one of this page's own app iframes (walk window.frames), and accept anything else whatever the host puts in source. The null-source path (preview and normal builds) is unchanged.
Prevention: a guard that drops input silently is invisible to logs by construction; when a message-driven feature does nothing, suspect the filter before the handler. A NUI message-source guard must reject by identity against the known child frames, never by assuming the host's source value.
Context: Porting the class of defect recorded in the external phone's log (a shape rule at lower specificity losing to a theme rule) and checking it against this phone.
Error: .sheet[data-variant="spotlight"] (0,2,0) set the search panel's dark fill and its downward drop shadow. In dark mode the panel also matches .screen.dark .sheet (0,3,0), which wins on specificity and repainted it with the bottom-sheet fill and an UPWARD shadow (0 -14px) - a top-anchored card lit from below. Correct in light, wrong in dark, so any one-theme check would have missed it.
Root cause: a theme rule at (0,3,0) beats a variant rule at (0,2,0), and there was no dark-qualified variant rule at higher specificity to win it back. Measured both themes: light box-shadow contained 24px 70px (its own), dark contained -14px 54px (the sheet's).
Fix: the fill and shadow are stated once as .screen .sheet[data-variant="spotlight"], .screen.dark .sheet[data-variant="spotlight"] - (0,3,0) wins in light, (0,4,0) wins in dark, both on specificity not source order. Only background and box-shadow moved, so the panel's separate positioning rule is untouched. Re-measured: dark now carries 24px 70px, the -14px gone.
Prevention: any rule on .sheet (or any component) must be checked against .screen.dark .sheet at (0,3,0), and a shared component measured in BOTH themes, since a specificity loss can be theme-asymmetric.
Context: Auditing the consequence of cataloguing v-sport's supplements as food / drink, after v-sport registers its own use handlers for them.
Error: v-inventory/server/main.lua rebinds UsableItems[name] for every usable item whose itype is in UseByType (food, drink, drug, medical). loadItemDefs() runs at boot AND on v-world:server:changed, which an admin fires by editing any item in the panel. So a handler registered through RegisterUsableItem was overwritten by the generic type effect on the first admin edit: every drug fell back to a flat stress relief and every sport supplement to plain nutrition, until the next restart. No error, no log line.
Root cause: the loop's own comment asserted that external handlers "use types outside UseByType and are therefore never clobbered". That held for v-clothing, but not for v-drugs, whose items are itype = drug. The assumption was documented rather than enforced, so nothing broke when it stopped being true.
Fix: RegisterUsableItem records the name in a ClaimedUse set, and the rebinding loop skips a claimed name. Verified in the real runtime: a harness claimed a plain food item, fired the reload event, and the claimed list after the reload contained that item plus v-sport's four supplements, all skipped.
Prevention: an invariant stated only in a comment is not enforced. When code deliberately does not overwrite something, the exclusion must be data the code reads, not a claim about what callers happen to do. Registration APIs that share a table with a rebuild path need an ownership marker.
Context: Setting out to give v-sport's supplements their food and drink value, and reading the use dispatch to find where nutrition would belong.
Error: v-inventory's use callback ran the item's handler and then removed one from the slot unconditionally, then announced "used". Three consequences, none of them logged: the phone's power bank, which takes its own charge out with RemoveItem, cost two per use; the same power bank cost one even when the battery was full and the handler had declined; and a v-sport supplement refused because its effect was already running was destroyed anyway, with an error toast and a success toast together.
Root cause: the dispatch had no way for a handler to say "keep it". v-sport's qb registrar had always honoured Items.use's return value (if Items.use(...) and entry.consume ~= false then RemoveItem), so the same refusal kept the item on qb-core and lost it here. The v-inventory wiring dropped that return value because the dispatch had nowhere to put it.
Fix: a handler that returns false keeps the item and suppresses the success toast; nil, true or anything else consumes it, so every handler written before this is unaffected. v-sport returns its own decision; the power bank returns false on all three of its exits. Verified in the real runtime: nil -> consume, true -> consume, false -> keep, and exports['v-sport']:UseItem on a refusal path returns false.
Prevention: when a dispatcher performs an irreversible action around a callback, the callback needs a way to veto it. Porting a registration from a framework that let the handler own the removal to one that does the removal itself silently changes who decides, and the difference only shows on the refusal path, which is the path nobody tests.
Context: Running the phone's own validator against the integrated copy for the first time.
Error: It reported callbacks 44 asked, 0 handled and named all 44 as unanswered, on a phone that registers 45 of them. Every other scan in the same script returned empty too, silently.
Root cause: the script builds paths with glob.glob(os.path.join(ROOT, 'client', '*.lua')). A FiveM resource lives under resources/[local]/, and glob reads [local] as a CHARACTER CLASS: it looked for a one-character directory named l, o, c or a, found none, and returned an empty list. No error, no warning - just nothing, which then reads as "everything is broken".
Fix: route every scan through a helper that escapes the base with glob.escape(ROOT). Verified afterwards: 44 asked, 45 handled. v-sport's validator was checked for the same flaw and does not have it - it uses pathlib, where only the pattern argument is interpreted, and it genuinely sees its 12 client and 9 server files.
Prevention: never interpolate a path into a glob pattern. Escape the base, or use pathlib.Path(base).glob(pattern) where the base stays literal. And when a tool reports that everything is broken, suspect the tool before the code.
Context: Reading the phone validator's output line by line rather than trusting its final verdict.
Error: app metadata, app descriptions and prefs round trip each printed a "not found" note and returned a pass. Two anchored on Config.StoreApps, a table this copy of the phone does not have; the third anchored on prefsOf = function( where this copy writes local function prefsOf(. Three green checks that examined nothing.
Root cause: a check that cannot locate its subject has two honest options - fail, or report that it could not run - and each of these took a third: report, and pass anyway. The summary line then counted them among the checks that succeeded.
Fix: end the apps block at the next top-level Config. declaration instead of a hardcoded name, and accept both function declaration forms. Once they ran, the app checks immediately found three apps with no description in either locale, which the store was presenting as third-party apps whose author wrote nothing.
Prevention: "cannot find X" is never a pass. A checker that reports a green light on something nobody looked at is worse than no checker, because it stops anyone looking.
Context: Closing several iterations with "boot test clean" as the verification for edits to client-side Lua.
Error: The claim was weaker than it sounded. Measured directly: a deliberately broken client file produced Starting resource v-radio, Started resource v-radio, module registered: v-radio (3 setting(s)) and no error of any kind. The file would simply never have run for a connected player.
Root cause: the server never reads client scripts. It ships them to the client, and a syntax error surfaces only in a player's own console when they connect.
Fix: tools/lua-syntax.py compiles all 300 Lua files and discards the result. Its first run reported v-hud's weapon test as a syntax error, which is valid FiveM: `WEAPON_UNARMED` between backticks is a CitizenFX joaat literal that stock Lua rejects. Those are replaced with a number before parsing, so valid code is not "fixed" into broken code.
Prevention: state what a verification actually covers. A boot test covers server scripts, manifests and resource start-up; client Lua, NUI assets and anything gated behind a connected player need their own check.
Context: Sweeping every server-loaded file for the defect just fixed in v-loadscreen: a net event parameter printed to the console without being sanitised. The sweep reported zero across 186 files.
Error: Zero was meaningless. Run against the pre-fix v-loadscreen/server.lua recovered from git, the same sweep also reported zero, on the one case known to be real.
Root cause: two faults in the function-body extractor. It started the brace depth at 0 while already inside the handler, so if type(line) ~= 'string' then return end on the first line closed the whole body and the scan never reached the print below it. It also counted for, while and do as separate openers, leaving every loop unbalanced by one.
Fix: depth starts at 1, and only function, if and do open a block - for and while are not counted because the do that follows them is what end closes. With that, the pre-fix file reports the defect and the fixed file reports nothing.
Prevention: a sweep for a known defect class must be run against a known instance before its silence is believed. Git history is the cheapest source of one: the version from before the fix is a free positive control.
Context: Verifying each change with a boot test and reporting "49 resources started, no errors", including for the v-phone migration that creates 61 tables at start-up.
Error: MariaDB was not running. oxmysql logged Unable to establish a connection to the database (ECONNREFUSED) and the boot carried on: resources started, modules registered, services registered, and the filter reported nothing.
Root cause: two things at once. The database here is on-demand by design (start-db.bat, never a Windows service), so it is off unless somebody starts it. And oxmysql logs that failure at [ info], not [error], so a filter matching SCRIPT ERROR|Failed to load|error parsing cannot see it. The boot test was answering "do the resources load" and being read as "does the server work".
Fix: start the database first, and match the connection line explicitly. With it running the same boot reports [12.3.2-MariaDB] Database server connection established! and no SQL failure anywhere, which is the first time the phone's 61 CREATE TABLE statements were actually exercised.
Prevention: a boot test's filter has to match on what a healthy boot SAYS, not only on what a broken one errors with. Absence of an error line is not evidence when the failure is logged as information.