HammerForge Data Portability¶
Last updated: October 25, 2026
This document describes how to move data in and out of HammerForge safely.
What Each Format Keeps¶
One row for each thing a level holds, one column for each file it goes through. The .map column is an export and then an import into an empty level. The .glb column is the bake, exported. The .hfprefab column is every piece of the level saved as one prefab and placed again. tests/test_format_table.gd takes a level that holds every row through each file and holds every cell to what comes back, so a change to an exporter that this table does not follow fails the tests. An export that loses something names it in the Console, for example ".map export left out 2 cordons, 1 note".
| What the level holds | .hflevel |
.tscn |
.map |
.glb |
.hfprefab |
|---|---|---|---|---|---|
| Brushes | Kept | Kept | Kept | Kept as mesh | Kept |
| Brush shapes | Kept | Kept | Lost | Kept as mesh | Kept |
| Cuts | Kept | Kept | Lost | Kept as mesh | Kept |
| Face materials | Kept | Kept | Kept | Kept | Kept |
| Texture settings | Kept | Kept | Kept as the map's texture axes | Kept as mesh UVs | Kept |
| Hand laid UVs | Kept | Kept | Lost | Kept as mesh UVs | Kept |
| Face paint | Kept | Kept | Lost | Kept as a baked texture | Kept |
| Sculpts | Kept | Kept | Lost | Kept as mesh | Kept |
| Smoothing groups | Kept | Kept | Lost | Kept as mesh normals | Kept |
| Hidden faces | Kept | Kept | Kept as the skip texture |
Kept as faces the mesh leaves out | Kept |
| Texture groups | Kept | Kept | Lost | Not applicable | Kept |
| Point entities | Kept | Kept | Kept | Lost | Kept |
| Entity properties | Kept | Kept | Kept as text | Lost | Kept |
| I/O wires | Kept | Kept | Kept | Lost | Kept |
| Brush entities | Kept | Kept | Kept | Kept as named nodes | Kept |
| Visgroups | Kept | Kept | Lost | Not applicable | Lost |
| Groups | Kept | Kept | Lost | Not applicable | Kept |
| Structures | Kept | Kept | Lost | Not applicable | Lost |
| Prefab copies | Kept | Kept | Lost | Not applicable | Lost |
| Cordons | Kept | Kept | Lost | Not applicable | Not applicable |
| Bake options | Kept | Kept | Lost | Not applicable | Not applicable |
| A brush's bake options | Kept | Kept | Lost | Not applicable | Kept |
| Floor paint | Kept | Kept | Lost | Kept as mesh | Not applicable |
| Notes | Kept | Kept | Lost | Lost | Kept |
- Kept as mesh. A
.glbholds what the bake built, so a row arrives as the triangles it made and not as something you can still edit. A cylinder, a cut or a sculpt cannot be got back from it; keep the.hflevelfor that. - Not applicable. The format has no place for the row, and the export leaves out nothing by not having one. A
.glbhas no visgroups or structures, and the bake has applied the cordons and the bake options already. A prefab is a set of pieces rather than a level, so it holds no cordons, level bake options or floor paint. - The
.mapcolumn. A brush keeps its faces and loses the shape it was made as, so a cylinder comes back as a plain brush with a cylinder's faces. A cut is left out rather than written as a solid that would fill its hole. Texture settings come back as the map's own texture axes, so a face draws the same while its numbers differ, and entity properties come back as text, which is how the format writes every value. - Groups in a prefab. A prefab keeps each group the selection holds whole, nested as it was. A group the selection holds only part of belongs to the level, and the prefab leaves it out (#1897).
- Structures and prefab copies keep their brushes wherever brushes are kept. What is lost is the record that can still change them: a staircase comes back as its steps.
- The
.tscncolumn is the default Scene Keeps setting, Brushes and bake. A scene set to keep only its bake loads its.hflevelwhen it opens, below. - Bake profiles are editor preferences rather than part of a level, and no file here carries them.
Source of Truth: .hflevel¶
.hflevelfiles are the canonical save format for brushes, paint layers, materials, entities, decals, and settings.- When region streaming is enabled, per-region paint data is stored in a sibling
<level>.hfregions/folder as one.hfrfile per region. Save As copies the streamed out regions' files into the new name's folder and clears any other region file there, the files of a level saved under that name before (#1929). - A region is written before its chunks are streamed out of memory. If that write fails the region stays loaded and you are told, so unsaved paint is not dropped by moving the cursor.
- A region is only listed in the
.hflevelindex once its.hfrfile exists on disk. - A region file holds every paint layer that has paint in that region, each as
{"id", "chunks"}. A rewrite keeps the entries of a layer the level no longer has, so undoing the layer's removal brings its paint back from disk. A region erased to nothing is written without that paint, and removed with its.previouscopy once no layer, current or removed, has paint there, so it does not come back when it streams in again (#1550). - An undo of paint in a streamed region rewrites the region files it changed, or removes them, so the undone paint does not stream back in (#1514). A new paint layer never takes the id of one removed this session or found in a region file, so it never loads another layer's paint (#1564).
- Files include a version field and default missing keys on load for backward compatibility. The bundle version is
HFLevelIO.FORMAT_VERSION(current: 2), raised by every change to what a level saves (#1638). A file stamped higher than this build reads is refused before anything is applied, with a message, rather than clearing the open level and restoring what it can parse. A file also names the format fragments the development build that wrote it was waiting on, informat_pending, and a build at the same number that lacks one refuses it too. A file with no version, or version 0, is an older one and still loads, because every key defaults. - Per-face UV data includes
uv_format_version(current: 3). Version 2 data keeps the old wall axes on every face laid on by hand (#907). Legacy data (version 0, pre-April 2026) used a different UV transform order (scale+offset before rotation). On load, legacy faces are auto-migrated: uniform-scale faces get their offset adjusted; non-uniform-scale faces with rotation are baked tocustom_uvs. No manual intervention is needed. - Per-face vertex winding includes
winding_version(current: 3). Legacy data (version 0, pre-April 2026) used CCW vertex winding for manually-created faces, which rendered inside-out under Godot 4's CW front-face convention. On load,apply_serialized_faces()detects v0 faces and runs a centroid-based migration: each face's normal is checked against the outward direction from the brush center, and faces pointing inward have their vertices reversed to CW. Mesh-extracted faces (already CW) are left unchanged. Version 1 data is correct except on the five shapes whose builders wound every face inside out before September 2026:PRISM_TRI,PRISM_PENT,OCTAHEDRON,DODECAHEDRONandICOSAHEDRON. A v1 brush of one of those shapes runs the same centroid migration, which is exact because all five are convex; every other v1 face is left alone, so a torus or another concave brush is never touched. Version 2 data is correct except for path tool brushes, whose two builders wound every face inside out before September 2026. Those areCUSTOMbrushes, so there is no shape to key the migration on; instead a brush saved below version 3 whose every face points at its own centroid runs the same centroid migration. That is what an inverted convex solid looks like and what a correctly wound closed solid cannot look like, concave or not, because the faces on its convex hull always point away from the centre. No manual intervention is needed. - Per-face
smoothing_groupsays which faces shade as one smooth surface, 0 for flat. Every face writes it. A file from before it loads every face flat, which is how such a brush baked. New round brushes and the curved faces of arches and domes start in group 1 (#1429). - Per-face
uv_groupanduv_group_justifyrecord a linked surface and the Justify last used on it. They are written only on a face in a group, so a level with no linked faces writes what it did before (#1437). - Per-face
uv_projection5 is the Face projection (#1419). A build from before it reads 5 as a projection it does not know and puts the face on Box UV. - Per-face displacement data writes
flip_diagonals: trueonly on a sculpt whose cells now split along the other diagonal: one that Flip or the negative-scale repair mirrored, or one that a resize turned a quarter round to put its face back in the corner order its shape generates. The second happens to a box face saved starting at a turned corner, which older flips left behind. The key records which diagonal splits each grid cell, so the surface folds the way it did. Every other sculpt saves without the key, exactly as before. A build without the key loads such a sculpt with every grid point in place but splits its cells the original way, so cells that are not flat fold the other way. - Autosaves write to
res://.hammerforge/<scene name>.hflevelby default, derived from the scene the level was opened from. A level whose scene has never been saved has no name to derive from and usesres://.hammerforge/autosave.hfleveluntil it does. - Files carry a
scenefield naming the scene they were saved from, a.tscnor a binary.scnalike. It is what lets a file say which level it holds when every level shares the default autosave path. Files written before September 2026 do not have it, and every key defaults, so they still load. - Store
.hflevelin version control for reliable recovery. Compare With... in the dock shows what changed since another version, an older save or one taken out of git withgit show HEAD~3:level.hflevel > old.hflevel, andgodot --headless -s res://tools/compare_levels.gd --path . -- old.hflevel new.hflevelprints the same list, exiting 1 when something changed (#1701). - A
WorldEnvironmentyou add to the level for its sky or fog is a node in the.tscn, not part of the.hflevel. Only the Include Level Environment switch (export_level_environment) is a level setting. - The level settings include
bake_cast_shadows, the Cast Shadows of every brush that sets none (#1444), and the picked pivot and Mirror Copy's choices:transform_pivot_point,mirror_copy_axesandmirror_copy_at_edge. The Active Material is saved with the scene and not in the.hflevel, since it is the material of the next brush rather than anything in the level. - A brush record carries
bake_cast_shadowandbake_lightmap_scalewhen the brush sets its own Cast Shadows or Lightmap Scale, and a visgroup record carriescast_shadowbesidevisible. A file without them bakes on the level's default, as before. A.mapexport leaves them out (#1444, #1438). - A structure's record carries
runfor a Trim Run: the wall edges and doorway outlines it was built along, as plain data, so an Update after a reopen or an undo follows the same walls. It is empty for every other type (#1414). - Visgroups and groups are saved with the order they were made in, beside the registry itself. The registries are JSON objects and
JSON.stringifysorts object keys, so without it the panel came back alphabetised on every reload. A file written before September 2026 has no order recorded and loads the way it always did. - Save compression (Console > Controls) decides the form on disk. On, the default, the bundle is deflated: a 45-brush level is about 3 KB. Off, it is plain JSON written one value per line with keys in a stable order, which is the form to use when the level is reviewed and merged like any other file in the repository. The same level is about 250 KB that way, and the loader reads either form without being told which.
- A
.hflevelrecordssaved_at, stamped as the file is written rather than when the level was captured. An autosave of a level nothing has changed therefore writes nothing, because what decides that is the level and not the clock. - Save Level writes the file it is given, every time. A save to a second path is a copy of the level, not a no-op, even when nothing has changed since the last one.
Which File Opens: the .tscn Wins¶
A level lives in two files, and they are written by two different commands. Godot's own Ctrl+S writes the scene; Save Level writes the .hflevel. Nothing reconciles them, and nothing merges them; HammerForge only tells you when they have come apart.
On open, the scene wins, because the scene is what Godot loads. A .hflevel saved after the last Ctrl+S is not what comes up. Use Load Level to bring it in.
HammerForge says so when it happens. On the frame the dock binds to a level it compares the two files' modification times, and if the .hflevel is the newer one it puts a line in the Console and a toast on screen. It says it once per open, not once per rebind, because after that the scene in the editor has moved on and Load Level would cost you whatever you did since.
A .hflevel records the scene it was saved from, so a file that belongs to another level is not reported against this one. That matters because a level can still be pointed at a shared path by hand, and Load Level on another level's file would overwrite the open one. Files written before September 2026 carry no such record; they are still reported, and the message says it cannot tell which level they hold.
The same record is what stops an autosave landing on someone else's file. An autosave is a write nobody asked for, on a timer, so it is refused when the target .hflevel says it holds a different scene, and the refusal is said once. Save Level is never refused: writing over another level's file on purpose is a thing you are allowed to do.
Save Scene As takes the record with it. A scene saved under a new name keeps its .hflevel path when only the extension changes (level.tscn to level.scn) or when the level has a path of its own, so the file would go on naming the old scene and every autosave after it would be refused. Instead, when that file names the scene as it was a moment before the save, the level autosaves as soon as the scene is saved, and the file names the new scene. The copy left under the old name is then the other level: if you open it, its autosave is the one refused. With autosave off nothing is written, and there is no autosave to refuse.
The one exception is a level set to keep only its baked geometry, below: that scene has no brushes to win with, so it loads its .hflevel when it opens.
What the Scene Keeps¶
HammerForge gives an owner to almost everything it makes, so Ctrl+S writes the brushes and the geometry baked from them into the .tscn. A 100-brush level is about 261 KB of scene against 4 KB of .hflevel, and a bake adds another 149 KB that is derivable from the brushes already in the file. For a greybox session that is what you commit and what a teammate has to merge.
Scene Keeps on the LevelRoot chooses what goes in:
| Setting | The .tscn holds |
Notes |
|---|---|---|
| Brushes and bake (default) | Both | The scene is the whole level on its own, and has geometry at runtime without the plugin. |
| Brushes only | The sources | The scene stays the size of its brushes. Bake again to get geometry back; there is none at runtime until you do. |
| Baked geometry only | The geometry | The lightest scene. The brushes live in the .hflevel and are loaded when the scene opens. |
Changing the setting re-owns what is already in the level, so the next Ctrl+S writes what the setting says rather than what the level happened to be built with.
Each setting's scene size and open time, as .tscn and as .scn, from 100 to 3,000 brushes, are measured on Performance and Limits.
Whichever setting is on, the scene also carries the records that describe the brushes without being brushes: visgroups and their visibility, groups, arrays, hollows, structures and prefab instances. They are not nodes, so they ride in a live_registries property on the LevelRoot rather than as children. Before September 2026 they did not ride anywhere, and a level reopened from its scene came back as loose geometry the structure panels could no longer edit. A scene saved back then is repaired on open as far as it can be: a visgroup is put back from the members that still name it, and comes back visible.
Floor paint rides there too since October 2026: each paint layer's cells, heightmap, terrain slots and wall heights, and the layer in use (#1854). Before that the scene kept the layer nodes and none of their paint, so a level reopened from its scene had empty layers under floors nothing could edit. A streamed level carries the index of its region files and the level file they sit beside, and its cells stay in those files, so the reopened scene streams them in (#2006). A save of a level that has lost track of its region files is refused rather than clearing a folder. A scene saved before the change opens with what it has; Load .hflevel brings the paint back from a file saved after it was painted.
A brush's faces go into the scene with everything on them: material, UV settings, custom UVs, paint layers and, since October 2026, the sculpt (#854). Before that the scene dropped every sculpt and only the .hflevel kept it; Load .hflevel brings one back from a file saved after the sculpt was made. A build from before the change still reads a sculpt from a newer scene, but drops it again the next time it saves.
A sphere, ellipsoid, torus or capsule is rebuilt from its shape when the scene opens, and since October 2026 it has the same faces at every size (#852, #858). An older build let rounding split some of its flat quads in two at some sizes: a sphere at 1,000 units had 2,340 faces rather than 2,240, and a stretched ellipsoid or torus usually had a few extra. Such a brush opens with those quads whole, and each face takes the material and UV settings of the old face that faced its way (#851), so it looks the same. Since #1057 these shapes are built from 16 segments by 8 bands, 128 faces, rather than Godot's 64 by 32; an older one opens rebuilt that way, each face again taking the look of the old face that faced its way. Since #1085 a brush's sides sets its segments, as it does a cylinder's, so one that carries 5 or more opens with that many, and the .hflevel carries sides for every round shape.
Baked geometry only needs somewhere to put the brushes. A level with no .hflevel path keeps them in the scene regardless, because dropping a level's only copy of its brushes is not a trade worth making silently. If the .hflevel is missing when such a scene opens, HammerForge says so rather than opening an empty level.
Building a Map Out of Level Pieces¶
Saving a piece of level as its own scene and instancing it two or three times into a parent is supported, and is how a corridor, a room module or a prop cluster gets reused.
A brush id is unique within one level, not within a scene. A saved scene freezes the ids its brushes had, so two instances of the same piece carry the same set of ids, on purpose. That is not a collision, because every lookup by id resolves inside its own LevelRoot: each instance has its own brush cache and its own containers, and one copy cannot answer with the other's brush. Adding a second copy does not renumber, move or unregister anything in the first, and each copy bakes its own geometry.
The alternative would be re-minting every id when an instance enters the tree. That was deliberately not done: it makes ids unstable across loads, and the prefab system tracks by stable UID rather than by name for exactly that reason. A piece that came back with different ids every time it was opened would stop being the same piece.
What is a defect is one level holding two brushes with the same id. The brush cache is keyed by it, so the second to register overwrites the first and one of the two cannot be addressed at all: not by a visgroup, a group, a hollow or array record, nudge_brushes_by_id, tie_brushes_to_entity, or the Console. The ways in are a hand-edited .hflevel, a .tscn where a DraftBrush was copied with Godot's own node duplication instead of Ctrl+D, or a state record naming the same id twice. Validate reports it, naming the id. There is no auto-fix: re-minting one of the pair silently re-points whichever records happened to mean it, and which one you wanted is not knowable.
Entity I/O Serialization¶
- Entity I/O connections are stored per-entity in the
io_outputskey of each entity record. - Each connection is a Dictionary:
{output_name, target_name, input_name, parameter, delay, fire_once}. - Connections are captured by
capture_entity_info()and restored byrestore_entity_from_info(). A load that cannot give an entity its saved node name, a@Node3D@14saved before #1179, retargets the wires aimed at it to the name it gets. - Missing
io_outputskey on load = no connections (backward-compatible).
Brush Entity Class Serialization¶
- Brush entity class (any class
entities.jsonmarksis_brush_entity:func_detail,func_wall,func_clip,func_physbox,func_illusionary,func_door,func_button,door_basic,trigger_onceandtrigger_multiple) is stored in thebrush_entity_classkey of each brush record. func_detailbrushes bake into one mesh per material and one collision body, the way the structural path groups and split by Cast Shadows and Lightmap Scale as it splits, with a convex hull per brush so a pile of clutter still collides as the separate solids it is. A detail brush that carries an entity name, I/O outputs or a node name you typed keeps a node of its own instead, because that is the address the runtime finds it by. TheBox_3style name every brush starts with does not count, whatever shape it names, so a detail brush that was bevelled or vertex-edited still groups (#1144).- Missing key on load = no entity class (standard structural brush).
.map Import / Export¶
- Use
.mapto exchange basic brush layouts with other editors. - Units are converted in both directions. A
.mapfile carries bare numbers and never says what a unit is. Every editor that writes one is on Quake units, where a player is 56 to 72 tall and the grid steps in 16s; HammerForge is on Godot's metric scale, where the playtest player is 1.6. The Map units/m row in the dock File section says how many.mapunits one HammerForge unit is worth, and it applies to import and to export. The default is 32, which is the figure Func_Godot uses for the same exchange and which puts a 56-unit player at 1.75. - A 128 x 32 x 112 corridor imports as 4 wide, 3.5 high and 1 deep. A 12-metre room exports as 384 units, which is 24 of the other editor's grid squares.
- Point entity origins take the same conversion as the plane points, because an origin is a position in the same space. Other numeric keys do not: a
func_door'sspeedis a distance per second in the source game's units, and only that game knows what it meant, so the keys travel as written. - The texture scale takes the conversion too. A
.mapreader divides the projected point by the scale field, so scaling the points and leaving the scale alone would tile the texture 32 times more often on a room that is otherwise the right size. - An export records the figure it used in
worldspawn, as_hf_units_per_metre. An import reads it and uses it in preference to the dialog, so reopening your own export gives back the level you exported however the setting has moved since. A file from another editor states nothing and is read at whatever the row says. A figure of zero, a negative one or a non-number is refused and the row's value stands. - Changing the row between an import and an export rescales the level, on purpose. That is the way to move a map from one convention to another.
- The conversion lives in
HFFileSystem, not inMapIO.MapIO.parse_map_text()andMapIO.export_map_from_level()convert nothing unless a figure is passed, so a caller that wants the file's own numbers gets them. - The axes are converted in both directions too.
.mapis Z-up across the whole Quake family; Godot is Y-up. Nothing converted, so a corridor drawn 112 units high arrived 3.5 metres deep and one metre high, and a floor exported from here opened in TrenchBroom as a wall. It went unnoticed for a long time because it is symmetric: out of HammerForge and back came home, and only the crossing was wrong. - The turn is a rotation about X: the file's
(x, y, z)becomes(x, z, -y)here, and the reverse on the way out. Being a rotation, it leaves handedness alone, so the winding reversal below is unaffected by it. - That particular rotation is chosen so the level looks from above exactly as it did in the editor it came from. Func_Godot uses a cyclic
(y, z, x), which is equally correct and puts the map down at ninety degrees to the way the source editor drew it. That is noticeable the moment a piece is imported alongside geometry that is already there. - Point entity origins take the turn, for the same reason they take the scale. An
angledoes not: it is a compass bearing in the file's own horizontal plane and would need the turn applied to it as a direction rather than as a point, which is a per-key decision the importer does not make. Afunc_doorneeds none: it reads itsanglein the file's own sense, 0 east and 90 north, so a door opens the same way on both sides. - The Valve 220 texture axes take the turn as well. The reader computes the texture coordinate by projecting the point onto those axes, and rotating both by the same rotation leaves that projection unchanged, so the UVs come out of the conversion exactly as they went in.
- An export records which way up it wrote, in
worldspawn, as_hf_axis_convention:quakefor the format's own axes andgodotfor this project's. A file that saysgodotis left alone on import. A file that says nothing is turned, because every editor other than this one writes the format's axes. - A
.mapexported by a HammerForge build from before this change carries this project's axes and does not say so, and there is nothing in such a file to tell it apart from one another editor wrote. Export it again from a current build, which records the convention, rather than relying on the import to guess. - Axis-aligned boxes use the optimized primitive path; tilted, clipped, and other non-axis-aligned convex brushes import and export as CUSTOM face geometry. An axis-aligned brush thinner than 0.1 on a side imports as CUSTOM at its real thickness, since a box that thin would be widened to 0.1 (#1635).
- Import works the face polygons out rather than reading them off the file. A
.mapface line names three points on an infinite plane, not the corners of a face, and the solid is the intersection of the half spaces behind its planes. A set of planes that closes nothing still imports, on the old reading of the points as corners, so an ill-formed brush arrives wrong rather than not at all. - Key/value properties round-trip through the supported Classic Quake and Valve 220 adapters, on all three kinds of block: point entities, brush entities, and
worldspawn. Afunc_doorkeeps itsspeed,waitandangle, a trigger keeps thetargetit points at, and the map keeps itswadlist, itsmessageand itsmapversion.worldspawn's keys live onLevelRoot.map_worldspawn_propertiesand travel in the.hflevel. - A point entity keeps every key on it, including those its class does not name, and since October 2026 the scene saves them all (#1855). Before that a Ctrl+S kept only the keys the class names, so an imported key was gone after a reopen.
target/targetnameis kept as an ordinary property rather than translated into HammerForge's own I/O. The reverse mapping is not one-to-one, so the pair survives a round trip without the editor guessing what it means.- Per-face material names round-trip. Each face line names the material its
material_idxpoints at, and an import maps that name back to a palette slot by name. - The texture field is positional and whitespace delimited, so a palette name with a space in it is written with underscores (
Red BrickbecomesRed_Brick). Import matches on the same token. - A name the palette does not already hold mints a placeholder slot named after it, so the palette mirrors the file and the Surface panel says
*water1on the water. A.mapnames a texture without saying where it lives, so a placeholder carries no resource path rather than a guessed one that would resolve to nothing. Loading the palette first still matches the real materials by name. - The name is also kept on the face itself, in
FaceData.map_texture, so an import and an export are lossless in a project with no materials loaded at all.__defaultis written only for a face that has neither a palette slot nor an imported name. - The name stays with the face through everything that rebuilds or copies it: a resize, a
sideschange, Godot's Duplicate, a save and reopen of the scene, the faces that Clip, Carve, Bevel, Inset and Clip to Convex make from existing ones, a Flip, and a generated structure's Update. Before #859 each of those dropped it or put it on the wrong face, so an imported trigger whose palette had been cleared exported as a plain solid after its first resize. - UV offset, rotation and scale are written in both formats, converted into the units a
.mapuses: the rotation goes out in degrees (FaceData.uv_rotationis radians), and the offset and scale go out in texels of the face's texture, which is how every Quake family editor reads them (#894). The scale isunits_per_metre / (uv_scale * size)and the offsetuv_offset * size, per axis, wheresizeis that side of the texture in pixels: the palette material's albedo texture, or 64 for a face with no texture, an empty slot or a placeholder. A face at the default alignment on a 64 pixel texture is written0 0 0 0.5 0.5at 32 units a metre. A zero or non-finite scale is written as the default and warned about, since every Quake family compiler divides by it. A negative scale keeps its sign; it mirrors the texture. Valve 220 additionally carries the texture axes, turned by the face's rotation, because a Valve 220 reader projects with the axes as written and never applies the rotation field (#899); Classic Quake has no field for them, and its readers turn their own axes by the rotation. - Import reads the offset, rotation and scale back from any file, the same conversion in reverse, against the palette texture of the same name or 64 pixels when the palette has none. A round trip keeps every face's alignment in both formats (#885), and a file from another editor arrives aligned the way that editor showed it, given the same textures (#894). A scale written in place of zero comes back as 1. The Valve 220 axes are not read back; the face keeps its own projection.
- Authored entity names round-trip as
targetname, on both point entities and brush entities. The authored name is the address every I/O connection is aimed at, so without it a wired level comes back inert. - Brushes tied as one entity export as one entity block with a brush block for each brush, named or not, and an import gives the brushes of a block back tied as one entity, so an unnamed two-leaf door comes back as one door.
- A
func_clipis the exception. It exports intoworldspawnwith every face named clip, whatever its faces are painted with, which is how Hammer and TrenchBroom write clip collision; a face that came in ascommon/cliportools/toolsclipkeeps that spelling. An import gives a world brush whose every face is namedclip,common/cliportools/toolsclip, in any case, back as afunc_clip. A clip with a Physics Layer, a name or another property off its default goes out as afunc_clipblock instead, so it comes back with them (#1409). - A Hidden face exports named
skip, the name map compilers drop, or under the name of that kind it came in with. An import gives a face namedskip,nodraw,null,common/nodraw,common/caulkortools/toolsnodraw, in any case, back as a Hidden face, with no palette slot made for it (#1719). - A wire aimed at a brush by its node name, as the Objects tab can aim one, is written aimed at its entity's address in the file: the entity's name, or for an entity with no name the node name of its first brush, which the block then carries as its
targetname. Nothing in the file answers to a brush's node name, so the wire would otherwise dangle (#1155). When another entity in the file already answers to that name, the block gets the next free one instead,Box_1forBox_0, so the wire does not reach both (#1229). - Entity I/O connections round-trip, one key/value line per connection. The key is the output name and the value is
target,input,parameter,delay,fire_once, which is the order Hammer writes a VMF connection:
{
"classname" "func_button"
"targetname" "btn"
"OnPressed" "door,Open,,0.0,0"
}
- A line with the five fields of a connection but a blank target or input, or a delay that is not a number, cannot be used and is dropped on import. The import result names how many were dropped rather than losing them in silence. The editor refuses to create one, so a file carrying them was written somewhere else or by hand.
- There is no
connections { }block, because.mapentity bodies are key/value lines and nothing else: a nested brace inside an entity is read as a brush by every parser including this one, so a block would not survive its own round trip. - One line per connection, so two outputs on the same event both reach the file. The import reads the key/value lines in file order rather than through a dictionary, which would keep only the last of a repeated key.
- A line is read back as a connection only if it has five comma-separated fields, a numeric delay, and a target and input that are actually there. An ordinary entity property does not look like that, so wiring is told apart from settings without a naming rule on the key.
- The format defines no escape for a comma, so a comma inside a field is written as a space rather than escaped. A reader splitting on the comma would otherwise get a different number of fields than the writer wrote.
- HammerForge surface-paint layers are not preserved, so
.hflevelremains the editable source of truth. - Treat
.mapas a blockout exchange format, not a full fidelity export. - Cutters are not exported. A
.mapworldspawn holds additive solids only, so a cut brush written into one would fill the hole it was made for instead of cutting it. Carved shapes export uncut; bake or export.glbwhen the carve has to come with them. - Face planes are written in
.mapwinding, which is the reverse of the clockwise-from-outside orderFaceDatastores, so exported hulls are the right way out for compilers and other editors. Import applies the same conversion in reverse. Cylinders used to be written the other way round and now match the box and custom-face writers. - One plane per flat surface. A
.mapbrush is an intersection of half spaces, so a cylinder cap is a single plane rather than one plane per fan wedge: a prism exports assides + 2planes. Writing the fan produced3 * sides, most of them exact duplicates, which several external compilers report as a degenerate brush. - A cylinder exports the prism on screen at any side count, and each plane carries the texture of the face that lies on it under any scale. The planes come from the same ring as Godot's
CylinderMesh, starting on +Z, and each plane's face is found by its direction in the brush's own frame, before the transform. The export used to start on +X, so a 5, 6 or 7 sided cylinder came out turned against the one drawn, and it compared transformed normals, which lean towards the stretched axis, so a cylinder at a scale of (4, 1, 1) wrote 12 of its 18 planes with a neighbouring face's texture (#880). - A file that does not parse is refused before the level is touched. Unbalanced braces, face lines that are not three points, and text outside any block are reported, the current level is left alone, and no undo entry is created.
- Multi-format export: Classic Quake and Valve 220 format adapters are available via the format selector in the dock File section. Valve 220 includes UV texture axes from FaceData. The axes are resolved against the face normal, not against the stored projection alone: Valve 220 requires both axes to lie in the face plane, and a stored projection knows nothing about which way the face points. A projection whose axes would lie along the normal falls back to the dominant-normal choice.
- Entity property keys and values may contain quotes, backslashes and
//. HammerForge escapes\and"on export and reads key/value lines as quoted tokens, so what it writes it reads back unchanged. The.mapformat itself defines no escaping rule, so a file written this way shows a literal\"if it is opened by a tool that does not expect one — keep quotes out of values you intend to hand to another editor. - Reading is deliberately conservative in the other direction: only
\"and\\are treated as escapes, so an unescaped Windows path from another tool (textures\maps\wall) survives intact. The one case that cannot be resolved either way is a literal\\written by a tool that does not escape; HammerForge reads it as a single backslash.
Import Vertex Welding¶
Legacy .map files from Hammer, TrenchBroom, and other editors often carry floating-point representation drift in vertex coordinates. Two vertices that should be coincident may differ by a fraction of a unit, producing micro-gaps or non-planar faces after import.
MapIO.parse_map_text() automatically welds near-coincident parsed vertices before constructing brush geometry. The tolerance is controlled by MapIO.import_weld_tolerance (default 0.01). Vertices within this distance are averaged to a shared position via BFS grouping over a spatial hash with 27-cell neighbor lookup, so pairs that straddle a snap-grid boundary are still caught.
The tolerance is in .map units and the weld runs before the unit conversion above, because the drift it exists to close is drift in the numbers as the other editor wrote them. At the default scale 0.01 of a map unit is about a third of a millimetre on the imported level.
To adjust the tolerance:
MapIO.import_weld_tolerance = 0.05 # increase for very noisy legacy files
MapIO.import_weld_tolerance = 0.0 # disable welding entirely
After import, run Check Only (Test tab) to detect any remaining non-planar faces or micro-gaps between brushes. The validation system offers auto-fix methods (weld_brush_vertices, fix_non_planar_faces) for post-import cleanup.
.glb Export¶
.glbexport writes the baked geometry only.- A successful bake is required before export.
- Use
Bake -> Export .glbwhen you need DCC or engine interoperability.
Project Presets: hammerforge_presets/¶
- Brush presets (
.tres), the team's bake profiles (bake_profiles.json) and structure presets (structure_presets.json) live inres://hammerforge_presets/, outside the addon, so an upgrade does not delete them. Commit the folder. structure_presets.jsonis{"version": 1, "presets": {type: {name: {setting: value}}}}, named Structure settings by type. A preset is read through the type's settings: a value out of range refuses that preset by name, a setting the type no longer has is dropped, and a missing one takes its default. A type this version does not know is kept on write, and a file that does not parse is never written over (#1415).- A preset or prefab name is turned into a file name one way (
HFFileNames): characters a file name cannot hold are replaced, leading dots are dropped, a name Windows reserves for a device, such asCONorLPT1, gets the kind after it (CON_preset), and a name already taken is numbered_2,_3(#1464). bake_profiles.jsonis{"version": 1, "profiles": {name: {setting: value}}}, written with sorted keys and one option a line so a change reads as a small diff. A value this version cannot use is left out with a warning on read and kept in the file on write, for anyone whose version can. A number past its setting's range is held to the range on read, with a warning. A file that does not parse, such as one with merge conflict markers, is never written over: Save and Delete refuse until it is fixed.
Material Library¶
- The material palette can be saved and loaded independently, from Save Library and Load Library in the Paint tab or via
MaterialManager.save_library()/load_library(). - Library files are JSON containing material resource paths — portable across projects. A material with no resource path, which is any made in the editor session rather than loaded from disk, cannot be recorded: its slot saves empty and
get_dropped_save_slots()names it.save_library()returnsERR_SKIPwhen no slot could be recorded at all. - The library path can be stored alongside
.hflevelsaves.
Prototype Textures¶
- HammerForge ships with 150 built-in SVG prototype textures at
addons/hammerforge/textures/prototypes/. - Click Refresh Prototypes in Paint tab → Materials → Material Library to batch-load all textures into the palette.
- Once loaded, prototype materials are serialized in
.hflevelsaves, as resource paths. The same rule as the material library applies: a material with no resource path, which is any made in the editor session rather than loaded from disk, cannot be recorded. Saving one now warns and records its class and name, and the slot loads empty. - Prototype textures are included in the plugin directory and travel with the project automatically.
Entity Definitions¶
- Entity types and brush entity classes are loaded from
entities.json(data-driven, not hardcoded). - Add your own in
res://hammerforge_entities.json. That file overlays the plugin'sentities.json, and an entry with the same classname replaces the plugin one. Prefer it over editingres://addons/hammerforge/entities.json, which is overwritten when the plugin is upgraded. entity_definitions_pathcan also name an.fgdfile, or several files joined with;, a later class over an earlier one; see Entity classes from an FGD in the user guide (#1721).- A
LevelRootcan point somewhere else through itsentity_definitions_pathexport. The point entity palette and the brush entity dropdown both read the same merged result, so a custom point entity is placeable and a custom brush class is assignable without further setup. - Definitions include
classname,description,color,is_brush_entity,properties, optionalscene_path, the optionalclassandscenewhich name the Godot node orPackedScenethat Test Level and the exports build for the entity, and optionaloutputsandinputsnaming the I/O the class fires and answers to. A property may carrymaps_tonaming the engine property its value is written to. An entity that stores no value for a property, such as one placed before the class gained it, is exported with the property'sdefault. The dock also reads presentation keys straight from the JSON:label,preview, andcategory. scene_propertynames the property an instance uses to choose its own model, wherescenenames one for the whole class.prop_staticuses it: the path a mapper types is instantiated as the entity's preview in the viewport and as the node the bake and the playtest export carry. A path that does not resolve leaves the class's own preview in place and says so in the log.resource_propertiesmaps a property name to the resource class it holds, for a property that stores a path and a node that wants the thing at the end of it:ambient_sound's Stream is a path a mapper types and anAudioStreamthe player needs. A path that does not resolve leaves the property empty and says so in the log. The Entity panel gives such a property a file picker.presetsnames sets of property values a class offers, such as a light's Warm Ceiling. They are only values: a preset cannot add a property or change a class.- A property may carry
tooltip, which the Entity panel shows on it. input_methodsmaps an input name to the engine method it means, for a class whose node already implements it:logic_timermapsStarttoTimer.start(). See the I/O section of the user guide.output_signalsmaps an engine signal on the class's node to the output it raises:logic_timermapstimeouttoOnTimer. See the I/O section of the user guide.- Keys this list does not mention are carried through to the level root untouched, so a project's own definition file can hold whatever it likes beside the ones the plugin reads.
Prefabs: .hfprefab¶
.hfprefabfiles store reusable brush + entity groups as JSON.- Transforms are stored relative to the group centroid, so prefabs can be placed at any world position.
- Brush IDs are stripped on capture; new ones are assigned on instantiation.
- A record's place in its list says which piece of the prefab it is. When a Push comes from a copy missing an earlier piece, each record after the gap carries
prefab_piece, the piece it was, so every linked copy pairs its pieces with the right records and keeps what it keeps on the right piece. A file written beforeprefab_pieceexisted has none, and its pieces are their places, as they always were (#1976). - A group the selection holds whole is kept: a
groupslist records each one'snameand theparentgroup it is inside, outer ones first, and each piece'sgroup_idnames its own. Placement makes a group of its own for each, nested as they were, under its name or the next free one, so a prefab of a building with a roof group and a wall group places with both. A group the selection holds only part of is not named, so a prefab never puts a piece in a group of the level it is placed into. A file written beforegroupsexisted has none, and places as it did (#1897). - Entity I/O connections are captured and remapped to new entity names when instantiated. The remap runs over the entities the placement just created, not over a name lookup, so an authored name that collides with another copy's node name cannot send it to the wrong one.
- The authored entity name travels verbatim, so placing a prefab twice gives both copies the same name. The I/O remap works off node names, which are made unique on placement; rename the copies yourself if two of them are meant to be told apart by an output.
- Face materials travel by path, not by slot number. A face’s material is an index into the level’s palette, so a
materialsblock records what each referenced slot meant and placement resolves those paths against the destination palette, appending any it does not already hold and rewriting the face indices to match. Placing the same prefab twice does not add the material twice. A material with no file of its own (made in the editor session, a.mapimport's placeholder, or one saved inside a scene) is recorded by name: placement finds that same material or one of that name in the destination palette, and appends it otherwise. While the editor that wrote the file still has the material, the material itself is appended; after that only a blank stand-in carrying its name can be, and the placement says so. One with no name either cannot be carried, so the placement warns and the face keeps whatever the destination holds there — save the material to disk first if its look should travel. - A recorded material the destination project cannot load is reported by name, and those faces keep the index they had rather than being pointed somewhere wrong.
- A
.hfprefabwritten before thematerialsblock existed has none, and is placed exactly as it was before. - Data encoding uses the same
HFLevelIO.encode_variant()/decode_variant()pipeline as.hflevel(handles Vector3, Transform3D, Basis, etc.). - Prefab files are saved to
res://prefabs/by default. The directory is created automatically on first save. - A prefab is written whole, as a level is: to a
.writingfile first, with the old one kept as.previousuntil the new one is in place. A save cut short, or a Push to Source that fails, leaves the old prefab for every linked copy to read, and a prefab left only as.previousstill loads. The.mapexport and Settings Export write the same way (#1758). - Prefabs are portable between projects — just copy
.hfprefabfiles to another project'sres://prefabs/folder.
Autosave Safety¶
- Autosave serializes and writes on a background thread. The editor blocks only for the part that has to read the live level: walking the scene and resolving the materials it references. At 400 brushes that is about 50 ms, against about 125 ms of encoding that happens on the thread.
- Saves first write a
.writingsidecar, then replace the destination. - The existing file is copied to
<level>.hflevel.previousbefore it is replaced, and that copy is restored if the rename fails. On load, a missing destination with a.previousbeside it is promoted automatically. When a whole.writingis beside them too, the save stopped after the old file was taken away and before the new one took its name, and the.writingis promoted instead: it was written in full before the old file was copied aside, so a crash at that moment does not cost the last save (#1642). - If a write fails (e.g., disk full, permissions),
autosave_failedfires for autosaves andhflevel_save_failedfor manual saves, and the dock shows a red warning label. - The next autosave interval retries automatically.
- Manual save is always available via Test tab → File section. Success is reported only after the matching background write finishes, not when the work is queued.
- Queued writes run in the order they were requested, so two saves to the same path in quick succession leave the newer one on disk.
- When region streaming is on, a failed
.hfregionssidecar fails the whole save rather than writing a.hflevelwhose index points at region files that are missing or stale. - Keep
.hflevelfiles in version control and treat the warning state as the authoritative failure signal.
Damaged Level Files¶
A level file reaches the loader from disk, git merges, sync tools and hand edits, so the loader reads it as untrusted (#1641):
- A file that does not parse is refused with a warning, and the open level is left as it was. The file is checked whole before anything is applied, the settings included.
- A file with an unresolved merge in it (<<<<<<<, ======= and >>>>>>> lines) is refused with a message that says so. Keep one side of each block and open it again.
- One brush record that cannot be read costs that brush, not the load: a size, transform or centre that is not a finite number, or a field of the wrong kind, leaves that brush out with a warning naming it.
- A primitive brush whose face corners are damaged (a corner that is not three finite numbers, a face with fewer than three, or a corner moved so the faces no longer close) is built again from its shape and size, without its faces' materials, and says so. A custom brush in that state is left out. Corners within a millimetre count as one, and a corner on the middle of a face's edge is not a seam, so no brush this build saves is touched (#1769).
- A setting of the wrong kind is left as it was, with a warning naming it.
- tests/test_damaged_level_files.gd feeds the loader over a hundred damaged copies of real levels from a fixed seed on every run.
Recommended Pipeline¶
- Design and iterate in HammerForge.
- Save
.hflevelto preserve full fidelity editing data. - Bake when you need runtime geometry.
- Export
.glbfor downstream tools or external engines.