DramaticShapeVoxelMod archival mirror · read-only
702d8049f0d28319f92aa0fe87797ffe37b316fc

initial commit

DramaticShape · 2026-07-26 · parent none (root commit)

initial commit

Changed files

CHANGELOG.md                                                 |  545 ++++++++
 README.md                                                    |  120 ++
 assets/docs/buidling_to_voxel/sprite_extract.py              |   62 +
 assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md |  216 ++++
 assets/docs/buidling_to_voxel/voxel_build_verify.py          |  162 +++
 assets/docs/buildings/B01-unnamed-building.md                |  129 ++
 assets/docs/buildings/B02-unnamed-building.md                |  130 ++
 assets/docs/buildings/B03-unnamed-building.md                |  134 ++
 assets/docs/buildings/B04-unnamed-building.md                |  111 ++
 assets/docs/buildings/B05-unnamed-building.md                |  133 ++
 assets/docs/buildings/B06-unnamed-building.md                |  130 ++
 assets/docs/buildings/B07-unnamed-building.md                |  122 ++
 assets/docs/buildings/B08-unnamed-building.md                |  143 +++
 assets/docs/buildings/B09-unnamed-building.md                |  124 ++
 assets/docs/buildings/B10-unnamed-building.md                |  131 ++
 assets/docs/buildings/B11-unnamed-building.md                |  102 ++
 assets/docs/buildings/B12-unnamed-building.md                |   99 ++
 assets/docs/buildings/B13-unnamed-building.md                |  119 ++
 assets/docs/buildings/B14-unnamed-building.md                |  140 +++
 assets/docs/buildings/B15-unnamed-building.md                |  144 +++
 assets/docs/buildings/B16-unnamed-building.md                |  129 ++
 assets/docs/buildings/B17-unnamed-building.md                |  103 ++
 assets/docs/buildings/B18-unnamed-building.md                |  121 ++
 assets/docs/buildings/B19-unnamed-building.md                |  135 ++
 assets/docs/buildings/B20-unnamed-building.md                |  208 ++++
 assets/docs/buildings/B21-unnamed-building.md                |  142 +++
 assets/docs/buildings/B22-unnamed-building.md                |  168 +++
 assets/docs/buildings/B23-unnamed-building.md                |  109 ++
 assets/docs/buildings/B24-unnamed-building.md                |  145 +++
 assets/docs/buildings/B25-unnamed-building.md                |  142 +++
 assets/docs/buildings/B26-unnamed-building.md                |  138 +++
 assets/docs/buildings/B27-unnamed-building.md                |  116 ++
 assets/docs/buildings/B28-unnamed-building.md                |  120 ++
 assets/docs/buildings/B29-unnamed-building.md                |  123 ++
 assets/docs/buildings/B30-unnamed-building.md                |  108 ++
 assets/docs/buildings/B31-unnamed-building.md                |  126 ++
 assets/docs/buildings/B32-unnamed-building.md                |  156 +++
 assets/docs/buildings/B33-unnamed-building.md                |  125 ++
 assets/docs/buildings/B34-unnamed-building.md                |   97 ++
 assets/docs/buildings/README.md                              |  288 +++++
 assets/docs/buildings/REMAINING.md                           |   88 ++
 assets/voxels/agatha.lua                                     |  322 +++++
 assets/voxels/balding_guy.lua                                |  148 +++
 assets/voxels/beauty.lua                                     |  299 +++++
 assets/voxels/bike_shop_clerk.lua                            |  155 +++
 assets/voxels/biker.lua                                      |  330 +++++
 assets/voxels/bird.lua                                       |  292 +++++
 assets/voxels/blue.lua                                       |  308 +++++
 assets/voxels/boulder.lua                                    |  189 +++
 assets/voxels/brunette_girl.lua                              |  299 +++++
 assets/voxels/bruno.lua                                      |  336 +++++
 assets/voxels/captain.lua                                    |  149 +++
 assets/voxels/channeler.lua                                  |  267 ++++
 assets/voxels/clerk.lua                                      |  151 +++
 assets/voxels/clipboard.lua                                  |  127 ++
 assets/voxels/cook.lua                                       |  330 +++++
 assets/voxels/cooltrainer_f.lua                              |  302 +++++
 assets/voxels/cooltrainer_m.lua                              |  300 +++++
 assets/voxels/daisy.lua                                      |  270 ++++
 assets/voxels/fairy.lua                                      |  334 +++++
 assets/voxels/fisher.lua                                     |  320 +++++
 assets/voxels/fishing_guru.lua                               |  168 +++
 assets/voxels/fossil.lua                                     |  138 +++
 assets/voxels/gambler.lua                                    |  272 ++++
 assets/voxels/gambler_asleep.lua                             |  234 ++++
 assets/voxels/gameboy_kid.lua                                |  161 +++
 assets/voxels/gentleman.lua                                  |  323 +++++
 assets/voxels/giovanni.lua                                   |  270 ++++
 assets/voxels/girl.lua                                       |  282 +++++
 assets/voxels/gramps.lua                                     |  161 +++
 assets/voxels/granny.lua                                     |  157 +++
 assets/voxels/guard.lua                                      |  155 +++
 assets/voxels/gym_guide.lua                                  |  139 +++
 assets/voxels/hiker.lua                                      |  285 +++++
 assets/voxels/koga.lua                                       |  296 +++++
 assets/voxels/lance.lua                                      |  367 ++++++
 assets/voxels/link_receptionist.lua                          |  155 +++
 assets/voxels/little_boy.lua                                 |  131 ++
 assets/voxels/little_girl.lua                                |  257 ++++
 assets/voxels/lorelei.lua                                    |  273 ++++
 assets/voxels/middle_aged_man.lua                            |  310 +++++
 assets/voxels/middle_aged_woman.lua                          |  295 +++++
 assets/voxels/mom.lua                                        |  151 +++
 assets/voxels/monster.lua                                    |  367 ++++++
 assets/voxels/mr_fuji.lua                                    |  287 +++++
 assets/voxels/nurse.lua                                      |  163 +++
 assets/voxels/oak.lua                                        |  312 +++++
 assets/voxels/old_amber.lua                                  |   96 ++
 assets/voxels/paper.lua                                      |  120 ++
 assets/voxels/poke_ball.lua                                  |  115 ++
 assets/voxels/pokedex.lua                                    |  139 +++
 assets/voxels/red.lua                                        |  299 +++++
 assets/voxels/red_bike.lua                                   |  341 +++++
 assets/voxels/rocker.lua                                     |  267 ++++
 assets/voxels/rocket.lua                                     |  328 +++++
 assets/voxels/safari_zone_worker.lua                         |  154 +++
 assets/voxels/sailor.lua                                     |  294 +++++
 assets/voxels/scientist.lua                                  |  281 +++++
 assets/voxels/seel.lua                                       |  332 +++++
 assets/voxels/silph_president.lua                            |  163 +++
 assets/voxels/silph_worker_f.lua                             |  262 ++++
 assets/voxels/silph_worker_m.lua                             |  160 +++
 assets/voxels/snorlax.lua                                    |  183 +++
 assets/voxels/super_nerd.lua                                 |  299 +++++
 assets/voxels/swimmer.lua                                    |  223 ++++
 assets/voxels/waiter.lua                                     |  290 +++++
 assets/voxels/warden.lua                                     |  163 +++
 assets/voxels/youngster.lua                                  |  288 +++++
 data/voxel_heights.lua                                       | 1000 +++++++++++++++
 lib/BuildBudget.lua                                          |   61 +
 lib/Buildings.lua                                            |  694 +++++++++++
 lib/ChunkMesher.lua                                          |  963 ++++++++++++++
 lib/Mat4.lua                                                 |  112 ++
 lib/ModSetting.lua                                           |  132 ++
 lib/ShadowMap.lua                                            |  392 ++++++
 lib/SpriteBillboards.lua                                     |  200 +++
 lib/Structures.lua                                           | 2001 ++++++++++++++++++++++++++++++
 lib/TerrainAtlas.lua                                         |  344 +++++
 lib/TileShape.lua                                            |  259 ++++
 lib/TiltShift.lua                                            |  177 +++
 lib/Voxel3D.lua                                              |  582 +++++++++
 lib/VoxelGrid.lua                                            |   71 ++
 lib/VoxelModels.lua                                          |  147 +++
 lib/VoxelScene.lua                                           |  433 +++++++
 lib/VoxelState.lua                                           |  111 ++
 lib/WorldCurve.lua                                           |  101 ++
 main.lua                                                     |  248 ++++
 manifest.json                                                |   17 +
 mod.card                                                     |   30 +
 tests/dramatic_shape_test.lua                                |  173 +++
 tools/__pycache__/build_voxels.cpython-313.pyc               |  Bin 0 -> 35994 bytes
 tools/__pycache__/building_images.cpython-313.pyc            |  Bin 0 -> 7823 bytes
 tools/__pycache__/building_voxels.cpython-313.pyc            |  Bin 0 -> 36647 bytes
 tools/build_voxels.py                                        |  645 ++++++++++
 tools/building_images.py                                     |  112 ++
 tools/building_voxels.py                                     |  911 ++++++++++++++
 136 files changed, 31933 insertions(+)

Diff

Diff truncated at 200.0 KB — fetch the full change with git show 702d8049f0 after cloning.

diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..f91f08d
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,545 @@
+# Changelog
+
+## Unreleased
+
+Real shadows. The sun moves to the southeast and drops to 45 degrees, so
+shadows fall northwest -- up and to the left on screen -- and run about as
+long as the thing throwing them is tall.
+
+### Added
+
+- `lib/ShadowMap.lua`: the scene rendered once from the sun into an
+  orthographic depth map, which the main pass then samples per fragment.
+  What the sun cannot see is in shadow, whatever surface it is, so a
+  shadow climbs a wall, drapes over a roof and slides across a passing
+  NPC with no case in the code -- and every caster is simply whatever the
+  pass draws. The terrain mesh goes in, which means buildings, trees,
+  ledges, signs and every prop cast, where before only characters did.
+- Depth is packed into two 8-bit channels of an ordinary color canvas
+  (~16 bits over a ~700px frustum, a hundredth of a world pixel).
+  Readable depth textures are the least portable corner of the graphics
+  API, and the mod's contract is that an unsupported driver falls back
+  rather than errors: `available()` reports, and VoxelScene keeps the old
+  flat decals when it says no.
+- The map resolution is picked per frame from a 1024/1536/2048 ladder
+  against a 0.45 world-pixels-per-texel target, because the light frustum
+  is fitted to the world view and that swings 3x between the closest zoom
+  and a maximised window at the widest. The frustum is snapped to whole
+  texels, without which every shadow edge in the world crawls as you walk.
+- Ambient occlusion, the genuine article: each vertex counts the
+  neighbours crowding it and steps down once per neighbour, on top faces
+  (four corners, three neighbours each) and now on upright faces too --
+  the crease a wall rises out of, and the inside corners where flanking
+  columns box it in. It is the complement of the shadow pass rather than
+  a duplicate: the map draws the long directional shadow, this draws the
+  dark seam in every corner the sky cannot see into, at scales finer than
+  a shadow map texel.
+- Plus a ground-contact term for the prebuilt prop quads -- per-pixel
+  plants, signs and lone trees, and the round-tree stamps. Those arrive
+  from Structures already finished, so the neighbour counting has no
+  columns to count; what it can still say is that the floor blocks half
+  the sky, so a voxel's first 6px of rise ramps back to full light. It is
+  what stops a prop reading as pasted over the ground rather than
+  standing on it, and outdoors it is most of what AO does at all, since
+  trees and posts are nearly all prop geometry.
+- One knob for the lot: `AO_STRENGTH` in `ChunkMesher` scales every term
+  (they are written as darkening amounts, not multipliers), against a
+  floor that keeps a crank from punching holes of pure black.
+- The **V-CURVE** row in OPTIONS: the curved world, the Animal Crossing
+  horizon. Every vertex is pushed down by the square of its horizontal
+  distance from the camera's focus, and that is the whole effect -- a
+  quadratic is nearly zero near its vertex, so the ground being played on
+  stays flat, and the falloff accelerates, so the far edge rolls away over
+  a near horizon and the town reads as sitting on a small sphere.
+- It is deliberately NOT a fisheye. A fisheye is a LENS -- a screen-space
+  warp -- which bends straight lines everywhere including right in front of
+  the player, resamples every pixel to do it, and would leave this mode's
+  art blurred and crawling. Bending the WORLD is one line in the vertex
+  shader: lines near the camera stay straight and not a pixel is resampled.
+- Displacing along Y only is what keeps it readable rather than
+  nauseating: the drop depends on where a column stands, not how tall it
+  is, so the world tips away and the buildings on it stay upright.
+- Shadows and the wireframe ride along for free. Both are already worked
+  out before the bend -- the shadow map in flat world space, the grid in
+  model space -- so the bend carries them exactly as if they had been
+  painted on, and neither the light frustum nor the grid needs to know the
+  curve exists. `Voxel3D.project` applies the same drop on the CPU, which
+  is what keeps the overworld's 2D field FX on their ground points.
+- The strength scales with the view height, so a rung reads the same at
+  every zoom, and the ladder is calibrated against the far edge of the
+  visible ground rather than against nothing.
+- `lib/ModSetting.lua`: the ladder/store/rows a setting of this mod's own
+  needs, now that there are two of them. V-GRID's copy of it moved here.
+- A **75 degree** rung on the VOXEL ladder, below the 15/35/50 it shared
+  with the engine's TILT: low enough to read as a diorama shot from table
+  height. Tilt could not have it -- its flat plane degenerates into a
+  horizon line down there -- but geometry only gets more of itself to show.
+- The **V-GRID** row in OPTIONS: a one-display-pixel wireframe along every
+  voxel edge, 3D Dot Game Heroes style. Every mesh here is built one unit
+  per voxel in its OWN model space, so the seams are that space's integer
+  planes, and reading them in model space rather than world space is what
+  keeps them glued to a thing however it is posed -- a character's slab
+  leans back by the camera's pitch and its seams lean with it.
+- The seams fade out where a voxel shrinks under about 3 display pixels.
+  Survey zoom draws a world pixel at roughly a display pixel, and a wall
+  seen nearly edge-on squashes one to nothing at any zoom; drawn anyway,
+  the lines land closer together than they are wide and the wireframe
+  stops being a wireframe and becomes a flat 45% dimming of the scene.
+- `lib/VoxelGrid.lua` owns the toggle. It is NOT a pipeline: it owns no
+  pass of the frame, it parameterises the voxel one, so it has nothing to
+  put in `drawWorld` or `present` and the registry rightly rejects it. A
+  plain mod setting instead -- `options:define` for the store and the mod
+  manager's page, `ui.options.rows` for the row in OPTIONS next to VOXEL
+  and T-SHIFT. Both rows read and write the one stored value.
+- The wireframe is a SECOND COMPILATION of the scene shader rather than a
+  branch inside it, because it needs shader derivatives (`fwidth`) -- the
+  one part of the mode a driver can refuse. A refusal costs the grid and
+  nothing else.
+- `tests/drivers/voxel_shadow_probe.lua`: reports the fitted frustum and
+  the resolution rung, dumps the map itself, and shoots a stand point at
+  every pitch. `SHADOW_SUN="kx,kz"` retunes the bearing for one run,
+  `SHADOW_GRID=1` forces the wireframe on, and `SHADOW_ZOOM` pins the
+  zoom, without which two runs are not comparable -- a driver inherits
+  whatever the player left in `options.lua`, and the world view size (which
+  the light frustum is fitted to) swings 3x across that range.
+
+### Fixed
+
+- VOID FILL did nothing in voxel mode, in two separate ways.
+
+  **BLACK crashed the build.** The mode is not a block at all --
+  `TileRenderer.borderBlockFor` answers `false` for it -- and `Structures`
+  added 1 to that `false`. The arithmetic threw, which failed the mesh
+  build for every map in the neighbourhood, which left the mode with no
+  terrain and dropped it to the flat 2D path entirely. It now builds no
+  ring: `tileLookup` answers nil past the body and those keys are never
+  written, which the rest of the file already copes with -- every
+  neighbour query in it reaches one step outside the analysed range and
+  reads nil for its trouble, so an absent cell is the shape "nothing" has
+  always had here.
+
+  **WATER changed nothing on screen.** The ring is BAKED INTO THE MESH in
+  this mode rather than drawn each frame, and nothing dropped the cache
+  when the option moved, so the old ring simply stayed until the meshes
+  were invalidated for some other reason. The pipeline's update hook now
+  polls `TileRenderer.voidFill` and invalidates on a change -- polled
+  rather than hooked because the engine changes it from three places (the
+  options row, `applyOptions` on load, `setVoidFill`) and none of them
+  announces it, and checked ahead of the active() gate so switching it
+  while voxel mode is off still drops what is cached.
+
+  `tests/drivers/voxel_void_probe.lua` walks the three modes and reports
+  the border block, whether the mesh built and whether the scene took the
+  3D path. It deliberately does NOT invalidate the cache itself, since
+  doing so would hide the second half of this.
+
+- Water and flowers did not animate. The 2D path animates them by
+  OVERDRAWING the animated cells on top of the static tile layer each
+  frame, which a single static mesh has no equivalent of -- the geometry
+  samples one texture and that is that. So `TerrainAtlas` animates the
+  texture instead: a private copy of the atlas whose animated tile slots
+  are rewritten when the step advances, which moves every instance of that
+  tile across the whole mesh at once. Which is what the Game Boy does in
+  the first place (`home/vcopy.asm` rewrites the tile's VRAM bytes); the
+  overdraw is the port's workaround for a tile layer, not the original.
+  ~130 pixels of work three times a second, on the same
+  `TileRenderer.animFrame` clock the 2D path uses, so the two can never
+  disagree about which frame they are on.
+- The frame files (`flower1..3.png`) are raw grayscale and have to land on
+  the colours of the tile they replace, but the two recolour paths do not
+  share a rule -- SGB bakes one world palette over everything, RED++ picks
+  a palette group per tile graphic. So the shade mapping is LEARNED from
+  the atlas: read the static tile's slot in the raw art and in the finished
+  atlas side by side and ask what each shade became. Right under both
+  without this file knowing which one ran.
+- Terrain art was off the pixel grid by up to half a pixel, with one art
+  pixel per tile sampled twice and another never at all. A tile is 8
+  texels across 8 world pixels -- one texel per pixel exactly -- and
+  `ChunkMesher`'s uv inset squeezed that art into a 7-texel sample range
+  while the quad still covered 8 world pixels, so it advanced 7/8 of a
+  texel per pixel and drifted. The inset exists to stop the rasteriser
+  reaching a neighbouring tile along a shared edge, but half a texel was
+  fifty times more than that needs: 0.02 is as safe (interpolation error
+  is nowhere near it) and drifts 0.25% of a pixel across a whole tile.
+  Nothing showed the fault until the voxel wireframe drew the grid those
+  pixels were supposed to be sitting on.
+
+### Changed
+
+- `Voxel3D.SHADOW_KX/KZ` are -0.85 / -0.55, from +0.30 / +0.45: the sun
+  crosses to the southeast and drops from 62 degrees to 45. The bearing
+  leans WEST of northwest on purpose -- a character is drawn as a slab
+  leaning away from the camera, which covers the ground due north of its
+  feet, so a shadow thrown straight up-screen lands entirely underneath
+  the figure casting it and is never seen.
+- `Voxel3D.SHADOW_ALPHA` 0.32 -> 0.40, a quarter darker.
+- `FACE_SHADE` east 0.78 -> 0.84 and west 0.78 -> 0.72. The two were equal
+  because the old sun sat due northwest and they were symmetric about it;
+  under a southeastern sun east is a lit flank and west a shaded one.
+- A character's shadow lookup runs off the UPRIGHT card the sun saw, not
+  the leaning slab the camera sees (`Voxel3D.draw`'s `sunModel`). Casting
+  the leaning slab instead would shrink every shadow to nothing as the
+  camera flattened toward top-down; looking up with the leaned position
+  put each sprite's own card across its front.
+- The contact-shadow term in `ChunkMesher` was a one-directional stripe
+  keyed to a northwestern sun -- two neighbours, one corner, top faces
+  only. It is now the ambient occlusion above.
+- The light frustum is fitted to the ground the CAMERA CAN SEE rather than
+  to a view-sized box around the focus, and both of its margins are now
+  asymmetric -- for opposite reasons. The camera sits south of its focus
+  and looks north, so the ground it sees runs far north and barely south;
+  the sun sits southeast, so the casters for that ground stand south and
+  east of it. Paying for a view-sized box plus caster margin on all four
+  sides covered about a third of what was on screen at 75 degrees, and
+  overpaid at 15.
+- Shadows ease off at the frustum's rim instead of ending on it. Past the
+  low rungs the horizon is further out than any box worth paying for, and
+  a covered region that simply stops draws a hard line across the middle
+  distance where every shadow ends at once.
+
+The Pokemon Center interiors. One `POKECENTER` group in
+`data/voxel_heights.lua` plus one new class, and because
+VIRIDIAN_POKECENTER places every tile the tileset's other maps use, the
+one pin set covers all eleven Centers and the Celadon Hotel.
+
+### Added
+
+- A `counter` class (8px, upright): half-cell furniture. One 8px band,
+  so exactly the drawing's bottom row stands up as the front and every
+  row above it rides the top face in drawn order. `table`'s 12px could
+  not be retuned for it; the houses share that class.
+
+- `tilesets.POKECENTER` in `data/voxel_heights.lua`. Before it, the
+  detector merged the wall-touching counters and healing machines into
+  the wall band and towered them 3-6 blocks, flattened the machines'
+  near-black screens to void, read the pillar bases, plant pots and
+  machine bodies as ponds (the $14/$32/$48 stale-cache water fallback),
+  boxed each plant pair into one hedge cube, and extruded the lounge
+  seat -- a PERSON is drawn into its tile art -- into a monolith wearing
+  his face.
+- The pins, by shape: the wall band, windows, poster, pillars and the
+  16px machine bodies are `wall`; the counters (with the nurse's tray)
+  are `counter` and the PC's desk is `table`; the machine screens and
+  the PC are `billboard`, standing on the pinned boxes below them; the
+  potted plants are `prop` standees like every other interior plant.
+- The lounge couch with the man sitting on it is a `counter` box: its
+  bottom row stands up as the couch's front and the cushion and the man
+  ride the top face, each drawn exactly once.
+  He cannot be stood upright, and the reason is structural rather than a
+  tuning question. His skin pixels span two tile rows and stop dead at
+  the row 9/10 seam; folding two rows upright requires both to share a
+  class, which makes the box two tiles deep, and a fully folded box
+  repeats its north row across its whole top face. So every upright
+  arrangement puts his head on screen two or three times -- as a 16px
+  seat-back, on the front and twice more on the top; as a 32px bookcase,
+  a cabinet taller than the room's own walls. Dropping the seat in front
+  of him to floor level only changes which copy you see. Nor can he be a
+  standee: the drawing has no floor margin, so all three non-black
+  shades touch the cluster rim and the mask drains 307 of its 420
+  interior pixels -- 46% of him even segmented alone, because his skin
+  is the same light shade as the couch behind him.
+- Survey evidence: full before/after passes of VIRIDIAN_POKECENTER at
+  15/35/50 degrees, plus spot-checks of CELADON_POKECENTER and
+  CELADON_HOTEL (shared tileset, both inherit correctly) and of
+  REDS_HOUSE_1F, OAKS_LAB and VIRIDIAN_CITY (unchanged -- the new class
+  is additive and no other tileset lists it).
+
+## 1.3.0
+
+Terrain meshing goes asynchronous, instanced and bounded. The first voxel
+frame used to build every neighbourhood map synchronously (a ~2.4s
+freeze), retain every map's analysis forever (gigabytes over a
+cross-region trek), and string stray pixels along map seams.
+
+### Changed
+
+- Mesh builds stream in the background. `ChunkMesher` queues per-map
+  build jobs and `pump()` -- driven from the pipeline's update -- runs
+  them inside a few-millisecond frame budget (`lib/BuildBudget.lua`
+  suspends the build coroutine mid-loop when the slice is spent). The
+  camera tween holds at flat until the current map's terrain exists, so
+  toggling voxel mode shows a handful of flat frames instead of a frozen
+  one; neighbours pop in as they finish. Warp fades prefetch the
+  destination (the pipeline update ticks while the Transition covers the
+  screen, with a wider pump slice), so a door exit lands on terrain that
+  is already built.
+- Vertex packing goes through FFI into one native buffer
+  (`Mesh:setVertices(ByteData)`) instead of a Lua table per vertex --
+  the headless table path remains for the pure `geometry()` API and its
+  suite.
+- Round-tree hulls are carved once per (tileset, art, ground set) and
+  kept as stamps -- template plus cell offset, expanded during vertex
+  packing -- instead of materialized per-cell quad tables. A route's
+  border forest was ~500 quads x hundreds of cells of retained heap.
+- Mesh and analysis caches evict down to the live neighbourhood (current
+  map + rendered neighbours, plus one set of history so a house
+  round-trip keeps the town warm). Evicted meshes are released
+  explicitly. Memory over Pallet -> Mt Moon: was ~2.9GB and monotonic,
+  now oscillates between ~90 and 200MB.
+
+### Fixed
+
+- Stray pixels along map seams: ring props (border-tree hulls) whose
+  quad CENTER sat exactly on a neighbour body's edge line escaped the
+  strict point-in-rect mask and survived as fragments of otherwise
+  dropped trees. Object quads now keep/drop by their full extent,
+  boundary inclusive; props straddling the body edge also stay whole
+  instead of shedding their outer half.
+- The one-step "ledge hop" when crossing a connection into a tree-ringed
+  map: the seam step stands the player one cell off the new map, where
+  `Map:cellTile` border-extends into the borderBlock -- a raised tile on
+  maps ringed with trees. Off-map ground now reads as height 0 (the
+  departed neighbour's flat walkway, which is what is actually rendered
+  there).
+
+## 1.2.0
+
+Buildings stop being boxes wearing their own elevation. A profiled
+building is voxelized from its own sprite, band by band -- the pipeline
+written up in `assets/docs/buidling_to_voxel/`.
+
+### Added
+
+- `lib/Buildings.lua`: a building archetype. Where the volume path folds a
+  whole drawing upright (roof, facade and sloped ends alike) into one box,
+  this classifies each BAND of the drawing by the 3D surface it depicts and
+  applies the matching operation: top-facing rows lay flat over the
+  footprint, the facade extrudes straight back, an awning band juts past
+  the walls, and the drawn taper at the ends becomes a stepped slope in
+  elevation. Every visible voxel carries a real texel of the drawing, so
+  the model recolours with the atlas.
+- A `buildings` section in `data/voxel_heights.lua`. A building is matched
+  by its exact tile grid (the drawings are catalogued in `assets/docs/buildings/`),
+  so one entry covers every map that places the same art -- Red's house,
+  Blue's house, Bill's, the Copycat's and the two Fuchsia houses are one
+  seven-placement entry, and Oak's lab is a second. Only the band table is
+  authored; the silhouette, the taper rate, the eave height and every
+  window and doorway are measured off the pixels.
+- The flat-roofed civic block and its sixteen relatives -- every Pokemon
+  Center and every Poke Mart, Fuchsia Gym, the museum, the Game Corner,
+  Celadon Mansion and its department store, the Power Plant, the Route 5
+  and Route 22 gates, Silph Co and five anonymous scenery blocks. They are
+  one architecture drawn at eleven different footprints, from 4x4 cells up
+  to Silph Co's 8x12, and they share one band table: their lattice is drawn
+  from straight above, so the measured taper comes out flat and the whole
+  band is depth under one level roof. No new roof mode was needed -- the
+  drawn profile was always the shape, and a drawing with no taper simply
+  yields a level one.
+- Pewter's museum hall (`assets/docs/buildings/B24`). It is the one
+  sloped-roof building in this pass, and the only one so far whose roof
+  texture is not a plain repeat: the drawing states its own period by
+  repeating the whole lattice-and-course motif, rows 8..31 again at 32..55,
+  so the cycle is 24 rows and the roof carries its drawn courses across the
+  depth instead of a bare lattice. The band below them is the roof's
+  fascia, wider than the wall it covers, so it belongs to the roof and
+  lands on the south rim. Note the museum is TWO drawings: this hall and
+  the east entrance beside it, which is B18 and shares its drawing with the
+  Route 2 gate.
+- The rest of the 2:1 sloped-roof buildings: both gym drawings (the
+  standard one at Cinnabar, Pewter, Vermilion and Viridian plus the
+  Fighting Dojo, and the wider Celadon / Cerulean / Saffron one), the 4x2
+  cottage that houses Mr Fuji, the Cubone house, Bill's grandpa, the Name
+  Rater and the Viridian school, Cerulean's three wide houses, the day
+  care, and three scenery blocks -- among them B01, which at 19 placements
+  is the commonest drawing in the game. Each one's roof band is pixel for
+  pixel one of the three already authored, so they take that sibling's band
+  table unchanged: 16 rows for Red's house's, 32 for Oak's lab's, 64 for
+  the museum's.
+- The Safari Zone rest houses and the Victory Road entrance, the first
+  buildings outside the `OVERWORLD` tileset -- `buildings` is keyed by
+  tileset and until now only had the one section. The rest house's
+  corrugated roof repeats every 5 rows rather than the overworld lattice's
+  8, which is the point of `roofCycle` being authored per building.
+- Route 10's scenery block, via a new `seal` field. Its drawing has no
+  black base course -- it ends on a row of light brick -- so the silhouette
+  flood climbed in from the south border through the mortar and hollowed
+  the wall out, leaving 72% of the sprite in 65 pieces. `seal` names the
+  sides a drawing runs off rather than closing, and the flood does not seed
+  there: sealed, it is 95% in one piece, and the model is its twin the
+  museum's. No other building sets it, and none changes by a voxel.
+- Together these take the mod to 31 of the catalogue's 34 drawings and 144
+  of its 147 placements. The last three, and why each resists, are written
+  up in `assets/docs/buildings/REMAINING.md`.
+- A roof no longer runs past the drawing's own silhouette. These sprites
+  are inset from their boxes, and the columns outside the inset carry no
+  roof: they now get none, and the fascia belongs to the outermost columns
+  the drawing actually paints. Before, they raised a four-voxel slab of
+  black kerb the full depth of the building, flanking its walls at ground
+  level. Nothing shipped hit this -- Red's house and Oak's lab are drawn
+  edge to edge -- so no existing model changes by a single voxel.
+- Windows and doorways sink a voxel behind their frames, found rather than
+  listed: a pane is a non-black region the drawing seals off behind its own
+  black outline. A nested frame (the door's own little window) layers for
+  free.
+- `tools/building_voxels.py`: the reference implementation of the same
+  algorithm, with the geometric asserts and isometric previews Stage 5 of
+  the methodology calls for. It and the runtime agree exactly on voxel and
+  shell counts for all 19 templates -- 96,617 / 12,866 for Red's house up
+  to 3,715,963 / 146,762 for Silph Co, which ship as 3,410 and 13,994
+  quads. Its slope asserts read the drawn columns only and stand down for a
+  roof with no taper, where the check is that the roof is level instead;
+  its previews fit the projection to the model rather than to a fixed
+  camera, so a building taller or deeper than the first two still lands on
+  the canvas.
+
+### Changed
+
+- A voxelized building is as tall as its facade plus its roof slab rather
+  than as tall as its drawing: the roof rows are DEPTH now, not height, so
+  Red's house is 36px over a 4x3-cell plot instead of a 48px cube.
+- Round trees (the `cylinder` pin: lone canopies and the border tree
+  wall) stop being lathes -- the sprite wrapped around a 12-segment
+  column read as exactly that, art smeared on a barrel. Each cell is now
+  a real voxel hull: the canopy is segmented out of its cell as the
+  darkest-pixel outline plus everything it encloses (which also drops
+  the background grass that used to inflate every row to full width, and
+  the cast shadow under the ball), and each mask row runs its own span's
+  circular chord in depth -- the front view is the sprite pixel for
+  pixel, the plan view is the sprite's width profile turned in depth.
+  Dithered art with no closed outline (the tree wall) falls back to
+  light-shades-only flooding, per the methodology doc's boundary rule.
+  Sides de-outline like building extrusions so flanks read as canopy
+  rather than solid black, and dome caps keep their outline on the rim
+  while the interior samples the canopy a couple of rows deeper.
+- A `post` standee pool, pinned for the overworld's vertical fence-post
+  cell (tiles 14/85 -- across every map the pair appears only as this
+  cell). The detector already turns HORIZONTAL fence runs (tile 57) into
+  per-post standees, but a vertical run of repeated cells trips its
+  scenery-repetition guard and fell to the volume path as a
+  fence-textured tower (Viridian's west line, Route 25).
+  `post` extracts every CELL as its own cluster -- pooled clustering
+  would stand the whole line up as one drawing-tall slab at one depth --
+  and classifies pixels the way the detector does (non-white is body)
+  rather than by the pinned-prop outline rule, which would strip the
+  posts to black skeletons; at the detector's own 6px depth, pinned and
+  detected fences look alike.
+- Town signs move from the `billboard` pool to a new `signpost` pool: the
+  same per-pixel standing slab, but 2 voxels thin instead of 10. A sign
+  is a plate on a stick, and the standee body that keeps a TV from
+  vanishing at shallow angles read as a solid block of furniture here.
+- The ground under a round tree matches the tree's own drawn background
+  instead of the map's commonest ground tile. The hull's segmentation
+  already knows which pixels are NOT the tree; those pixels are scored
+  against every flat ground tile the map places and the closest art
+  wins, per template -- so border trees drawn over checker grass stand
+  on checker grass even on a map that is mostly pale path (the old
+  fallback painted path under every mid-forest tree, which has no flat
+  neighbour to vote with). The drawn cast shadow stays out of the score:
+  no ground tile carries a shadow, and its darks would drag every match.
+
+### Fixed
+
+- The generic town-house tileset (`HOUSE` -- Blue's house, Daisy at her
+  table, and eighteen more homes, the schoolhouse and the trashed house
+  among them) is now pinned in `data/voxel_heights.lua` the way Red's
+  rooms already were: the dining table stops towering as a wall-height
+  volume and sits at table height with its front folded upright, stools
+  become seat-high boxes that characters sit on, the corner potted
+  plants become per-pixel standees instead of texture-smeared box
+  stacks, the bookcases get clean capped tops, and the wall band (with
+  its window, picture and the schoolhouse blackboard) stays one 16px
+  face.  The schoolhouse's open book stands on the pinned tabletop as a
+  cutout, and the trashed house's ransacked table corner keeps table
+  height.
+- Interior door mats lie flat again in Red's and the generic houses.
+  Their collision tile is $14, which the engine's stale-cache fallback
+  counts as water in every tileset, so the rug recessed into a pond lip;
+  a `ground` pin now overrides the water read.
+
+## 1.1.0
+
+Interior furniture gets the shapes it depicts (docs/voxel-survey.md is the
+procedure that found and verified these).
+
+### Added
+
+- Furniture shape classes in the profile: `bed` (a low slab wearing its
+  top-down art), `table` and `desk` (boxes at their drawn height whose
+  faces fold the artwork up), `relief` (a prop drawn from above -- a
+  game console -- lying flat and extruding a few voxels inside its
+  outline), the standee pools `billboard` (10px) / `prop` (5px) /
+  `stool` (5px, and characters standing on its walkable cell sit at
+  seat height) / `cutout` (one voxel: pure profile, for the vase on the
+  table), and the stair archetypes `stair_e`/`stair_w` (a rising flight
+  of real steps) and `stair_down_e`/`stair_down_w` (a sunken stairwell
+  descending below the floor -- stairs that lead down).  Separate pools
+  cluster separately, so touching drawings never merge into one cutout.
+- Standee clusters split into per-pixel connected components, each
+  standing on its own feet in the depth band of the row it is drawn in:
+  two stools stacked in adjacent cells become two stools, and no
+  fragment of a drawing ever floats at its bounding-box height.  A
+  `cutout` keeps only its largest component -- a cast shadow's drawn
+  edge is background, not a floating scrap.
+- `bookcase` class: free-standing shelf drawings collapse in ranks onto
+  one-cell-deep boxes at their full drawn height, back rows becoming
+  hidden floor; the trim row above a rank -- undetected structure or a
+  row pinned `table`, since the same trim tiles cap other furniture --
+  is adopted as its cap.  Pinned for Oak's Lab (`DOJO`).
+- Oak's Lab tables pinned `table`: the starter-ball display and the
+  north tables stand at real table height, the display frame's black
+  corner brackets no longer auto-extract into standing prisms, and the
+  Poke Ball / Pokedex sprites ride the authored height onto the
+  tabletops.
+- Pinned props drawn directly above a pinned box stand ON it: the PC
+  monitor on its desk, the flower pot on the dining table.
+- Profile pins for `REDS_HOUSE_1` / `REDS_HOUSE_2` (Red's house and the
+  Copycat's, both floors): bed, stools, tables, PC desk with its
+  standing monitor, bookcases, TV standing on the floor behind the game
+  console's relief, potted plant, flower pot, both staircases, and the
+  wall/window band.
+- `tests/drivers/voxel_survey.lua`: screenshot-survey driver behind the
+  repeatable inspection procedure (SURVEY_MAP / SURVEY_SPOTS /
+  SURVEY_LEVELS / SHOT_DIR), documented in docs/voxel-survey.md.
+
+### Changed
+
+- Pinned props are segmented the way the art is authored: objects wear a
+  black outline, so background is the shades touching the cluster's edge
+  (white floor around a TV, grey tabletop around a vase) flooded in from
+  the aprons; the outline, its interior, paint whites and anything they
+  enclose survive -- pixel-perfect cutouts on any surface.
+- Indoor structure analysis floods background from all four aprons and
+  accepts ground contact on any side (outdoors keeps the south-only rule
+  that protects roofs), so face-on furniture drawings voxelize per pixel
+  instead of rising as wall-height volumes.
+- Profile-pinned standees are 10px deep (detected props stay 6px), so a
+  deliberate object like a TV keeps a body at shallow camera angles.
+- Authored upright boxes fold their artwork up every face (flanks and
+  back wear the front stack darkened) and top faces keep the drawn
+  tabletop: face-on rows wear the row above the fold instead of
+  repeating their front art lying flat, and a run that folded entirely
+  tops with the furniture row drawn above it (a bookcase's shelf trim).
+- Characters no longer ride a pinned stair tile's class height: stairs
+  are walked through at floor level, fixing the step-up onto thin air in
+  front of stairwells.
+
+## 1.0.0
+
+First release, ported from the engine-internal voxel branch onto the
+`render_pipelines` mod API.
+
+### Added
+
+- `voxel` render pipeline: 3D diorama overworld with extruded terrain,
+  depth-buffered occlusion, leaning sprite billboards, drop shadows and
+  contact AO. VOXEL options row and hotkey `6` (OFF / 15 / 35 / 50).
+- `tiltshift` render pipeline: a `worldPresent` post-process giving the
+  miniature-photo look. T-SHIFT options row and hotkey `9` (OFF / 1 / 2 / 3).
+- Carved voxel models for 67 overworld sprites, plus the
+  `tools/build_voxels.py` that produced them.
+- `data/voxel_heights.lua`, the hand-authored tile shape profile.
+
+### Changed from the pre-mod version
+
+- The level is no longer the mod's to keep. The engine owns the ladder, the
+  options rows, the hotkeys, persistence and the TILT exclusion; the mod
+  keeps only the camera-angle tween.
+- Persistence moved from `save.options.voxel` / `save.options.tiltshift` to
+  `save.options.pipelines.voxel` / `.tiltshift`.
+- Hotkeys moved from `4`/`9` to `6`/`9`: the fork already uses `4` for
+  survey zoom.
+- The tilt-shift pass is a declared `worldPresent` stage rather than a call
+  spliced into the world draw, so it composes with any world pipeline
+  instead of only this one.
+- The cut-tree animation now draws in voxel mode; the pre-mod version
+  omitted it from the 3D field-effect list.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fad3d8f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,120 @@
+# Dramatic Shape Voxel Mod
+
+The overworld as a 3D diorama. Terrain is extruded into real geometry,
+occlusion comes from a depth buffer rather than a y-sort, characters stand
+as leaning sprite slabs, a shadow map throws real cast shadows across
+whatever they land on, and an optional tilt-shift pass sells the
+miniature-model look.
+
+Purely presentational. Nothing here reaches collision, movement, triggers
+or scripts — it changes what the world *looks* like and nothing about what
+it *is*. Battles, menus and cutscenes are untouched; only the free-roam
+overworld draws differently.
+
+## Using it
+
+| control | does |
+| --- | --- |
+| `6`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → OFF (camera pitch) |
+| `9`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) |
+| the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel |
+| the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon |
+
+75 degrees is a rung the engine's flat **TILT** could not have: it projects
+the world canvas as one rigid plane, which degenerates into a horizon line
+down there, where real geometry only gets more of itself to show.
+
+V-CURVE is the *curved world* — every vertex pushed down by the square of
+its horizontal distance from the camera's focus, so the ground under you
+stays flat and the map bends away over a near horizon. It is not a fisheye:
+a fisheye is a lens, a screen-space warp that bends straight lines
+everywhere and resamples every pixel to do it. This bends the geometry, so
+lines near the camera stay straight and the art stays crisp.
+
+Both pipeline levels persist in `save.options.pipelines`, and V-GRID and
+V-CURVE in `save.options.modOptions.DRAMATIC_SHAPE` — the same place the mod
+manager's own settings page for this mod reads, so each row is one setting
+in two places. VOXEL and the engine's **TILT** are two answers to the same
+question, so switching one on switches the other off.
+
+Without shader or depth-canvas support the rows still cycle but the world
+stays 2D — the mode reports itself unavailable and the engine keeps the
+vanilla path.
+
+## How it is built
+
+This is a *rendering pipeline* mod (see `docs/modding.md`). `main.lua`
+registers two records in the engine's `render_pipelines` registry and
+writes almost nothing else — the ladder, the options rows, the hotkeys,
+persistence and the free-roam gate are all engine plumbing driven from
+those records:
+
+- **`voxel`** — a `drawWorld` pipeline. Replaces the world pass with a
+  rendered 3D scene and returns it as one window-resolution canvas.
+- **`tiltshift`** — a `worldPresent` pipeline. Post-processes the finished
+  world *before* the UI composites, so the diorama blurs and the text boxes
+  in front of it stay crisp.
+
+Everything else lives in `lib/`, loaded through the mod's own namespace
+(`V.require`) rather than `package.path`, which a mod directory is not on:
+
+| module | role |
+| --- | --- |
+| `Voxel3D` | the 3D pass: shader, depth buffer, camera, projection |
+| `ShadowMap` | the sun's own pass: what it cannot see is in shadow |
+| `WorldCurve` | the curved world: the Animal Crossing horizon |
+| `VoxelGrid` | the wireframe toggle |
+| `ModSetting` | a setting of this mod's own: ladder, store, options row |
+| `Mat4` | the 4x4 matrix math the camera and model transforms need |
+| `VoxelState` | the camera angle and its ease; the engine owns the level |
+| `VoxelScene` | assembles one frame: shadows, terrain, characters, grass |
+| `ChunkMesher` | a map's block layer → a terrain mesh, cached per map |
+| `Structures` | flood-fills drawn things and decides how tall each is |
+| `Buildings` | a profiled building sprite → a voxel model, band by band |
+| `TileShape` | resolves every tile to an extrusion class and art mode |
+| `TerrainAtlas` | the texture geometry samples: SGB palette bakes, and the animated water/flower tiles |
+| `SpriteBillboards` | a sprite frame → a leaning slab with side relief |
+| `VoxelModels` | the carved character models in `assets/voxels/` |
+| `TiltShift` | the two-pass separable blur and saturation lift |
+
+`data/voxel_heights.lua` is the hand-authored shape profile: listing a tile
+there pins its height and art mode over the automatic detector, and its
+`buildings` section names whole building sprites to voxelize band by band
+(`assets/docs/buidling_to_voxel/` is the pipeline; `assets/docs/buildings/` catalogues the
+drawings). Another mod can pin its own tiles by shipping the same file, and
+can replace a character's model with `overrides/voxels/<name>.lua` — models
+resolve through the engine's asset search path before falling back to ours.
+
+`tools/build_voxels.py` is what carved `assets/voxels/` out of the
+overworld sprite sheets. `tools/building_voxels.py` is the reference
+implementation of the building voxelizer — it builds the same models
+offline, asserts their geometry and renders isometric previews, and its
+voxel and shell counts are what `lib/Buildings.lua` is checked against.
+
+## Engine seams it relies on
+
+The mod reads a few engine internals (hence `"permissions":
+["engine_internals"]`), all of them deliberate read-only seams:
+
+- `Player:pose()` / `NPC:pose()` — the sheet, position, facing and step
+  phase a frame renders to, so 2D and 3D can never disagree about a pose.
+- `SpriteRenderer:resolveImage()` and `SpriteRenderer.STAND/WALK` — the
+  live sprite image and frame tables, so palette modes and sprite-replacing
+  mods apply to the geometry with no second copy of the logic.
+- `TileRenderer.recolorSample` — the exact shade cutoffs the 2D palette
+  shader uses, so baked terrain lands on the same colours as flat tiles.
+- `TileRenderer.animFrame()` / `.atlasImageData()` / `.defaultAnimatedTiles()`
+  — the tile-animation clock, the pixels behind the atlas texture, and the
+  vanilla water/flower spec. A static mesh cannot overdraw animated cells
+  the way the tile layer does, so it animates the texture instead; all
+  three are needed to do that on the engine's own clock and colours.
+
+## Tests
+
+```sh
+luajit mods/DRAMATIC_SHAPE/tests/dramatic_shape_test.lua
+```
+
+Runs headless: the mod loads, both pipelines land in the registry with the
+shape the engine dispatches on, the ladders and persistence round-trip, and
+the whole thing stays inert on a machine with no 3D support.
diff --git a/assets/docs/buidling_to_voxel/sprite_extract.py b/assets/docs/buidling_to_voxel/sprite_extract.py
new file mode 100644
index 0000000..97b6a83
--- /dev/null
+++ b/assets/docs/buidling_to_voxel/sprite_extract.py
@@ -0,0 +1,62 @@
+"""Stage 1: sample a GB sprite to native resolution, extract palette +
+silhouette (light-only flood fill), write sprite_data.json.
+Usage: python3 sprite_extract.py <sprite.png>"""
+import json
+from collections import deque, Counter
+from PIL import Image
+
+import sys
+SRC = sys.argv[1] if len(sys.argv) > 1 else 'sprite.png'
+img = Image.open(SRC).convert('RGB')
+W, H = img.size
+sx, sy = W // 64, H // 48          # 8x8 blocks
+assert sx * 64 == W and sy * 48 == H, (W, H)
+
+# sample block centers -> 64x48
+px = img.load()
+grid = [[px[x * sx + sx // 2, y * sy + sy // 2] for x in range(64)] for y in range(48)]
+
+# palette
+counts = Counter(c for row in grid for c in row)
+pal = sorted(counts, key=lambda c: -counts[c])
+print('palette:', [(('#%02x%02x%02x' % c), counts[c]) for c in pal])
+idx = {c: i for i, c in enumerate(pal)}
+
+lum = lambda c: 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
+
+# flood fill "outside" from border pixels, blocked by the dark outline
+outside = [[False] * 64 for _ in range(48)]
+q = deque()
+for x in range(64):
+    for y in (0, 47):
+        if lum(grid[y][x]) > 60 and not outside[y][x]:
+            outside[y][x] = True; q.append((x, y))
+for y in range(48):
+    for x in (0, 63):
+        if lum(grid[y][x]) > 60 and not outside[y][x]:
+            outside[y][x] = True; q.append((x, y))
+while q:
+    x, y = q.popleft()
+    for nx, ny in ((x+1,y),(x-1,y),(x,y+1),(x,y-1)):
+        if 0 <= nx < 64 and 0 <= ny < 48 and not outside[ny][nx] and lum(grid[ny][nx]) > 60:
+            outside[ny][nx] = True; q.append((nx, ny))
+
+n_out = sum(r.count(True) for r in outside)
+print('outside pixels:', n_out, '(doc says 218)')
+
+# per-band outside count for sanity vs the doc's silhouette table
+for r in range(6):
+    band = sum(outside[y][x] for y in range(r*8, r*8+8) for x in range(64))
+    print(f'  r{r+1}: {band}')
+
+# ascii mask (o = outside)
+for y in range(48):
+    print(''.join('o' if outside[y][x] else '.' for x in range(64)))
+
+rows = []
+for y in range(48):
+    rows.append(''.join('.' if outside[y][x] else '%x' % idx[grid[y][x]] for x in range(64)))
+
+json.dump({'pal': ['#%02x%02x%02x' % c for c in pal], 'rows': rows},
+          open('sprite_data.json', 'w'))
+print('wrote sprite_data.json')
diff --git a/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md b/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md
new file mode 100644
index 0000000..40bea60
--- /dev/null
+++ b/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md
@@ -0,0 +1,216 @@
+# Sprite → Voxel Methodology
+## Translating Game Boy ¾-view overworld sprites into 3D voxel models
+
+Worked example: Red's house exterior — `PALLET_TOWN` blocks 56/57/60/61, 64×48 px
+(`reds_house_voxel.html`). The same pipeline applies to any GB building sprite.
+
+---
+
+## Core principle
+
+A Game Boy overworld sprite is a fake-3D projection: it packs several different
+3D facings into one flat image. Roof tops are drawn as if seen from above,
+walls as if seen from the front, and sloped surfaces as diagonal silhouettes.
+Voxelization is therefore **not** one operation — it is (1) classifying each
+region of the sprite by which 3D surface it depicts, then (2) applying the
+matching geometric operation per region.
+
+Two invariants govern everything:
+
+1. **Every visible voxel color comes from a sprite pixel** wherever the lookup
+   lands inside the silhouette. Synthesis is allowed only for geometry the
+   sprite implies but never paints (undersides, depth extensions, interiors),
+   and synthesized cells must continue the sprite's own periodic patterns and
+   4-shade palette.
+2. **The sprite is ground truth, not the tile documentation.** The tile-grid
+   doc described 1px ground flanks; the actual sprite has 5px insets on the
+   ground floor and a 3-level wedding-cake silhouette. Always extract from the
+   real pixels and verify any doc claims against the extracted mask.
+
+---
+
+## Stage 1 — Extraction
+
+Sample the sprite down to its native resolution (uploads are usually integer
+upscales; sample the center of each scale×scale block, never bilinear).
+Quantize to the palette — for GB art expect exactly `#ffffff / #aaaaaa /
+#555555 / #000000`. Anything else in the histogram means the sampling grid is
+misaligned.
+
+Compute the silhouette with a flood fill from the image border that spreads
+**only through light pixels (luminance > 130)**. This threshold is load-bearing:
+the black outline (#000) and the dark shading (#555) together form the
+boundary. A naive "not black" threshold lets the fill eat the #555 shaded
+flanks and corrupts the silhouette — that bug produced a 398-vs-148 pixel
+discrepancy on the first pass here.
+
+Output: a JSON of the palette plus one string per row (`.` = outside the
+outline, digit = palette index). Dump the mask as ASCII and read it — this is
+where the building's real structure becomes visible, and it is the input to
+Stage 2. Tooling: `sprite_extract.py`.
+
+## Stage 2 — Band classification
+
+Segment the sprite rows into horizontal bands and assign each a facing. The
+cues generalize:
+
+| Cue in the mask / pixels | Reads as | 3D treatment |
+| --- | --- | --- |
+| Top band, plain fill with full-width course lines | Roof top seen from above | Lay horizontal (flat) |
+| Silhouette tapering at a constant rate (here 2 px per row) with slat/stripe texture | Sloped roof surface; the taper rate **is** the slope | Stepped diagonal surface, 1 down per (taper rate) out |
+| Band containing window/door frames | Vertical facade | Straight extrusion |
+| Full-width band with a black underline sitting above an inset band | Ledge / awning overhang | Extrusion + protrusion |
+| Dark `#555` runs beside a facade under a taper | Shadow on the wall beneath an eave | Leave as wall — the geometry above produces the shadow's meaning |
+
+The band table for Red's house, which Blue's house shares verbatim:
+
+| Sprite rows | Content | Treatment |
+| --- | --- | --- |
+| 0–15, x16–47 | Coursed grey panel, black rims, highlight course | Flat horizontal top |
+| 0–15, flanks | White/dark slats, 2 px-per-row taper | Sloped ends, 2:1 |
+| 16–23 | Gable wall, 3 windows, slat flank pixels | Extrude; roof solid overwrites the corners |
+| 24–31 | Awning slab, double black underline | Extrude + 2-voxel ledge front/back |
+| 32–47 | Ground floor siding, door (own inner pane), 2 windows | Extrude; recess panes |
+
+**Interpretation rules settled during this build — do not relearn them:**
+a top-facing band must end up level everywhere (no synthesized skirts rising
+through it); a tapering silhouette on a top band means *sloped surfaces in
+elevation*, not chamfered corners in plan; and the slope's eave tips should
+land where the sprite's taper pixels stop (here: just above the awning band).
+
+## Stage 3 — Geometry construction
+
+**Vertical bands** extrude straight back to depth `D` (26 here). Front and
+back layers take the sprite pixel; interior layers take a **de-outlined**
+color — if the pixel is black, walk inward up to 3 px for the first non-black
+color. Without this, the side faces of the model are solid outline-black
+slabs. With it, sides read as material with black front/back corner edges,
+which is the correct GB-cartoon look. The back face becomes a mirror of the
+front for free; that is sprite-pure and acceptable.
+
+**Ledges** replicate the band's front pixels ±2 voxels in z past the walls.
+The sprite's own black-underline rows become the visible dark underside.
+
+**Recesses** delete the front voxel of every *non-black* pixel inside a
+window/door rect. Frames stay proud; the identically-colored voxel behind
+becomes the pane, one voxel deep. Rects with nested frames (the door's inner
+window) produce layered relief automatically.
+
+**The flat top** lays the top-facing rows horizontal. The band is shallower
+than the house (16 rows vs 34+ of depth), so extend it by **cycling a mid-row
+band whose period matches the course rhythm** (rows 5–8 here, period 4) —
+this continues both course lines and slat columns seamlessly. Map the
+outermost sprite rows to the front/back rims so the black-line/fascia trim
+survives.
+
+**Sloped ends** are driven by an elevation profile `T(x)`: flat at `YTOP`
+over the plateau, dropping 1 voxel per (taper rate = 2) columns outward to
+the eave tips. Build a solid of constant vertical thickness (4) following
+`T(x)`, spanning the full roof depth including overhangs. Build order
+matters: walls first, then the roof solid **overwrites** wall voxels it
+intersects, then **trim** any wall voxel above `T(x)` so nothing pokes
+through the surface. The wall strip left exposed beneath the slope shows the
+sprite's own #555 shadow pixels — the sprite encodes this geometry.
+
+**Outline pass**: cells on the roof's outer boundary get black (top layer),
+grey (second layer), black (below) — reproducing the sprite's
+black-grey-black fascia — and all interior undersides are dark.
+
+Parameters used here, to tune per building: depth `D=26`; roof overhang 4 in
+x and z beyond the walls; slab thickness 4; `YTOP=35`, derived so the eave
+tips (after the 10-step drop) land one voxel above the awning band, matching
+where the sprite's taper ends.
+
+## Stage 4 — Color sourcing off the sprite
+
+For any roof cell, map z to a sprite row (`roofSy`), then look up `(x, row)`.
+If that lands inside the silhouette, use the pixel. If it lands outside
+(overhang extensions), continue the sprite's periodic texture: the slat
+rhythm is period 3 with a per-side phase (left flank white at `x % 3 == 1`,
+right at `x % 3 == 2` — derive the phases from the actual pixels, and mind
+negative-modulo semantics in JS). Plain top areas fall back to the mid grey.
+Ground plane and path are presentation-only and the single place non-palette
+colors are permitted.
+
+## Stage 5 — Verification (non-negotiable)
+
+Every bug in this build was caught by one of these, none by eyeballing alone:
+
+1. **Dual implementation parity.** Build the identical algorithm in a
+   reference implementation (Python) and in the shipping runtime (JS in the
+   HTML). Diff total voxel count and post-cull shell count — they must match
+   exactly (final build: 58,356 / 14,169).
+2. **Numeric asserts on intent.** Flatness: the set of y-layers above the
+   walls must be exactly the slab layers. Slope: the top-surface profile at
+   mid-depth must read tips → 1-per-2 steps → flat plateau, mirrored. Zero
+   wall voxels with `y > T(x)`. Full wall coverage by the roof footprint.
+3. **Isometric preview.** A ~60-line painter's-algorithm render (sort by
+   `(x+z, y)`, draw top/left/right faces of shell voxels) catches texture and
+   layering mistakes cheaply before touching the runtime.
+4. **Hidden-face culling.** Drop voxels whose 6 neighbors all exist before
+   instancing; render the shell as one InstancedMesh.
+
+Tooling: `voxel_build_verify.py` (builds, asserts, renders previews).
+
+## Repeat checklist
+
+1. Obtain the sprite; sample to native resolution via block centers.
+2. Extract palette + silhouette (light-only flood fill, threshold 130);
+   review the ASCII mask.
+3. Segment rows into bands using the Stage-2 cues; write the band table
+   before writing any geometry code.
+4. Measure taper rates from the mask; derive `T(x)`, `YTOP`, overhangs, `D`.
+5. Build: extrude verticals (de-outlined interiors) → ledges → recesses →
+   flat top (mid-row cycling) → sloped solids (overwrite, then trim) →
+   outline pass → ground presentation.
+6. Verify: parity counts, profile/flatness/poke asserts, iso preview.
+7. Ship: embed palette + row strings in the HTML; the builder runs
+   client-side and doubles as the reference implementation of the algorithm.
+
+## Applying this in the mod
+
+Because tilesets are shared, the band table can be keyed by tile id rather
+than by sprite: each id gets a treatment record (extrude / lay-flat / slope,
+plus ledge, recess, warp flags) in the spirit of
+`mods/DRAMATIC_SHAPE/data/voxel_heights.lua`. Pinning blocks 56/57/60/61 with
+the profile above voxelizes Blue's house identically for free and propagates
+to the Fuchsia City, Route 25, and Saffron City instances of the same art.
+The door's lower-left tile (27) is the warp/walkable tile — keep its recessed
+front face aligned with the collision cell so the 3D doorway matches
+`REDS_HOUSE_1F`'s warp.
+
+### What shipping it settled
+
+The mod implements this as `mods/DRAMATIC_SHAPE/lib/Buildings.lua`, driven by
+a `buildings` list in the profile. Three things changed from the sketch
+above, each for a reason worth keeping:
+
+**Key the band table by the building's tile GRID, not by tile id.** A tile
+id is not a band: tile 23 is the house's awning course *and* the top of its
+roof *and* the eave course that ends Oak's lab's much taller roof. Matching
+the exact grid (`../buildings/` catalogues one per building, with every
+map that places it) is unambiguous, still shares one entry across all seven
+placements of Red's house, and cost nothing to verify — a scan of all 222
+maps returns exactly the catalogued placements.
+
+**Measure everything measurable.** Only the band table needs a human to read
+the drawing. The silhouette, the taper rate, the eave height and every
+window and doorway come off the pixels: a pane is a non-black region the
+drawing seals behind its own black frame, and `T(x) = YTOP - topRow(x)`
+falls straight out of the mask — which is also what makes `YTOP` stop being
+hand-tuned. The eave tips landing one voxel above the awning, tuned by hand
+here, then happens by itself.
+
+**Depth is the plot, and the drawn row → depth mapping has a direction.**
+In a diorama `D` is free; on a map it is the building's footprint (48px for
+Red's house, 64 for the lab), which is why the roof band has to be cycled so
+far. And the drawing looks at the roof from the north: its top rows are the
+FAR edge and its bottom rows the eave over the facade. Getting that backwards
+is invisible in the counts and in a symmetric preview — it shows up in game
+as a fascia along the wrong rim.
+
+One colour note: the outline pass's grey fascia band (`GREY` at `t-1`) is
+right for the raw GB palette but comes out white once the atlas is
+recoloured, turning every sloped end into a black-and-white zip. The
+drawing's own eave is black / `#555` / black, and using that reads correctly
+under every palette.
diff --git a/assets/docs/buidling_to_voxel/voxel_build_verify.py b/assets/docs/buidling_to_voxel/voxel_build_verify.py
new file mode 100644
index 0000000..c970d74
--- /dev/null
+++ b/assets/docs/buidling_to_voxel/voxel_build_verify.py
@@ -0,0 +1,162 @@
+"""Stages 3+5: reference voxel builder (mirrors the JS in the HTML),
+hidden-face culling, and isometric painter previews for verification.
+Run after sprite_extract.py in the same directory."""
+import json
+from PIL import Image, ImageDraw
+
+data = json.load(open('sprite_data.json'))
+rows = data['rows']
+WHITE, GREY, DARK, BLACK = 0, 1, 2, 3
+PAL = [(0xff, 0xff, 0xff), (0xaa, 0xaa, 0xaa), (0x55, 0x55, 0x55), (0x00, 0x00, 0x00),
+       (0xb7, 0xc7, 0x8d), (0xb1, 0xc1, 0x87), (0xd9, 0xd3, 0xc2)]
+GROUND1, GROUND2, PATH = 4, 5, 6
+
+inside = lambda x, y: 0 <= x < 64 and 0 <= y < 48 and rows[y][x] != '.'
+col = lambda x, y: int(rows[y][x])
+
+D = 26                      # wall depth in voxels
+vox = {}
+
+def interior_color(sx, sy):
+    """side faces shouldn't be solid outline-black: sample inward past the outline"""
+    c = col(sx, sy)
+    if c != BLACK:
+        return c
+    step = 1 if sx < 32 else -1
+    for d in range(1, 4):
+        nx = sx + step * d
+        if inside(nx, sy) and col(nx, sy) != BLACK:
+            return col(nx, sy)
+    return c
+
+# ---- walls: sprite rows 16..47 extruded straight back --------------------
+for sy in range(16, 48):
+    y = 47 - sy
+    for sx in range(64):
+        if not inside(sx, sy):
+            continue
+        front, mid = col(sx, sy), interior_color(sx, sy)
+        for z in range(D):
+            vox[(sx, y, z)] = front if (z == 0 or z == D - 1) else mid
+
+# ---- awning ledge: rows 24..31 protrude 2 front and back -----------------
+for sy in range(24, 32):
+    y = 47 - sy
+    for sx in range(64):
+        if inside(sx, sy):
+            c = col(sx, sy)
+            for z in (-2, -1, D, D + 1):
+                vox[(sx, y, z)] = c
+
+# ---- recess panes: delete the front voxel of every non-black pixel -------
+RECESS = [(16, 23, 17, 23), (32, 39, 17, 23), (40, 47, 17, 23),   # gable windows
+          (32, 39, 33, 39), (40, 47, 33, 39),                      # ground windows
+          (19, 28, 34, 45)]                                        # door interior
+for x0, x1, y0, y1 in RECESS:
+    for sy in range(y0, y1 + 1):
+        for sx in range(x0, x1 + 1):
+            if inside(sx, sy) and col(sx, sy) != BLACK:
+                vox.pop((sx, 47 - sy, 0), None)
+
+# ---- roof: flat top over x16..47, DIAGONAL sloped ends (1 down per 2 out)
+ROOF_Z0, ROOF_Z1 = -4, 29
+ROOF_X0, ROOF_X1 = -4, 67
+YTOP = 35
+
+def T(x):                              # elevation profile = the sprite's / \ taper
+    if x < 16: return YTOP - ((16 - x + 1) // 2)
+    if x > 47: return YTOP - ((x - 47 + 1) // 2)
+    return YTOP                        # [2,1]/[3,1]: horizontal, flat
+
+def roof_sy(z):
+    df, db = z - ROOF_Z0, ROOF_Z1 - z
+    if db <= 6: return db              # back rows 0..6 (rim + courses)
+    if df <= 8: return 15 - df         # front rows 15..7 (fascia + rim)
+    return 5 + ((df - 9) % 4)          # middle: cycle rows 5..8 (course rhythm)
+
+def roof_col(x, z):
+    sy = roof_sy(z)
+    if 0 <= x < 64 and inside(x, sy):
+        return col(x, sy)              # real sprite pixels: slats on ends, courses on top
+    if x < 16: return WHITE if x % 3 == 1 else DARK
+    if x > 47: return WHITE if x % 3 == 2 else DARK
+    return GREY
+
+for x in range(ROOF_X0, ROOF_X1 + 1):
+    t = T(x)
+    for z in range(ROOF_Z0, ROOF_Z1 + 1):
+        outer = x in (ROOF_X0, ROOF_X1) or z in (ROOF_Z0, ROOF_Z1)
+        for y in range(t - 3, t + 1):
+            if y == t:
+                c = BLACK if outer else roof_col(x, z)
+            elif outer:
+                c = GREY if y == t - 1 else BLACK
+            else:
+                c = DARK
+            vox[(x, y, z)] = c
+
+# trim wall corners that would poke above the sloped ends
+for x in range(0, 64):
+    t = T(x)
+    if t < 31:
+        for y in range(t + 1, 32):
+            for z in range(0, D):
+                vox.pop((x, y, z), None)
+
+# ---- ground plate + path -------------------------------------------------
+for x in range(-10, 75):
+    for z in range(-12, 39):
+        vox[(x, -1, z)] = GROUND1 if (x + z) & 1 else GROUND2
+for x in range(17, 31):
+    for z in range(-12, 0):
+        vox[(x, -1, z)] = PATH
+
+# ---- cull to shell -------------------------------------------------------
+shell = []
+for (x, y, z), c in vox.items():
+    if all((x + dx, y + dy, z + dz) in vox for dx, dy, dz in
+           ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1))):
+        continue
+    shell.append((x, y, z, c))
+print('voxels:', len(vox), 'shell:', len(shell))
+
+# ---- isometric preview (front = -z, so mirror z for the render) ----------
+S = 4
+ZM = 40
+pts = []
+for x, y, z, c in shell:
+    pts.append((x, y, ZM - z, c))
+pts.sort(key=lambda p: (p[0] + p[2], p[1]))
+
+def P(x, y, z):
+    return (2 * (x - z) * S + 1250, ((x + z) - 2 * y) * S + 300)
+
+img = Image.new('RGB', (1800, 900), (0xca, 0xdc, 0x9f))
+dr = ImageDraw.Draw(img)
+has = {(x, y, z) for x, y, z, c in pts}
+def shade(c, f):
+    r, g, b = PAL[c]
+    return (int(r * f), int(g * f), int(b * f))
+for x, y, z, c in pts:
+    if (x, y + 1, z) not in has:   # top
+        dr.polygon([P(x, y + 1, z), P(x + 1, y + 1, z), P(x + 1, y + 1, z + 1), P(x, y + 1, z + 1)], fill=shade(c, 1.0))
+    if (x + 1, y, z) not in has:   # right (+x)
+        dr.polygon([P(x + 1, y, z), P(x + 1, y + 1, z), P(x + 1, y + 1, z + 1), P(x + 1, y, z + 1)], fill=shade(c, 0.62))
+    if (x, y, z + 1) not in has:   # toward viewer (original -z front)
+        dr.polygon([P(x, y, z + 1), P(x + 1, y, z + 1), P(x + 1, y + 1, z + 1), P(x, y + 1, z + 1)], fill=shade(c, 0.82))
+img.save('preview_front.png')
+
+# second angle: from the back-right, no mirror
+pts2 = sorted(((x, y, z, c) for x, y, z, c in shell), key=lambda p: (p[0] + p[2], p[1]))
+img2 = Image.new('RGB', (1800, 900), (0xca, 0xdc, 0x9f))
+dr2 = ImageDraw.Draw(img2)
+has2 = {(x, y, z) for x, y, z, c in pts2}
+for x, y, z, c in pts2:
+    if (x, y + 1, z) not in has2:
+        dr2.polygon([P(x, y + 1, z), P(x + 1, y + 1, z), P(x + 1, y + 1, z + 1), P(x, y + 1, z + 1)], fill=shade(c, 1.0))
+    if (x + 1, y, z) not in has2:
+        dr2.polygon([P(x + 1, y, z), P(x + 1, y + 1, z), P(x + 1, y + 1, z + 1), P(x + 1, y, z + 1)], fill=shade(c, 0.62))
+    if (x, y, z + 1) not in has2:
+        dr2.polygon([P(x, y, z + 1), P(x + 1, y, z + 1), P(x + 1, y + 1, z + 1), P(x, y + 1, z + 1)], fill=shade(c, 0.82))
+img2.save('preview_back.png')
+print('previews written')
diff --git a/assets/docs/buildings/B01-unnamed-building.md b/assets/docs/buildings/B01-unnamed-building.md
new file mode 100644
index 0000000..fe92a26
--- /dev/null
+++ b/assets/docs/buildings/B01-unnamed-building.md
@@ -0,0 +1,129 @@
+# B01 - Unnamed building
+
+![Unnamed building](img/B01_x10.png)
+
+`OVERWORLD` tileset, **4 x 3 cells** (8 x 6 tiles of 8px, 64 x 48 px). Appears **19 times** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..4, y=1..3; tile column c=1..8, tile row r=1..6. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4 
+      +-----+-----+-----+-----+
+   y1 | A B | C C | C C | D E |
+      | F G | H H | H H | I J |
+      +-----+-----+-----+-----+
+   y2 | K L | M M | M M | N O |
+      | P Q | Q Q | R R | R S |
+      +-----+-----+-----+-----+
+   y3 | P M | M M | M M | M S |
+      | T U | U U | U U | U V |
+      +-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4   
+      +---------+---------+---------+---------+
+   y1 |   5   6 |   7   7 |   7   7 |   8   9 |
+      |  21  22 |  23  23 |  23  23 |  24  25 |
+      +---------+---------+---------+---------+
+   y2 |  37  38 |  10  10 |  10  10 |  40  41 |
+      |  15  34 |  34  34 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+
+   y3 |  15  10 |  10  10 |  10  10 |  10  31 |
+      |  78  26 |  26  26 |  26  26 |  26  79 |
+      +---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {   5,   6,   7,   7,   7,   7,   8,   9 },  -- r1
+  {  21,  22,  23,  23,  23,  23,  24,  25 },  -- r2
+  {  37,  38,  10,  10,  10,  10,  40,  41 },  -- r3
+  {  15,  34,  34,  34,  75,  75,  75,  31 },  -- r4
+  {  15,  10,  10,  10,  10,  10,  10,  31 },  -- r5
+  {  78,  26,  26,  26,  26,  26,  26,  79 },  -- r6
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `A` = tile 5, used 1x
+- `B` = tile 6, used 1x
+- `C` = tile 7, used 4x
+- `D` = tile 8, used 1x
+- `E` = tile 9, used 1x
+- `F` = tile 21, used 1x
+- `G` = tile 22, used 1x
+- `H` = tile 23, used 4x
+- `I` = tile 24, used 1x
+- `J` = tile 25, used 1x
+- `K` = tile 37, used 1x
+- `L` = tile 38, used 1x
+- `M` = tile 10, used 10x
+- `N` = tile 40, used 1x
+- `O` = tile 41, used 1x
+- `P` = tile 15, used 2x
+- `Q` = tile 34, used 3x
+- `R` = tile 75, used 3x
+- `S` = tile 31, used 2x
+- `T` = tile 78, used 1x
+- `U` = tile 26, used 6x
+- `V` = tile 79, used 1x
+
+![distinct tiles](img/B01_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID
+```
+
+Every cell is solid - this building has no door of its own.
+
+## Silhouette
+
+186 of the 3072 px inside the box are ground outside the black outline, so the 64x48 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:48px, c2:16px, c7:16px, c8:48px
+    r3   c1:5px, c8:5px
+    r4   c1:8px, c8:8px
+    r5   c1:8px, c8:8px
+    r6   c1:8px, c8:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| SAFFRON_CITY | (36,1) | - | scenery, no entrance |
+| SAFFRON_CITY | (2,3) | - | scenery, no entrance |
+| SAFFRON_CITY | (10,3) | - | scenery, no entrance |
+| SAFFRON_CITY | (4,9) | - | scenery, no entrance |
+| SAFFRON_CITY | (8,9) | - | scenery, no entrance |
+| SAFFRON_CITY | (28,9) | - | scenery, no entrance |
+| SAFFRON_CITY | (32,9) | - | scenery, no entrance |
+| SAFFRON_CITY | (4,27) | - | scenery, no entrance |
+| SAFFRON_CITY | (12,27) | - | scenery, no entrance |
+| SAFFRON_CITY | (16,27) | - | scenery, no entrance |
+| SAFFRON_CITY | (20,27) | - | scenery, no entrance |
+| SAFFRON_CITY | (32,27) | - | scenery, no entrance |
+| SAFFRON_CITY | (4,33) | - | scenery, no entrance |
+| SAFFRON_CITY | (8,33) | - | scenery, no entrance |
+| SAFFRON_CITY | (12,33) | - | scenery, no entrance |
+| SAFFRON_CITY | (16,33) | - | scenery, no entrance |
+| SAFFRON_CITY | (24,33) | - | scenery, no entrance |
+| SAFFRON_CITY | (28,33) | - | scenery, no entrance |
+| SAFFRON_CITY | (32,33) | - | scenery, no entrance |
diff --git a/assets/docs/buildings/B02-unnamed-building.md b/assets/docs/buildings/B02-unnamed-building.md
new file mode 100644
index 0000000..bd721b2
--- /dev/null
+++ b/assets/docs/buildings/B02-unnamed-building.md
@@ -0,0 +1,130 @@
+# B02 - Unnamed building
+
+![Unnamed building](img/B02_x10.png)
+
+`OVERWORLD` tileset, **4 x 4 cells** (8 x 8 tiles of 8px, 64 x 64 px). Appears **15 times** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..4, y=1..4; tile column c=1..8, tile row r=1..8. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4 
+      +-----+-----+-----+-----+
+   y1 | A B | B B | B B | B C |
+      | D E | E E | E E | E D |
+      +-----+-----+-----+-----+
+   y2 | D E | E E | E E | E D |
+      | F G | G G | G G | G H |
+      +-----+-----+-----+-----+
+   y3 | I J | J J | J J | J K |
+      | I L | L L | L L | L K |
+      +-----+-----+-----+-----+
+   y4 | I L | L L | L L | L K |
+      | M N | N N | N N | N O |
+      +-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4   
+      +---------+---------+---------+---------+
+   y1 |  76  83 |  83  83 |  83  83 |  83  77 |
+      |  90  18 |  18  18 |  18  18 |  18  90 |
+      +---------+---------+---------+---------+
+   y2 |  90  18 |  18  18 |  18  18 |  18  90 |
+      |  92  23 |  23  23 |  23  23 |  23  93 |
+      +---------+---------+---------+---------+
+   y3 |  15  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+
+   y4 |  15  75 |  75  75 |  75  75 |  75  31 |
+      |  78  26 |  26  26 |  26  26 |  26  79 |
+      +---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {  76,  83,  83,  83,  83,  83,  83,  77 },  -- r1
+  {  90,  18,  18,  18,  18,  18,  18,  90 },  -- r2
+  {  90,  18,  18,  18,  18,  18,  18,  90 },  -- r3
+  {  92,  23,  23,  23,  23,  23,  23,  93 },  -- r4
+  {  15,  10,  10,  10,  10,  10,  10,  31 },  -- r5
+  {  15,  75,  75,  75,  75,  75,  75,  31 },  -- r6
+  {  15,  75,  75,  75,  75,  75,  75,  31 },  -- r7
+  {  78,  26,  26,  26,  26,  26,  26,  79 },  -- r8
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `A` = tile 76, used 1x
+- `B` = tile 83, used 6x
+- `C` = tile 77, used 1x
+- `D` = tile 90, used 4x
+- `E` = tile 18, used 12x
+- `F` = tile 92, used 1x
+- `G` = tile 23, used 6x
+- `H` = tile 93, used 1x
+- `I` = tile 15, used 3x
+- `J` = tile 10, used 6x
+- `K` = tile 31, used 3x
+- `L` = tile 75, used 12x
+- `M` = tile 78, used 1x
+- `N` = tile 26, used 6x
+- `O` = tile 79, used 1x
+
+![distinct tiles](img/B02_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID
+    y4   SOLID  SOLID  SOLID  SOLID
+```
+
+Every cell is solid - this building has no door of its own.
+
+## Silhouette
+
+258 of the 4096 px inside the box are ground outside the black outline, so the 64x64 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:25px, c8:25px
+    r2   c1:24px, c8:24px
+    r3   c1:24px, c8:24px
+    r4   c1:24px, c8:24px
+    r5   c1:8px, c8:8px
+    r6   c1:8px, c8:8px
+    r7   c1:8px, c8:8px
+    r8   c1:8px, c8:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| CELADON_CITY | (28,6) | - | scenery, no entrance |
+| CELADON_CITY | (32,6) | - | scenery, no entrance |
+| CELADON_CITY | (36,6) | - | scenery, no entrance |
+| CELADON_CITY | (38,16) | (39,19) | `CELADON_MART_5F` |
+| CELADON_CITY | (42,16) | - | scenery, no entrance |
+| CELADON_CITY | (2,24) | - | scenery, no entrance |
+| CELADON_CITY | (14,24) | - | scenery, no entrance |
+| CELADON_CITY | (18,24) | - | scenery, no entrance |
+| CELADON_CITY | (26,24) | - | scenery, no entrance |
+| CELADON_CITY | (38,24) | - | scenery, no entrance |
+| CELADON_CITY | (18,28) | - | scenery, no entrance |
+| SAFFRON_CITY | (0,32) | - | scenery, no entrance |
+| SAFFRON_CITY | (36,32) | - | scenery, no entrance |
+| VERMILION_CITY | (14,0) | - | scenery, no entrance |
+| VERMILION_CITY | (20,0) | - | scenery, no entrance |
diff --git a/assets/docs/buildings/B03-unnamed-building.md b/assets/docs/buildings/B03-unnamed-building.md
new file mode 100644
index 0000000..0a26fda
--- /dev/null
+++ b/assets/docs/buildings/B03-unnamed-building.md
@@ -0,0 +1,134 @@
+# B03 - Unnamed building
+
+![Unnamed building](img/B03_x10.png)
+
+`OVERWORLD` tileset, **4 x 4 cells** (8 x 8 tiles of 8px, 64 x 64 px). Appears **15 times** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..4, y=1..4; tile column c=1..8, tile row r=1..8. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4 
+      +-----+-----+-----+-----+
+   y1 | A B | B B | B B | B C |
+      | D E | E E | E E | E D |
+      +-----+-----+-----+-----+
+   y2 | D E | E E | E E | E D |
+      | F G | G G | G G | G H |
+      +-----+-----+-----+-----+
+   y3 | I J | J J | J J | J K |
+      | I L | L L | L L | L K |
+      +-----+-----+-----+-----+
+   y4 | I L | M N | L L | L K |
+      | O P | * Q | P P | P R |
+      +-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4   
+      +---------+---------+---------+---------+
+   y1 |  76  83 |  83  83 |  83  83 |  83  77 |
+      |  90  18 |  18  18 |  18  18 |  18  90 |
+      +---------+---------+---------+---------+
+   y2 |  90  18 |  18  18 |  18  18 |  18  90 |
+      |  92  23 |  23  23 |  23  23 |  23  93 |
+      +---------+---------+---------+---------+
+   y3 |  15  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+
+   y4 |  15  75 |  11  12 |  75  75 |  75  31 |
+      |  78  26 |  27  28 |  26  26 |  26  79 |
+      +---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {  76,  83,  83,  83,  83,  83,  83,  77 },  -- r1
+  {  90,  18,  18,  18,  18,  18,  18,  90 },  -- r2
+  {  90,  18,  18,  18,  18,  18,  18,  90 },  -- r3
+  {  92,  23,  23,  23,  23,  23,  23,  93 },  -- r4
+  {  15,  10,  10,  10,  10,  10,  10,  31 },  -- r5
+  {  15,  75,  75,  75,  75,  75,  75,  31 },  -- r6
+  {  15,  75,  11,  12,  75,  75,  75,  31 },  -- r7
+  {  78,  26,  27,  28,  26,  26,  26,  79 },  -- r8
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 1x
+- `A` = tile 76, used 1x
+- `B` = tile 83, used 6x
+- `C` = tile 77, used 1x
+- `D` = tile 90, used 4x
+- `E` = tile 18, used 12x
+- `F` = tile 92, used 1x
+- `G` = tile 23, used 6x
+- `H` = tile 93, used 1x
+- `I` = tile 15, used 3x
+- `J` = tile 10, used 6x
+- `K` = tile 31, used 3x
+- `L` = tile 75, used 10x
+- `M` = tile 11, used 1x
+- `N` = tile 12, used 1x
+- `O` = tile 78, used 1x
+- `P` = tile 26, used 4x
+- `Q` = tile 28, used 1x
+- `R` = tile 79, used 1x
+
+![distinct tiles](img/B03_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID
+    y4   SOLID  DOOR   SOLID  SOLID
+```
+
+Enterable cell: (2,4).
+
+## Silhouette
+
+258 of the 4096 px inside the box are ground outside the black outline, so the 64x64 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:25px, c8:25px
+    r2   c1:24px, c8:24px
+    r3   c1:24px, c8:24px
+    r4   c1:24px, c8:24px
+    r5   c1:8px, c8:8px
+    r6   c1:8px, c8:8px
+    r7   c1:8px, c8:8px
+    r8   c1:8px, c8:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| CELADON_CITY | (32,16) | (33,19) | `GAME_CORNER_PRIZE_ROOM` |
+| CELADON_CITY | (30,24) | (31,27) | `CELADON_DINER` |
+| CELADON_CITY | (34,24) | (35,27) | `CELADON_CHIEF_HOUSE` |
+| CELADON_CITY | (42,24) | (43,27) | `CELADON_HOTEL` |
+| CERULEAN_CITY | (12,22) | (13,25) | `BIKE_SHOP` |
+| ROUTE_2 | (2,12) | - | scenery, no entrance |
+| ROUTE_2 | (2,40) | (3,43) | `VIRIDIAN_FOREST_SOUTH_GATE` |
+| ROUTE_5 | (16,24) | (17,27) | `UNDERGROUND_PATH_ROUTE_5` |
+| ROUTE_6 | (16,10) | (17,13) | `UNDERGROUND_PATH_ROUTE_6` |
+| ROUTE_7 | (4,10) | (5,13) | `UNDERGROUND_PATH_ROUTE_7` |
+| SAFFRON_CITY | (28,26) | (29,29) | `MR_PSYCHICS_HOUSE` |
+| VERMILION_CITY | (6,0) | (7,3) | `VERMILION_OLD_ROD_HOUSE` |
+| VERMILION_CITY | (8,10) | (9,13) | `POKEMON_FAN_CLUB` |
+| VERMILION_CITY | (14,10) | (15,13) | `VERMILION_TRADE_HOUSE` |
+| VERMILION_CITY | (22,16) | (23,19) | `VERMILION_PIDGEY_HOUSE` |
diff --git a/assets/docs/buildings/B04-unnamed-building.md b/assets/docs/buildings/B04-unnamed-building.md
new file mode 100644
index 0000000..07f631f
--- /dev/null
+++ b/assets/docs/buildings/B04-unnamed-building.md
@@ -0,0 +1,111 @@
+# B04 - Unnamed building
+
+![Unnamed building](img/B04_x10.png)
+
+`OVERWORLD` tileset, **4 x 2 cells** (8 x 4 tiles of 8px, 64 x 32 px). Appears **12 times** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..4, y=1..2; tile column c=1..8, tile row r=1..4. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4 
+      +-----+-----+-----+-----+
+   y1 | A B | C C | C C | D E |
+      | F G | H H | H H | I J |
+      +-----+-----+-----+-----+
+   y2 | K L | M N | O O | P Q |
+      | R S | * T | S S | S U |
+      +-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4   
+      +---------+---------+---------+---------+
+   y1 |   5   6 |   7   7 |   7   7 |   8   9 |
+      |  21  22 |  23  23 |  23  23 |  24  25 |
+      +---------+---------+---------+---------+
+   y2 |  37  38 |  11  12 |  10  10 |  40  41 |
+      |  78  26 |  27  28 |  26  26 |  26  79 |
+      +---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {   5,   6,   7,   7,   7,   7,   8,   9 },  -- r1
+  {  21,  22,  23,  23,  23,  23,  24,  25 },  -- r2
+  {  37,  38,  11,  12,  10,  10,  40,  41 },  -- r3
+  {  78,  26,  27,  28,  26,  26,  26,  79 },  -- r4
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 1x
+- `A` = tile 5, used 1x
+- `B` = tile 6, used 1x
+- `C` = tile 7, used 4x
+- `D` = tile 8, used 1x
+- `E` = tile 9, used 1x
+- `F` = tile 21, used 1x
+- `G` = tile 22, used 1x
+- `H` = tile 23, used 4x
+- `I` = tile 24, used 1x
+- `J` = tile 25, used 1x
+- `K` = tile 37, used 1x
+- `L` = tile 38, used 1x
+- `M` = tile 11, used 1x
+- `N` = tile 12, used 1x
+- `O` = tile 10, used 2x
+- `P` = tile 40, used 1x
+- `Q` = tile 41, used 1x
+- `R` = tile 78, used 1x
+- `S` = tile 26, used 4x
+- `T` = tile 28, used 1x
+- `U` = tile 79, used 1x
+
+![distinct tiles](img/B04_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  DOOR   SOLID  SOLID
+```
+
+Enterable cell: (2,2).
+
+## Silhouette
+
+154 of the 2048 px inside the box are ground outside the black outline, so the 64x32 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:48px, c2:16px, c7:16px, c8:48px
+    r3   c1:5px, c8:5px
+    r4   c1:8px, c8:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| FUCHSIA_CITY | (10,26) | (11,27) | `FUCHSIA_BILLS_GRANDPAS_HOUSE` |
+| LAVENDER_TOWN | (6,8) | (7,9) | `MR_FUJIS_HOUSE` |
+| LAVENDER_TOWN | (2,12) | (3,13) | `LAVENDER_CUBONE_HOUSE` |
+| LAVENDER_TOWN | (6,12) | (7,13) | `NAME_RATERS_HOUSE` |
+| PEWTER_CITY | (28,12) | (29,13) | `PEWTER_NIDORAN_HOUSE` |
+| PEWTER_CITY | (6,28) | (7,29) | `PEWTER_SPEECH_HOUSE` |
+| ROUTE_12 | (10,76) | (11,77) | `ROUTE_12_SUPER_ROD_HOUSE` |
+| ROUTE_16 | (6,4) | (7,5) | `ROUTE_16_FLY_HOUSE` |
+| ROUTE_2 | (14,18) | (15,19) | `ROUTE_2_TRADE_HOUSE` |
+| ROUTE_8 | (12,2) | (13,3) | `UNDERGROUND_PATH_ROUTE_8` |
+| VIRIDIAN_CITY | (20,8) | (21,9) | `VIRIDIAN_NICKNAME_HOUSE` |
+| VIRIDIAN_CITY | (20,14) | (21,15) | `VIRIDIAN_SCHOOL_HOUSE` |
diff --git a/assets/docs/buildings/B05-unnamed-building.md b/assets/docs/buildings/B05-unnamed-building.md
new file mode 100644
index 0000000..cf9898e
--- /dev/null
+++ b/assets/docs/buildings/B05-unnamed-building.md
@@ -0,0 +1,133 @@
+# B05 - Unnamed building
+
+![Unnamed building](img/B05_x10.png)
+
+`OVERWORLD` tileset, **4 x 4 cells** (8 x 8 tiles of 8px, 64 x 64 px). Appears **11 times** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..4, y=1..4; tile column c=1..8, tile row r=1..8. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4 
+      +-----+-----+-----+-----+
+   y1 | A B | B B | B B | B C |
+      | D E | E E | E E | E D |
+      +-----+-----+-----+-----+
+   y2 | D E | E E | E E | E D |
+      | F G | G G | G G | G H |
+      +-----+-----+-----+-----+
+   y3 | I J | J J | J J | J K |
+      | I L | L L | L L | L K |
+      +-----+-----+-----+-----+
+   y4 | I L | M N | O P | L K |
+      | Q R | * S | T T | R U |
+      +-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4   
+      +---------+---------+---------+---------+
+   y1 |  76  83 |  83  83 |  83  83 |  83  77 |
+      |  90  18 |  18  18 |  18  18 |  18  90 |
+      +---------+---------+---------+---------+
+   y2 |  90  18 |  18  18 |  18  18 |  18  90 |
+      |  92  23 |  23  23 |  23  23 |  23  93 |
+      +---------+---------+---------+---------+
+   y3 |  15  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+
+   y4 |  15  75 |  11  12 |  66  67 |  75  31 |
+      |  78  26 |  27  28 |  74  74 |  26  79 |
+      +---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {  76,  83,  83,  83,  83,  83,  83,  77 },  -- r1
+  {  90,  18,  18,  18,  18,  18,  18,  90 },  -- r2
+  {  90,  18,  18,  18,  18,  18,  18,  90 },  -- r3
+  {  92,  23,  23,  23,  23,  23,  23,  93 },  -- r4
+  {  15,  10,  10,  10,  10,  10,  10,  31 },  -- r5
+  {  15,  75,  75,  75,  75,  75,  75,  31 },  -- r6
+  {  15,  75,  11,  12,  66,  67,  75,  31 },  -- r7
+  {  78,  26,  27,  28,  74,  74,  26,  79 },  -- r8
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 1x
+- `A` = tile 76, used 1x
+- `B` = tile 83, used 6x
+- `C` = tile 77, used 1x
+- `D` = tile 90, used 4x
+- `E` = tile 18, used 12x
+- `F` = tile 92, used 1x
+- `G` = tile 23, used 6x
+- `H` = tile 93, used 1x
+- `I` = tile 15, used 3x
+- `J` = tile 10, used 6x
+- `K` = tile 31, used 3x
+- `L` = tile 75, used 8x
+- `M` = tile 11, used 1x
+- `N` = tile 12, used 1x
+- `O` = tile 66, used 1x
+- `P` = tile 67, used 1x
+- `Q` = tile 78, used 1x
+- `R` = tile 26, used 2x
+- `S` = tile 28, used 1x
+- `T` = tile 74, used 2x
+- `U` = tile 79, used 1x
+
+![distinct tiles](img/B05_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID
+    y4   SOLID  DOOR   SOLID  SOLID
+```
+
+Enterable cell: (2,4).
+
+## Silhouette
+
+258 of the 4096 px inside the box are ground outside the black outline, so the 64x64 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:25px, c8:25px
+    r2   c1:24px, c8:24px
+    r3   c1:24px, c8:24px
+    r4   c1:24px, c8:24px
+    r5   c1:8px, c8:8px
+    r6   c1:8px, c8:8px
+    r7   c1:8px, c8:8px
+    r8   c1:8px, c8:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| CELADON_CITY | (40,6) | (41,9) | `CELADON_POKECENTER` |
+| CERULEAN_CITY | (18,14) | (19,17) | `CERULEAN_POKECENTER` |
+| CINNABAR_ISLAND | (10,8) | (11,11) | `CINNABAR_POKECENTER` |
+| FUCHSIA_CITY | (18,24) | (19,27) | `FUCHSIA_POKECENTER` |
+| LAVENDER_TOWN | (2,2) | (3,5) | `LAVENDER_POKECENTER` |
+| PEWTER_CITY | (12,22) | (13,25) | `PEWTER_POKECENTER` |
+| ROUTE_10 | (10,16) | (11,19) | `ROCK_TUNNEL_POKECENTER` |
+| ROUTE_4 | (10,2) | (11,5) | `MT_MOON_POKECENTER` |
+| SAFFRON_CITY | (8,26) | (9,29) | `SAFFRON_POKECENTER` |
+| VERMILION_CITY | (10,0) | (11,3) | `VERMILION_POKECENTER` |
+| VIRIDIAN_CITY | (22,22) | (23,25) | `VIRIDIAN_POKECENTER` |
diff --git a/assets/docs/buildings/B06-unnamed-building.md b/assets/docs/buildings/B06-unnamed-building.md
new file mode 100644
index 0000000..c2fba44
--- /dev/null
+++ b/assets/docs/buildings/B06-unnamed-building.md
@@ -0,0 +1,130 @@
+# B06 - Unnamed building
+
+![Unnamed building](img/B06_x10.png)
+
+`OVERWORLD` tileset, **4 x 4 cells** (8 x 8 tiles of 8px, 64 x 64 px). Appears **8 times** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..4, y=1..4; tile column c=1..8, tile row r=1..8. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4 
+      +-----+-----+-----+-----+
+   y1 | A B | B B | B B | B C |
+      | D E | E E | E E | E D |
+      +-----+-----+-----+-----+
+   y2 | D E | E E | E E | E D |
+      | F G | G G | G G | G H |
+      +-----+-----+-----+-----+
+   y3 | I J | J J | J J | J K |
+      | I L | L L | L L | L K |
+      +-----+-----+-----+-----+
+   y4 | I L | M N | O P | L K |
+      | Q R | * S | T T | R U |
+      +-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4   
+      +---------+---------+---------+---------+
+   y1 |  76  83 |  83  83 |  83  83 |  83  77 |
+      |  90  18 |  18  18 |  18  18 |  18  90 |
+      +---------+---------+---------+---------+
+   y2 |  90  18 |  18  18 |  18  18 |  18  90 |
+      |  92  23 |  23  23 |  23  23 |  23  93 |
+      +---------+---------+---------+---------+
+   y3 |  15  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+
+   y4 |  15  75 |  11  12 |  68  69 |  75  31 |
+      |  78  26 |  27  28 |  74  74 |  26  79 |
+      +---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {  76,  83,  83,  83,  83,  83,  83,  77 },  -- r1
+  {  90,  18,  18,  18,  18,  18,  18,  90 },  -- r2
+  {  90,  18,  18,  18,  18,  18,  18,  90 },  -- r3
+  {  92,  23,  23,  23,  23,  23,  23,  93 },  -- r4
+  {  15,  10,  10,  10,  10,  10,  10,  31 },  -- r5
+  {  15,  75,  75,  75,  75,  75,  75,  31 },  -- r6
+  {  15,  75,  11,  12,  68,  69,  75,  31 },  -- r7
+  {  78,  26,  27,  28,  74,  74,  26,  79 },  -- r8
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 1x
+- `A` = tile 76, used 1x
+- `B` = tile 83, used 6x
+- `C` = tile 77, used 1x
+- `D` = tile 90, used 4x
+- `E` = tile 18, used 12x
+- `F` = tile 92, used 1x
+- `G` = tile 23, used 6x
+- `H` = tile 93, used 1x
+- `I` = tile 15, used 3x
+- `J` = tile 10, used 6x
+- `K` = tile 31, used 3x
+- `L` = tile 75, used 8x
+- `M` = tile 11, used 1x
+- `N` = tile 12, used 1x
+- `O` = tile 68, used 1x
+- `P` = tile 69, used 1x
+- `Q` = tile 78, used 1x
+- `R` = tile 26, used 2x
+- `S` = tile 28, used 1x
+- `T` = tile 74, used 2x
+- `U` = tile 79, used 1x
+
+![distinct tiles](img/B06_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID
+    y4   SOLID  DOOR   SOLID  SOLID
+```
+
+Enterable cell: (2,4).
+
+## Silhouette
+
+258 of the 4096 px inside the box are ground outside the black outline, so the 64x64 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:25px, c8:25px
+    r2   c1:24px, c8:24px
+    r3   c1:24px, c8:24px
+    r4   c1:24px, c8:24px
+    r5   c1:8px, c8:8px
+    r6   c1:8px, c8:8px
+    r7   c1:8px, c8:8px
+    r8   c1:8px, c8:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| CERULEAN_CITY | (24,22) | (25,25) | `CERULEAN_MART` |
+| CINNABAR_ISLAND | (14,8) | (15,11) | `CINNABAR_MART` |
+| FUCHSIA_CITY | (4,10) | (5,13) | `FUCHSIA_MART` |
+| LAVENDER_TOWN | (14,10) | (15,13) | `LAVENDER_MART` |
+| PEWTER_CITY | (22,14) | (23,17) | `PEWTER_MART` |
+| SAFFRON_CITY | (24,8) | (25,11) | `SAFFRON_MART` |
+| VERMILION_CITY | (22,10) | (23,13) | `VERMILION_MART` |
+| VIRIDIAN_CITY | (28,16) | (29,19) | `VIRIDIAN_MART` |
diff --git a/assets/docs/buildings/B07-unnamed-building.md b/assets/docs/buildings/B07-unnamed-building.md
new file mode 100644
index 0000000..746537b
--- /dev/null
+++ b/assets/docs/buildings/B07-unnamed-building.md
@@ -0,0 +1,122 @@
+# B07 - Unnamed building
+
+![Unnamed building](img/B07_x10.png)
+
+`OVERWORLD` tileset, **4 x 3 cells** (8 x 6 tiles of 8px, 64 x 48 px). Appears **7 times** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..4, y=1..3; tile column c=1..8, tile row r=1..6. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4 
+      +-----+-----+-----+-----+
+   y1 | A B | C C | C C | D E |
+      | F G | H H | H H | I J |
+      +-----+-----+-----+-----+
+   y2 | K L | M N | M M | O P |
+      | Q H | H H | H H | H R |
+      +-----+-----+-----+-----+
+   y3 | S N | T U | M M | N V |
+      | W X | * Y | X X | X Z |
+      +-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4   
+      +---------+---------+---------+---------+
+   y1 |   5   6 |   7   7 |   7   7 |   8   9 |
+      |  21  22 |  23  23 |  23  23 |  24  25 |
+      +---------+---------+---------+---------+
+   y2 |  37  38 |  10  34 |  10  10 |  40  41 |
+      |  92  23 |  23  23 |  23  23 |  23  93 |
+      +---------+---------+---------+---------+
+   y3 |  15  34 |  11  12 |  10  10 |  34  31 |
+      |  78  26 |  27  28 |  26  26 |  26  79 |
+      +---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {   5,   6,   7,   7,   7,   7,   8,   9 },  -- r1
+  {  21,  22,  23,  23,  23,  23,  24,  25 },  -- r2
+  {  37,  38,  10,  34,  10,  10,  40,  41 },  -- r3
+  {  92,  23,  23,  23,  23,  23,  23,  93 },  -- r4
+  {  15,  34,  11,  12,  10,  10,  34,  31 },  -- r5
+  {  78,  26,  27,  28,  26,  26,  26,  79 },  -- r6
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 1x
+- `A` = tile 5, used 1x
+- `B` = tile 6, used 1x
+- `C` = tile 7, used 4x
+- `D` = tile 8, used 1x
+- `E` = tile 9, used 1x
+- `F` = tile 21, used 1x
+- `G` = tile 22, used 1x
+- `H` = tile 23, used 10x
+- `I` = tile 24, used 1x
+- `J` = tile 25, used 1x
+- `K` = tile 37, used 1x
+- `L` = tile 38, used 1x
+- `M` = tile 10, used 5x
+- `N` = tile 34, used 3x
+- `O` = tile 40, used 1x
+- `P` = tile 41, used 1x
+- `Q` = tile 92, used 1x
+- `R` = tile 93, used 1x
+- `S` = tile 15, used 1x
+- `T` = tile 11, used 1x
+- `U` = tile 12, used 1x
+- `V` = tile 31, used 1x
+- `W` = tile 78, used 1x
+- `X` = tile 26, used 4x
+- `Y` = tile 28, used 1x
+- `Z` = tile 79, used 1x
+
+![distinct tiles](img/B07_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  DOOR   SOLID  SOLID
+```
+
+Enterable cell: (2,3).
+
+## Silhouette
+
+218 of the 3072 px inside the box are ground outside the black outline, so the 64x48 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:48px, c2:16px, c7:16px, c8:48px
+    r3   c1:5px, c8:5px
+    r4   c1:24px, c8:24px
+    r5   c1:8px, c8:8px
+    r6   c1:8px, c8:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| FUCHSIA_CITY | (26,25) | (27,27) | `WARDENS_HOUSE` |
+| FUCHSIA_CITY | (30,25) | (31,27) | `FUCHSIA_GOOD_ROD_HOUSE` |
+| PALLET_TOWN | (4,3) | (5,5) | `REDS_HOUSE_1F` |
+| PALLET_TOWN | (12,3) | (13,5) | `BLUES_HOUSE` |
+| ROUTE_25 | (44,1) | (45,3) | `BILLS_HOUSE` |
+| SAFFRON_CITY | (6,3) | (7,5) | `COPYCATS_HOUSE_1F` |
+| SAFFRON_CITY | (12,9) | (13,11) | `SAFFRON_PIDGEY_HOUSE` |
diff --git a/assets/docs/buildings/B08-unnamed-building.md b/assets/docs/buildings/B08-unnamed-building.md
new file mode 100644
index 0000000..1f553e4
--- /dev/null
+++ b/assets/docs/buildings/B08-unnamed-building.md
@@ -0,0 +1,143 @@
+# B08 - Unnamed building
+
+![Unnamed building](img/B08_x10.png)
+
+`OVERWORLD` tileset, **4 x 6 cells** (8 x 12 tiles of 8px, 64 x 96 px). Appears **6 times** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..4, y=1..6; tile column c=1..8, tile row r=1..12. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4 
+      +-----+-----+-----+-----+
+   y1 | A B | B B | B B | B C |
+      | D E | E E | E E | E D |
+      +-----+-----+-----+-----+
+   y2 | D E | E E | E E | E D |
+      | F G | G G | G G | G H |
+      +-----+-----+-----+-----+
+   y3 | I J | J J | J J | J K |
+      | I L | L L | L L | L K |
+      +-----+-----+-----+-----+
+   y4 | I J | J J | J J | J K |
+      | I L | L L | L L | L K |
+      +-----+-----+-----+-----+
+   y5 | I J | J J | J J | J K |
+      | I L | L L | L L | L K |
+      +-----+-----+-----+-----+
+   y6 | I L | L L | L L | L K |
+      | M N | N N | N N | N O |
+      +-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4   
+      +---------+---------+---------+---------+
+   y1 |  76  83 |  83  83 |  83  83 |  83  77 |
+      |  90  18 |  18  18 |  18  18 |  18  90 |
+      +---------+---------+---------+---------+
+   y2 |  90  18 |  18  18 |  18  18 |  18  90 |
+      |  92  23 |  23  23 |  23  23 |  23  93 |
+      +---------+---------+---------+---------+
+   y3 |  15  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+
+   y4 |  15  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+
+   y5 |  15  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+
+   y6 |  15  75 |  75  75 |  75  75 |  75  31 |
+      |  78  26 |  26  26 |  26  26 |  26  79 |
+      +---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {  76,  83,  83,  83,  83,  83,  83,  77 },  -- r1
+  {  90,  18,  18,  18,  18,  18,  18,  90 },  -- r2
+  {  90,  18,  18,  18,  18,  18,  18,  90 },  -- r3
+  {  92,  23,  23,  23,  23,  23,  23,  93 },  -- r4
+  {  15,  10,  10,  10,  10,  10,  10,  31 },  -- r5
+  {  15,  75,  75,  75,  75,  75,  75,  31 },  -- r6
+  {  15,  10,  10,  10,  10,  10,  10,  31 },  -- r7
+  {  15,  75,  75,  75,  75,  75,  75,  31 },  -- r8
+  {  15,  10,  10,  10,  10,  10,  10,  31 },  -- r9
+  {  15,  75,  75,  75,  75,  75,  75,  31 },  -- r10
+  {  15,  75,  75,  75,  75,  75,  75,  31 },  -- r11
+  {  78,  26,  26,  26,  26,  26,  26,  79 },  -- r12
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `A` = tile 76, used 1x
+- `B` = tile 83, used 6x
+- `C` = tile 77, used 1x
+- `D` = tile 90, used 4x
+- `E` = tile 18, used 12x
+- `F` = tile 92, used 1x
+- `G` = tile 23, used 6x
+- `H` = tile 93, used 1x
+- `I` = tile 15, used 7x
+- `J` = tile 10, used 18x
+- `K` = tile 31, used 7x
+- `L` = tile 75, used 24x
+- `M` = tile 78, used 1x
+- `N` = tile 26, used 6x
+- `O` = tile 79, used 1x
+
+![distinct tiles](img/B08_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID
+    y4   SOLID  SOLID  SOLID  SOLID
+    y5   SOLID  SOLID  SOLID  SOLID
+    y6   SOLID  SOLID  SOLID  SOLID
+```
+
+Every cell is solid - this building has no door of its own.
+
+## Silhouette
+
+322 of the 6144 px inside the box are ground outside the black outline, so the 64x96 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:25px, c8:25px
+    r2   c1:24px, c8:24px
+    r3   c1:24px, c8:24px
+    r4   c1:24px, c8:24px
+    r5   c1:8px, c8:8px
+    r6   c1:8px, c8:8px
+    r7   c1:8px, c8:8px
+    r8   c1:8px, c8:8px
+    r9   c1:8px, c8:8px
+    r10  c1:8px, c8:8px
+    r11  c1:8px, c8:8px
+    r12  c1:8px, c8:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| CELADON_CITY | (2,4) | - | scenery, no entrance |
+| SAFFRON_CITY | (6,16) | - | scenery, no entrance |
+| SAFFRON_CITY | (10,16) | - | scenery, no entrance |
+| SAFFRON_CITY | (24,16) | - | scenery, no entrance |
+| SAFFRON_CITY | (28,16) | - | scenery, no entrance |
+| SAFFRON_CITY | (32,16) | - | scenery, no entrance |
diff --git a/assets/docs/buildings/B09-unnamed-building.md b/assets/docs/buildings/B09-unnamed-building.md
new file mode 100644
index 0000000..efc9c64
--- /dev/null
+++ b/assets/docs/buildings/B09-unnamed-building.md
@@ -0,0 +1,124 @@
+# B09 - Unnamed building
+
+![Unnamed building](img/B09_x6.png)
+
+`OVERWORLD` tileset, **6 x 4 cells** (12 x 8 tiles of 8px, 96 x 64 px). Appears **5 times** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..6, y=1..4; tile column c=1..12, tile row r=1..8. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4   x5   x6 
+      +-----+-----+-----+-----+-----+-----+
+   y1 | A B | B B | B B | B B | B B | B C |
+      | D E | E E | E E | E E | E E | E D |
+      +-----+-----+-----+-----+-----+-----+
+   y2 | D E | E E | E E | E E | E E | E D |
+      | F G | G G | G G | G G | G G | G H |
+      +-----+-----+-----+-----+-----+-----+
+   y3 | I J | J J | J K | K J | J J | J L |
+      | I K | K K | K K | K K | K K | K L |
+      +-----+-----+-----+-----+-----+-----+
+   y4 | I K | K K | M N | J J | K K | K L |
+      | O P | P P | * Q | P P | P P | P R |
+      +-----+-----+-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4       x5       x6   
+      +---------+---------+---------+---------+---------+---------+
+   y1 |  76  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  77 |
+      |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      +---------+---------+---------+---------+---------+---------+
+   y2 |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      |  92  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  93 |
+      +---------+---------+---------+---------+---------+---------+
+   y3 |  15  10 |  10  10 |  10  75 |  75  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+
+   y4 |  15  75 |  75  75 |  11  12 |  10  10 |  75  75 |  75  31 |
+      |  78  26 |  26  26 |  27  28 |  26  26 |  26  26 |  26  79 |
+      +---------+---------+---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {  76,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  77 },  -- r1
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r2
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r3
+  {  92,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  93 },  -- r4
+  {  15,  10,  10,  10,  10,  75,  75,  10,  10,  10,  10,  31 },  -- r5
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r6
+  {  15,  75,  75,  75,  11,  12,  10,  10,  75,  75,  75,  31 },  -- r7
+  {  78,  26,  26,  26,  27,  28,  26,  26,  26,  26,  26,  79 },  -- r8
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 1x
+- `A` = tile 76, used 1x
+- `B` = tile 83, used 10x
+- `C` = tile 77, used 1x
+- `D` = tile 90, used 4x
+- `E` = tile 18, used 20x
+- `F` = tile 92, used 1x
+- `G` = tile 23, used 10x
+- `H` = tile 93, used 1x
+- `I` = tile 15, used 3x
+- `J` = tile 10, used 10x
+- `K` = tile 75, used 18x
+- `L` = tile 31, used 3x
+- `M` = tile 11, used 1x
+- `N` = tile 12, used 1x
+- `O` = tile 78, used 1x
+- `P` = tile 26, used 8x
+- `Q` = tile 28, used 1x
+- `R` = tile 79, used 1x
+
+![distinct tiles](img/B09_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y4   SOLID  SOLID  DOOR   SOLID  SOLID  SOLID
+```
+
+Enterable cell: (3,4).
+
+## Silhouette
+
+258 of the 6144 px inside the box are ground outside the black outline, so the 96x64 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:25px, c12:25px
+    r2   c1:24px, c12:24px
+    r3   c1:24px, c12:24px
+    r4   c1:24px, c12:24px
+    r5   c1:8px, c12:8px
+    r6   c1:8px, c12:8px
+    r7   c1:8px, c12:8px
+    r8   c1:8px, c12:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| CELADON_CITY | (26,16) | (28,19) | `GAME_CORNER` |
+| CINNABAR_ISLAND | (4,0) | (6,3) | `POKEMON_MANSION_1F` |
+| CINNABAR_ISLAND | (4,6) | (6,9) | `CINNABAR_LAB` |
+| FUCHSIA_CITY | (16,0) | (18,3) | `SAFARI_ZONE_GATE` |
+| FUCHSIA_CITY | (20,10) | (22,13) | `FUCHSIA_MEETING_ROOM` |
diff --git a/assets/docs/buildings/B10-unnamed-building.md b/assets/docs/buildings/B10-unnamed-building.md
new file mode 100644
index 0000000..e06b9a7
--- /dev/null
+++ b/assets/docs/buildings/B10-unnamed-building.md
@@ -0,0 +1,131 @@
+# B10 - Unnamed building
+
+![Unnamed building](img/B10_x6.png)
+
+`OVERWORLD` tileset, **6 x 4 cells** (12 x 8 tiles of 8px, 96 x 64 px). Appears **5 times** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..6, y=1..4; tile column c=1..12, tile row r=1..8. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4   x5   x6 
+      +-----+-----+-----+-----+-----+-----+
+   y1 | A B | C C | C C | C C | C C | D E |
+      | F G | H H | H H | H H | H H | G I |
+      +-----+-----+-----+-----+-----+-----+
+   y2 | F G | H H | H H | H H | H H | G I |
+      | F J | K K | K K | K K | K K | L I |
+      +-----+-----+-----+-----+-----+-----+
+   y3 | M N | O O | P Q | R P | O O | S T |
+      | U P | P P | P P | P P | P P | P V |
+      +-----+-----+-----+-----+-----+-----+
+   y4 | U O | O O | O O | O O | W X | O V |
+      | Y Z | Z Z | Z Z | Z Z | * a | Z b |
+      +-----+-----+-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4       x5       x6   
+      +---------+---------+---------+---------+---------+---------+
+   y1 |   5   6 |  83  83 |  83  83 |  83  83 |  83  83 |   8   9 |
+      |  21  56 |  18  18 |  18  18 |  18  18 |  18  18 |  56  25 |
+      +---------+---------+---------+---------+---------+---------+
+   y2 |  21  56 |  18  18 |  18  18 |  18  18 |  18  18 |  56  25 |
+      |  21  22 |  23  23 |  23  23 |  23  23 |  23  23 |  24  25 |
+      +---------+---------+---------+---------+---------+---------+
+   y3 |  37  38 |  10  10 |  34  47 |  63  34 |  10  10 |  40  41 |
+      |  15  34 |  34  34 |  34  34 |  34  34 |  34  34 |  34  31 |
+      +---------+---------+---------+---------+---------+---------+
+   y4 |  15  10 |  10  10 |  10  10 |  10  10 |  11  12 |  10  31 |
+      |  78  26 |  26  26 |  26  26 |  26  26 |  27  28 |  26  79 |
+      +---------+---------+---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {   5,   6,  83,  83,  83,  83,  83,  83,  83,  83,   8,   9 },  -- r1
+  {  21,  56,  18,  18,  18,  18,  18,  18,  18,  18,  56,  25 },  -- r2
+  {  21,  56,  18,  18,  18,  18,  18,  18,  18,  18,  56,  25 },  -- r3
+  {  21,  22,  23,  23,  23,  23,  23,  23,  23,  23,  24,  25 },  -- r4
+  {  37,  38,  10,  10,  34,  47,  63,  34,  10,  10,  40,  41 },  -- r5
+  {  15,  34,  34,  34,  34,  34,  34,  34,  34,  34,  34,  31 },  -- r6
+  {  15,  10,  10,  10,  10,  10,  10,  10,  11,  12,  10,  31 },  -- r7
+  {  78,  26,  26,  26,  26,  26,  26,  26,  27,  28,  26,  79 },  -- r8
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 1x
+- `A` = tile 5, used 1x
+- `B` = tile 6, used 1x
+- `C` = tile 83, used 8x
+- `D` = tile 8, used 1x
+- `E` = tile 9, used 1x
+- `F` = tile 21, used 3x
+- `G` = tile 56, used 4x
+- `H` = tile 18, used 16x
+- `I` = tile 25, used 3x
+- `J` = tile 22, used 1x
+- `K` = tile 23, used 8x
+- `L` = tile 24, used 1x
+- `M` = tile 37, used 1x
+- `N` = tile 38, used 1x
+- `O` = tile 10, used 12x
+- `P` = tile 34, used 12x
+- `Q` = tile 47, used 1x
+- `R` = tile 63, used 1x
+- `S` = tile 40, used 1x
+- `T` = tile 41, used 1x
+- `U` = tile 15, used 2x
+- `V` = tile 31, used 2x
+- `W` = tile 11, used 1x
+- `X` = tile 12, used 1x
+- `Y` = tile 78, used 1x
+- `Z` = tile 26, used 8x
+- `a` = tile 28, used 1x
+- `b` = tile 79, used 1x
+
+![distinct tiles](img/B10_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y4   SOLID  SOLID  SOLID  SOLID  DOOR   SOLID
+```
+
+Enterable cell: (5,4).
+
+## Silhouette
+
+186 of the 6144 px inside the box are ground outside the black outline, so the 96x64 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:48px, c2:16px, c11:16px, c12:48px
+    r5   c1:5px, c12:5px
+    r6   c1:8px, c12:8px
+    r7   c1:8px, c12:8px
+    r8   c1:8px, c12:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| CINNABAR_ISLAND | (14,0) | (18,3) | `CINNABAR_GYM` |
+| PEWTER_CITY | (12,14) | (16,17) | `PEWTER_GYM` |
+| SAFFRON_CITY | (22,0) | (26,3) | `FIGHTING_DOJO` |
+| VERMILION_CITY | (8,16) | (12,19) | `VERMILION_GYM` |
+| VIRIDIAN_CITY | (28,4) | (32,7) | `VIRIDIAN_GYM` |
diff --git a/assets/docs/buildings/B11-unnamed-building.md b/assets/docs/buildings/B11-unnamed-building.md
new file mode 100644
index 0000000..752b3d9
--- /dev/null
+++ b/assets/docs/buildings/B11-unnamed-building.md
@@ -0,0 +1,102 @@
+# B11 - Unnamed building
+
+![Unnamed building](img/B11_x6.png)
+
+`OVERWORLD` tileset, **6 x 2 cells** (12 x 4 tiles of 8px, 96 x 32 px). Appears **5 times** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..6, y=1..2; tile column c=1..12, tile row r=1..4. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4   x5   x6 
+      +-----+-----+-----+-----+-----+-----+
+   y1 | A B | C C | C C | C C | C C | D E |
+      | F G | H H | H H | H H | H H | I J |
+      +-----+-----+-----+-----+-----+-----+
+   y2 | K L | M N | O M | M O | M M | P Q |
+      | R S | S S | S S | S S | S S | S T |
+      +-----+-----+-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4       x5       x6   
+      +---------+---------+---------+---------+---------+---------+
+   y1 |   5   6 |   7   7 |   7   7 |   7   7 |   7   7 |   8   9 |
+      |  21  22 |  23  23 |  23  23 |  23  23 |  23  23 |  24  25 |
+      +---------+---------+---------+---------+---------+---------+
+   y2 |  37  38 |  10  34 |  35  10 |  10  35 |  10  10 |  40  41 |
+      |  78  26 |  26  26 |  26  26 |  26  26 |  26  26 |  26  79 |
+      +---------+---------+---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {   5,   6,   7,   7,   7,   7,   7,   7,   7,   7,   8,   9 },  -- r1
+  {  21,  22,  23,  23,  23,  23,  23,  23,  23,  23,  24,  25 },  -- r2
+  {  37,  38,  10,  34,  35,  10,  10,  35,  10,  10,  40,  41 },  -- r3
+  {  78,  26,  26,  26,  26,  26,  26,  26,  26,  26,  26,  79 },  -- r4
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `A` = tile 5, used 1x
+- `B` = tile 6, used 1x
+- `C` = tile 7, used 8x
+- `D` = tile 8, used 1x
+- `E` = tile 9, used 1x
+- `F` = tile 21, used 1x
+- `G` = tile 22, used 1x
+- `H` = tile 23, used 8x
+- `I` = tile 24, used 1x
+- `J` = tile 25, used 1x
+- `K` = tile 37, used 1x
+- `L` = tile 38, used 1x
+- `M` = tile 10, used 5x
+- `N` = tile 34, used 1x
+- `O` = tile 35, used 2x
+- `P` = tile 40, used 1x
+- `Q` = tile 41, used 1x
+- `R` = tile 78, used 1x
+- `S` = tile 26, used 10x
+- `T` = tile 79, used 1x
+
+![distinct tiles](img/B11_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+```
+
+Every cell is solid - this building has no door of its own.
+
+## Silhouette
+
+154 of the 3072 px inside the box are ground outside the black outline, so the 96x32 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:48px, c2:16px, c11:16px, c12:48px
+    r3   c1:5px, c12:5px
+    r4   c1:8px, c12:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| CELADON_CITY | (20,14) | - | scenery, no entrance |
+| CERULEAN_CITY | (14,10) | - | scenery, no entrance |
+| CERULEAN_CITY | (34,10) | - | scenery, no entrance |
+| CERULEAN_CITY | (18,24) | - | scenery, no entrance |
+| CERULEAN_CITY | (28,24) | - | scenery, no entrance |
diff --git a/assets/docs/buildings/B12-unnamed-building.md b/assets/docs/buildings/B12-unnamed-building.md
new file mode 100644
index 0000000..87ec66f
--- /dev/null
+++ b/assets/docs/buildings/B12-unnamed-building.md
@@ -0,0 +1,99 @@
+# B12 - Unnamed building
+
+![Unnamed building](img/B12_x10.png)
+
+`FOREST` tileset, **4 x 2 cells** (8 x 4 tiles of 8px, 64 x 32 px). Appears **5 times** in the game. Tile ids index `assets/generated/tilesets/forest.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..4, y=1..2; tile column c=1..8, tile row r=1..4. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4 
+      +-----+-----+-----+-----+
+   y1 | A B | B B | B B | B C |
+      | D E | E E | E E | E F |
+      +-----+-----+-----+-----+
+   y2 | G H | I J | K K | H L |
+      | M H | * N | H H | H O |
+      +-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4   
+      +---------+---------+---------+---------+
+   y1 |   8   9 |   9   9 |   9   9 |   9  12 |
+      |  24  25 |  25  25 |  25  25 |  25  28 |
+      +---------+---------+---------+---------+
+   y2 |  40  41 |  42  43 |   1   1 |  41  44 |
+      |  56  41 |  58  59 |  41  41 |  41  60 |
+      +---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {   8,   9,   9,   9,   9,   9,   9,  12 },  -- r1
+  {  24,  25,  25,  25,  25,  25,  25,  28 },  -- r2
+  {  40,  41,  42,  43,   1,   1,  41,  44 },  -- r3
+  {  56,  41,  58,  59,  41,  41,  41,  60 },  -- r4
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 58, used 1x
+- `A` = tile 8, used 1x
+- `B` = tile 9, used 6x
+- `C` = tile 12, used 1x
+- `D` = tile 24, used 1x
+- `E` = tile 25, used 6x
+- `F` = tile 28, used 1x
+- `G` = tile 40, used 1x
+- `H` = tile 41, used 6x
+- `I` = tile 42, used 1x
+- `J` = tile 43, used 1x
+- `K` = tile 1, used 2x
+- `L` = tile 44, used 1x
+- `M` = tile 56, used 1x
+- `N` = tile 59, used 1x
+- `O` = tile 60, used 1x
+
+![distinct tiles](img/B12_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  DOOR   SOLID  SOLID
+```
+
+Enterable cell: (2,2).
+
+## Silhouette
+
+101 of the 2048 px inside the box are ground outside the black outline, so the 64x32 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:17px, c8:17px
+    r2   c1:16px, c8:17px
+    r3   c1:10px, c8:8px
+    r4   c1:8px, c8:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| SAFARI_ZONE_CENTER | (16,18) | (17,19) | `SAFARI_ZONE_CENTER_REST_HOUSE` |
+| SAFARI_ZONE_EAST | (24,8) | (25,9) | `SAFARI_ZONE_EAST_REST_HOUSE` |
+| SAFARI_ZONE_NORTH | (34,2) | (35,3) | `SAFARI_ZONE_NORTH_REST_HOUSE` |
+| SAFARI_ZONE_WEST | (2,2) | (3,3) | `SAFARI_ZONE_SECRET_HOUSE` |
+| SAFARI_ZONE_WEST | (10,10) | (11,11) | `SAFARI_ZONE_WEST_REST_HOUSE` |
diff --git a/assets/docs/buildings/B13-unnamed-building.md b/assets/docs/buildings/B13-unnamed-building.md
new file mode 100644
index 0000000..3172b70
--- /dev/null
+++ b/assets/docs/buildings/B13-unnamed-building.md
@@ -0,0 +1,119 @@
+# B13 - Unnamed building
+
+![Unnamed building](img/B13_x6.png)
+
+`OVERWORLD` tileset, **6 x 4 cells** (12 x 8 tiles of 8px, 96 x 64 px). Appears **4 times** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..6, y=1..4; tile column c=1..12, tile row r=1..8. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4   x5   x6 
+      +-----+-----+-----+-----+-----+-----+
+   y1 | A B | B B | B B | B B | B B | B C |
+      | D E | E E | E E | E E | E E | E D |
+      +-----+-----+-----+-----+-----+-----+
+   y2 | D E | E E | E E | E E | E E | E D |
+      | F G | G G | G G | G G | G G | G H |
+      +-----+-----+-----+-----+-----+-----+
+   y3 | I J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+
+   y4 | I L | L L | L L | L L | L L | L K |
+      | M N | N N | N N | N N | N N | N O |
+      +-----+-----+-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4       x5       x6   
+      +---------+---------+---------+---------+---------+---------+
+   y1 |  76  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  77 |
+      |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      +---------+---------+---------+---------+---------+---------+
+   y2 |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      |  92  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  93 |
+      +---------+---------+---------+---------+---------+---------+
+   y3 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+
+   y4 |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      |  78  26 |  26  26 |  26  26 |  26  26 |  26  26 |  26  79 |
+      +---------+---------+---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {  76,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  77 },  -- r1
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r2
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r3
+  {  92,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  93 },  -- r4
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r5
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r6
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r7
+  {  78,  26,  26,  26,  26,  26,  26,  26,  26,  26,  26,  79 },  -- r8
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `A` = tile 76, used 1x
+- `B` = tile 83, used 10x
+- `C` = tile 77, used 1x
+- `D` = tile 90, used 4x
+- `E` = tile 18, used 20x
+- `F` = tile 92, used 1x
+- `G` = tile 23, used 10x
+- `H` = tile 93, used 1x
+- `I` = tile 15, used 3x
+- `J` = tile 10, used 10x
+- `K` = tile 31, used 3x
+- `L` = tile 75, used 20x
+- `M` = tile 78, used 1x
+- `N` = tile 26, used 10x
+- `O` = tile 79, used 1x
+
+![distinct tiles](img/B13_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y4   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+```
+
+Every cell is solid - this building has no door of its own.
+
+## Silhouette
+
+258 of the 6144 px inside the box are ground outside the black outline, so the 96x64 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:25px, c12:25px
+    r2   c1:24px, c12:24px
+    r3   c1:24px, c12:24px
+    r4   c1:24px, c12:24px
+    r5   c1:8px, c12:8px
+    r6   c1:8px, c12:8px
+    r7   c1:8px, c12:8px
+    r8   c1:8px, c12:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| ROUTE_15 | (8,6) | - | scenery, no entrance |
+| ROUTE_16 | (18,2) | - | scenery, no entrance |
+| ROUTE_7 | (12,8) | - | scenery, no entrance |
+| ROUTE_8 | (2,8) | - | scenery, no entrance |
diff --git a/assets/docs/buildings/B14-unnamed-building.md b/assets/docs/buildings/B14-unnamed-building.md
new file mode 100644
index 0000000..8abc1c1
--- /dev/null
+++ b/assets/docs/buildings/B14-unnamed-building.md
@@ -0,0 +1,140 @@
+# B14 - Unnamed building
+
+![Unnamed building](img/B14_x6.png)
+
+`OVERWORLD` tileset, **6 x 6 cells** (12 x 12 tiles of 8px, 96 x 96 px). Appears **3 times** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..6, y=1..6; tile column c=1..12, tile row r=1..12. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4   x5   x6 
+      +-----+-----+-----+-----+-----+-----+
+   y1 | A B | B B | B B | B B | B B | B C |
+      | D E | E E | E E | E E | E E | E D |
+      +-----+-----+-----+-----+-----+-----+
+   y2 | D E | E E | E E | E E | E E | E D |
+      | F G | G G | G G | G G | G G | G H |
+      +-----+-----+-----+-----+-----+-----+
+   y3 | I J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+
+   y4 | I J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+
+   y5 | I J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+
+   y6 | I L | L L | L L | L L | L L | L K |
+      | M N | N N | N N | N N | N N | N O |
+      +-----+-----+-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4       x5       x6   
+      +---------+---------+---------+---------+---------+---------+
+   y1 |  76  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  77 |
+      |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      +---------+---------+---------+---------+---------+---------+
+   y2 |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      |  92  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  93 |
+      +---------+---------+---------+---------+---------+---------+
+   y3 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+
+   y4 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+
+   y5 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+
+   y6 |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      |  78  26 |  26  26 |  26  26 |  26  26 |  26  26 |  26  79 |
+      +---------+---------+---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {  76,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  77 },  -- r1
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r2
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r3
+  {  92,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  93 },  -- r4
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r5
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r6
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r7
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r8
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r9
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r10
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r11
+  {  78,  26,  26,  26,  26,  26,  26,  26,  26,  26,  26,  79 },  -- r12
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `A` = tile 76, used 1x
+- `B` = tile 83, used 10x
+- `C` = tile 77, used 1x
+- `D` = tile 90, used 4x
+- `E` = tile 18, used 20x
+- `F` = tile 92, used 1x
+- `G` = tile 23, used 10x
+- `H` = tile 93, used 1x
+- `I` = tile 15, used 7x
+- `J` = tile 10, used 30x
+- `K` = tile 31, used 7x
+- `L` = tile 75, used 40x
+- `M` = tile 78, used 1x
+- `N` = tile 26, used 10x
+- `O` = tile 79, used 1x
+
+![distinct tiles](img/B14_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y4   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y5   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y6   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+```
+
+Every cell is solid - this building has no door of its own.
+
+## Silhouette
+
+322 of the 9216 px inside the box are ground outside the black outline, so the 96x96 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:25px, c12:25px
+    r2   c1:24px, c12:24px
+    r3   c1:24px, c12:24px
+    r4   c1:24px, c12:24px
+    r5   c1:8px, c12:8px
+    r6   c1:8px, c12:8px
+    r7   c1:8px, c12:8px
+    r8   c1:8px, c12:8px
+    r9   c1:8px, c12:8px
+    r10  c1:8px, c12:8px
+    r11  c1:8px, c12:8px
+    r12  c1:8px, c12:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| CELADON_CITY | (14,4) | - | scenery, no entrance |
+| ROUTE_16 | (18,6) | - | scenery, no entrance |
+| ROUTE_18 | (34,4) | - | scenery, no entrance |
diff --git a/assets/docs/buildings/B15-unnamed-building.md b/assets/docs/buildings/B15-unnamed-building.md
new file mode 100644
index 0000000..4a78d79
--- /dev/null
+++ b/assets/docs/buildings/B15-unnamed-building.md
@@ -0,0 +1,144 @@
+# B15 - Unnamed building
+
+![Unnamed building](img/B15_x6.png)
+
+`OVERWORLD` tileset, **6 x 6 cells** (12 x 12 tiles of 8px, 96 x 96 px). Appears **3 times** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..6, y=1..6; tile column c=1..12, tile row r=1..12. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4   x5   x6 
+      +-----+-----+-----+-----+-----+-----+
+   y1 | A B | B B | B B | B B | B B | B C |
+      | D E | E E | E E | E E | E E | E D |
+      +-----+-----+-----+-----+-----+-----+
+   y2 | D E | E E | E E | E E | E E | E D |
+      | F G | G G | G G | G G | G G | G H |
+      +-----+-----+-----+-----+-----+-----+
+   y3 | I J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+
+   y4 | I J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+
+   y5 | I J | J J | J L | L J | J J | J K |
+      | I L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+
+   y6 | I L | L L | M N | J J | L L | L K |
+      | O P | P P | * Q | P P | P P | P R |
+      +-----+-----+-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4       x5       x6   
+      +---------+---------+---------+---------+---------+---------+
+   y1 |  76  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  77 |
+      |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      +---------+---------+---------+---------+---------+---------+
+   y2 |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      |  92  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  93 |
+      +---------+---------+---------+---------+---------+---------+
+   y3 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+
+   y4 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+
+   y5 |  15  10 |  10  10 |  10  75 |  75  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+
+   y6 |  15  75 |  75  75 |  11  12 |  10  10 |  75  75 |  75  31 |
+      |  78  26 |  26  26 |  27  28 |  26  26 |  26  26 |  26  79 |
+      +---------+---------+---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {  76,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  77 },  -- r1
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r2
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r3
+  {  92,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  93 },  -- r4
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r5
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r6
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r7
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r8
+  {  15,  10,  10,  10,  10,  75,  75,  10,  10,  10,  10,  31 },  -- r9
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r10
+  {  15,  75,  75,  75,  11,  12,  10,  10,  75,  75,  75,  31 },  -- r11
+  {  78,  26,  26,  26,  27,  28,  26,  26,  26,  26,  26,  79 },  -- r12
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 1x
+- `A` = tile 76, used 1x
+- `B` = tile 83, used 10x
+- `C` = tile 77, used 1x
+- `D` = tile 90, used 4x
+- `E` = tile 18, used 20x
+- `F` = tile 92, used 1x
+- `G` = tile 23, used 10x
+- `H` = tile 93, used 1x
+- `I` = tile 15, used 7x
+- `J` = tile 10, used 30x
+- `K` = tile 31, used 7x
+- `L` = tile 75, used 38x
+- `M` = tile 11, used 1x
+- `N` = tile 12, used 1x
+- `O` = tile 78, used 1x
+- `P` = tile 26, used 8x
+- `Q` = tile 28, used 1x
+- `R` = tile 79, used 1x
+
+![distinct tiles](img/B15_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y4   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y5   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y6   SOLID  SOLID  DOOR   SOLID  SOLID  SOLID
+```
+
+Enterable cell: (3,6).
+
+## Silhouette
+
+322 of the 9216 px inside the box are ground outside the black outline, so the 96x96 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:25px, c12:25px
+    r2   c1:24px, c12:24px
+    r3   c1:24px, c12:24px
+    r4   c1:24px, c12:24px
+    r5   c1:8px, c12:8px
+    r6   c1:8px, c12:8px
+    r7   c1:8px, c12:8px
+    r8   c1:8px, c12:8px
+    r9   c1:8px, c12:8px
+    r10  c1:8px, c12:8px
+    r11  c1:8px, c12:8px
+    r12  c1:8px, c12:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| CELADON_CITY | (22,4) | (24,9) | `CELADON_MANSION_1F` |
+| ROUTE_12 | (8,16) | (10,21) | `ROUTE_12_GATE_1F` |
+| ROUTE_6 | (8,2) | (10,7) | `ROUTE_6_GATE` |
diff --git a/assets/docs/buildings/B16-unnamed-building.md b/assets/docs/buildings/B16-unnamed-building.md
new file mode 100644
index 0000000..f0d0598
--- /dev/null
+++ b/assets/docs/buildings/B16-unnamed-building.md
@@ -0,0 +1,129 @@
+# B16 - Unnamed building
+
+![Unnamed building](img/B16_x5.png)
+
+`OVERWORLD` tileset, **8 x 4 cells** (16 x 8 tiles of 8px, 128 x 64 px). Appears **3 times** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..8, y=1..4; tile column c=1..16, tile row r=1..8. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4   x5   x6   x7   x8 
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y1 | A B | C C | C C | C C | C C | C C | C C | D E |
+      | F G | H H | H H | H H | H H | H H | H H | G I |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y2 | F G | H H | H H | H H | H H | H H | H H | G I |
+      | F J | K K | K K | K K | K K | K K | K K | L I |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y3 | M N | O O | P Q | R P | P Q | R P | O O | S T |
+      | U P | P P | P P | P P | P P | P P | P P | P V |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y4 | U O | O O | O O | O O | O O | O O | W X | O V |
+      | Y Z | Z Z | Z Z | Z Z | Z Z | Z Z | * a | Z b |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4       x5       x6       x7       x8   
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y1 |   5   6 |  83  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  83 |   8   9 |
+      |  21  56 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  56  25 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y2 |  21  56 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  56  25 |
+      |  21  22 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  24  25 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y3 |  37  38 |  10  10 |  34  47 |  63  34 |  34  47 |  63  34 |  10  10 |  40  41 |
+      |  15  34 |  34  34 |  34  34 |  34  34 |  34  34 |  34  34 |  34  34 |  34  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y4 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  11  12 |  10  31 |
+      |  78  26 |  26  26 |  26  26 |  26  26 |  26  26 |  26  26 |  27  28 |  26  79 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {   5,   6,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,   8,   9 },  -- r1
+  {  21,  56,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  56,  25 },  -- r2
+  {  21,  56,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  56,  25 },  -- r3
+  {  21,  22,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  24,  25 },  -- r4
+  {  37,  38,  10,  10,  34,  47,  63,  34,  34,  47,  63,  34,  10,  10,  40,  41 },  -- r5
+  {  15,  34,  34,  34,  34,  34,  34,  34,  34,  34,  34,  34,  34,  34,  34,  31 },  -- r6
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  11,  12,  10,  31 },  -- r7
+  {  78,  26,  26,  26,  26,  26,  26,  26,  26,  26,  26,  26,  27,  28,  26,  79 },  -- r8
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 1x
+- `A` = tile 5, used 1x
+- `B` = tile 6, used 1x
+- `C` = tile 83, used 12x
+- `D` = tile 8, used 1x
+- `E` = tile 9, used 1x
+- `F` = tile 21, used 3x
+- `G` = tile 56, used 4x
+- `H` = tile 18, used 24x
+- `I` = tile 25, used 3x
+- `J` = tile 22, used 1x
+- `K` = tile 23, used 12x
+- `L` = tile 24, used 1x
+- `M` = tile 37, used 1x
+- `N` = tile 38, used 1x
+- `O` = tile 10, used 16x
+- `P` = tile 34, used 18x
+- `Q` = tile 47, used 2x
+- `R` = tile 63, used 2x
+- `S` = tile 40, used 1x
+- `T` = tile 41, used 1x
+- `U` = tile 15, used 2x
+- `V` = tile 31, used 2x
+- `W` = tile 11, used 1x
+- `X` = tile 12, used 1x
+- `Y` = tile 78, used 1x
+- `Z` = tile 26, used 12x
+- `a` = tile 28, used 1x
+- `b` = tile 79, used 1x
+
+![distinct tiles](img/B16_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y4   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  DOOR   SOLID
+```
+
+Enterable cell: (7,4).
+
+## Silhouette
+
+186 of the 8192 px inside the box are ground outside the black outline, so the 128x64 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:48px, c2:16px, c15:16px, c16:48px
+    r5   c1:5px, c16:5px
+    r6   c1:8px, c16:8px
+    r7   c1:8px, c16:8px
+    r8   c1:8px, c16:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| CELADON_CITY | (6,24) | (12,27) | `CELADON_GYM` |
+| CERULEAN_CITY | (24,16) | (30,19) | `CERULEAN_GYM` |
+| SAFFRON_CITY | (28,0) | (34,3) | `SAFFRON_GYM` |
diff --git a/assets/docs/buildings/B17-unnamed-building.md b/assets/docs/buildings/B17-unnamed-building.md
new file mode 100644
index 0000000..e0e664f
--- /dev/null
+++ b/assets/docs/buildings/B17-unnamed-building.md
@@ -0,0 +1,103 @@
+# B17 - Unnamed building
+
+![Unnamed building](img/B17_x6.png)
+
+`OVERWORLD` tileset, **6 x 2 cells** (12 x 4 tiles of 8px, 96 x 32 px). Appears **3 times** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..6, y=1..2; tile column c=1..12, tile row r=1..4. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4   x5   x6 
+      +-----+-----+-----+-----+-----+-----+
+   y1 | A B | C C | C C | C C | C C | D E |
+      | F G | H H | H H | H H | H H | I J |
+      +-----+-----+-----+-----+-----+-----+
+   y2 | K L | M N | O P | P O | P P | Q R |
+      | S T | * U | T T | T T | T T | T V |
+      +-----+-----+-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4       x5       x6   
+      +---------+---------+---------+---------+---------+---------+
+   y1 |   5   6 |   7   7 |   7   7 |   7   7 |   7   7 |   8   9 |
+      |  21  22 |  23  23 |  23  23 |  23  23 |  23  23 |  24  25 |
+      +---------+---------+---------+---------+---------+---------+
+   y2 |  37  38 |  11  12 |  35  10 |  10  35 |  10  10 |  40  41 |
+      |  78  26 |  27  28 |  26  26 |  26  26 |  26  26 |  26  79 |
+      +---------+---------+---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {   5,   6,   7,   7,   7,   7,   7,   7,   7,   7,   8,   9 },  -- r1
+  {  21,  22,  23,  23,  23,  23,  23,  23,  23,  23,  24,  25 },  -- r2
+  {  37,  38,  11,  12,  35,  10,  10,  35,  10,  10,  40,  41 },  -- r3
+  {  78,  26,  27,  28,  26,  26,  26,  26,  26,  26,  26,  79 },  -- r4
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 1x
+- `A` = tile 5, used 1x
+- `B` = tile 6, used 1x
+- `C` = tile 7, used 8x
+- `D` = tile 8, used 1x
+- `E` = tile 9, used 1x
+- `F` = tile 21, used 1x
+- `G` = tile 22, used 1x
+- `H` = tile 23, used 8x
+- `I` = tile 24, used 1x
+- `J` = tile 25, used 1x
+- `K` = tile 37, used 1x
+- `L` = tile 38, used 1x
+- `M` = tile 11, used 1x
+- `N` = tile 12, used 1x
+- `O` = tile 35, used 2x
+- `P` = tile 10, used 4x
+- `Q` = tile 40, used 1x
+- `R` = tile 41, used 1x
+- `S` = tile 78, used 1x
+- `T` = tile 26, used 8x
+- `U` = tile 28, used 1x
+- `V` = tile 79, used 1x
+
+![distinct tiles](img/B17_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  DOOR   SOLID  SOLID  SOLID  SOLID
+```
+
+Enterable cell: (2,2).
+
+## Silhouette
+
+154 of the 3072 px inside the box are ground outside the black outline, so the 96x32 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:48px, c2:16px, c11:16px, c12:48px
+    r3   c1:5px, c12:5px
+    r4   c1:8px, c12:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| CERULEAN_CITY | (8,10) | (9,11) | `CERULEAN_BADGE_HOUSE` |
+| CERULEAN_CITY | (26,10) | (27,11) | `CERULEAN_TRASHED_HOUSE` |
+| CERULEAN_CITY | (12,14) | (13,15) | `CERULEAN_TRADE_HOUSE` |
diff --git a/assets/docs/buildings/B18-unnamed-building.md b/assets/docs/buildings/B18-unnamed-building.md
new file mode 100644
index 0000000..f9789b5
--- /dev/null
+++ b/assets/docs/buildings/B18-unnamed-building.md
@@ -0,0 +1,121 @@
+# B18 - Unnamed building
+
+![Unnamed building](img/B18_x6.png)
+
+`OVERWORLD` tileset, **6 x 4 cells** (12 x 8 tiles of 8px, 96 x 64 px). Appears **2 times** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..6, y=1..4; tile column c=1..12, tile row r=1..8. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4   x5   x6 
+      +-----+-----+-----+-----+-----+-----+
+   y1 | A B | B B | B B | B B | B B | B C |
+      | D E | E E | E E | E E | E E | E D |
+      +-----+-----+-----+-----+-----+-----+
+   y2 | D E | E E | E E | E E | E E | E D |
+      | F G | G G | G G | G G | G G | G H |
+      +-----+-----+-----+-----+-----+-----+
+   y3 | I J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+
+   y4 | I L | M N | L L | L L | L L | L K |
+      | O P | * Q | P P | P P | P P | P R |
+      +-----+-----+-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4       x5       x6   
+      +---------+---------+---------+---------+---------+---------+
+   y1 |  76  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  77 |
+      |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      +---------+---------+---------+---------+---------+---------+
+   y2 |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      |  92  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  93 |
+      +---------+---------+---------+---------+---------+---------+
+   y3 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+
+   y4 |  15  75 |  11  12 |  75  75 |  75  75 |  75  75 |  75  31 |
+      |  78  26 |  27  28 |  26  26 |  26  26 |  26  26 |  26  79 |
+      +---------+---------+---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {  76,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  77 },  -- r1
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r2
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r3
+  {  92,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  93 },  -- r4
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r5
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r6
+  {  15,  75,  11,  12,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r7
+  {  78,  26,  27,  28,  26,  26,  26,  26,  26,  26,  26,  79 },  -- r8
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 1x
+- `A` = tile 76, used 1x
+- `B` = tile 83, used 10x
+- `C` = tile 77, used 1x
+- `D` = tile 90, used 4x
+- `E` = tile 18, used 20x
+- `F` = tile 92, used 1x
+- `G` = tile 23, used 10x
+- `H` = tile 93, used 1x
+- `I` = tile 15, used 3x
+- `J` = tile 10, used 10x
+- `K` = tile 31, used 3x
+- `L` = tile 75, used 18x
+- `M` = tile 11, used 1x
+- `N` = tile 12, used 1x
+- `O` = tile 78, used 1x
+- `P` = tile 26, used 8x
+- `Q` = tile 28, used 1x
+- `R` = tile 79, used 1x
+
+![distinct tiles](img/B18_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y4   SOLID  DOOR   SOLID  SOLID  SOLID  SOLID
+```
+
+Enterable cell: (2,4).
+
+## Silhouette
+
+258 of the 6144 px inside the box are ground outside the black outline, so the 96x64 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:25px, c12:25px
+    r2   c1:24px, c12:24px
+    r3   c1:24px, c12:24px
+    r4   c1:24px, c12:24px
+    r5   c1:8px, c12:8px
+    r6   c1:8px, c12:8px
+    r7   c1:8px, c12:8px
+    r8   c1:8px, c12:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| PEWTER_CITY | (18,2) | (19,5) | `MUSEUM_1F` |
+| ROUTE_2 | (14,36) | (15,39) | `ROUTE_2_GATE` |
diff --git a/assets/docs/buildings/B19-unnamed-building.md b/assets/docs/buildings/B19-unnamed-building.md
new file mode 100644
index 0000000..18c98ac
--- /dev/null
+++ b/assets/docs/buildings/B19-unnamed-building.md
@@ -0,0 +1,135 @@
+# B19 - Unnamed building
+
+![Unnamed building](img/B19_x4.png)
+
+`PLATEAU` tileset, **20 x 5 cells** (40 x 10 tiles of 8px, 320 x 80 px). Appears **1 time** in the game. Tile ids index `assets/generated/tilesets/plateau.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..20, y=1..5; tile column c=1..40, tile row r=1..10. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4   x5   x6   x7   x8   x9  x10  x11  x12  x13  x14  x15  x16  x17  x18  x19  x20 
+      +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+   y1 | A B | C C | C C | C C | C C | C C | C C | C C | C C | C C | C C | C C | C C | C C | C C | C C | C C | C C | C C | A B |
+      | D E | F F | F F | F F | F F | F F | G H | C C | C C | C C | C C | C C | C C | G H | F F | F F | F F | F F | F F | D E |
+      +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+   y2 | I J | K K | K K | K K | I J | K K | L M | C C | C C | C C | C C | C C | C C | L M | K K | I J | K K | K K | K K | I J |
+      | N O | K K | K K | K K | N O | K K | L M | C C | C C | C C | C C | C C | C C | L M | K K | N O | K K | K K | K K | N O |
+      +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+   y3 | N O | K K | K K | K K | N O | K K | A B | C C | C C | C C | C C | C C | C C | A B | K K | N O | K K | K K | K K | N O |
+      | I J | P P | P P | P P | I J | P P | D E | F F | F F | F F | F F | F F | F F | D E | P P | I J | P P | P P | P P | I J |
+      +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+   y4 | Q Q | Q Q | Q Q | Q Q | Q Q | Q Q | I J | K K | K K | K K | K K | K K | K K | I J | Q Q | Q Q | Q Q | Q Q | Q Q | Q Q |
+      | R Q | Q Q | R Q | Q Q | R Q | Q Q | N O | K K | K K | K K | K K | K K | K K | N O | R Q | Q Q | R Q | Q Q | R Q | Q Q |
+      +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+   y5 | Q Q | Q Q | Q Q | Q Q | Q Q | Q Q | N O | K K | K K | S T | S T | K K | K K | N O | Q Q | Q Q | Q Q | Q Q | Q Q | Q Q |
+      | Q Q | R Q | Q Q | R Q | Q Q | R Q | I J | P P | P P | * U | * U | P P | P P | I J | Q Q | R Q | Q Q | R Q | Q Q | R Q |
+      +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4       x5       x6       x7       x8       x9      x10      x11      x12      x13      x14      x15      x16      x17      x18      x19      x20   
+      +---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+
+   y1 |  37  38 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |  37  38 |
+      |  40  41 |  13  13 |  13  13 |  13  13 |  13  13 |  13  13 |  32  33 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |  32  33 |  13  13 |  13  13 |  13  13 |  13  13 |  13  13 |  40  41 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+
+   y2 |  21  22 |  15  15 |  15  15 |  15  15 |  21  22 |  15  15 |  46  47 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |  46  47 |  15  15 |  21  22 |  15  15 |  15  15 |  15  15 |  21  22 |
+      |   5   6 |  15  15 |  15  15 |  15  15 |   5   6 |  15  15 |  46  47 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |  46  47 |  15  15 |   5   6 |  15  15 |  15  15 |  15  15 |   5   6 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+
+   y3 |   5   6 |  15  15 |  15  15 |  15  15 |   5   6 |  15  15 |  37  38 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |  37  38 |  15  15 |   5   6 |  15  15 |  15  15 |  15  15 |   5   6 |
+      |  21  22 |  14  14 |  14  14 |  14  14 |  21  22 |  14  14 |  40  41 |  13  13 |  13  13 |  13  13 |  13  13 |  13  13 |  13  13 |  40  41 |  14  14 |  21  22 |  14  14 |  14  14 |  14  14 |  21  22 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+
+   y4 |  35  35 |  35  35 |  35  35 |  35  35 |  35  35 |  35  35 |  21  22 |  15  15 |  15  15 |  15  15 |  15  15 |  15  15 |  15  15 |  21  22 |  35  35 |  35  35 |  35  35 |  35  35 |  35  35 |  35  35 |
+      |  45  35 |  35  35 |  45  35 |  35  35 |  45  35 |  35  35 |   5   6 |  15  15 |  15  15 |  15  15 |  15  15 |  15  15 |  15  15 |   5   6 |  45  35 |  35  35 |  45  35 |  35  35 |  45  35 |  35  35 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+
+   y5 |  35  35 |  35  35 |  35  35 |  35  35 |  35  35 |  35  35 |   5   6 |  15  15 |  15  15 |  11  12 |  11  12 |  15  15 |  15  15 |   5   6 |  35  35 |  35  35 |  35  35 |  35  35 |  35  35 |  35  35 |
+      |  35  35 |  45  35 |  35  35 |  45  35 |  35  35 |  45  35 |  21  22 |  14  14 |  14  14 |  27  28 |  27  28 |  14  14 |  14  14 |  21  22 |  35  35 |  45  35 |  35  35 |  45  35 |  35  35 |  45  35 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {  37,  38,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,  37,  38 },  -- r1
+  {  40,  41,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  32,  33,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,  32,  33,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  40,  41 },  -- r2
+  {  21,  22,  15,  15,  15,  15,  15,  15,  21,  22,  15,  15,  46,  47,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,  46,  47,  15,  15,  21,  22,  15,  15,  15,  15,  15,  15,  21,  22 },  -- r3
+  {   5,   6,  15,  15,  15,  15,  15,  15,   5,   6,  15,  15,  46,  47,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,  46,  47,  15,  15,   5,   6,  15,  15,  15,  15,  15,  15,   5,   6 },  -- r4
+  {   5,   6,  15,  15,  15,  15,  15,  15,   5,   6,  15,  15,  37,  38,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,  37,  38,  15,  15,   5,   6,  15,  15,  15,  15,  15,  15,   5,   6 },  -- r5
+  {  21,  22,  14,  14,  14,  14,  14,  14,  21,  22,  14,  14,  40,  41,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  40,  41,  14,  14,  21,  22,  14,  14,  14,  14,  14,  14,  21,  22 },  -- r6
+  {  35,  35,  35,  35,  35,  35,  35,  35,  35,  35,  35,  35,  21,  22,  15,  15,  15,  15,  15,  15,  15,  15,  15,  15,  15,  15,  21,  22,  35,  35,  35,  35,  35,  35,  35,  35,  35,  35,  35,  35 },  -- r7
+  {  45,  35,  35,  35,  45,  35,  35,  35,  45,  35,  35,  35,   5,   6,  15,  15,  15,  15,  15,  15,  15,  15,  15,  15,  15,  15,   5,   6,  45,  35,  35,  35,  45,  35,  35,  35,  45,  35,  35,  35 },  -- r8
+  {  35,  35,  35,  35,  35,  35,  35,  35,  35,  35,  35,  35,   5,   6,  15,  15,  15,  15,  11,  12,  11,  12,  15,  15,  15,  15,   5,   6,  35,  35,  35,  35,  35,  35,  35,  35,  35,  35,  35,  35 },  -- r9
+  {  35,  35,  45,  35,  35,  35,  45,  35,  35,  35,  45,  35,  21,  22,  14,  14,  14,  14,  27,  28,  27,  28,  14,  14,  14,  14,  21,  22,  35,  35,  45,  35,  35,  35,  45,  35,  35,  35,  45,  35 },  -- r10
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 2x
+- `A` = tile 37, used 4x
+- `B` = tile 38, used 4x
+- `C` = tile 3, used 84x
+- `D` = tile 40, used 4x
+- `E` = tile 41, used 4x
+- `F` = tile 13, used 32x
+- `G` = tile 32, used 2x
+- `H` = tile 33, used 2x
+- `I` = tile 21, used 12x
+- `J` = tile 22, used 12x
+- `K` = tile 15, used 80x
+- `L` = tile 46, used 4x
+- `M` = tile 47, used 4x
+- `N` = tile 5, used 12x
+- `O` = tile 6, used 12x
+- `P` = tile 14, used 24x
+- `Q` = tile 35, used 84x
+- `R` = tile 45, used 12x
+- `S` = tile 11, used 2x
+- `T` = tile 12, used 2x
+- `U` = tile 28, used 2x
+
+![distinct tiles](img/B19_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y4   DOOR   DOOR   DOOR   DOOR   DOOR   DOOR   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  DOOR   DOOR   DOOR   DOOR   DOOR   DOOR 
+    y5   DOOR   DOOR   DOOR   DOOR   DOOR   DOOR   SOLID  SOLID  SOLID  DOOR   DOOR   SOLID  SOLID  SOLID  DOOR   DOOR   DOOR   DOOR   DOOR   DOOR 
+```
+
+Enterable cells: (1,4), (2,4), (3,4), (4,4), (5,4), (6,4), (15,4), (16,4), (17,4), (18,4), (19,4), (20,4), (1,5), (2,5), (3,5), (4,5), (5,5), (6,5), (10,5), (11,5), (15,5), (16,5), (17,5), (18,5), (19,5), (20,5).
+
+## Silhouette
+
+1621 of the 25600 px inside the box are ground outside the black outline, so the 320x80 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:14px, c2:14px, c3:4px, c4:4px, c5:4px, c6:4px, c7:4px, c8:4px, c9:4px, c10:4px, c11:4px, c12:4px, c13:4px, c14:4px, c15:4px, c16:4px, c17:4px, c18:4px, c19:4px, c20:4px, c21:4px, c22:4px, c23:4px, c24:4px, c25:4px, c26:4px, c27:4px, c28:4px, c29:4px, c30:4px, c31:4px, c32:4px, c33:4px, c34:4px, c35:4px, c36:4px, c37:4px, c38:4px, c39:14px, c40:14px
+    r2   c1:10px, c2:10px, c3:16px, c4:16px, c5:16px, c6:16px, c7:16px, c8:16px, c9:16px, c10:16px, c11:16px, c12:16px, c13:15px, c14:15px, c15:1px, c27:15px, c28:15px, c29:16px, c30:16px, c31:16px, c32:16px, c33:16px, c34:16px, c35:16px, c36:16px, c37:16px, c38:16px, c39:10px, c40:10px
+    r3   c1:10px, c2:10px, c9:10px, c10:10px, c13:8px, c14:8px, c15:1px, c27:8px, c28:8px, c31:10px, c32:10px, c39:10px, c40:10px
+    r4   c1:8px, c2:8px, c9:8px, c10:8px, c13:8px, c14:8px, c15:1px, c27:8px, c28:8px, c31:8px, c32:8px, c39:8px, c40:8px
+    r5   c1:8px, c2:8px, c9:8px, c10:8px, c13:13px, c14:13px, c15:1px, c27:13px, c28:13px, c31:8px, c32:8px, c39:8px, c40:8px
+    r6   c1:10px, c2:10px, c3:9px, c4:8px, c5:8px, c6:8px, c7:8px, c8:8px, c9:10px, c10:10px, c11:9px, c12:8px, c13:10px, c14:10px, c15:16px, c16:16px, c17:16px, c18:16px, c19:16px, c20:16px, c21:16px, c22:16px, c23:16px, c24:16px, c25:16px, c26:16px, c27:10px, c28:10px, c29:9px, c30:8px, c31:10px, c32:10px, c33:9px, c34:8px, c35:8px, c36:8px, c37:8px, c38:8px, c39:10px, c40:10px
+    r7   c13:10px, c14:10px, c27:10px, c28:10px
+    r8   c13:8px, c14:8px, c27:8px, c28:8px
+    r9   c13:8px, c14:8px, c27:8px, c28:8px
+    r10  c13:15px, c14:15px, c15:9px, c16:8px, c17:8px, c18:8px, c23:8px, c24:8px, c25:8px, c26:8px, c27:15px, c28:15px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| INDIGO_PLATEAU | (0,1) | (9,5) | `INDIGO_PLATEAU_LOBBY` |
+| INDIGO_PLATEAU | (0,1) | (10,5) | `INDIGO_PLATEAU_LOBBY` |
diff --git a/assets/docs/buildings/B20-unnamed-building.md b/assets/docs/buildings/B20-unnamed-building.md
new file mode 100644
index 0000000..203e74e
--- /dev/null
+++ b/assets/docs/buildings/B20-unnamed-building.md
@@ -0,0 +1,208 @@
+# B20 - Unnamed building
+
+![Unnamed building](img/B20_x5.png)
+
+`OVERWORLD` tileset, **8 x 12 cells** (16 x 24 tiles of 8px, 128 x 192 px). Appears **1 time** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..8, y=1..12; tile column c=1..16, tile row r=1..24. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4   x5   x6   x7   x8 
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y1 | A B | B B | B B | B B | B B | B B | B B | B C |
+      | D E | E E | E E | E E | E E | E E | E E | E D |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y2 | D E | E E | E E | E E | E E | E E | E E | E D |
+      | F G | G G | G G | G G | G G | G G | G G | G H |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y3 | I J | J J | J J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y4 | I J | J J | J J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y5 | I J | J J | J J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y6 | I J | J J | J J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y7 | I J | J J | J J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y8 | I J | J J | J J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y9 | I J | J J | J J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+  y10 | I J | J J | J J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+  y11 | I J | J J | J L | L J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+  y12 | I L | L L | M N | J J | L L | L L | L L | L K |
+      | O P | P P | * Q | P P | P P | P P | P P | P R |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4       x5       x6       x7       x8   
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y1 |  76  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  77 |
+      |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y2 |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      |  92  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  93 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y3 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y4 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y5 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y6 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y7 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y8 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y9 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+  y10 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+  y11 |  15  10 |  10  10 |  10  75 |  75  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+  y12 |  15  75 |  75  75 |  11  12 |  10  10 |  75  75 |  75  75 |  75  75 |  75  31 |
+      |  78  26 |  26  26 |  27  28 |  26  26 |  26  26 |  26  26 |  26  26 |  26  79 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {  76,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  77 },  -- r1
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r2
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r3
+  {  92,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  93 },  -- r4
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r5
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r6
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r7
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r8
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r9
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r10
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r11
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r12
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r13
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r14
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r15
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r16
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r17
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r18
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r19
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r20
+  {  15,  10,  10,  10,  10,  75,  75,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r21
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r22
+  {  15,  75,  75,  75,  11,  12,  10,  10,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r23
+  {  78,  26,  26,  26,  27,  28,  26,  26,  26,  26,  26,  26,  26,  26,  26,  79 },  -- r24
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 1x
+- `A` = tile 76, used 1x
+- `B` = tile 83, used 14x
+- `C` = tile 77, used 1x
+- `D` = tile 90, used 4x
+- `E` = tile 18, used 28x
+- `F` = tile 92, used 1x
+- `G` = tile 23, used 14x
+- `H` = tile 93, used 1x
+- `I` = tile 15, used 19x
+- `J` = tile 10, used 126x
+- `K` = tile 31, used 19x
+- `L` = tile 75, used 138x
+- `M` = tile 11, used 1x
+- `N` = tile 12, used 1x
+- `O` = tile 78, used 1x
+- `P` = tile 26, used 12x
+- `Q` = tile 28, used 1x
+- `R` = tile 79, used 1x
+
+![distinct tiles](img/B20_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y4   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y5   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y6   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y7   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y8   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y9   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y10  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y11  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y12  SOLID  SOLID  DOOR   SOLID  SOLID  SOLID  SOLID  SOLID
+```
+
+Enterable cell: (3,12).
+
+## Silhouette
+
+514 of the 24576 px inside the box are ground outside the black outline, so the 128x192 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:25px, c16:25px
+    r2   c1:24px, c16:24px
+    r3   c1:24px, c16:24px
+    r4   c1:24px, c16:24px
+    r5   c1:8px, c16:8px
+    r6   c1:8px, c16:8px
+    r7   c1:8px, c16:8px
+    r8   c1:8px, c16:8px
+    r9   c1:8px, c16:8px
+    r10  c1:8px, c16:8px
+    r11  c1:8px, c16:8px
+    r12  c1:8px, c16:8px
+    r13  c1:8px, c16:8px
+    r14  c1:8px, c16:8px
+    r15  c1:8px, c16:8px
+    r16  c1:8px, c16:8px
+    r17  c1:8px, c16:8px
+    r18  c1:8px, c16:8px
+    r19  c1:8px, c16:8px
+    r20  c1:8px, c16:8px
+    r21  c1:8px, c16:8px
+    r22  c1:8px, c16:8px
+    r23  c1:8px, c16:8px
+    r24  c1:8px, c16:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| SAFFRON_CITY | (16,10) | (18,21) | `SILPH_CO_1F` |
diff --git a/assets/docs/buildings/B21-unnamed-building.md b/assets/docs/buildings/B21-unnamed-building.md
new file mode 100644
index 0000000..eb04ebb
--- /dev/null
+++ b/assets/docs/buildings/B21-unnamed-building.md
@@ -0,0 +1,142 @@
+# B21 - Unnamed building
+
+![Unnamed building](img/B21_x4.png)
+
+`OVERWORLD` tileset, **12 x 6 cells** (24 x 12 tiles of 8px, 192 x 96 px). Appears **1 time** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..12, y=1..6; tile column c=1..24, tile row r=1..12. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4   x5   x6   x7   x8   x9  x10  x11  x12 
+      +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+   y1 | A B | B B | B B | B B | B B | B B | B B | B B | B B | B B | B B | B C |
+      | D E | E E | E E | E E | E E | E E | E E | E E | E E | E E | E E | E D |
+      +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+   y2 | D E | E E | E E | E E | E E | E E | E E | E E | E E | E E | E E | E D |
+      | F G | G G | G G | G G | G G | G G | G G | G G | G G | G G | G G | G H |
+      +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+   y3 | I J | J J | J J | J J | J J | J J | J J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+   y4 | I J | J J | J J | J J | J J | J J | J J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+   y5 | I J | J J | J J | J J | J J | J J | J L | L J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+   y6 | I L | L L | L L | L L | L L | L L | M N | J J | L L | L L | L L | L K |
+      | O P | P P | P P | P P | P P | P P | * Q | P P | P P | P P | P P | P R |
+      +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4       x5       x6       x7       x8       x9      x10      x11      x12   
+      +---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+
+   y1 |  76  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  77 |
+      |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+
+   y2 |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      |  92  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  93 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+
+   y3 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+
+   y4 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+
+   y5 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  75 |  75  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+
+   y6 |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  11  12 |  10  10 |  75  75 |  75  75 |  75  75 |  75  31 |
+      |  78  26 |  26  26 |  26  26 |  26  26 |  26  26 |  26  26 |  27  28 |  26  26 |  26  26 |  26  26 |  26  26 |  26  79 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {  76,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  77 },  -- r1
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r2
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r3
+  {  92,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  93 },  -- r4
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r5
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r6
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r7
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r8
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  75,  75,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r9
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r10
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  11,  12,  10,  10,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r11
+  {  78,  26,  26,  26,  26,  26,  26,  26,  26,  26,  26,  26,  27,  28,  26,  26,  26,  26,  26,  26,  26,  26,  26,  79 },  -- r12
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 1x
+- `A` = tile 76, used 1x
+- `B` = tile 83, used 22x
+- `C` = tile 77, used 1x
+- `D` = tile 90, used 4x
+- `E` = tile 18, used 44x
+- `F` = tile 92, used 1x
+- `G` = tile 23, used 22x
+- `H` = tile 93, used 1x
+- `I` = tile 15, used 7x
+- `J` = tile 10, used 66x
+- `K` = tile 31, used 7x
+- `L` = tile 75, used 86x
+- `M` = tile 11, used 1x
+- `N` = tile 12, used 1x
+- `O` = tile 78, used 1x
+- `P` = tile 26, used 20x
+- `Q` = tile 28, used 1x
+- `R` = tile 79, used 1x
+
+![distinct tiles](img/B21_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y4   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y5   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y6   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  DOOR   SOLID  SOLID  SOLID  SOLID  SOLID
+```
+
+Enterable cell: (7,6).
+
+## Silhouette
+
+322 of the 18432 px inside the box are ground outside the black outline, so the 192x96 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:25px, c24:25px
+    r2   c1:24px, c24:24px
+    r3   c1:24px, c24:24px
+    r4   c1:24px, c24:24px
+    r5   c1:8px, c24:8px
+    r6   c1:8px, c24:8px
+    r7   c1:8px, c24:8px
+    r8   c1:8px, c24:8px
+    r9   c1:8px, c24:8px
+    r10  c1:8px, c24:8px
+    r11  c1:8px, c24:8px
+    r12  c1:8px, c24:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| ROUTE_22 | (2,0) | (8,5) | `ROUTE_22_GATE` |
diff --git a/assets/docs/buildings/B22-unnamed-building.md b/assets/docs/buildings/B22-unnamed-building.md
new file mode 100644
index 0000000..4374c5e
--- /dev/null
+++ b/assets/docs/buildings/B22-unnamed-building.md
@@ -0,0 +1,168 @@
+# B22 - Unnamed building
+
+![Unnamed building](img/B22_x5.png)
+
+`OVERWORLD` tileset, **8 x 8 cells** (16 x 16 tiles of 8px, 128 x 128 px). Appears **1 time** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..8, y=1..8; tile column c=1..16, tile row r=1..16. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4   x5   x6   x7   x8 
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y1 | A B | B B | B B | B B | B B | B B | B B | B C |
+      | D E | E E | E E | E E | E E | E E | E E | E D |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y2 | D E | E E | E E | E E | E E | E E | E E | E D |
+      | F G | G G | G G | G G | G G | G G | G G | G H |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y3 | I J | J J | J J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y4 | I J | J J | J J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y5 | I J | J J | J J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y6 | I J | J J | J J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y7 | I J | J J | J L | L J | J L | L J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y8 | I L | L L | M N | J J | M N | J J | O P | L K |
+      | Q R | R R | * S | R R | * S | R R | T T | R U |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4       x5       x6       x7       x8   
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y1 |  76  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  77 |
+      |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y2 |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      |  92  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  93 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y3 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y4 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y5 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y6 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y7 |  15  10 |  10  10 |  10  75 |  75  10 |  10  75 |  75  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y8 |  15  75 |  75  75 |  11  12 |  10  10 |  11  12 |  10  10 |  68  69 |  75  31 |
+      |  78  26 |  26  26 |  27  28 |  26  26 |  27  28 |  26  26 |  74  74 |  26  79 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {  76,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  77 },  -- r1
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r2
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r3
+  {  92,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  93 },  -- r4
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r5
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r6
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r7
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r8
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r9
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r10
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r11
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r12
+  {  15,  10,  10,  10,  10,  75,  75,  10,  10,  75,  75,  10,  10,  10,  10,  31 },  -- r13
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r14
+  {  15,  75,  75,  75,  11,  12,  10,  10,  11,  12,  10,  10,  68,  69,  75,  31 },  -- r15
+  {  78,  26,  26,  26,  27,  28,  26,  26,  27,  28,  26,  26,  74,  74,  26,  79 },  -- r16
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 2x
+- `A` = tile 76, used 1x
+- `B` = tile 83, used 14x
+- `C` = tile 77, used 1x
+- `D` = tile 90, used 4x
+- `E` = tile 18, used 28x
+- `F` = tile 92, used 1x
+- `G` = tile 23, used 14x
+- `H` = tile 93, used 1x
+- `I` = tile 15, used 11x
+- `J` = tile 10, used 70x
+- `K` = tile 31, used 11x
+- `L` = tile 75, used 78x
+- `M` = tile 11, used 2x
+- `N` = tile 12, used 2x
+- `O` = tile 68, used 1x
+- `P` = tile 69, used 1x
+- `Q` = tile 78, used 1x
+- `R` = tile 26, used 8x
+- `S` = tile 28, used 2x
+- `T` = tile 74, used 2x
+- `U` = tile 79, used 1x
+
+![distinct tiles](img/B22_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y4   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y5   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y6   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y7   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y8   SOLID  SOLID  DOOR   SOLID  DOOR   SOLID  SOLID  SOLID
+```
+
+Enterable cells: (3,8), (5,8).
+
+## Silhouette
+
+386 of the 16384 px inside the box are ground outside the black outline, so the 128x128 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:25px, c16:25px
+    r2   c1:24px, c16:24px
+    r3   c1:24px, c16:24px
+    r4   c1:24px, c16:24px
+    r5   c1:8px, c16:8px
+    r6   c1:8px, c16:8px
+    r7   c1:8px, c16:8px
+    r8   c1:8px, c16:8px
+    r9   c1:8px, c16:8px
+    r10  c1:8px, c16:8px
+    r11  c1:8px, c16:8px
+    r12  c1:8px, c16:8px
+    r13  c1:8px, c16:8px
+    r14  c1:8px, c16:8px
+    r15  c1:8px, c16:8px
+    r16  c1:8px, c16:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| CELADON_CITY | (6,6) | (8,13) | `CELADON_MART_1F` |
+| CELADON_CITY | (6,6) | (10,13) | `CELADON_MART_1F` |
diff --git a/assets/docs/buildings/B23-unnamed-building.md b/assets/docs/buildings/B23-unnamed-building.md
new file mode 100644
index 0000000..dd1a5a2
--- /dev/null
+++ b/assets/docs/buildings/B23-unnamed-building.md
@@ -0,0 +1,109 @@
+# B23 - Unnamed building
+
+![Unnamed building](img/B23_x4.png)
+
+`PLATEAU` tileset, **18 x 3 cells** (36 x 6 tiles of 8px, 288 x 48 px). Appears **1 time** in the game. Tile ids index `assets/generated/tilesets/plateau.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..18, y=1..3; tile column c=1..36, tile row r=1..6. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4   x5   x6   x7   x8   x9  x10  x11  x12  x13  x14  x15  x16  x17  x18 
+      +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+   y1 | A B | C C | C C | C C | C C | C C | C C | C C | C C | C C | C C | C C | C C | C C | C C | C C | C C | A B |
+      | D E | F F | F F | F F | F F | F F | F F | F F | F F | F F | F F | F F | F F | F F | F F | F F | F F | D E |
+      +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+   y2 | G H | I I | I I | I I | I I | I I | I I | I I | J K | C C | C C | C C | C C | J K | I I | I I | I I | G H |
+      | L M | I I | I I | I I | I I | I I | I I | I I | J K | C C | C C | C C | C C | J K | I I | I I | I I | L M |
+      +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+   y3 | L M | I I | I I | I I | N O | I I | I I | I I | J K | C C | C C | C C | C C | J K | N O | I I | I I | L M |
+      | G H | P P | P P | P P | * Q | P P | P P | P P | J K | C C | C C | C C | C C | J K | * Q | P P | P P | G H |
+      +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4       x5       x6       x7       x8       x9      x10      x11      x12      x13      x14      x15      x16      x17      x18   
+      +---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+
+   y1 |  37  38 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |   3   3 |  37  38 |
+      |  40  41 |  13  13 |  13  13 |  13  13 |  13  13 |  13  13 |  13  13 |  13  13 |  13  13 |  13  13 |  13  13 |  13  13 |  13  13 |  13  13 |  13  13 |  13  13 |  13  13 |  40  41 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+
+   y2 |  21  22 |  15  15 |  15  15 |  15  15 |  15  15 |  15  15 |  15  15 |  15  15 |  46  47 |   3   3 |   3   3 |   3   3 |   3   3 |  46  47 |  15  15 |  15  15 |  15  15 |  21  22 |
+      |   5   6 |  15  15 |  15  15 |  15  15 |  15  15 |  15  15 |  15  15 |  15  15 |  46  47 |   3   3 |   3   3 |   3   3 |   3   3 |  46  47 |  15  15 |  15  15 |  15  15 |   5   6 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+
+   y3 |   5   6 |  15  15 |  15  15 |  15  15 |  11  12 |  15  15 |  15  15 |  15  15 |  46  47 |   3   3 |   3   3 |   3   3 |   3   3 |  46  47 |  11  12 |  15  15 |  15  15 |   5   6 |
+      |  21  22 |  14  14 |  14  14 |  14  14 |  27  28 |  14  14 |  14  14 |  14  14 |  46  47 |   3   3 |   3   3 |   3   3 |   3   3 |  46  47 |  27  28 |  14  14 |  14  14 |  21  22 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {  37,  38,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,   3,  37,  38 },  -- r1
+  {  40,  41,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  13,  40,  41 },  -- r2
+  {  21,  22,  15,  15,  15,  15,  15,  15,  15,  15,  15,  15,  15,  15,  15,  15,  46,  47,   3,   3,   3,   3,   3,   3,   3,   3,  46,  47,  15,  15,  15,  15,  15,  15,  21,  22 },  -- r3
+  {   5,   6,  15,  15,  15,  15,  15,  15,  15,  15,  15,  15,  15,  15,  15,  15,  46,  47,   3,   3,   3,   3,   3,   3,   3,   3,  46,  47,  15,  15,  15,  15,  15,  15,   5,   6 },  -- r4
+  {   5,   6,  15,  15,  15,  15,  15,  15,  11,  12,  15,  15,  15,  15,  15,  15,  46,  47,   3,   3,   3,   3,   3,   3,   3,   3,  46,  47,  11,  12,  15,  15,  15,  15,   5,   6 },  -- r5
+  {  21,  22,  14,  14,  14,  14,  14,  14,  27,  28,  14,  14,  14,  14,  14,  14,  46,  47,   3,   3,   3,   3,   3,   3,   3,   3,  46,  47,  27,  28,  14,  14,  14,  14,  21,  22 },  -- r6
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 2x
+- `A` = tile 37, used 2x
+- `B` = tile 38, used 2x
+- `C` = tile 3, used 64x
+- `D` = tile 40, used 2x
+- `E` = tile 41, used 2x
+- `F` = tile 13, used 32x
+- `G` = tile 21, used 4x
+- `H` = tile 22, used 4x
+- `I` = tile 15, used 56x
+- `J` = tile 46, used 8x
+- `K` = tile 47, used 8x
+- `L` = tile 5, used 4x
+- `M` = tile 6, used 4x
+- `N` = tile 11, used 2x
+- `O` = tile 12, used 2x
+- `P` = tile 14, used 16x
+- `Q` = tile 28, used 2x
+
+![distinct tiles](img/B23_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID  DOOR   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  DOOR   SOLID  SOLID  SOLID
+```
+
+Enterable cells: (5,3), (15,3).
+
+## Silhouette
+
+1297 of the 13824 px inside the box are ground outside the black outline, so the 288x48 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:14px, c2:14px, c3:4px, c4:4px, c5:4px, c6:4px, c7:4px, c8:4px, c9:4px, c10:4px, c11:4px, c12:4px, c13:4px, c14:4px, c15:4px, c16:4px, c17:4px, c18:4px, c19:4px, c20:4px, c21:4px, c22:4px, c23:4px, c24:4px, c25:4px, c26:4px, c27:4px, c28:4px, c29:4px, c30:4px, c31:4px, c32:4px, c33:4px, c34:4px, c35:14px, c36:14px
+    r2   c1:10px, c2:10px, c3:16px, c4:16px, c5:16px, c6:16px, c7:16px, c8:16px, c9:16px, c10:16px, c11:16px, c12:16px, c13:16px, c14:16px, c15:16px, c16:16px, c17:16px, c18:16px, c19:16px, c20:16px, c21:16px, c22:16px, c23:16px, c24:16px, c25:16px, c26:16px, c27:16px, c28:16px, c29:16px, c30:16px, c31:16px, c32:16px, c33:16px, c34:16px, c35:10px, c36:10px
+    r3   c1:10px, c2:10px, c17:8px, c18:8px, c19:4px, c20:4px, c21:4px, c22:4px, c23:4px, c24:4px, c25:4px, c26:4px, c27:8px, c28:8px, c35:10px, c36:10px
+    r4   c1:8px, c2:8px, c17:8px, c18:8px, c19:1px, c27:8px, c28:8px, c35:8px, c36:8px
+    r5   c1:8px, c2:8px, c17:8px, c18:8px, c19:1px, c27:8px, c28:8px, c29:34px, c30:34px, c35:8px, c36:8px
+    r6   c1:15px, c2:15px, c3:9px, c4:8px, c5:8px, c6:8px, c7:8px, c8:8px, c11:8px, c12:8px, c13:8px, c14:8px, c15:8px, c16:8px, c17:8px, c18:8px, c19:1px, c27:8px, c28:8px, c29:18px, c30:18px, c31:9px, c32:8px, c33:8px, c34:8px, c35:15px, c36:15px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| ROUTE_23 | (0,29) | (4,31) | `VICTORY_ROAD_1F` |
+| ROUTE_23 | (0,29) | (14,31) | `VICTORY_ROAD_2F` |
diff --git a/assets/docs/buildings/B24-unnamed-building.md b/assets/docs/buildings/B24-unnamed-building.md
new file mode 100644
index 0000000..5108ee6
--- /dev/null
+++ b/assets/docs/buildings/B24-unnamed-building.md
@@ -0,0 +1,145 @@
+# B24 - Unnamed building
+
+![Unnamed building](img/B24_x5.png)
+
+`OVERWORLD` tileset, **8 x 6 cells** (16 x 12 tiles of 8px, 128 x 96 px). Appears **1 time** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..8, y=1..6; tile column c=1..16, tile row r=1..12. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4   x5   x6   x7   x8 
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y1 | A B | C C | C C | C C | C C | C C | C C | D E |
+      | F G | H H | H H | H H | H H | H H | H H | G I |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y2 | F G | H H | H H | H H | H H | H H | H H | G I |
+      | F J | K K | K K | K K | K K | K K | K K | L I |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y3 | F G | H H | H H | H H | H H | H H | H H | G I |
+      | F G | H H | H H | H H | H H | H H | H H | G I |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y4 | F J | K K | K K | K K | K K | K K | K K | L I |
+      | M N | O O | O O | O O | O O | O O | O O | P Q |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y5 | R S | S S | S S | S S | S T | T S | S S | S U |
+      | R T | T T | T T | T T | T T | T T | T T | T U |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y6 | R T | T T | T T | T T | V W | S S | T T | T U |
+      | X Y | Y Y | Y Y | Y Y | * Z | Y Y | Y Y | Y a |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4       x5       x6       x7       x8   
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y1 |   5   6 |  83  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  83 |   8   9 |
+      |  21  56 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  56  25 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y2 |  21  56 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  56  25 |
+      |  21  22 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  24  25 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y3 |  21  56 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  56  25 |
+      |  21  56 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  56  25 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y4 |  21  22 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  24  25 |
+      |  37  38 |  34  34 |  34  34 |  34  34 |  34  34 |  34  34 |  34  34 |  40  41 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y5 |  15  10 |  10  10 |  10  10 |  10  10 |  10  75 |  75  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y6 |  15  75 |  75  75 |  75  75 |  75  75 |  11  12 |  10  10 |  75  75 |  75  31 |
+      |  78  26 |  26  26 |  26  26 |  26  26 |  27  28 |  26  26 |  26  26 |  26  79 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {   5,   6,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,   8,   9 },  -- r1
+  {  21,  56,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  56,  25 },  -- r2
+  {  21,  56,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  56,  25 },  -- r3
+  {  21,  22,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  24,  25 },  -- r4
+  {  21,  56,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  56,  25 },  -- r5
+  {  21,  56,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  56,  25 },  -- r6
+  {  21,  22,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  24,  25 },  -- r7
+  {  37,  38,  34,  34,  34,  34,  34,  34,  34,  34,  34,  34,  34,  34,  40,  41 },  -- r8
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  75,  75,  10,  10,  10,  10,  31 },  -- r9
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r10
+  {  15,  75,  75,  75,  75,  75,  75,  75,  11,  12,  10,  10,  75,  75,  75,  31 },  -- r11
+  {  78,  26,  26,  26,  26,  26,  26,  26,  27,  28,  26,  26,  26,  26,  26,  79 },  -- r12
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 1x
+- `A` = tile 5, used 1x
+- `B` = tile 6, used 1x
+- `C` = tile 83, used 12x
+- `D` = tile 8, used 1x
+- `E` = tile 9, used 1x
+- `F` = tile 21, used 6x
+- `G` = tile 56, used 8x
+- `H` = tile 18, used 48x
+- `I` = tile 25, used 6x
+- `J` = tile 22, used 2x
+- `K` = tile 23, used 24x
+- `L` = tile 24, used 2x
+- `M` = tile 37, used 1x
+- `N` = tile 38, used 1x
+- `O` = tile 34, used 12x
+- `P` = tile 40, used 1x
+- `Q` = tile 41, used 1x
+- `R` = tile 15, used 3x
+- `S` = tile 10, used 14x
+- `T` = tile 75, used 26x
+- `U` = tile 31, used 3x
+- `V` = tile 11, used 1x
+- `W` = tile 12, used 1x
+- `X` = tile 78, used 1x
+- `Y` = tile 26, used 12x
+- `Z` = tile 28, used 1x
+- `a` = tile 79, used 1x
+
+![distinct tiles](img/B24_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y4   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y5   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y6   SOLID  SOLID  SOLID  SOLID  DOOR   SOLID  SOLID  SOLID
+```
+
+Enterable cell: (5,6).
+
+## Silhouette
+
+202 of the 12288 px inside the box are ground outside the black outline, so the 128x96 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:48px, c2:16px, c15:16px, c16:48px
+    r8   c1:5px, c16:5px
+    r9   c1:8px, c16:8px
+    r10  c1:8px, c16:8px
+    r11  c1:8px, c16:8px
+    r12  c1:8px, c16:8px
+```
+
+## Where it stands
+
+| map | cell (x,y) | door | leads to |
+| --- | --- | --- | --- |
+| PEWTER_CITY | (10,2) | (14,7) | `MUSEUM_1F` |
diff --git a/assets/docs/buildings/B25-unnamed-building.md b/assets/docs/buildings/B25-unnamed-building.md
new file mode 100644
index 0000000..b194fe3
--- /dev/null
+++ b/assets/docs/buildings/B25-unnamed-building.md
@@ -0,0 +1,142 @@
+# B25 - Unnamed building
+
+![Unnamed building](img/B25_x5.png)
+
+`OVERWORLD` tileset, **8 x 6 cells** (16 x 12 tiles of 8px, 128 x 96 px). Appears **1 time** in the game. Tile ids index `assets/generated/tilesets/overworld.png`, 16 per row.
+
+Coordinates are 1-based and local to the building: cell x=1..8, y=1..6; tile column c=1..16, tile row r=1..12. No terrain padding is included - edge rows and columns that were pure ground have been trimmed.
+
+## Symbol grid
+
+```
+          x1   x2   x3   x4   x5   x6   x7   x8 
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y1 | A B | B B | B B | B B | B B | B B | B B | B C |
+      | D E | E E | E E | E E | E E | E E | E E | E D |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y2 | D E | E E | E E | E E | E E | E E | E E | E D |
+      | F G | G G | G G | G G | G G | G G | G G | G H |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y3 | I J | J J | J J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y4 | I J | J J | J J | J J | J J | J J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y5 | I J | J J | J J | J J | J L | L J | J J | J K |
+      | I L | L L | L L | L L | L L | L L | L L | L K |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+   y6 | I L | L L | L L | L L | M N | J J | L L | L K |
+      | O P | P P | P P | P P | * Q | P P | P P | P R |
+      +-----+-----+-----+-----+-----+-----+-----+-----+
+```
+
+## Same grid, raw tile ids
+
+```
+            x1       x2       x3       x4       x5       x6       x7       x8   
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y1 |  76  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  83 |  83  77 |
+      |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y2 |  90  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  18 |  18  90 |
+      |  92  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  23 |  23  93 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y3 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y4 |  15  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y5 |  15  10 |  10  10 |  10  10 |  10  10 |  10  75 |  75  10 |  10  10 |  10  31 |
+      |  15  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  75 |  75  31 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+   y6 |  15  75 |  75  75 |  75  75 |  75  75 |  11  12 |  10  10 |  75  75 |  75  31 |
+      |  78  26 |  26  26 |  26  26 |  26  26 |  27  28 |  26  26 |  26  26 |  26  79 |
+      +---------+---------+---------+---------+---------+---------+---------+---------+
+```
+
+As a 1-based Lua array, `rows[r][c]`:
+
+```lua
+local rows = {
+  {  76,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  83,  77 },  -- r1
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r2
+  {  90,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  18,  90 },  -- r3
+  {  92,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  23,  93 },  -- r4
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r5
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r6
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  10,  31 },  -- r7
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r8
+  {  15,  10,  10,  10,  10,  10,  10,  10,  10,  75,  75,  10,  10,  10,  10,  31 },  -- r9
+  {  15,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  75,  31 },  -- r10
+  {  15,  75,  75,  75,  75,  75,  75,  75,  11,  12,  10,  10,  75,  75,  75,  31 },  -- r11
+  {  78,  26,  26,  26,  26,  26,  26,  26,  27,  28,  26,  26,  26,  26,  26,  79 },  -- r12
+}
+```
+
+## Legend
+
+_Legend pending._
+
+- `*` = tile 27, used 1x
+- `A` = tile 76, used 1x
+- `B` = tile 83, used 14x
+- `C` = tile 77, used 1x
+- `D` = tile 90, used 4x
+- `E` = tile 18, used 28x
+- `F` = tile 92, used 1x
+- `G` = tile 23, used 14x
+- `H` = tile 93, used 1x
+- `I` = tile 15, used 7x
+- `J` = tile 10, used 42x
+- `K` = tile 31, used 7x
+- `L` = tile 75, used 54x
+- `M` = tile 11, used 1x
+- `N` = tile 12, used 1x
+- `O` = tile 78, used 1x
+- `P` = tile 26, used 12x
+- `Q` = tile 28, used 1x
+- `R` = tile 79, used 1x
+
+![distinct tiles](img/B25_atlas.png)
+
+## Collision
+
+Cell walkability reads the **bottom-left 8px tile** of each cell (`src/world/Map.lua:6`):
+
+```
+    y1   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y2   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y3   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y4   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y5   SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID  SOLID
+    y6   SOLID  SOLID  SOLID  SOLID  DOOR   SOLID  SOLID  SOLID
+```
+
+Enterable cell: (5,6).
+
+## Silhouette
+
+322 of the 12288 px inside the box are ground outside the black outline, so the 128x96 box is **not** a solid rectangle - a pixel-perfect cutout has to follow the outline. Terrain pixels by tile row:
+
+```
+    r1   c1:25px, c16:25px
+    r2   c1:24px, c16:24px
+    r3   c1:24px, c16:24px
+    r4   c1:24px, c16:24px
+    r5   c1:8px, c16:8px
+    r6