An MCP server for Source-engine map work. It reads .vmf and .bsp files, lints a map against
the game's own FGD, edits a .vmf without reformatting it, patches a compiled map's entities
without recompiling, drives the compilers under Wine — Hammer++ by default, stock on demand or
as a fallback — and measures a map
against the limits of the format.
It never talks to a running engine. It reads and writes files and runs compilers, and nothing else. That is what lets it hold no lock and sit beside a live server without disturbing it.
> read_map_geometry rp_nycity_day.bsp
a 1.13 GB map — header and lump directory only, 1 ms
MODELS 1218 / 1024 119% ████████████▏ over
TEXINFO 11841 / 12288 96% █████████▋
VERTEXES 62270 / 65536 95% █████████▌
BRUSHES 6913 / 8192 84% ████████▍
4 lumps at or past 80% of their ceiling.
MODELS is past it outright — and this map loads every day. That is
not a broken map: it is evidence that the compilers which built it
raise that ceiling. The tool reports it in those terms, rather than
an error it cannot substantiate.
Measured on 11/08/2026. The tool returns JSON; the bars are this README's rendering of it. Every number above is in that JSON, and docs/measuring.md says what corroborates each one.
flowchart LR
VMF[".vmf"] --> LINT["read_vmf_lint<br/>against the game's FGD"]
LINT --> EDIT["edit_vmf<br/>splice, never reserialise"]
EDIT --> VMF
LINT --> C["vbsp · vvis · vrad<br/>under Wine"]
C --> BSP[".bsp"]
BSP --> M["measure · pack · patch entities"]
M -. never .-x ENG["a running engine"]
style ENG stroke-dasharray: 4 4
| Measure | how full each lump is, world extents, prop inventory, packed assets, sightlines |
| Judge | the same measurements against a budget profile, as a verdict per criterion — so a caller knows when it is done, not just where it stands |
| Read and lint | entities, outputs and brush counts of a .vmf; every finding checked against the FGD the game itself declares |
| Edit | entities, keyvalues and outputs of a .vmf, by splicing byte ranges — untouched bytes stay untouched |
| Build | brushes from a shape description, wound and textured the way vbsp expects, refused unless they close |
| Optimise | func_detail, hint brushes including diagonal ones, per-face lightmap scale — the decisions a compiled map no longer contains |
| Compile | vbsp/vvis/vrad under Wine, findings per stage, and a leak turned into a named entity |
| Ship | resolve every asset a map references and find what will be missing, pack them into a .bsp — by hand or derived from the map — check a nav mesh still matches |
| Open | a Workshop .gma: read its index, pull one map out of a gigabyte without unpacking the rest |
| Patch without recompiling | rewrite a compiled map's entity list through a .lmp |
Every reader above answers how much. None of them answers is that enough — so anything
driving this toolchain can measure a map forever without learning that it is finished.
read_map_report closes that: it runs the readers and judges them against a budget
profile.
> read_map_report rp_nycity_day.bsp --profile source-stock
FAIL 19 pass · 3 warn · 3 fail · 1 skipped
fail MODELS 119% past MAX_MAP_MODELS
fail LIGHTING 264% 42.29 MiB of a 16 MiB ceiling
fail edicts 174% 3555 entities against MAX_EDICTS (2048)
skip luxel-density -- nothing calibrates a threshold for this
Measured on 11/08/2026. The LIGHTING line is the one that was not already known: this map
carries 42.29 MiB of lightmap data against MAX_MAP_LIGHTING's 16 MiB, and it holds no
HDR at all (lumps 53, 54 and 58 are empty), so that is LDR alone. It is a second stock
ceiling exceeded, in a lump entirely independent of the first — which corroborates the
MODELS reading rather than repeating it.
Two things this design refuses to do:
- It never restates a limit. Ceilings live once, in
src/bsp/geometry.tsand inLIMITS, read from Valve's headers with a date. A profile carries thresholds — policy — and every one of them states its own provenance, including "we chose it". - It never invents a threshold to fill a row.
luxel-densityis measurable and uncalibrated, so it reportsskippedand says so. A confident verdict about nothing is worse than an admitted gap, and the same goes for the overall verdict: a run that judged nothing comes backskipped, neverpass.
edit_vmf used to say it outright: creating a brush means choosing planes and texture axes,
and a tool that does that without an oracle produces maps that compile and are wrong. The
argument was right. What changed is that the oracles exist now, so the conclusion expired
rather than the reasoning.
write_vmf_solid takes a shape — box, wedge for a ramp, cylinder for an n-sided
prism, or convex for a hull given face by face — and two things it gets right by
construction rather than by care:
- Winding. Every face is wound against the solid's own centroid, so a normal that points inward is turned around before it is written. A new shape cannot introduce a winding bug.
- Texture axes. Not invented: vbsp's own base-axis table. All six of its branches are
reproduced exactly by
gen_probe.py, which was written by hand and has been through a real compile and a real boot. So a ramp does not reach a seventh entry — there is no seventh. What it exercises is the selection: picking the closest base for a normal that matches none of them exactly. That step is Valve's algorithm rather than an extrapolation, and it is the part still owed a compile.
Nothing is written until the result has been read back by read_vmf_solids and passed. The
writer goes volume → planes and the checker goes planes → volume, so neither hides the
other's sign error, and a solid that does not close is refused rather than reported.
And because two programs written on the same afternoon agreeing proves less than it looks: a six-brush room built entirely by this tool is compiled by vbsp for real, and seals. Remove one wall and it leaks. Without that second half, a writer that emitted nothing at all would pass the first test just as happily.
A missing asset is the only failure on this page a mapper never sees at home — they have the
files. read_map_dependencies resolves every asset a compiled map references and says where
each will come from: packed inside the map, found in the game's own content, or
missing.
The walk is recursive because a one-level walk gives a short, plausible, wrong answer. It
follows VMT patch and include chains, $bottommaterial and $fallbackmaterial, a
model's own material list, the skybox's six sides, and the detail sprite config.
On the production map — 11/08/2026:
10 520 assets resolved (10 493 packed · 27 from the game)
5 faults
nodegraph-name-mismatch maps/graphs/rp_nyc1ty_day.ain
texture ×4 referenced by model materials, present nowhere
That first line is a real defect in a map that has shipped and runs daily: the packed
nodegraph is rp_nyc1ty_day.ain, with a digit 1 where an i belongs. The engine loads
maps/graphs/<mapname>.ain, will never find it, and the NPCs navigate without it. Nothing
warns about this at compile time, because nothing checks that a packed file's name matches
the map it was packed into.
- 3983
.vhv— vrad's per-prop vertex lighting. Delete these and every static prop in the map goes flat. - the built cubemaps under
materials/maps/<mapname>/. - everything the engine finds by naming convention rather than by reference: the
skybox's six sides derived from
skyname, the detail sprite material and its.vbspconfig,maps/<mapname>.nav,maps/graphs/<mapname>.ain, the level sounds list.
That last group is the same mechanism that let the misspelled .ain ship: a file the engine
locates by deriving its name is invisible to a dependency walk and to the compiler. Getting
it right in both directions matters — miss it and the tool reports a dozen essential files as
dead weight.
Counted separately again: the 251 files under sound/, scripts/ and particles/, which
this walk does not follow. A soundscape is named by a string defined in a manifest, and
info_particle_system names an effect, not a file.
What is left — 254 on that map — is the only number that means anything, and it is still not a delete list.
And where in the game it was found matters as much as whether. An asset inside a VPK is base content: every player who owns that game has it. An asset sitting loose in the content tree usually is not — it resolves at home because it is on that disk, and it is a checkerboard for everyone else.
run_pack with auto: true packs exactly those, deriving the list from the map and
returning it so what was packed is visible rather than inferred. It never packs VPK content.
detail.vbsp loose in its own root. No rule separates a mapper's work from
a game's loose files — they live under the same install. The asymmetry is what makes erring
toward inclusion right: packing a stock file wastes kilobytes, missing a custom one ships a
broken map. exclude drops the ones you recognise.
A .gma is how the Workshop ships everything, maps included, so until now a Workshop map had
to be unpacked by hand before anything here could look at it — which meant the corpus a mapper
learns from was out of reach.
The format concatenates every file after an index with no padding, so one pass over the index
gives random access to the whole archive. run_gma_extract pulls maps/rp_pinescity_v2b.bsp
out of a 245 MB addon without reading the materials beside it.
Read across every archive on this machine, 11/08/2026: 56 archives, no failures — the largest 1143 MB, the busiest 6521 entries, seven of them carrying a map. Header and index only.
lightmapscale is units per luxel, so it reads backwards: smaller is finer and more
expensive, and the cost is an area. The probe room, compiled at four scales — 11/08/2026:
| scale | luxels | LIGHTING |
|---|---|---|
| 8 | 17 424 | 69 760 B |
| 16 (Hammer's default) | 4 624 | 18 560 B |
| 32 | 1 296 | 5 248 B |
| 64 | 400 | 1 664 B |
Each doubling divides the bill by roughly four. That arithmetic is why the production map
audited here carries 264% of MAX_MAP_LIGHTING — nobody arrives there by choosing a fine
lightmap once, but by never coarsening the surfaces that did not need one. A warehouse floor
at 16 and the same floor at 32 are indistinguishable to a player and differ fourfold on disk.
set_lightmap_scale selects by solid, material, which way a face points, minimum area, or a
combination, and projects the luxel count before writing. It refuses to touch every face
in the map unless told all: true — rescaling a whole map is legitimate and is never what
someone meant by accident.
MAX_BRUSH_LIGHTMAP_DIM_WITHOUT_BORDER, read from
bspfile.h). vbsp does not refuse more — it splits the face until each piece fits. So
lowering the scale on a large surface multiplies faces as well as luxels, and FACES has a
ceiling of its own. (The similarly-named MAX_LIGHTMAP_DIM_WITHOUT_BORDER in that header
aliases the displacement value of 125; reading the obvious name gives four times the real
limit for a brush face.)
The largest single lever on a Source map's performance, and a .bsp cannot tell you it was
ever pulled: vbsp folds func_detail into the world as detail brushes, so nothing in a
shipped map records which brushes were structural. set_solid_class moves them, either way.
A structural brush splits the BSP tree and spawns visleaves around itself. A pillar standing in a room, compiled three ways — 11/08/2026, stock compilers:
| leaves | clusters | VISIBILITY | |
|---|---|---|---|
| empty room | 29 | 4 | 44 B |
| pillar, structural | 33 | 7 | 74 B |
pillar, func_detail |
29 | 4 | 44 B |
The detail pillar returns the tree to the empty room's numbers exactly. It is still there, still solid, still drawn — vvis simply no longer knows about it.
Read that narrowly, because it is the easiest result on this page to over-read. It says the
pillar left the visibility tree. It does not say the pillar became free: it still draws,
still counts in FACES, and still costs its lightmap. On a real map that second bill is
usually the one that decides, and read_map_report is what puts a number on it.
func_detail
brush does not seal the map. Move a wall into one and the next compile leaks. No static check
here can rule that out — sealing is a property of the whole hull, not of one brush — so the
tool returns what to compile next rather than implying the move was safe. The test suite
proves both halves: detailing an interior pillar is clean, detailing a wall of the same room
leaks.
It refuses two things outright. A brush inside a hidden block — Hammer's storage for a
visgroup-hidden brush — because moving it out would unhide it as a side effect of an edit that
said nothing about visibility. And an entity whose classname is not the one asked for, rather
than putting brushes somewhere the caller did not name.
func_detail, hints, per-face lightmap scale: the decisions that most affect how a map
performs exist only in the .vmf. vvis consumes hints and they are gone from the .bsp,
vbsp folds detail into the world. So a tool that can audit a compiled map in detail is still
unable to act on anything it finds there — the acting has to happen on the source.
write_hint_brush places a slab carrying TOOLS/TOOLSHINT on the plane vvis should cut
along and TOOLS/TOOLSSKIP everywhere else, and rotateZ turns it, which is how a cut is
made diagonal.
Compiled three ways on the probe room, 11/08/2026, stock compilers:
| leaves | clusters | VISIBILITY | |
|---|---|---|---|
| no hint | 29 | 4 | 44 B |
| hint, axial | 33 | 8 | 84 B |
| hint, 45° | 35 | 10 | 124 B |
The diagonal is not the axial cut turned round: in the same room it subdivides further. That is the lever behind an audit finding on three shipped city maps, whose BSP trees choose diagonal split planes at roughly 80× the rate of a same-genre control by another author — an author building a city whose streets do not run along the axes reaches for exactly this.
Finer is not automatically better: those clusters cost VIS data and compile time, and one small room is one sample. What the table settles is that the lever works and that the two cuts are not equivalent. The tool says so itself — it returns the measurement to take next, because a hint that changes no leaf count did nothing and still costs a plane in the tree.
A VMF stores a brush as planes. read_vmf_solids intersects those half-spaces back into a
volume, and checks what comes out: closed, convex, inside the world, on a grid, with texture
axes that actually lie across their own faces.
The direction is the point. Anything that writes a brush goes volume → planes; this goes planes → volume. A sign error or an inverted winding cannot survive both, so the two check each other instead of sharing a bug — which is what "no tool without an oracle" has to mean before this repository can write geometry at all.
The subtle case, and the reason the closure test is not a corner count: reverse one side's winding on a box and its half-space faces outward, so the solid becomes an infinite prism. The four corners at the closed end survive and satisfy every half-space, so counting corners returns four and looks healthy. What gives it away is that those four are coplanar and the volume is zero. Removing the volume half of that test turns exactly one test red, which is how it was checked rather than reasoned about.
Every solid also reports the coarsest grid all its corners land on, and the report carries the distribution. That turns a piece of workshop lore — build everything on one grid, 8 is a good one — into something a map can be asked about.
Measured 11/08/2026 on ttt_traps.vmf, the only Hammer-written map shipped with the game:
| Grid | Solids | |
|---|---|---|
| 16 | 3 | 4% |
| 8 | 24 | 32% |
| 4 | 1 | 1% |
| 2 | 18 | 24% |
| 1 | 29 | 39% |
75 solids, none off-grid, no errors. So a map that shipped and plays is not built to one uniform grid. Read that carefully, though: this metric is the coarsest grid every corner fits, so a single odd coordinate anywhere on a brush drops the whole brush to 1. It measures how uniform the geometry ended up, not what grid someone worked at.
.claude/skills/ carries two skills, and they live here rather than in the workshop that uses
this server, because this is where map work belongs:
| Skill | What it holds |
|---|---|
source-map |
driving the tooling — which tool answers which question, in what order, and what each one cannot tell you. building.md is the order to build a map in, written from two real builds rather than from the tool list |
source-mapping |
the craft — brushwork and grid, visibility, lighting, displacements, entities, performance, scale and composition, materials, atmosphere, and the myths |
They reference each other and neither copies the other. Every rule in source-mapping carries
how to check it, and marks itself:
| Mark | Meaning |
|---|---|
[moteur] |
read in Valve's own source, with the file and the date |
[consensus] |
widely agreed among mappers, not verified here |
[contesté] |
mappers disagree, and the disagreement is stated |
[mesuré] |
measured in this workshop, with the sample size |
tooling-coverage.md is
the honest half: it maps each family of rules to the tool that verifies it, and names the ones
nothing verifies. That column is not a gap in the tooling — it is the nature of the work, and
an agent that dresses a judgement up as a metric produces a wrong number and false confidence.
The [mesuré] markers come from an audit of shipped city maps whose raw files stay in a private
repository. What is here is what survived a contradiction pass, together with what did not —
measured-corpus.md keeps both, and
the refutations are the more useful half.
docs/hammer-parity.md has a row for every command in the editor,
naming the tool that covers it or the reason it never will be covered. Two rows say "not
covered" rather than "not applicable" — displacement subdivision, and copying one face's
alignment onto another — and those two are the whole of the gap.
A test reads that document and fails if it names a tool this server does not have, if it omits one it does, or if the count it states has moved.
The distinction matters more here than the feature list. Every claim below is backed by a dated
measurement in docs/, or it says it is not.
| Area | State |
|---|---|
| BSP reading | proven — cross-checked against three independent witnesses |
| Measurement | proven — same |
| VMF reading and lint | proven — every rule verified by a fault injected on purpose |
VMF writing (edit_vmf) |
proven — comments, blank lines and indentation survive |
Brush creation (write_vmf_solid) |
proven — a room built entirely by it compiles sealed, and leaks when one wall is removed |
| Compiling, both toolchains | proven — a leak caused, then located, on stock and Hammer++ |
| Python sidecar (srctools) | proven |
| Game discovery | proven for Garry's Mod only — the readers are generic Source, but one game has been run here |
| Moving and deleting brushes | proven — a room turned 45 degrees compiles sealed, and leaks when one of its walls is deleted |
| Reading the game's content | proven — searches and model bounds checked against Garry's Mod's own install |
| Vertex editing | proven — a slab tilted into a wedge stays a valid brush; every move that would break a face is refused |
| Writing displacements | proven — srctools reads back the grid, the relief and the blend this wrote |
| Entity wiring | proven — a wire to a name nothing has, and an input a class does not have, are both found against the real FGD |
| Reading displacements | proven — srctools reads the same power, grid, start and extremes from the same file |
| Hollowing | proven — the walls sum exactly to the outer volume less the room, and the hollowed probe still seals |
| Clipping | proven — a room whose every wall was cut diagonally compiles sealed; the two halves sum to the original volume |
| Texture alignment | proven — aligning to world reproduces, on all 36 faces, the six axis pairs gen_probe.py states by hand |
Tracing an uncompiled .vmf |
proven — 5000 rays agree with the compiled map's own tracer to under half a unit, and the broadphase agrees with brute force bit for bit |
Rendering a .vmf |
proven — the id buffer agrees with a traced ray at 2000 pixels, and the PNG inflates back to the framebuffer byte for byte |
| Finding a leak without compiling | proven — a hole cut in a sealed fixture is found, and the path out ends at it; the probe map, which compiles clean and boots, reads as sealed |
| Rooms and doorways | partly proven — a fixture stating its own dimensions returns three rooms and two doorways of exactly 96 units, a 40-unit counter top is reported as unreachable rather than as a room, and every merge is reported with the comparison that decided it. The method is a heuristic and says so; it is also sensitive to cell scan order, reproduced in test/watershed.test.ts and open as #48 |
| The floor plan | proven — the drawn area equals the sections' own area from the page geometry alone, every room label falls inside its room, and no two labels overlap |
| Measuring a place | proven — a corridor built 256 wide measures 256, a doorway built 96 measures 96, and one built 100 measures 100 where the voxel estimate would still say 96 |
| Per-map rules | proven — a corridor built 256 passes a 192 bar and fails a 320 one with both numbers; a rule that cannot fail is refused at load; and a write goes through on a map that violates every rule it has |
| The write guard | proven — each of the five VMF writers is called for real on a map inside srcds/ and must refuse |
| Entity-lump patching | codec proven; its effect in game is not — see gate B |
read_vprof |
not written: no real sample to calibrate it against |
git clone https://github.com/ProjectSocietyStudio/hammer-mcp
cd hammer-mcp && pnpm install && pnpm build
./sidecar/setup.sh # Python venv + srctools, for the VMF/FGD/pakfile tools
node dist/index.js install # declares the server in <repoRoot>/.mcp.jsonNothing throws when a prerequisite is missing — run health and it says exactly what is
absent and what that costs you.
.mcp.json points at dist/, which is
gitignored, so a checkout that has not been built serves the tool list of whatever was built
last — silently, with no error and no warning. Reconnecting matters just as much: a client
holds the tool list it was handed when it connected. health reports both halves under
tools: how many tools this server is serving, and whether its build is older than the
source. On 13/08/2026 this cost twelve tools and forty minutes —
the finding.
| Needed for | If missing | |
|---|---|---|
| Node ≥ 20 | everything | nothing starts |
Python 3 + srctools |
VMF, FGD, pakfile | those tools only |
| Wine (measured on 9.0) | run_compile, run_pack |
those two only |
| A Source game with its compilers | same | same |
srcds/bin/ has
none of them.
read_source_games finds what is installed by reading Steam's own files and each game's
gameinfo.txt — appid, install directory, mod directory, and the FGD the game declares. So
Counter-Strike: Source lints against cstrike.fgd without anyone typing that name.
Only Garry's Mod has actually been run here. Every profile records where each of its values came
from and whether a file was read; health reports it. See
docs/formats.md#game-profiles.
read_* observes, run_* executes, verb_noun mutates. Tools that write or execute are
guarded: they need confirm: true, or their name in toolAllowlist.
A guarded tool asks permission twice, and the two gates belong to different people.
confirm: true is ours — the server refuses without it, and an agent satisfies it by
itself, which is what makes a destructive call deliberate rather than incidental. Twenty-five
of them also carry anthropic/requiresUserInteraction, which is the client's gate: it
asks for a human, and no argument an agent can pass will satisfy it.
toolAllowlist lifts both. An operator who writes a tool down as pre-approved has
answered the question the prompt asks, in a config file, on purpose; leaving the human gate
up would contradict that rather than read it strictly. Nothing else changes — guarded still
holds, and a tool not on the list keeps both gates, which stays the default.
| Tool | Realm | Guarded | What it does |
|---|---|---|---|
health |
local |
State of the toolchain: game profile, binaries, FGDs, Wine, sidecar — and how many tools this build serves, and whether it is older than the source | |
read_source_games |
local |
Source games installed here, read from Steam and from gameinfo.txt |
|
read_bsp_info |
map |
Header of a .bsp: ident, version, mapRevision, all 64 lumps, and whether vrad produced HDR |
|
read_bsp_entities |
map |
Entities of lump 0, filtered and paginated, with a classname histogram | |
read_map_extents |
map |
Real world extents (lump 14), in Hammer units and in metres | |
read_map_geometry |
map |
How full each lump is, and how much room is left before vbsp's ceiling | |
read_prop_survey |
map |
Prop inventory, and which prop_dynamic are dynamic for nothing |
|
read_pakfile |
map |
Contents of the embedded pakfile (lump 40), and the compile evidence it carries | |
read_sightlines |
map |
Longest clear lines of sight, traced against the world tree | |
read_brush_volumes |
map |
Footprint and volume of each brush entity, by class | |
read_materials |
map |
Material table of a compiled map, and how many TEXINFO reference each one |
|
read_lightmap_budget |
map |
Where a compiled map's lightmap resolution went: total luxels, distribution, costliest faces | |
read_visleaf_stats |
map |
Quality of a compiled map's visibility split: leaf/cluster counts, leaf volume distribution | |
read_map_report |
map |
Judges a map against a budget profile: a verdict per criterion, not another number | |
read_fgd_class |
map |
A class's schema per the game's FGD: keyvalues, inputs, outputs | |
read_vmf |
map |
Entities, outputs and counts of a .vmf. collapseInstances expands func_instance |
|
read_game_content |
map |
Hammer's texture and model browsers: what the game actually has, by name | |
read_model_info |
map |
A prop's hull, skins, sequences and materials, before you place it | |
read_vmf_solids |
map |
Rebuilds every brush from its planes: is it closed, convex, in the world, on a grid | |
read_displacements |
map |
The terrain grids of a .vmf, their vertices in world space, and their seams |
|
write_displacement |
map |
● | Creates terrain grids on selected faces — 5×5, 9×9 or 17×17 |
sew_displacements |
map |
● | Pulls displacements back together along the edges they share |
sculpt_displacement |
map |
● | Flatten, raise, slope or noise — with a seed, so it can be made twice |
paint_displacement |
map |
● | The blend channel: uniform, by height, or by slope |
write_portal |
map |
● | Areaportals and occluders — the runtime half of visibility |
set_map_properties |
map |
● | worldspawn: the sky, the detail sprites, the fog |
write_vmf_solid |
map |
● | Creates brushes — box, wedge, prism, cone, arch, sphere, torus, stairs, or a hull face by face. One material, or one per role: top, bottom, sides |
transform_solids |
map |
● | Moves, turns, scales or mirrors brushes already in the file, texture lock included |
delete_solids |
map |
● | Removes brushes. The counterpart write_vmf_solid never had |
read_map_organisation |
map |
Visgroups, groups and the cordon, with what belongs to each | |
set_visgroup |
map |
● | Names a selection so later calls can say the name instead of a box |
group_solids |
map |
● | Hammer groups: several brushes that click as one |
set_cordon |
map |
● | The cordon box, and whether it is on — the one setting that changes what compiles |
hollow_solids |
map |
● | Turns a block into the walls of a room, mitred — Hammer's version overlaps |
clip_solids |
map |
● | Cuts brushes with a plane — front, back, or both. Hammer's most-used tool |
move_vertices |
map |
● | Moves a brush's corners — Hammer's VM tool, with its refusals |
set_face_material |
map |
● | Sets the material on selected faces. Nothing could change one before |
align_faces |
map |
● | Realigns a texture: to the world, to the face's own plane, or fitted to it |
set_smoothing_groups |
map |
● | Hammer's 1-to-32 groups, written as the bitmask the file stores |
write_hint_brush |
map |
● | Places a hint brush, straight or diagonal, to shape where vvis splits the map |
set_solid_class |
map |
● | Moves brushes between the world and a brush entity — func_detail and back |
set_lightmap_scale |
map |
● | Sets lightmapscale on selected faces, and projects the luxel bill before writing |
read_entity_report |
map |
Hammer's Entity Report: every entity and its keyvalues, filterable | |
validate_io |
map |
Every output checked against the FGD — the wire that fires into nothing | |
read_vmf_lint |
map |
What will break at compile time or in game, before compiling | |
read_vmf_trace |
map |
Traces a ray or a swept player hull through an uncompiled .vmf: what is in the way, how far, which face |
|
read_vmf_visibility |
map |
Whether named pairs of points can see each other, and the brush that blocks the ones that cannot | |
read_vmf_nearest_surface |
map |
Exact distance from a point to the nearest surface, and which face it belongs to | |
render_vmf_view |
map |
Renders an uncompiled .vmf from a camera and returns the picture: form and occlusion, no textures |
|
read_vmf_leak |
map |
Whether a .vmf seals, without compiling it, with the path out |
|
read_vmf_rooms |
map |
The rooms of a .vmf, the doorways between them and how they connect — every merge it made with the reason, the doorway that merge stopped reporting, and the standable places no walk reaches. Each doorway carries both its cell-count estimate and its measured width, from a swept player hull at the same col |
|
read_vmf_surfaces |
map |
Faces sorted into floor, wall, ceiling — and which of them a person could touch | |
render_vmf_plan |
map |
A dimensioned floor plan: rooms, areas, doorway widths as measured rather than counted, grid, scale bar, north | |
measure_vmf_clearance |
map |
Free width and headroom at a point, measured with a swept player hull, not a ray. Says when no body fits there rather than reporting the hull's own width | |
measure_vmf_approach |
map |
How much clear room a person has in front of each entity — the door that opens into a wall | |
read_vmf_sightlines |
map |
Longest clear sight lines in an uncompiled .vmf, sampled where a person can stand |
|
check_vmf_rules |
map |
Checks a map against its own <map>.rules.json. Reports; never refuses. A run where a rule matched nothing is skipped, never pass, and it says what segmentation the verdict rests on. A width or clearance violation carries evidence: the direction swept, the yaw it came from, what stopped it — and measured: null rather than a bare zero when no body fits where it would have measured |
|
write_vmf |
map |
● | Creates an empty .vmf — Hammer's File > New. Refuses to overwrite. Checks the skyname it writes against the game's own content, and names complete skies the game has when that one is absent |
edit_vmf |
map |
● | Edits a .vmf by splicing: entities, keyvalues, outputs. Nothing else moves |
run_pakfile_extract |
map |
● | Gets files out of a compiled map's pakfile — the counterpart read_pakfile never had. Refuses an entry that would escape the destination, never overwrites unless told |
run_cubemap2hdr |
local |
● | Turns an LDR cubemap into the HDR one Source loads in HDR mode. Refuses anything but DXT1 up front, naming the format the file actually is |
run_tga2skybox |
local |
● | Builds a cubemap .vtf from six TGA faces, LDR and HDR — the half of cubemap work that does not need the engine running. Drives ficool2's tga2skybox |
run_bsp_rename |
local |
● | Renames a compiled .bsp and the content packed inside it — the nodegraph, cubemap patches, soundscripts. Drives ficool2's bsp_rename, a separate download; health says whether it is there |
run_compile |
local |
● | vbsp, vvis and vrad under Wine, findings per stage. Drives the Hammer++ chain by default, with its culling flags; falls back to the stock compilers when Hammer++ is absent and says which one ran |
read_compile_log |
map |
Turns compiler output into findings, each with what the message actually means | |
read_leak |
map |
Turns **** leaked **** into a position and a named entity |
|
read_map_dependencies |
map |
Every asset a map references, and whether each will be there: packed, from the game, or missing | |
read_gma |
map |
Header and index of a Workshop archive: what is in it, and where | |
run_gma_extract |
map |
● | Pulls matching files out of a .gma by offset, without unpacking the rest |
run_pack |
local |
● | Packs files into a .bsp via bspzip. auto derives the list from the map itself |
read_nav |
map |
Says whether a nav mesh still matches its map | |
read_lump_patch |
map |
Decodes a .lmp and its entities |
|
write_lump_patch |
map |
● | Builds an entity patch from add/update/remove operations |
read_lump_patch_status |
map |
Compares built patches to what is deployed, and to map revisions |
The map realm is offline file work; local runs a binary on the host.
Three rules, stated in full in CONTRIBUTING.md:
- A passing test proves nothing until it has been shown it can fail. Every test here was sabotaged and watched go red before it was accepted. One of them failed that check and had to be rewritten — docs/architecture.md says which, and why.
- No number nobody read. Every figure in this repository comes from a file that was read or a measurement that was taken, with its date. Values that cannot be verified are marked unverified or left out — never presented as fact.
- The commit carries its documentation. A README describing a tool that does not exist is a bug, not a delay.
There is a corollary for tools: no tool without an oracle. Several gaps in this repository are documented rather than filled, because no independent way to check the answer could be built.
The dated record of what was tried, measured, and got wrong:
- docs/gates.md — the feasibility gates: do the compilers run under Wine, does the engine honour a lump patch, does the Hammer++ chain behave like the stock one
- docs/measuring.md — what a 1.13 GB production map says about itself, and the three ways of sampling sightlines that all returned confident, wrong numbers
- docs/compiling.md — Wine, Hammer++, what
cullactually saves, and the compiler messages that name the wrong thing - docs/formats.md — the
.lmpformat as measured, the sidecar, the FGD, game profiles - docs/architecture.md — why the write path is a splice, the write discipline, and shared plumbing
- docs/audits/fishke/ — three production maps read end to end with this server: the raw readings, the analyses, and the adversarial pass that refuted two of their own conclusions
<repoRoot>/.hammer-mcp/config.json, every field optional. See
config.example.json.
| Field | Default |
|---|---|
game |
gmod — the profile used when a call does not name one |
gameProfiles |
{} — per-profile overrides: binDir, gameDir, plusPlusBinDir, fgd, branch |
backend |
wine |
winePrefix |
~/.wine |
sidecarPython |
<stateDir>/sidecar-venv/bin/python |
externalToolsDir |
<stateDir>/tools — standalone tools this server drives, such as ficool2's bsp_rename.exe. They belong to no game install, so nothing can discover them; health lists which are present |
toolAllowlist |
[] |
Normally there is nothing to configure: paths come from discovery. gameProfiles is for what
discovery cannot see — a game installed outside Steam, compilers shipped in a separate app, an
engine branch we do not classify.
gmodBin, gmodGameDir and gmodBinPlusPlus are the older spelling of
gameProfiles.gmod.{binDir,gameDir,plusPlusBinDir}. They still work. Setting both to different
values is refused at load rather than silently resolved: the losing half would leave no trace
in any output.
pnpm build # tsc -> dist/
pnpm test # vitest
pnpm typecheck # tsc --noEmit, tests includedMost of this suite is not unit tests: it drives the real compilers under Wine, the real Python
sidecar and real maps. None of that ships with the repository, so pnpm test on a bare machine
skips what it cannot run — and says so:
[hammer-mcp] Tests needing these are SKIPPED, not passing:
- the Python sidecar venv (run sidecar/setup.sh)
- a large production .bsp
That is deliberate: a silently skipped test and a passing test look too much alike. HAMMER_MCP_REPO
points the suite at the tree holding your game content when it is not the parent of the checkout.
No Valve content is committed here. The only binary fixtures are test/fixtures/hmcp_probe.{vmf,bsp},
generated by test/fixtures/gen_probe.py and compiled from it.
@projectsociety/mcp-core— the shared MCP plumbing: tool registry, guarded calls, audit log, config loadinggmod-mcp— the other side: a server that does drive a running Garry's Mod engine
MIT. See LICENSE.