FireFront
Fire that actually spreads: structure to structure, into the trees, across open ground. Wind-driven, rain-damped, stopped by water. Charred trees, burn scars, real drops, tree regrowth, fires that survive restarts and remember who lit them.Changelog
1.0.4 — 2026-11-01
Updated due to the Valheim 1.0.21 patch. Rebuilt and checked against Valheim 1.0.21 (network version 41); no code changes beyond the version string, and the wire protocol stays 1, so 1.0.3 and 1.0.4 play together. The store page's website link is https://ravenirongames.com/. The BepInExPack dependency is now 5.4.2360.
1.0.3 — 2026-09-25
Updated due to 1.0.16 Patch.
1.0.2
Fixes from a code review of 1.0.1. The wire protocol stays 1, so 1.0.1 and 1.0.2 play together.
- Changing worlds no longer carries fire into the next one. In single player or when hosting, logging out of a world with fire burning and loading another (or the same one again) kept the old world's fire running in the new one. Its burning objects pointed at unrelated objects in the new world, so pieces there could be destroyed, the old ground fire burned at its old coordinates, trees waiting to regrow were planted into the new world, and the new world's fire save was overwritten. Now the fire is saved to its own world when that world closes and then forgotten, and the next world loads its own. Dedicated servers were not affected.
- A new fire's first trees no longer char almost at once. The tree-fire clock stood still while nothing burned, so the first tick of the next fire charged the whole quiet spell as burn time: after a few quiet minutes a tree lit by a fire arrow went to its last health in one tick and charred before it was old enough to spread. The clock now stops with the fire, so a new fire's trees burn at the configured pace from the first tick.
- The server checks what a client asks it to light or put out. An ignite or extinguish request names an object, and the server used to build that object and take it over whatever it was, however far away. A modified client could name any object, another player's character included, and the server would take it over. The server now lights only trees, logs and burnable pieces, refuses a request for something more than 320 m from where the player's game last reported being, and puts out only an object that is already burning, without building anything. Pressing the extinguish key while aiming at a piece that is not burning no longer takes that piece from the player standing next to it.
- Ground fire is drawn where it burns, whatever each player's
GroundCellSize. The server now sends its own cell size with the ground fire, and each client places the flames, the warmth and the scorch marks with it. A player who had set a different size saw the whole fire at scaled coordinates, far from where it really burned, and was not warmed by it. Against a server older than 1.0.2 a client uses its own size, as before. - Soaked trees are left alone. Spread skipped a doused tree (a Dousing Bomb line) or a tree past the Ashlands edge only after building it and taking it from the nearby player, every 0.75 s. It now skips them before building anything.
- Each fire remembers who lit it. Until now the igniter FireFront saved and offered to
companion mods was one for the whole map, so Ragnarok's Wrath, for one, could only blame
whoever lit the first fire since the map was last clear of fire, even after that fire had
gone out. Now every burning object and ground cell records the player who lit it, fire
that spreads keeps the igniter of the fire it came from, and the igniter is saved and
restored with the fire. A save from an older version loads with the igniter unknown. The
old map-wide value is unchanged, so older companion builds keep working. Newer ones can ask
for each fire's igniter through
FireManager.CollectActiveFiresWithIgnitersandFireManager.FireIgniterNear. Nothing new goes over the network for it (a client's ignite request already carried the attacker's id), so it changes nothing for 1.0.1 clients. - Rain no longer puts out every fire at once after a pause. Turning fire back on with
fireset enabled truecharged the whole pause as rain time, so every fire in the rain went out on resume. Rain now ages fire by at most two spread cycles at a time. - The store page's website link now goes to the Raven Iron website.
This DLL was built from the commit tagged v1.0.2; the GitHub release names that commit and
gives the DLL's md5.
Rig evidence (2026-09-24, on the dedicated test rig, from a build of e720f2b — on the
same code as this release, since only documents and the store page's website link changed
after it). A dedicated server on Valheim 1.0.15 (crossplay), plus single-player worlds on
the same build; one client throughout. A fire stayed in its own world across a world change
in single player: the next world loaded with nothing burning and no fire lines, and
re-entering the first world restored its one burning tree. The same held joining the
dedicated server: no local fire carried over, and no restore or burn lines appeared at join.
Five arrow hits on the dedicated server all carried the same igniter id, and the fire save
file's meta line and its ground lines, read while the fire burned, matched it; a second fire was also lit by arrow
(the plan was startfire), so it carried the same id and a fire with no igniter was not
shown there — the only such fire seen was the rain check's startfire fire below, whose
fire event logged igniter 0 (its per-fire igniter was not read back). A client running a
different GroundCellSize than the server (2 against the server's 1) drew scorch marks
sized and placed for the server's cells once it joined, not its own. Extinguish by id put a
fire out with no forced re-creation and no re-light, and after clearfires, pressing the
extinguish key (G) at an unlit tree built nothing. After about two minutes with nothing
burning, a tree lit by a fire arrow took a first tick of under 1 % of its health and burned
at the configured pace from there (1.0.1 would have charged the whole two minutes in that
first tick, more than half the tree's health), and was still burning two minutes later. With rain
forced on (fireweather force Rain), one tree lit with startfire, and fire paused for
40 s with fireset enabled false, the tree kept burning after the resume and went out
15–18 s later on its rain-shortened timer, not at the resume itself — one tree and a 40 s
pause is a smaller check than the planned two to four trees and 60 s. BurnPlayerBuildings false held throughout: ignite aimed at a player's wall answered Not burnable, and the
only piece that caught was part of an unowned world ruin. Settings were confirmed back to
their pre-test values afterward. Zero exceptions on either side.
Not tried in game: soaked or Ashlands-barred trees being skipped rather than re-created
mid-spread (no burning neighbour was near the doused tree in this run); the late-ZDO ignite
retry (every request in this run resolved directly, so the retry path was never used); the
tree tick under a slower spread cycle (spreadinterval 10); being warmed by ground fire
under a mismatched GroundCellSize (only where the flames and scorch marks sat was
checked); a 1.0.1 client against a 1.0.2 server and the reverse (checked by code review, not
restaged here); a queued fire keeping its igniter; a fire started by lightning or a creature
recording no igniter; a save from before 1.0.2 restoring with the igniter unknown (covered
off-game); a companion build that still reads the old map-wide igniter; a dedicated-server
restart with fire still burning; a world change while hosting with another player connected;
the server ignoring an ignite request for something that cannot burn, which any fire hit on
a stone wall sends; the server refusing a request for a target out of reach (320 m to
light, 200 m to put out), which only a modified client would send; and a second
player: two players lighting separate fires at once, which would test igniter attribution
between two real attackers, and the extinguish key pressed at an unlit piece beside another
player. Off-game: 1815 checks, 0 failed.
1.0.1
- FireFront no longer sets the Ashlands alight. The Ashlands' own fire (cinders, lava,
burning ground) touches wood there all the time, and FireFront caught from every touch, so
the whole biome burned and could not be played. A new setting,
FireInAshlands(fireset ashlands), is off by default. With it off, FireFront starts no fire in the Ashlands by any path: vanilla fire, spread from outside, a client's ignite request, console commands, or a fire save written by 1.0.0, whose Ashlands fires are dropped at load. The Ashlands' own fire works as it does without FireFront. It is a new key, so the default reaches installs that already have a config file;burntheworlddoes not change it; the server's copy is the one read. While the server refuses fire there it logs at most one[ASHLANDS]line a minute, andignite,startfireandfiregroundignitesay why they did nothing. - The DLL no longer carries the build machine's folder. Every DLL through 1.0.0 carried an absolute build path that included the build machine's user name. The build now maps source paths to a neutral root, so the DLL's checksum follows the commit it was built from, not the folder it was built in. This DLL was built from the commit tagged v1.0.1.
Rig evidence (2026-09-23, on the dedicated test rig, this source built just before the
commit). In the Ashlands at about (51, -9126): startfire lit nothing, and the server refused
13 ignite requests the client forwarded for four Ashlands objects, logging its [ASHLANDS]
line. fireset ashlands true, typed on the client, set the server's value and startfire
then lit an Ashlands tree; fireset ashlands false turned it off again and the next
startfire lit nothing. At spawn, startfire lit a beech as before. No errors from the
mod in the session.
1.0.0
The first release. The code is 0.24.0's with the version string changed; the wire protocol stays 1, so 0.24 and 1.0 play together (each side shows the "different version, works together" notice).
- What 1.0 means is in docs/ROADMAP.md, "The shorter road": the server is the authority for everything a client can ask of it (0.23), client and server compare versions at connect and numbers read the same on every language setting (0.24), the MIT licence, and a README that describes what happens on a dedicated server.
- The README pass: every sentence checked against the code with a dedicated server as
the reference, by five section auditors and a missing-feature finder with two refuters
per finding, then rechecked by two independent readers. Nineteen corrections, among
them: scorch marks fade after five minutes and lasting dirt is the opt-in
dirtpaint; firebreak ground stops only fire crossing the ground; which commands run on the server and need its admin list; threefiresetkeys the table was missing; and the arson disclosure, stated as the code behaves: one igniter id for the whole server, the first player to light a fire while nothing burns, saved in the fire save file and read by a companion mod.
Rig evidence (2026-09-23, on the dedicated test rig). A 0.23 client on the 1.0 server:
the server logged [VERSION] peer ... has not answered FireFront's version check in 60 s ... This server runs 1.0.0 (protocol 1). and the tester saw the centre-screen message. A 1.0
client on a 0.23 server: the client logged [VERSION] FireFront: the server did not answer the version check ... about 30 s after joining and the tester saw the centre-screen message.
German number format: the 1.0 server ran as de-DE under Mono (the probe logged 1.5 formats as '1,5', '1.5' parses as 15, the trap 0.24 removed) and booted clean; the save file's
cross-culture reading is covered by the harness. Admin refusal (0.23), matching versions and
the decimal comma (0.24) were played on code 1.0 does not change.
0.24.0
- Client and server compare versions at connect. Nothing put FireFront's version on the
wire before, so a mixed pair failed silently: ignition doing nothing, fire the server
simulated that the client never drew. A new routed RPC,
FireFront_Version, carries the plugin version and a wire protocol number both ways once the client can reach the server (Fire/VersionCheck.cs). Same version: one Info line on each side. Same protocol, different version: a Warn and a top-left message, since they work together. Different protocol: a Warn and a centre-screen message saying fire will not show or sync correctly. The protocol number (Plugin.WireProtocol, 1 as of this release) changes only when an RPC's name, arguments or payload layout change. - Older builds on either side are caught too. A client whose server never answers (a
server on 0.23 or older, or none) tells the player after three tries 10 s apart. A server
gives every connected peer 60 s; one that never sent its version gets a Warn in the server
log and, once that player's character has spawned, a console line plus a centre-screen
message on the client, through two channels
vanilla registers on every client (
RemotePrintand the routedShowMessage), so it works with no FireFront or an old one on the far side. The new RPC is in the 0.23 sender guard ([AUTH] routed-sender guard armed: 14 methods); the boot line readsAll 13 FireFront RPCs. - On-screen version notices wait for the player's character to spawn, plus 3 s; the log and console lines are written at once. Found by the review before release: the reply arrives within one round trip, several seconds before the character exists, and a top-left message needs the character while a centre message under the loading screen is never seen.
- Numbers mean the same thing on every language setting. There was not one invariant
culture in the tree. The fire save file was written and read with the machine's culture, so
a store written on an English machine and read on a German one put
1.5back as15, andfireset spreadradius 1,5typed on a German client reached the server's BepInEx parser, which is invariant, as 15. Every number FireFront writes as text or reads from text now goes throughUtils/InvariantNumbers.cs: written in the invariant culture at round-trip precision, read in the invariant culture with two allowances for old files and for typing (a single comma with no dot is a decimal comma; the Unicode minus reads as-), and thousands separators, NaN and the infinities refused rather than guessed. The save file keeps format version 1: a store written by any earlier build, on any culture, reads as its writer meant it, so an upgrade loses nothing. The server applies the same rewrite to a forwardedfireset, for clients older than 0.24. Vanilla's own console argument parser (startfire 12,5) was already invariant and is untouched. - The off-game harness covers it: 1,680 new checks run the reader and writer under ten cultures (invariant, en-US, de-DE, fr-FR, ru-RU, sv-SE, tr-TR, pt-BR, es-ES, fi-FI), including every culture reading every other culture's pre-0.24 save file. The harness runs on .NET with ICU culture data, which formats sv-SE and fi-FI negatives with U+2212: stricter than the Mono the game runs on.
- A texture-generator name match lowercased with the machine's culture, which on a Turkish
machine turns
Iinto dotlessı; it lowercases invariantly now. - MIT licence, in the repository and in the zip (
LICENSE). Copyright Raven Iron Games and FireFront contributors. - docs/ROADMAP.md is rewritten to the shorter road agreed on 2026-09-23: 1.0 is 0.23, this release, the licence and a README pass; the rest moves after 1.0.
Rig evidence (2026-09-23, on the dedicated test rig, both sides on 0.24.0, en-US).
Server boot: All 13 FireFront RPCs registered, [AUTH] routed-sender guard armed: 14 methods (13 FireFront + RPC_Damage). On join, server: [VERSION] peer <id> (Steam_...) runs FireFront 0.24.0, same as this server.; client: [VERSION] the server runs FireFront 0.24.0, same as this game.; nothing on screen. fireset burnduration 240,5 typed with a decimal comma read fireset burnduration = 240.5 on this machine and [server] FireFront: fireset burnduration = 240.5 on the server. (before 0.24 an en-US machine read it as 2405, clamped to 600); fireset spreadradius 1,5 came back as 2, the key's minimum, where before 0.24 it would have been 15.
Zero exceptions on either side. Not played, left out on purpose: a 0.23 client against a
0.24 server and the reverse (the two mismatch warnings), and a de-DE process reading the fire
save file; the last is covered by the harness's 1,680 checks, which run the same code.
The shipped build adds the notice-timing fix above, found by review after this session; it
changes only what a player sees when versions differ.
0.23.0
- A non-admin can no longer change the server's settings. A
firesetforwarded from a client, and the ConfigurationManager sync that lands on the same server-side setter, are checked against the server's own adminlist before anything is applied: the vanilla kick/ban check (ZNet.ListContainsIdon the socket's host name) that the relayed commands already use. A refused change answers[server] FireFront: fireset <key> refused — you are not in the server's adminlist.in the sender's console and is logged on the server under[AUTH]. The check is server-side only, on purpose: a client-side gate is false on every server without an adminlist file, which is how 0.21.6 made the sync a silent no-op. - The server checks who a routed RPC came from. Valheim reads a routed RPC's sender id
straight off the wire and relays it verbatim, so a modified client could write the
server's id as the sender, address the package to everybody, and every other client's
"only from the server" filter would pass it. A Harmony prefix on
ZRoutedRpc.RPC_RoutedRPC, server side, now drops a package for any of FireFront's twelve RPCs, or for vanilla'sRPC_Damage(the tree, log and wear-and-tear prefixes act on its sender), whose claimed sender is not the peer the connection belongs to. Scoped to those thirteen methods so no other traffic is touched. A client cannot register with the server's own id (vanilla refuses it as already connected), so a sender that matches its connection is never the server either. Drops are logged at Warn, one line per peer per kind of refusal per ten seconds, with a count of what was not logged. The guard reports[AUTH] routed-sender guard armed: 13 methodsat RPC registration; ifZNet.m_peerscannot be reflected it says so at Warn and every handler's own clamps stand alone, as in 0.22. firesetsays where a value landed, and the server always answers. A typedfiresetwrites the copy on the typist's machine first (several keys are read on both sides) and then forwards it. Before 0.23 the server never refused, so the local reply stood in for both. Found in play on the rig: a refused non-admin sawburntheworld = True (in force: True)and read it as the server's state. On a client connected to a server the reply now sayson this machine, a second line says the server answers separately, and the server replies to every forwardedfiresetas a[server]line:= <value> on the server, refused (the server's value is unchanged, the local copy keeps what was set), no such key, or could not read the value. The ConfigurationManager route gets the same replies.- Extinguish requests are bounded by the server's settings. The radius a client sends
with the key or a Dousing Bomb was applied as sent. It is now capped at the larger of the
server's
ExtinguishGroundRadiusandDousingBombRadius, and the position must be within 200 m of where the server last saw the player (its reference position, refreshed every 2 s). Out of reach or not finite is refused and logged; over the cap is clamped and logged. docs/CONFIG-KEY-MAP.md no longer calls the server's copies of those two keys dead. - Paint assignments are rate-limited on the client. The server sends at most one per second per peer; more than ten in five seconds are dropped and logged. The 4096-cell cap and the radius clamp per message are unchanged.
- The README no longer says creatures burn on a dedicated server. Players do (the server tells the owning client, since 0.21.8); creatures burn in a hosted game only, because a dedicated server has no physical world where they stand.
- Struck from the roadmap as already done: restored ground-fire cells do get a
FireBurnZone(0.21.9,9900edf; the restore path queues them forUpgradeDarkGroundCells).
Rig evidence (2026-09-23, on the dedicated test rig, both sides on the same build).
Server boot: [AUTH] routed-sender guard armed: 13 methods (12 FireFront + RPC_Damage), no
FireFront warning or error. As admin: fireset burntheworld true then false applied on the
server (fireset (remote from <peer>): burntheworld = True, then = False); clearfires
relayed and ran. Off the adminlist (vanilla re-read the file within 10 s, no restart):
[AUTH] refused fireset 'burntheworld = false' from a peer not in the adminlist; a
ConfigurationManager slider, [AUTH] refused fireset 'dirtpaintradius = 8'; G and the
Dousing Bomb still put fires out. The forged packets, from a rig-only test plugin that is not
part of FireFront: a PaintAssign claiming the server's id, [AUTH] dropped a routed RPC (method -677775963) ... it claims sender <server>, the connection is peer <owner>; thirty at once, one
line and (29 more of these from this peer in the last 10 s not logged) on the next; an
RPC_Damage with FireFront's tick marker (method 1130726949), dropped; a FireDamage of 1e9
(method -1026555515), dropped; an honest request with a 100 km radius, extinguish radius 100000.0 m clamped to the server's 15.0 m; one 5 km away, refused an extinguish request at (4945,-31): 5000 m from where the server last saw the player, limit 200 m. Found in play and
fixed the same evening: the fireset wording (the entry above). Retested on the fixed build:
as admin the console read burntheworld = True on this machine, the sent-to-the-server line,
then [server] FireFront: fireset burntheworld = True on the server.; as non-admin the
[server] line read refused — you are not in the server's adminlist. The server's value is unchanged; the copy on your machine keeps what you set. Zero exceptions either side.
0.22.1
- A player who joins mid-fire now sees the fire. Object fires (structures, trees, logs)
were announced only by a
FireEventbroadcast at ignition; a client that connected after it saw the tree taking damage, charring and falling with no flames on it. The join-time snapshot request the ground fire already had (FireFront_GroundSyncRequest, sent once the routed channel can reach the server) is now answered with a second package,FireFront_ObjectFireSync: every burning ZDOID with its burn age and whether it has dropped to embers. The client books each one exactly as aFireEventwould (it is idempotent against fires it already heard about), backdates its own smoulder clock by the server's age so a fire that burned ten minutes elsewhere does not start fresh, and asks again after 10 s if no object snapshot arrived (three tries; the server rate-limits per sender, so a repeat that was not needed costs nothing). Only the connected server's reply is accepted. A burner that is not loaded on the client yet is drawn when it instantiates, as before. - Charred wood looks like charred wood, and the embers are dialled back. The 0.22.0
char was a dark tint over the vanilla bark with a uniform ember lattice glowing at the
game's Ashlands HDR value (4.78) through every crack of the whole trunk: it consumed the
tree. Now the client builds, once per species, a charred albedo FROM the species' own bark
texture (read back through a RenderTexture; the bundle textures are not CPU-readable) -
domain-warped "alligator" plates cut by black fissures, the grain kept as tone, per-plate
variation, pale ash flecks on the plate tops - and a normal map that carries the char
relief over the vanilla bark grain, AG-packed like every map the game ships. The ember
glow lives only in scattered pockets:
CharredEmberCoverage(0.2) is the fraction of the trunk carrying live embers, thresholded at an honest quantile of a low-frequency field so the number means what it says; inside a pocket the glow bleeds along the fissures and adds pin-point embers on the plates, the way vanilla's own Ashlands tree mask is a field of points, and nothing glows outside.CharredEmberIntensity(0.4; 1.0 ≈ the Ashlands value) sets the heat, so at the default only the cores cross the bloom threshold. Four mask variants, picked per object from its ZDOID, so neighbouring snags glow in different places; the same masks and dial drive the live-burn bark char below the fire front. When the read- back fails (no graphics device, unexpected format) the tint look of 0.22.0 stays. The generator isSharedMedia.ProceduralTextures, vendored from libs-Tools and previewable offline withlibs-Tools/CSharp/TexturePreviewagainst the extracted vanilla bark - the contact sheet there is what shipped.firedumptexwrites what this client generated as PNG underBepInEx/config/FireFront-textures/. - Charred wood smokes after the fire is out. A charred trunk (two or three points on
its lower two thirds) and a fallen charred log (three points along it) give off thin grey
wisps for
CharredSmokeSeconds(90 s, world time, so every peer and every late joiner sees the same stage), full for two thirds of it and tapering to nothing; the same Lux-lit vanilla smoke material the fire uses, at a hundredth of the rate, drifting with the real wind. Off under LowSpec,CharredSmokeEnabledotherwise, capped at 32 smoking objects world-wide. - The whole-tree glow from the first in-game run (NomadicWar, PR #3, 2026-09-20) is closed
at the root. Trunks rendered as pink-white rods root to crown, canopies flat pink, distant
LOD trunks neon. The mask kept bright orange in RGB everywhere with the pattern only in
alpha; Custom/Vegetation does multiply by alpha (checked in BOTH the forward and the
deferred GLSL:
u_xlat2.xyz = u_xlat2.www * u_xlat2.xyz), but Standard's_EmissionMapdoes not, and mip levels average RGB and alpha separately, so at LOD distance a sparse mask became "mean alpha × bright orange" over the whole surface. The mask is now PREMULTIPLIED with alpha 255: black between embers in every pass, every shader and every mip. Also: a mask is always bound (Texture2D.blackTexturewhen the generator could not run - an unbound slot is Unity's default WHITE, and 0.22.0 drove_EmissionColorinto exactly that); atlas species (Pine, Fir) get masks confined to the bark block during a live burn, found by eroding the atlas alpha and keeping the largest solid component, so needle cards never light; the charred glow fades to a third past 30-90 m and the live-burn embers drop to 30 % when the rig is far, so a distant snag is black, not a lamp. - The scorch grid is gone. Rows of dark squares across burnt hillsides: one near-opaque
1.5 m umber disc per 1 m cell, feathered rims overlapping LIGHTER than the discs (a
lattice), horizontal quads cutting into slopes, a palette lighter than a Black Forest floor
so the tiles read both ways, on
Sprites/Default. Now a multiply-blended soot blot (vanilla'sCustom/Particle (Unlit)from the ember donor,Blend OneMinusSrcAlpha SrcAlphaon black vertices with the shader's fog on - see the last entry below for why notDstColor Zero- every other fade in the shader off, queue 2950) that darkens whatever is lit - grass, sun, shadow and the fire's own light survive, and two blots overlapping only get darker - laid on the terrain's own normal by raycast, about two blots in five cells at 1.6-2.2x the cell, jittered, with a noise-warped outline and pale ash flecks. The first five placements log[SCORCH](position, terrain hit, shader, blend) so "no marks" is diagnosable as absent vs invisible. The 0.21.x alpha disc remains the fallback when no donor material exists. - Only the server may paint fire on a client.
ZRoutedRpcrelays the sender id verbatim, and the two cosmetic handlers (FireEvent,GroundFireSync) accepted it from anyone, so a modded client could address a forged event to Everybody and put phantom fires on every screen. All three client-side handlers now apply theIsFromServercheck the fire-damage handler already had. (Found by the 2026-09-20 decompile pass;ZRoutedRpc.Registeris also aDictionary.Addthat throws on a duplicate name, not the overwrite the old comment claimed — the per-instance registration guard is what keeps it safe, and the comment now says so.) - New
firesetkeys:charredember,charredembercover,charredsmoke,charredsmokeseconds.firestatusreports all four. No ledger rung: nothing renamed or re-defaulted. - Verified headless: 0.22.0 booted the real Valheim
l-1.0.15Linux dedicated server with every patch applied and 0 exceptions (~/valheim-testbed/boot-check.sh, see HANDOFF). - Five things fixed on review and on the first run (NomadicWar, 2026-09-20): the charred
textures are generated on worker threads, because the first version built a 512² field and
four 512² masks on the main thread the frame the first tree caught, a freeze of several
hundred milliseconds (the field build starts at plugin load on a client, so a tree that
streams in already charred does not wait for it either); the join snapshot's burn age is clamped and turned into an ignition
instant on arrival, so a burner that instantiates a minute after the packet is not a minute
behind; the scorch thinning is keyed on the ground cell rather than the metre, which at
a GroundCellSize below 1 folded neighbouring cells onto one pick; and every velocity and
force module now writes x, y and z in one curve mode, because Unity logged
Particle Velocity curves must all be in the same modeonce per frame per live flame (80,000 lines in the first eight minutes of play, each a BepInEx disk write) -BuildFlamesset y to two constants and left x and z as constants, and the wind update assigned bare floats. The same shape sat inBuildCrownFlamessince 0.21.2, so 0.21.x logged it too whenever a crown burned. And a scorch decal now waits for its zone: the sync stream reports a cell going out anywhere on the map, during the loading screen included, and the mark was spawned on the spot - five of five logged marks had no terrain under them, 500 m from the player, floating at the synced height with no tilt. They queue and spawn, with the lifetime they have left, once the zone is loaded on that client (since 0.21.15 the remote-mirror path had this hole onmaintoo). - The six look items from that review, and one thing found under them. The scorch blot
is the size the entry above says: both callers passed the ground cell x 1.5 on top of the
spawner's own 1.6-2.2x, so a blot was 2.4-3.3 m on a 1 m cell and burnt ground sat under
2.7 multiply blots on average; the callers now pass the cell, which is 1.2 blot outlines
(about half a blot of full soot tone) per burnt square metre. The quad is fitted to five
terrain rays instead of one - centre and its four corners - so its plane is the mean of
what it spans and it is lifted along that normal by whatever its highest corner needs
(4 cm minimum; a lift past 35 cm - a corner more than 31 cm above the centre plane - is a
hollow no flat quad can lie in, and that mark is dropped and says so in the log; a
heightfield bump BETWEEN the five samples can still poke through, the slope case is
exact). The
[SCORCH] mark Nline now printsterrain-hits=k/5 lift=Lm. Under the tint question sat the real defect: the 0.22.1 decal drew nothing.Custom/Particle (Unlit)'s compiled fragment never outputs the texture's RGB - its colour is the VERTEX colour, and the texture contributes one channel, picked by_AlphaChannel, into alpha - soBlend DstColor Zeromultiplied the ground by white. The multiply now rides on alpha (_DstBlend= SrcAlpha,_AlphaChannel= red, the blot's tone: 0.30 as stored, which displays as the ground at about 30 % of its brightness at the heart), and it is fogged the way vanilla's own particles are rather than darkening ground that is already fog-grey: the quad's vertex colour is black, the shader's_FejdFogis ON so the fragment's RGB is exactly the fog term, andBlend OneMinusSrcAlpha SrcAlphaworks out toa * (1 - f) * lit + f * fog- the multiply under the fog (review of the fix found the bareframebuffer * ascaling the fog term too, a black blob on grey mist at 50 m). Every other fade the GLSL applies to that alpha is forced to 1 with the reason for each in the code:_SkyMask(a top-down depth test that hides anything under a canopy - the ground under a burnt tree is exactly that; the donor has it ON), the soft-particle depth fade (compiled under the GLOBAL keyword, not the material toggle;_SoftNearFade-1000 saturates it whatever the gap), and the near-camera fade (_CameraFadeFactor0.2 on the donor faded a mark out as the player walked up to it). The[SHADER-DIAG] scorch decal materialline reads every value back. The charred twin of a pine or fir now takes the bark-confined ember mask, not the plain one that lit the dead atlas' branch cards, and the post-burn glow is driven per material slot exactly as the live burn is: leaf and non-emissive slots get no block, a slot whose mask is not ready gets a black mask AND a black colour in the same block, and atlas clones keep a black mask at the material level for good (a coverage change no longer points them at the plain mask). The live-burn glow fades over 30-90 m on the very ramp the charred twin uses (FireVFXController.EmberDistanceFactor), instead of stepping to 30 % at 70 m, so a tree that turns from burning to charred at 50 m keeps its brightness; the 70 m step stays as the emitter cost LOD only. And retired ember-mask sets (a coverage change parks the old set, about 5.5 MB, because a destroyed texture samples white) are now freed: once every mask of the new generation exists - all four plain variants and all four of every parked atlas - and ten seconds of Time.time have passed (forty of the longest consumer tick), so every block has been re-bound; the argument that no block can then hold a colour against a dead mask is written aboveCharredTextures.ReapRetired, and the charred controller now writes one explicit black block when a tree's glow ends or is switched off, which closes the two holes that argument had.firestatusreportsretired masks N.
0.22.0
- Fire climbs the tree, and the height of the flames is the tree's health. A burning
tree or log now takes real, unseen fire damage every
TreeFireTickIntervalseconds (2 s), sized so a healthy tree dies atTreeFireKillFraction(90 %) ofBurnDurationSeconds; the fire front starts at the foot and climbs the trunk as the health drops, so flames in the crown mean a tree about to go. Every tick prints as[TREE-HP]underfiredebug. The damage cannot go through vanilla: every tree and log in 1.0.15 hasm_damageModifiers.m_fire = Immune, soTreeBase.RPC_Damagezeroes a fire hit before it touches health, and itsDamageText.ShowTextis unconditional and routed to every peer within 30 m, so no vanilla hit is ever silent (TreeBase.cs:113-122, DamageText.cs:184-192). The server therefore sends a HitData carrying a marker inm_statusEffectHash(serialized, unread by trees) to whichever peer OWNS the tree, and the mod's ownRPC_Damageprefix applies it there straight to the ZDO health: no text, no shake, no hit effect. On a dedicated server that owner is the nearest client, not the server, which has no GameObject for the tree at all and whose claimsReleaseNearbyZDOShands back within seconds - so nothing here claims ownership. Health is a ZDO float, replicated to every peer, and is what the flames on every client climb by; no new sync exists for it. The burn timer stays as the fallback authority (rain shortens the timer, not the ticks).TreeFireDamageEnabled falserestores the old clock-driven climb. - A tree the fire kills is charred in place, then falls or stands. There is no burnt tree
in the game (all 115
Charred*prefabs are Ashlands enemies and gear; verified against the 7921-prefab dump), so the dead tree is replaced by a clone of ITS OWN species, at the same transform and scale, re-skinned: Custom/Vegetation bark tinted to charcoal with the ridges pushed into fissures, an ember-crack mask glowing in HDR through the shader's own emissive path and fading overCharredEmberGlowSeconds, vanilla's AshlandsAsh_ddusting the upward faces through the moss layer, and the leaf cards deleted in both the forward and the shadow pass (_Color.a 0,_Cutoff 2). Pine and fir share one atlas for trunk and needles, so they get vanilla's own dead atlases instead (PineTree_01_dead,Pine_tree_small_dead). One cached material clone per vanilla material, shared by every charred tree of that species; the first draft'srenderer.materialsallocated and leaked a Material per slot per renderer per tree. The hover text reads "Charred Beech".CharredCollapseDelaySeconds(3 s) after the swap, theTreeDestructionRateroll (65 %) decides: COLLAPSE, and the charred trunk falls with vanilla's own felling - the species' log prefab thrown fromlog_spawnpwithTreeBase.SpawnLog's exact impulse, so it tips, hits the ground with the real crash (ImpactEffect:sfx_tree_fall_hit, dust, camera shake) and replicates throughZSyncTransform- born charred, no wood, no stub,CharredCoalMin-CharredCoalMaxcoal at the foot, and it crumbles to ashCharredLogCrumbleSeconds(20 s) after landing; or STAND, a blackened snag atCharredTreeHealthFractionhealth that a player can chop later for the same coal and nothing else. The fate, the times and the fall direction (downwind, scattered) are baked into the charred tree's ZDO, so they replicate, survive a save and a restart, and execute on whichever peer owns the tree when its time comes. Charred wood is spent fuel: it never ignites again, and spread never picks it. Regrowth is queued only for a collapse. - The 0.21.x fire visuals are gone, and why they looked like streaks. The LineRenderer
ribbons read a "flame" texture off
fire_pitby substring, which landed onlow_flames- an INACTIVE low-LOD emitter whose texture is an 8x8 point-filtered grey blob - and tiled it once per metre along camera-facing strips up to 30 m long, scrolled on the wrong UV axis, twelve of them additive on a 0.4 m trunk, with a gradient that never left 0..1 and reached the bloom only by stacking: white lasers. Read out of the 1.0.15 bundles with UnityPy, 2026-09-20. Vanilla flame is not a flame picture: it is a greyscale FLIPBOOK sheet onCustom/Gradient Mapped Particle (Unlit), recoloured per particle by two HDR colours delivered through custom vertex streams. The fire is now built that way, from clones of the game's own materials found by exact child name (fire_pit/flames (1),sparcs (1),bonfire/Smoke,flare,BlobLava/HeatDistort), never byShader.Find, which misses bundle-loaded shaders even after world load (the mod's own log proved it). Per burning object: vertical-billboard flipbook tongues at the fire front and a band of licks from the foot to the front; embers that are motes (0.02-0.06 m) with buoyancy, turbulence and drag, never stretched; smoke onLux Lit Particles/Bumped, lit by the fire and shadowed by the sun; a refraction heat shimmer (HeatHazeEnabled); vanilla's glow-halo trick; the bark itself blackening and glowing with ember cracks below the front (BarkCharEnabled, a property block, restored exactly when the fire goes out); and one point light at vanilla's campfire values with SOFT SHADOWS (FireShadowsEnabled), managed by the game's ownLightLod(fade at 40 m, shadows at 20 m, inside the player's point-light-shadow budget) andLightFlicker. Wind is read offEnvMandirectly; the per-frame reflection is gone. Textures the mod still bakes are sRGB or linear as appropriate, with full mip chains, trilinear and 8x anisotropic; 0.21.x built them at 128x128 with mips OFF. - Every existing Visuals key works again.
FireSmokeEnabled,TreeFlameScaling,CrownSparksEnabled,MaxFlameHeight,TallFireMaxConcurrentand their low-spec caps, andSmoulderingVfxEnabled, were all read only by the dead 0.20.x builder; the 0.21.x controller ignored them andfirestatusreported settings that were not in force. The low-spec preset now also drops fire shadows and heat haze first, since a shadow-casting point light is the single most expensive thing a fire draws. - Nothing is drawn on a headless server any more. 0.21.x built and updated a full LineRenderer rig for every burning object on the dedicated server, every frame, into a graphics device that does not exist.
- Remote (client) fire is keyed by ZDOID, like the server's. It was keyed by Component,
so a tree the client de-instantiated and rebuilt was a different key: the fire could
neither be found again nor told to stop, and a burner destroyed by the charring before its
stop event arrived left a phantom fire. A burner that instantiates after its fire started
now gets its fire on arrival; a fire whose object no longer exists anywhere is swept every
2 s. The client's event handler also no longer resolves the burner through
ComponentFromZdoid, which force-creates the object and CLAIMS OWNERSHIP of it - on a client that would have pulled a far-away tree's ownership, and the damage ticks with it, to whichever client heard about the fire first. - What is NOT possible, so nobody looks for it again: hardware ray tracing. Valheim
1.0.15 is Unity 6000.0.75f1 on the Built-in Render Pipeline (deferred, HDR), with zero
RayTracingShader assets and the Vulkan device created with every
VK_KHR_ray_tracing_*extension disabled. Shadow-casting lights, HDR emissive into the bloom, normal maps, soft particles, lit smoke and refraction are the ceiling, and vanilla already uses all of them. - New keys:
TreeFireDamageEnabled,TreeFireTickInterval,TreeFireKillFraction,CharredCollapseDelaySeconds,CharredCoalMin,CharredCoalMax,CharredTreeHealthFraction,CharredLogCrumbleSeconds,CharredEmberGlowSeconds(Trees);FireShadowsEnabled,HeatHazeEnabled,BarkCharEnabled(Visuals).TreeDestructionRateis now the COLLAPSE chance, ranged 0-100, and reachable fromfiresetat last (treedestruction); all of the above arefiresetkeys and appear infirestatus. - Fixed:
TreeLogfire measured the log alongtransform.forward; a log's long axis is its local +Y. The charred clone is scaled like the original (scaleScalar, wild firs run 1.5-3x; the first draft came up at 1.0). The charred flag is pre-baked into the ZDO beforeInstantiate(ZNetView execution order -20), so the Awake postfix sees it on the spawning peer too; set after Instantiate it was only ever seen by other peers. - Builds on Linux now:
libs/from libs-Tools plus a small Cecil publicizer for the two game assemblies (none existed on disk andassembly_publicizer.dllhas no runtimeconfig);~/.dotnet/dotnet build, 0 errors.
0.21.16
- Real dirt where the ground burned, if you want it. The
UseVanillaDirtPaintoption has been in the config for a long time and has never once worked on a dedicated server: the server tried to paint terrain it does not have, found nothing, and threw every patch away. The server now hands each burnt patch to one player's game - whoever holds that piece of ground, or the nearest player to it - and that game lays the dirt through the same terrain paint the Hoe uses: it follows the slope, joins up across cells, keeps the grass off, is seen by everyone and is saved with the world. It is permanent, like a hoe mark, which is why it stays off by default. Turn it on withfireset dirtpaint true(a server setting; the command forwards), andfireset scorchmarks falseto see it without the decal on top. Where it had worked before, in a hosted game, the painter never refreshed the terrain either, so the host saw its own dirt only after a reload, and it could not paint any zone nobody had ever hoed - which is most forest. Both fixed. - The README stopped promising something a dedicated server mostly cannot do. Dirt paths and tilled ground are firebreaks in a hosted game; on a dedicated server they are only near the world's centre, because that is the only ground the server itself keeps loaded, and everywhere else only water is. The wording now says so. The fix is its own release.
0.21.15
- Burnt ground leaves a scar you can actually see. On a dedicated server it never did. The scorch mark was drawn only on the machine running the simulation, and that machine has no screen - so the server quietly made about two thousand invisible ones every five minutes and no player ever saw a single mark. Your game now draws its own as each patch of ground burns out.
- A new fire no longer starts at full strength. Fire is supposed to build for its first few minutes rather than exploding outward. That ramp was measured from one clock shared by the whole world, which only restarted once every fire everywhere had gone out - so for as long as anything was still burning, a fresh torch fire began at full reach with no ramp at all. Every fire now has its own clock, which is what the concurrency limits had always used.
- The ground-fire visual limit finally applies to the machine doing the drawing. It governed the server, which on a dedicated server draws nothing, while your game drew every burning patch with no ceiling whatsoever. If you had turned the limit down, or switched on the low-spec preset to survive a big fire, neither reached you. Now they do.
- Dedicated servers stop doing a large amount of work that could never produce anything: a damage probe was attached to every burning object and every burning patch of ground, sweeping for victims four times a second, in a place where the things it sweeps for do not exist.
- Honest about a limit rather than pretending: creatures do not take fire damage on a dedicated server, and never have. Finding them needs a physical world, which a dedicated server does not have. Players burn everywhere, on their own machine, and are unaffected. The setting now says so.
- An object that burned down could, if a piece of the game had moved under us, quietly survive while everyone was told the fire went out. It now removes it anyway and says what happened.
firesetgainswaterblocks,maxkillsandfiresmoke. All three drive real behaviour and could not be reached from anywhere, despite the promise that every setting is live-tunable.- Corrected three settings' descriptions and the README's line about rain, all of which described behaviour the mod no longer has. Rain now puts out buildings and trees too, not just grass.
0.21.14
- Requires BepInExPack Valheim 5.4.2350, which is what this is actually built and tested against. The declared minimum had been left at 5.4.2333 while everything moved on around it - harmless so far, since it reads as a floor rather than a pin, but it was drift rather than a decision.
0.21.13
- Fire that would not go out. A tree could finish burning, or you could clear every fire on the server, and the flames stayed on screen - burning away on something that was no longer there, forever, while the fire count read zero. The message that says a fire has stopped identifies it by the object that was burning, and the most common way a fire stops is that the thing burned down, which takes that identity with it. Your game then had no way left to work out which flames the message meant, so it did nothing. It now remembers, when a fire starts, which flames belong to it, and sweeps up anything left burning on something that has gone.
0.21.12
- Whether fire keeps you warm, and how far that reaches, can now be changed from the console like
every other setting:
fireset firewarmthandfireset firewarmthradius. They were config-file only when the feature landed, which made them the odd ones out. - The README is a README again rather than an accumulated list of release announcements, and it now documents all 59 tunable settings - eight of them had never been written down anywhere.
0.21.11
- A wildfire keeps you warm. You could stand in the middle of a burning forest and freeze to
death. Fire now holds off Cold and Freezing while you are near it, exactly as a campfire does -
it goes through vanilla's own near-a-fire check, so shelter, frost resistance and getting wet all
still behave the way you expect. Reaches a little further than the fire can burn you, because a
wildfire should be felt from further away than it hurts.
FireKeepsYouWarm,FireWarmthRadius. - Fire damage worked only near where the fire started. 0.21.9 added a height check so burning ground could not hurt someone on the floor above it, and measured it against a height the server had no way to know - so a player even a little downhill of the fire's origin took nothing at all. The server can now work out the real ground height for itself, which fixes the damage, and also puts the flames on the ground instead of floating above or sunk into a slope.
- A joining player can no longer be impersonated to make the server shout. The request a client sends on connect is now checked against who is actually connected before the server acts on it.
- Fire effects a client had drawn for a world it has left are cleared properly - the object half of that was still leaking.
- The Apocalypse preset no longer recreates the very problem 0.21.9 fixed: it raises how many ground cells burn, so it now raises how many can be drawn to match.
0.21.10
- Join a server mid-fire and you now see the fire. Ground fire was sent to players purely as a running list of changes, so anything already alight when you connected was never mentioned to you at all - it burned invisibly beside you for the rest of its life, and it hurt. Your game now asks for the current state as soon as it connects. This was the last place a player's screen was never reconciled against the server.
- A dedicated server no longer builds fire effects for a machine with no screen. It was creating and simulating a particle system for every burning cell and every burning object, none of which anything could ever render - players draw their own from the sync. A server hosting from inside your own game is unaffected and still shows you its fire.
- Fire that survives a server restart can hurt things again. Restored cells were written straight into the simulation and skipped the step that gives a cell its damage zone, so after a restart creatures wandered through a burning forest untouched. They now join the queue and are set up a few at a time, so a restart does not stutter.
0.21.9
- Two ground-fire cells in five were burning invisibly, and had been for a long time. The cap
on how many cells get a particle visual defaulted to 30 while the cap on how many can burn
defaulted to 50, so at any real fire size the rest damaged you and showed nothing. It was sitting
in plain sight in a tester's log from three weeks ago -
ground 50/50andvfxcap 30on the same line, every heartbeat - and nobody had read the two numbers against each other. The visual cap now defaults to 200, and an existing config file still holding the old 30 is moved up with it, because a stored 30 was the mod's own default rather than anything anyone chose. A cap you set yourself is left alone. - A cell that cannot be drawn now waits its turn instead of staying dark forever. The cap was
decided by a race at the moment each cell lit and never revisited: a cell that lost burned
invisibly for its whole life even after half the fire had gone out and the visuals were free.
That is why the flame front read as patchy and lagging rather than simply smaller - and why
raising the cap on its own would have moved the threshold without fixing the shape. Cells now
take a visual as soon as another finishes, a few per cycle so it cannot spike the frame.
firestatusreports how many are lit and how many are waiting. - Checking those two caps no longer walks every live fire object twice per ignition, which was quadratic work across a spreading fire.
- The flame front was trailing the real one by up to two spread steps. Ground fire is sent to players as a running list of changes, and that send was sitting inside the spread cycle's own timer, so its one-second interval was rounded up to the next cycle boundary - one and a half seconds in practice, and up to ten on a server with a slow spread cycle. About nine cells were alight at the front that nobody had been told about, and ten already dead that everyone was still drawing. It now keeps its own clock, and sends twice as often.
- Fire is drawn on your ground, not the server's guess at it. A dedicated server has no terrain to measure against, so the height it sends for a burning cell is not a ground height at all - it is inherited from whatever first caught fire and then carried unchanged across the whole spread. On flat ground you would never notice; on a hillside the flames sank into the slope or floated above it, further out the further the fire had travelled. Your own game knows where the ground is and now asks itself.
- Fire is visible again after you log out and come back. The effects a client was drawing are destroyed with the world when you leave, but the record of them was not, so on rejoining those cells were treated as already drawn and stayed dark for the rest of the session - and it got worse every time you logged back in.
0.21.8
-
Fire has never hurt a player on a dedicated server. It does now. Damage was applied by a zone that polls the physics engine for characters standing near a fire. A dedicated server has no terrain, no instances and no colliders where players actually are, only networked data, so that poll found trees and scenery near the flames and never once found the player standing in them. Silent the whole way: no error, no warning, and it worked fine on a world someone hosted themselves, where their own character is a real local object. Reported by Wu'barrk, who stood in a fire on a dedicated server and did not burn, and confirmed from two servers' logs, where the zone's own staged diagnostics recorded colliders found four times and a character resolved zero times.
The server now reads player positions from the networked data, which is always available, and tells each player's own machine to set them alight, since vanilla's burning effect needs a live character and only that machine has one. The same division of labour the mod already uses for ignition. The physics zone keeps handling creatures wherever physics is real, and no longer touches players at all, so a host cannot be burned twice, and with FireHurtsPlayerOnly set it is no longer given a damage zone to attach at all - during a big fire that was up to two thousand objects a frame doing nothing.
A client ignores a fire-damage message that did not come from the server, and clamps the one it accepts. The sender check alone is not enough and it is worth being precise about why: Valheim reads the sender id off the wire and relays it unchanged, so it can be forged. The clamp is the guard that actually holds - without it the message would have been a one-packet server-wide instakill, and a non-finite value would have left a character whose health could be neither healed nor restored, saved to disk that way.
This is the third time the same trap has bitten: spread hit it in 0.17.4, tree regrowth in 0.21.5, and now player damage. Anything that reaches for physics or instances on the server is wrong by default.
-
Burning ground burns what is standing on it, not everything above and below it. A ground fire cell is tracked by its horizontal position, so testing whether a player is in one had to be given a height as well; without it a fire on the grass would have burned the player on the floor above it, or in the crypt below, with no flame in sight and nothing to say what was killing them.
-
A failure in the player-damage pass backs off and retries instead of switching itself off for the rest of the session. One unlucky frame would otherwise have restored the exact bug above on a server that still looked completely healthy.
-
Fire damage keeps its own clock. It was being delivered on the spread cycle's schedule, so a one-second damage interval actually landed every 1.5 seconds at stock settings - and a server with a slow spread cycle would have stretched it to ten.
0.21.7
- The config-manager sync did not work at all, and said nothing about it. 0.21.6 gated it
on a client-side admin check. That check asks Valheim whether the local player is on the
server's admin list, and it answers no whenever the server has no admin list, which is every
private test server, so a server's own admin was blocked on their own machine with no message anywhere:
the slider moved, the client's file was rewritten, the server never heard. The gate bought
nothing even where it worked, because
firesetis the typed route to the same server-side setter and has never been gated either, so anyone who could abuse the manager could type the command instead. It is gone. The real check belongs on the server, against its own admin list, and is still the deliberate scope left for a public release; it now covers both routes rather than one. - A setting changed at the main menu is delivered when the handshake finishes, not thrown away a frame after the world scene loads. The held changes were flushed as soon as Valheim's networking object existed, which is several seconds before a connection or an admin list, so the flush concluded the player was not an admin and discarded everything. Every time, for the one workflow the feature exists to serve.
- A
firesetwhose config file is momentarily locked no longer kills the sync for the session. Suppression while a command applies a value was a single flag cleared afterwards, and BepInEx writes the config file before it calls change handlers without guarding that write, so an editor or antivirus holding the file threw straight past the line that cleared it. It is now scoped to one key for one second and expires on its own. - A restored fire can no longer light a bystander when an earlier line has claimed its neighbour. Candidates already claimed still count towards "is this ambiguous", so two halves of one trunk cannot resolve into a confident wrong match once the first is taken.
- Regrowth plants at most five trees per cycle. A burned forest comes due all at once, and every plant is an object creation that every client in range then receives.
- The blaze age of a restored fire cannot leak into an unrelated later fire. Adoption now happens inside the restore rather than later in the same frame, which closes the window where an early return could strand it.
- A setting changed in the config manager is sent once, when it settles. ConfigurationManager
raises a change per keystroke and per drag frame, so typing "120" sent 1, then 12, then 120,
each one applied immediately to a running server - for a moment every fire on it burned out in
a single second, and a slider would do that tens of times a second. Changes now wait for a
short quiet period, coalesced per setting, with a ceiling so a slider still takes effect while
you watch it. A typed
firesetis unaffected and still goes immediately. - A restore that cannot find an object now says whether nothing matched or too much did.
0.21.6
-
Fire persistence was restoring the wrong objects, and lighting fires that were never burning. A burning object was saved under its ZDOID, and a ZDOID does not survive a world reload:
ZDO.Loadopens withm_uid.SetID(++ZDOID.m_loadID), so every object read from disk is handed a fresh sequential id and the one it was saved under is discarded. The user half becomes ZDOID's "unknown former user" sentinel, which is why every persisted entry prints as1:NNNNN. So the id in the store named a different object each boot. The existence check passed, because the id did exist, and that is how this survived from 0.18.0 to 0.21.5 with nothing in the log. Caught on the test server on 2026-09-18: one restart resolved 23 burning trees onto stumps, wood drops and rocks and restored none of them; the next resolved 12 onto live trees that had not been burning and set them alight, and the fire ran to 45 objects within a minute of a boot that was supposed to resume 23.A burning object is now stored with its prefab name and its LIVE position, and found again by both through the same sector scan the spread system uses. Live position matters more than it sounds: the position on a burner was captured once at ignition on the basis that "trees and pieces do not move", and a felled log has a Rigidbody, is thrown force and torque as it spawns, and is shoved again by every hit, while the server owns and simulates it. Logs are most of what a forest fire leaves burning, 18 of 21 entries in the store that caught this.
The match must be unambiguous: exactly one object of that prefab within 0.75 m, with each object claimed once. Two candidates means there is no way to tell which was burning, so the line is dropped. That is deliberate. A refusal costs one fire, while a guess starts one, and a bystander picked by nearest-match would inherit the dead object's burn age at full spread maturity. Nothing moves while the server is down, so the tolerance only has to absorb the round trip through the text store. One sector scan serves a whole cluster of lines, since they are all one fire.
The ZDOID is still written, as a diagnostic and so an older build can still read the file, and is no longer followed. Object lines written before 0.21.6 are dropped with a warning rather than guessed at, once, on the first boot; ground fire, scorched ground and tree regrowth were never affected because they were always keyed by position.
-
A restored fire with no surviving objects no longer ramps from cold. Ground cells come back without an event and are adopted into one a moment later, after the point where the restore had already released the blaze age it was supposed to hand over.
-
A repeated
firesetreaches the server again. A cache meant to stop a double send recorded what the client had sent rather than what the server held, so once the two diverged the client could never re-assert that setting, while the console still printed success. The double send is prevented at its source instead. -
A setting changed before joining a world is delivered on connect rather than dropped, which is the config manager's main-menu workflow and the reason the feature exists.
-
firetreeregrowwith regrowth switched off says so, instead of silently discarding the queue's retry schedule and reporting the result as though fire were in the way. -
firestatuskeeps its REFUSED suffix even if writing the config file throws, which is the one failure the migration is built around.
0.21.5
- Tree regrowth had never grown a tree on a dedicated server, and could not. Every entry
in
firetreeregrowlistwaited behindZNetScene.IsAreaReady, which (decompiled from the 1.0.15 server) first requires the zone to be in the zone system's loaded table. Headless, every zone a player stands in is a ghost zone and never enters that table, so the gate was false everywhere but world origin, and each entry burned its twenty 30-second retries and was dropped in silence. The test server's log holds 2,085 heartbeats over three weeks withregrowntrees 0; a forced attempt on 2026-09-18 answered "16 forced, 15 still pending" with the counter unmoved. Behind the gate it was worse:ZNetScene.SpawnObjectis a void in 1.0.x that broadcasts a "SpawnObject" RPC to every peer, each of which instantiates its own copy, and its null return was read as failure - so on a hosting client it would have planted one tree per connected machine, up to twenty times. Regrowth now instantiates the prefab directly on the server, which is exactly what vanilla's own RPC handler does on each receiver: one persistent ZDO, every client in range receives it. The readiness gate is gone, an attempt counts only when a spawn actually fails, a spot with something player-built within 3 m is dropped with a log line instead of retried, and one[REGROW]line per cycle says how many came back. Every entry dropped before this release is gone for good. - Settings changed in ConfigurationManager now reach the server. Its UI edits only the
machine it runs on, so on a client every server-side setting in it was a no-op: the slider
moved, the client's own file was rewritten, and the simulation never changed. The same trap
firesetwas given a relay for in 0.18.3, with no console line to hint at it. Any runtime change to a setting the server owns is now forwarded exactly as the equivalentfireset, gated on the same admin check, deduplicated so a typed command does not go twice, and forgotten when the connection changes. Genuinely client-side settings stay local. - Regrowth no longer plants into a live fire. A tree that came back into ground that was still burning caught immediately: on 2026-09-18 five of the first eight trees ever regrown on the dedicated server reignited within seconds, because ground fire routinely outlives the 900 second regrowth timer, leaving a burn that looks as dead as before. Fire within 4 m now defers the entry 30 seconds at a time, and a deferral is not an attempt.
- Turning tree regrowth off now stops trees already queued. The switch gated only the enqueue. That was invisible while nothing headless could spawn; with regrowth working, an admin who turned it off mid-fire would have watched the queue keep planting for another fifteen minutes, and survive a restart. Queued entries are kept, so turning it back on resumes.
- A tree that grows is saved immediately instead of at the next 60 second tick. The entry left memory the moment it succeeded, so a hard kill inside that window read it back and planted a second tree inside the first.
firetreeregrowreports what actually happened. It returned only "still pending", which falls the same way whether an entry grew a tree or was dropped, so a deletion read as a success. It now names grown, dropped and pending separately.- A regrowth spot whose build check cannot run is left alone rather than planted. The check failed open, so a server where the sector scan was unavailable would have grown trees up through people's floors.
- Attempt counts from an older store are no longer trusted. Before this release the counter incremented on every deferral, which headless meant every cycle, so a carried-over count sat near its cap for a reason that no longer exists and would drop the tree on its first real failure. They restore at zero.
- One scan serves a cluster of regrowth entries. The build check walks a 192 m block of ZDOs to answer a 3 m question, and entries come due together; it is now done once per cluster instead of once per entry.
firestatusno longer reports a crashed migration as a clean boot. The state that knew is reset before the console can read it, so the outcome is folded into the line itself.firestatusfrom a client now shows the server's config migration line. The status reply carried only the fire counts, so the one check 0.21.4's handoff asked for could not be made from a client. It arrives as a second[server]line.- A restored blaze no longer restarts its ramp cold.
FireEvent.RestoredRampAgehad been declared in 0.19.x and never assigned (the Ragnarok's Wrath session spotted the compiler warning), so after every server restart each fire fell to its ramp-start intensity and climbed again. The sidecar stores no per-event age, but every restored burner carries its own and an event is as old as its oldest burner; that age is applied and logged per event at restore.
0.21.4
-
Eight corrections to 0.21.3's config migration, and a harness that can reach it. 0.21.3 shipped the migration with a test project that compiled the pure ledger only — every engine-side rule was unmeasured, in the one mod of the three with a live rung of each kind. The harness now stubs BepInEx and compiles the real
FireConfig, and went 35 → 75 assertions. Each fix below was proven by reverting it and watching a named test fail; thirteen such mutations, all caught.- The pre-bind snapshot matched keys case-INSENSITIVELY. BepInEx's own
ConfigDefinition.Equalsis ordinal and case-sensitive, so a mis-cased line is a different key to the game. An ignore-case snapshot answered "present" for a key BepInEx treats as absent, which cancels the one step whose entire safety is that absence test. Read out ofBepInEx.dllrather than assumed; the test stub had it backwards too, so every apply assertion would have passed for a reason that does not hold on a real machine. - A retirement that failed was reported as harmless, and the version stamped anyway. Binding and removing a key both touch the file, so a transient lock — antivirus, cloud sync, a config manager, a second process in the same directory — takes the drop down through nobody's fault. The stamp then wrote "already migrated" and the retirement was never retried: a one-second lock made permanent. The drop now reports upward and the version is left unstamped, so the next boot tries again.
- A rebase row naming a key this build does not bind was skipped in silence, and the file stamped as though it had moved. It warns now.
- A retirement could be applied to a key this build STILL binds, deleting a live
setting outright. Relying on BepInEx's cast to throw was not protection:
Bindreturns the existing entry for an already-bound definition, so the cast only fails when the type differs. Refused explicitly now, with a warning that names the row. - The version stamp only ever rises. It was assigned unconditionally, so opening a world with an older build dragged a newer file's stamp down; rolling forward then replayed rungs against values an admin had since chosen, and a rebase cannot tell a deliberate choice from the old default it happens to equal.
- A negative stamp made the migration walk two billion rungs, freezing the boot thread on a hand-edited file. Clamped.
- One key could be decided twice in one boot when two rungs named it, the later silently winning. First rung wins now, and the same key can no longer be rebased and retired in the same pass.
- The status line reported the plan's INTENT, not what happened.
firestatusprinted "1 retired key dropped" for a key still sitting in the file, with the only contradiction a warning hundreds of log lines earlier. Refusals now correct the summary.
- The pre-bind snapshot matched keys case-INSENSITIVELY. BepInEx's own
0.21.3
-
The config file migrates itself. BepInEx persists every bound value to disk, so a changed default never reaches an install that already wrote the key. This mod has hit that twice and answered by hand both times: 0.18.7 renamed
VerboseLoggingtoDebugLoggingso a storedtruecould not follow it, and 0.19.14 could only tell people to set the smoulder threshold themselves after 0.19.13 moved its default from 0.45 to 0.65. Now the family's machinery, Wu'barrk's from Wings of the Valkyrie by way of Valkyrie's Cargo, does it: the raw file is read before any bind, a[Meta] ConfigVersionstamps the layout, a value still equal to an OLD default moves to the new one while anything an admin set stays (and is named in the log), a key no current build binds is dropped instead of riding along as an orphan, a copy of the previous file lands beside it as.v0.bakfirst, and a migration that cannot back the file up changes nothing and retries next boot. A failed migration never stops the mod loading.Version 1, this release:
SmoulderAfterFractionstill at 0.45 becomes 0.65; the orphanDebug.VerboseLoggingis removed. Every install seen in testing already carries 0.65, so on those the boot line reads "nothing to migrate" and stamps the version; the rung is for the testers 0.19.14 could only advise.firestatusprints this machine's own migration line. The decisions live inConfig/ConfigLedger.cs, pure and off-game, with a harness undertests/(tools\run-tests.ps1, 35 checks) - the mod's first.
0.21.2
-
Flames on the outside of the crown, where a leafy tree can show them. The first burning beech looked at on 0.21.1 showed sparks only and no flame. The column from 0.20.1 is a cone under a metre wide up the middle of the tree, and a beech's crown is solid foliage from about three metres up, so the leaves drew over every flame inside it; only the sparks, thrown on a 35 degree cone, escaped. A fir's bare trunk shows the column, which is why it was judged fine there and never on a broadleaf.
Tall burners now also get
CrownFlames: a hemispherical shell of flame sized to the tree's measured canopy (MeasureBurnerCrownRadius, the same renderer-bounds walk as the height, 4-6m on a wild beech), centred a little above mid-height, emitting from the outer third of the radius so the fire sits on the leaves rather than in them, in particles big enough to read at distance and licking upward. The trunk column stays. Cost scales with crown area, halves under the low-spec preset, and the existing tall-fire cap bounds how many burners get a shell at all. Judged by eye the same day on a beech and a fir in the Mountains: the beech shows flame over its crown, the fir keeps its column and reads fine - which also closes out the column from 0.20.1 and the additive fire from 0.20.5, both unjudged until now.
0.21.1
fireweather force <EnvName>/fireweather reset: a server-side weather override for testing. The first livefireweatheron 0.21.0 showed the machinery working and exposed the gap: vanilla'senv Rainwrites a debug override on the client it is typed on and nowhere else, so a player standing in forced rain saw their client and vanilla agree on 'Rain' while the server, correctly, answered 'Clear' for that spot. This sets the same field on the server (relayed, admin-gated like every relayed command), where the resolver already honours it ahead of the roll. Names are vanilla's, case-sensitive. Not saved; a restart clears it.
0.21.0
-
Rain now reaches a dedicated server, and it douses fire. Two things, because the first one turned out to be broken:
RainSuppressesGroundFirereadEnvMan.s_isWet, andEnvMan.UpdateEnvironmentreturns before choosing an environment when there is no main camera. A dedicated server has no camera, so its environment never changes from the startup value and that flag is false forever - the test server loggedraining Falsein every heartbeat of every run since August, including with the player standing in rain. The key only ever did anything in single-player or on a player-hosted world. (Wind updates on a separate path, which is why the wind reflection verified live and this never could.)The server now replays vanilla's own selection for the FIRE's position: weather is deterministic per environment period and biome sector, drawn from a Random seeded with the period number, so it computes the same answer every client does and caches it per 64m zone per period. The overrides vanilla applies ahead of the roll (forceenv, the
envcommand, a random event whose area covers the fire, an alt-biome forced environment, a persistent event whose radius covers the fire) are honoured in order; the two event kinds are scoped to the fire's position, because vanilla scopes them to the local player's and headless that is the origin. Clients blend into a new environment over 2s, so the server can lead a player's view by about that much. Not replicated: EnvZone (a per-player trigger volume) and the Ashlands/Deepnorth edge fixup that needs a loaded heightmap.And rain now acts on object fire, which by design it never touched: while rain falls on a burning tree or building it passes fire to nothing - no neighbours, no ZDO candidates, no ground seeds - and its clock runs faster, at
RainObjectBurnDurationMultiplier(0.3: about 3x, so a tree that catches in rain is out in ~72s at the 240s default). Ground fire gets the same clock treatment via the existingRainGroundBurnDurationMultiplier, which used to shorten only cells lit during rain and now also reaches the cells rain arrives on later. Direct ignitions still work in rain - a torch or a lightning strike lights the tree, the rain then puts it out - because rain stops spread, it does not forbid fire. Both new keys (RainSuppressesObjectFire,RainObjectBurnDurationMultiplier) are under[Weather], live asfireset rainobjects/rainobjectmultiplier, and off under burntheworld like every other restraint.fireweatherprints the environment FireFront resolves at your position; on a client it also prints what vanilla is showing you, and the two must agree, then it relays so the server prints its answer for the same spot. That is the verification. The status line'srainingis nowwet/totalburners.
0.20.7
- The spread diagnostic only logs when debug logging is on.
[SPREAD-DIAGNOSTIC]was added in 0.17.4 to prove that tree spread works on a dedicated server, whereWearNTear.AllPiecesis always empty. It did that, and then kept reporting the same counts every 5 seconds for as long as anything burned: 3,283 lines in one day on the test server, more than twice the heartbeat, none of them saying anything new. It is now gated behind the existingDebugLoggingkey (fireset debug true, which reaches the server like every other fireset key), so the tool is still there for the next "why won't it spread" report and silent otherwise. No new config key: the default-off flag that already exists is exactly the switch this line should have had.
0.20.6
-
Object fire VFX is budgeted per frame, like ground fire already was. Measured on a live client with 34 objects alight: CPU spikes of 3-4x the median arriving roughly every 1.4s, against a 0.75s spread interval. The cause is that
HandleFireEventBroadcastbuilt each burner's whole rig - two or three ParticleSystems plus a realtime Light - inline and synchronously, so an entire batch of ignitions from one spread pass was constructed in a single frame. Ground cells have gone through a queue with a per-frame budget since 0.18.6; object fire never did, and it is the more expensive of the two per instance.Ignitions now queue and drain at 2 per frame (lower than ground's 3, because a ground cell builds one cheap system and no light). An ignition that is extinguished or unloaded while queued is dropped rather than built and orphaned.
This cost was latent before 0.20.1 and that release is what exposed it: a tall burner builds a taller particle column, an extra crown-spark system and a longer-range light, which is several times the construction work of the small flame every burner used to get.
0.20.5
-
The blocky fire is fixed, and the blend was never the cause.
Custom/Particle (Unlit)exposes[Enum(Red,0,Green,1,Blue,2,Alpha,3)] _AlphaChanneland defaults to 0, Red - it takes alpha from whichever channel you nominate. FireFront's generated particle texture is white RGB with its falloff in the ALPHA channel, so red read 1.0 across the whole quad, every particle drew as a solid square, and no value of_SrcBlendcould have helped. Two were tried (3 SrcColor, then 5 SrcAlpha) and both produced blocks.Fixed at both ends deliberately: the material now sets
_AlphaChannel = 3(Alpha), and the additive material gets its own texture with the falloff baked into RGB as well, so the edges go to black whichever channel the shader actually samples - and under additive blending black adds nothing._Cullis set to Off so billboards are never wound away.Worth recording for next time: the
.shaderfile in the AssetRipper export is a//DummyShaderTextExporterstub, because shader bytecode cannot be decompiled. Its PROPERTY LIST is real and is what solved this; its body is not and must not be read as the shader's behaviour.
0.20.4
-
Fire rendered as hard-edged squares at 0.20.3. Fixed. The additive material copied vanilla's
_SrcBlend 3, which is SrcColor - a blend that ignores the alpha channel completely. FireFront's particle texture is white RGB that fades out THROUGH ALPHA, so every quad contributed at full strength right to its corners and the fire came out as a cloud of red squares. Vanilla can use SrcColor because its own textures bake the falloff into RGB; ours does not. Now_SrcBlend 5(SrcAlpha) x_DstBlend 1(One), the classic additive pairing for an alpha-faded texture. The lesson: copy vanilla's values only when you also have vanilla's texture. -
Confirmed in play:
Shader.Find("Custom/Particle (Unlit)")MISSES ON THE CLIENT TOO, not just on the headless server, so 0.20.2 would have silently fallen back to the old look and taught us nothing. The 0.20.3 borrow-from-a-vanilla-material route is what actually gets the shader, and the log says so:additive shader acquired via borrowed from a vanilla fire material: "Custom/Particle (Unlit)".
0.20.3
-
The additive flame material no longer depends on
Shader.Find. 0.20.2 asked forCustom/Particle (Unlit)by name, andShader.Findonly sees shaders currently resident - so it can miss one the game definitely ships, and it misses every time on a headless server, which loads none at all. It now falls back to reading the shader straight off a vanilla fire material (fire_pit, thenbonfire, thenpiece_groundtorch), preferring one that exposes_SrcBlend/_DstBlendso the additive blend can still be forced rather than inherited. A prefab registered in ZNetScene carries its materials, and a material always carries a live shader, so that route is not subject to load-order timing. Names are avoided on purpose: the only thing tying vanilla flame materials to shader names is AssetRipper's builtin fileID table, which is its own mapping and not the game's, and trusting it inverted the shader survey twice. The log now records which route won and what the shader actually turned out to be. -
tools/stop-test-server.ps1stopped crying wolf. It confirmed saves by grepping the log forWorld saved ( ...ms ), which Valheim 1.0.12 no longer emits - a clean shutdown now logsSavingand then Unload lines. The result was "world state is lost" after every clean stop: four in a row on 2026-09-12, all false, each disproved by looking at the world on disk. It now checks the world's own write time against the moment the stop began and treats the log line as a secondary signal, so the warning means something again. Takes-Worldand-SaveDirfor servers that keep saves somewhere other than LocalLow. Note 1.0.12 writes a world as a DIRECTORY, so a loose<World>.dbbeside it is a stale pre-1.0 backup whose timestamp means nothing.
0.20.2
-
Fire is drawn additively, so it reads as fire instead of as dots. Photographed in play at 0.20.1: a mass of flame particles looked like a hundred separate orange discs hanging in the air, not a body of fire. The cause is that the only particle shader that resolves in Valheim's build is
Sprites/Default, which is ALPHA BLENDED - overlapping particles occlude one another rather than accumulating light, so density never becomes brightness. Flames, crown sparks and ground fire now render through the game's ownCustom/Particle (Unlit)with_SrcBlend 3/_DstBlend 1/_ZWrite 0, the exact values vanilla uses onashrain_cinder.mat. Smoke deliberately stays alpha blended, because additive smoke glows instead of darkening.If that shader ever fails to resolve, flames fall back to the old alpha material and say so once in the log - a miss costs the old look rather than invisible fire.
Older entries are in the full changelog on GitHub: https://github.com/RavenIron-Games/FireFront/blob/main/CHANGELOG.md
