Initial commit: Nerdhalla Valheim World Map

- Phase 1: POI database (11,309 locations, SQLite with spatial queries)
- Phase 2: Tile decoder + Leaflet.js web viewer (3 map layers, search, categories)
- Phase 3: .db/.fch parser WIP for fog-of-war overlay
- Full README with roadmap, architecture, and usage docs
This commit is contained in:
Aelith 2026-06-16 13:18:39 -04:00
commit 3a08501b0d
61 changed files with 2010 additions and 0 deletions

18
.gitignore vendored Normal file
View file

@ -0,0 +1,18 @@
# Large data files (stored in /tmp/, not in repo)
*.json
*.db
*.bin.gz
# Python cache
__pycache__/
*.pyc
*.pyo
# OS files
.DS_Store
Thumbs.db
# Environment
.env
.venv
venv/

287
README.md Normal file
View file

@ -0,0 +1,287 @@
# Nerdhalla Valheim World Map
> **Seed:** `yzZ5fr2tGa` | **World:** 24000 × 24000 | **Biomes:** 9 | **POIs:** 11,309
An interactive web map of the Nerdhalla Valheim world, built from a `valheim-map.world` export. Includes biome/height map tiles, a searchable POI database, and a roadmap for fog-of-war integration.
---
## Quick Start
```bash
# Serve the map viewer
cd ~/projects/valheim-map/output && python3 -m http.server 8765
# Open: http://72.60.69.120:8765/index.html
```
**Live:** http://72.60.69.120:8765/index.html (port 8765, ufw allowed)
---
## Project Structure
```
~/projects/valheim-map/
├── src/ # Source scripts
│ ├── valheim_db.py # Phase 1: Build POI SQLite database from locations.json
│ ├── decode_valheim_tiles.py # Phase 2: Decode .bin.gz tiles → PNG images
│ ├── build_viewer.py # Phase 2: Generate Leaflet.js web viewer
│ ├── parse_valheim_db.py # Phase 3: Attempted .db parser (WIP)
│ └── parse_valheim_zpackage.py # Phase 3: ZPackage format exploration (WIP)
├── data/ # Data files (small)
│ ├── valheim_poi.db # SQLite POI database (55 MB)
│ └── Nerdhalla.fwl # World metadata file (499 bytes)
├── output/ # Generated map assets
│ ├── index.html # Leaflet.js web viewer (5.4 MB)
│ ├── world_composite.png # Full world map, biome+height (3.7 MB)
│ ├── world_biome.png # Full world biome classification (374 KB)
│ ├── world_height.png # Full world heightmap (2.4 MB)
│ ├── XX-YY_*.png # Individual tile images (16 tiles × 3 layers)
│ └── explored.png # (future) Explored/unexplored overlay
├── notes/ # Reference notes
│ └── tile-format-readme.txt # Original valheim-map.world tile format spec
└── README.md # This file
```
**Large files not in repo** (stored in /tmp/):
- `/tmp/valheim_locations.json` — 52 MB raw location export
- `/tmp/Nerdhalla.db` — 85 MB world save from Nerdcade AMP container
- `/tmp/Nerdhalla.json` — 1.5 GB JSON conversion (valheim-save-tools)
- `/tmp/valheim_tiles/tiles/*.bin.gz` — 16 tile files, ~100 MB total
---
## Roadmap
### ✅ Phase 1 — POI Database (Complete)
**Script:** `src/valheim_db.py`
Parsed `locations.json` (52 MB) into a queryable SQLite database with spatial support.
**Tables:**
- `locations` — 11,309 entries (prefab name, category, coordinates, dungeon/loot flags)
- `important_contents` — 24,407 entries (veggisirs, surtling cores, treasure, spawners)
- `random_contents` — 457,617 entries (dungeon interior loot)
- `dungeon_components` — 49,338 entries (room layouts)
**Spatial queries work:**
- "Highest density of tar pits?" → Zone (1500, 3500) has 11 in 1000u radius
- "Most Surtling Cores?" → Crypt3 @ (2039, -1162) has 20 cores
- "Best base location for tar farming?" → (1210, -4159) — 5 tar pits in 200m, Plains biome
### ✅ Phase 2 — Web Map Viewer (Complete)
**Scripts:** `src/decode_valheim_tiles.py`, `src/build_viewer.py`
Decoded 16 tile files (4×4 grid, 1024×1024 samples each) into PNG images and built a Leaflet.js viewer.
**Features:**
- **3 map layers:** Composite (biome+height shading), Biome (9-color classification), Height (grayscale elevation)
- **11,309 POI markers** — color-coded by category, clickable with loot popups
- **Category legend** — toggle groups on/off (boss, dungeon, vegvisir, vendor, resource, etc.)
- **Search** — by prefab name or category
- **Coordinate display** — Valheim world coords in popups
**Layer toggle buttons** (bottom-left): Composite | Biome | Height
### ⏳ Phase 3 — Fog-of-War Overlay (Blocked)
**Goal:** Show explored vs unexplored areas on the map.
**Blocked by:** The explored map data is stored per-player in `.fch` character files on each player's local machine, NOT in the world `.db` file. The `.db` contains 1.8M ZDOs (world objects, terrain, structures) but no explored bitmap.
**What we have:**
- `Nerdhalla.db` (85 MB) — pulled from Nerdcade AMP container, parsed via valheim-save-tools
- `Nerdhalla.fwl` (499 bytes) — world settings (seed, presets, difficulty)
- ZPackage parser WIP in `src/parse_valheim_zpackage.py`
**What's needed:** Player `.fch` files from:
- Windows: `%USERPROFILE%\AppData\LocalLow\IronGate\Valheim\characters\`
- Linux: `~/.config/unity3d/IronGate/Valheim/characters/`
### 🔮 Phase 4 — Cartography Table Integration (Planned)
**Goal:** A shared map of truth for the server.
**Approach options (in order of preference):**
1. **ServerSideMap mod (BepInEx)** — Gold standard. Strips map data from client files, hosts on server in real-time. As you explore, fog clears for everyone. Pin sharing with dedup. Requires BepInEx on AMP Valheim instance + all players install the mod.
2. **Periodic merge script** — Collect `.fch` files from players, merge explored bitmaps (OR operation), deduplicate pins (same name + within 15m), write back clean files. Run on cron.
3. **Cartography table automation** — Lightweight BepInEx plugin that auto-triggers "Record Discoveries" near the table. Still manual but removes forgetfulness.
**Pin cleaning strategy:**
- Dedup by name + proximity (15m radius)
- "Last writer wins" for conflicting pins
- Ghost pin prevention: track pin origin, only remove if no active player has it
---
## Technical Details
### Tile Format
From the valheim-map.world export Readme:
```
struct MapSample {
uint16_t biome; // 2 bytes
float height; // 4 bytes
float forestFactor; // 4 bytes
} // 10 bytes per sample
```
- 1024 × 1024 samples per tile = 10,485,760 bytes uncompressed
- 4 × 4 tiles = 16 tiles, 24000 × 24000 Valheim units
- Gzip compressed individually
**Biome enum:** None=0, Meadows=1, Swamp=2, Mountain=4, BlackForest=8, Plains=16, AshLands=32, DeepNorth=64, Ocean=256, Mistlands=512
### Coordinate Mapping
```
Valheim coords: (-12000, -12000) to (+12000, +12000)
Image coords: (0, 0) to (4096, 4096)
pixel_x = (valheim_x + 12000) / 24000 * 4096
pixel_y = (valheim_z + 12000) / 24000 * 4096
```
### Zone System
From the [Valheim Wiki](https://valheim.fandom.com/wiki/Zones):
- Zones are 64m × 64m
- Generated: 9×9 around each player (288m radius)
- Loaded: 5×5 (160m radius)
- Active: 3×3 (96m radius)
- Current: the zone(s) containing the player
### Database Schema
```sql
-- Core locations table
CREATE TABLE locations (
id INTEGER PRIMARY KEY,
prefab_name TEXT NOT NULL,
category TEXT NOT NULL,
pos_x REAL, pos_y REAL, pos_z REAL,
has_dungeon INTEGER DEFAULT 0,
has_important INTEGER DEFAULT 0,
has_random INTEGER DEFAULT 0
);
-- Important contents (veggisirs, cores, treasure)
CREATE TABLE important_contents (
id INTEGER PRIMARY KEY,
location_id INTEGER NOT NULL,
friendly_name TEXT,
count INTEGER,
icon TEXT,
FOREIGN KEY (location_id) REFERENCES locations(id)
);
-- Random contents (dungeon interior loot)
CREATE TABLE random_contents (
id INTEGER PRIMARY KEY,
location_id INTEGER NOT NULL,
type TEXT, parent TEXT, name TEXT, interior INTEGER,
FOREIGN KEY (location_id) REFERENCES locations(id)
);
-- Dungeon components (room layouts)
CREATE TABLE dungeon_components (
id INTEGER PRIMARY KEY,
location_id INTEGER NOT NULL,
delta_x REAL, delta_y REAL, rotation_y REAL,
parent_type TEXT, parent_name TEXT,
FOREIGN KEY (location_id) REFERENCES locations(id)
);
```
### .db File Format (ZPackage)
The world `.db` file uses Unity's ZPackage binary serialization format:
```
int32: world_version
double: net_time
int64: my_id
uint32: next_uid
int32: num_zdos
ZDO[]: zone data objects (1,800,945 in Nerdhalla)
Zones: zone management data
RandomEvent: random event state
SHA512: 64-byte hash
```
Each ZDO contains: uid, prefab hash, owner, data type, revision, key-value pairs (floats, vectors, strings, byte arrays), position, rotation.
**Current world version:** 37 (valheim-save-tools max: 34 — parses with warning)
---
## Usage Examples
### Query the POI database
```bash
sqlite3 ~/projects/valheim-map/data/valheim_poi.db
# Find all tar pits
SELECT pos_x, pos_z FROM locations WHERE prefab_name LIKE '%TarPit%';
# Find boss altars
SELECT prefab_name, pos_x, pos_z FROM locations WHERE category='boss';
# Find dungeons with most surtling cores
SELECT l.prefab_name, l.pos_x, l.pos_z, SUM(ic.count) as total
FROM locations l JOIN important_contents ic ON l.id = ic.location_id
WHERE ic.friendly_name LIKE '%SurtlingCore%'
GROUP BY l.id ORDER BY total DESC LIMIT 10;
# Find vegvisirs
SELECT ic.friendly_name, l.pos_x, l.pos_z
FROM locations l JOIN important_contents ic ON l.id = ic.location_id
WHERE ic.friendly_name LIKE '%Vegvisir%';
```
### Rebuild from scratch
```bash
# 1. Build POI database
python3 src/valheim_db.py
# 2. Decode tiles to PNG
python3 src/decode_valheim_tiles.py
# 3. Build web viewer
python3 src/build_viewer.py
# 4. Serve
cd output && python3 -m http.server 8765
```
---
## Data Sources
| Source | File | Size | Origin |
|--------|------|------|--------|
| valheim-map.world export | `locations.json` | 52 MB | ROG (E:\Downloads\MapData_yzZ5fr2tGa\) |
| valheim-map.world export | `tiles/*.bin.gz` | ~100 MB | ROG |
| AMP Valheim container | `Nerdhalla.db` | 85 MB | Nerdcade (`docker cp AMP_Nerdhalla01:/AMP/Valheim/896660/Saves/worlds_local/`) |
| AMP Valheim container | `Nerdhalla.fwl` | 499 B | Nerdcade |
---
## Future Work
- [ ] **Fog-of-war overlay** — get `.fch` files from players, extract explored bitmap
- [ ] **ServerSideMap mod** — install BepInEx on AMP Valheim, deploy shared map
- [ ] **Pin cleaning/syncing** — dedup, merge, ghost pin prevention
- [ ] **Auto-update cron** — periodically pull `.db` from Nerdcade, regenerate map
- [ ] **Forgejo repo** — host this project in a self-hosted git instance
- [ ] **FoundryVTT export** — convert POI data for tabletop use
- [ ] **Mobile-friendly** — responsive layout for phone viewing

BIN
data/Nerdhalla.fwl Normal file

Binary file not shown.

View file

@ -0,0 +1,19 @@
Tile format (from valheim-map.world export Readme.txt):
Each tile is a gzip-compressed binary file containing a flat array of:
struct MapSample {
uint16_t biome; // 2 bytes - biome enum
float height; // 4 bytes - height in Valheim units
float forestFactor; // 4 bytes - forest density
} // = 10 bytes per sample
1024 x 1024 samples per tile = 10,485,760 bytes uncompressed
4x4 tiles = 16 tiles total
World: 24000 x 24000 Valheim units
Biome enum:
None=0, Meadows=1, Swamp=2, Mountain=4, BlackForest=8,
Plains=16, AshLands=32, DeepNorth=64, Ocean=256, Mistlands=512
locations.json: list of every location with prefab name, position, contents
map.json: world metadata (seed, tile size, version)

BIN
output/00-00_biome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

BIN
output/00-00_composite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
output/00-00_height.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
output/00-01_biome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
output/00-01_composite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

BIN
output/00-01_height.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

BIN
output/00-02_biome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
output/00-02_composite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

BIN
output/00-02_height.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

BIN
output/00-03_biome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
output/00-03_composite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
output/00-03_height.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
output/01-00_biome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
output/01-00_composite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

BIN
output/01-00_height.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

BIN
output/01-01_biome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
output/01-01_composite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

BIN
output/01-01_height.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

BIN
output/01-02_biome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
output/01-02_composite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

BIN
output/01-02_height.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

BIN
output/01-03_biome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
output/01-03_composite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

BIN
output/01-03_height.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
output/02-00_biome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
output/02-00_composite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

BIN
output/02-00_height.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

BIN
output/02-01_biome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
output/02-01_composite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

BIN
output/02-01_height.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

BIN
output/02-02_biome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
output/02-02_composite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

BIN
output/02-02_height.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

BIN
output/02-03_biome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
output/02-03_composite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

BIN
output/02-03_height.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

BIN
output/03-00_biome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

BIN
output/03-00_composite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
output/03-00_height.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
output/03-01_biome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
output/03-01_composite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

BIN
output/03-01_height.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

BIN
output/03-02_biome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
output/03-02_composite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

BIN
output/03-02_height.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

BIN
output/03-03_biome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
output/03-03_composite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
output/03-03_height.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

252
output/index.html Normal file

File diff suppressed because one or more lines are too long

BIN
output/world_biome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

BIN
output/world_composite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

BIN
output/world_height.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

344
src/build_viewer.py Normal file
View file

@ -0,0 +1,344 @@
#!/usr/bin/env python3
"""
Build a Leaflet.js web viewer for the Nerdhalla Valheim world.
"""
import sqlite3, json, os
DB_PATH = "/tmp/valheim_poi.db"
OUTPUT_DIR = "/tmp/valheim_map_output"
VIEWER_PATH = os.path.join(OUTPUT_DIR, "index.html")
WORLD_SIZE = 24000
PIXELS_TOTAL = 4096
def valheim_to_pixel(vx, vz):
px = (vx + WORLD_SIZE/2) / WORLD_SIZE * PIXELS_TOTAL
py = (vz + WORLD_SIZE/2) / WORLD_SIZE * PIXELS_TOTAL
return px, py
def query_pois():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""
SELECT id, prefab_name, category, pos_x, pos_z, has_dungeon, has_important
FROM locations WHERE pos_x IS NOT NULL AND pos_z IS NOT NULL
ORDER BY category, prefab_name
""")
pois = []
for row in c.fetchall():
loc_id, name, cat, x, z, has_dungeon, has_important = row
px, py = valheim_to_pixel(x, z)
pois.append({
'id': loc_id, 'name': name, 'category': cat,
'x': round(x, 1), 'z': round(z, 1),
'px': round(px, 1), 'py': round(py, 1),
'has_dungeon': bool(has_dungeon), 'has_important': bool(has_important),
})
c.execute("""
SELECT l.id, l.prefab_name, l.pos_x, l.pos_z, ic.friendly_name, ic.count
FROM locations l JOIN important_contents ic ON l.id = ic.location_id
WHERE l.pos_x IS NOT NULL AND l.pos_z IS NOT NULL
ORDER BY ic.count DESC
""")
contents = []
for row in c.fetchall():
loc_id, name, x, z, fname, count = row
px, py = valheim_to_pixel(x, z)
contents.append({
'loc_id': loc_id, 'loc_name': name, 'item': fname, 'count': count,
'x': round(x, 1), 'z': round(z, 1),
'px': round(px, 1), 'py': round(py, 1),
})
conn.close()
return pois, contents
def build_viewer(pois, contents):
pois_json = json.dumps(pois)
contents_json = json.dumps(contents)
cat_colors = {
'boss': '#ff4444', 'dungeon': '#ff8800', 'vegvisir': '#44aaff',
'altar': '#aa88ff', 'vendor': '#ffdd00', 'structure': '#88cc88',
'resource': '#ff66aa', 'spawner': '#ff44ff', 'point_of_interest': '#44ffaa',
'other': '#888888',
}
cat_colors_json = json.dumps(cat_colors)
cat_labels = {
'boss': 'Boss Altars', 'dungeon': 'Dungeons', 'vegvisir': 'Vegvisirs',
'vendor': 'Vendors', 'altar': 'Altars / Stones', 'structure': 'Structures',
'resource': 'Resources', 'spawner': 'Spawners', 'point_of_interest': 'POIs',
'other': 'Other',
}
cat_labels_json = json.dumps(cat_labels)
# Build category counts
cat_counts = {}
for p in pois:
cat_counts[p['category']] = cat_counts.get(p['category'], 0) + 1
cat_counts_json = json.dumps(cat_counts)
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nerdhalla Valheim World Map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; font-family: 'Segoe UI', system-ui, sans-serif; }
#map { height: 100vh; width: 100vw; }
.leaflet-container { background: #1a1a2e; }
.info-panel {
position: absolute; top: 10px; right: 10px; z-index: 1000;
background: rgba(20, 20, 35, 0.92); color: #ddd;
padding: 12px 16px; border-radius: 8px; font-size: 13px;
max-width: 280px; max-height: 80vh; overflow-y: auto;
border: 1px solid #444; backdrop-filter: blur(8px);
}
.info-panel h3 { color: #ffd700; margin-bottom: 6px; font-size: 15px; }
.info-panel .stat { display: flex; justify-content: space-between; padding: 2px 0; }
.info-panel .stat span:last-child { color: #aaa; }
.legend {
position: absolute; bottom: 30px; right: 10px; z-index: 1000;
background: rgba(20, 20, 35, 0.92); color: #ddd;
padding: 10px 14px; border-radius: 8px; font-size: 12px;
border: 1px solid #444; backdrop-filter: blur(8px);
max-height: 50vh; overflow-y: auto;
}
.legend-item { display: flex; align-items: center; gap: 6px; padding: 2px 0; cursor: pointer; }
.legend-item .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.legend-item .count { color: #888; margin-left: auto; font-size: 11px; }
.legend-item.active { color: #fff; }
.legend-item.inactive { opacity: 0.4; }
.search-box {
position: absolute; top: 10px; left: 10px; z-index: 1000;
background: rgba(20, 20, 35, 0.92); border: 1px solid #444;
border-radius: 8px; padding: 8px 12px; backdrop-filter: blur(8px);
display: flex; gap: 6px; align-items: center;
}
.search-box input {
background: transparent; border: none; color: #ddd; font-size: 13px;
outline: none; width: 180px;
}
.search-box input::placeholder { color: #666; }
.search-box .results {
position: absolute; top: 100%; left: 0; right: 0;
background: rgba(20, 20, 35, 0.95); border: 1px solid #444;
border-radius: 0 0 8px 8px; max-height: 200px; overflow-y: auto;
display: none;
}
.search-box .results div { padding: 6px 12px; cursor: pointer; font-size: 12px; border-bottom: 1px solid #333; }
.search-box .results div:hover { background: rgba(255,255,255,0.1); }
.search-box .results div .cat { color: #888; font-size: 10px; }
.popup-content { font-size: 12px; line-height: 1.5; }
.popup-content .name { font-weight: bold; font-size: 14px; color: #ffd700; }
.popup-content .coords { color: #888; }
.popup-content .badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; margin: 2px 2px 0 0; }
.popup-content .badge-dungeon { background: #ff8800; color: #000; }
.popup-content .badge-treasure { background: #ffd700; color: #000; }
.toggle-layer {
position: absolute; bottom: 30px; left: 10px; z-index: 1000;
display: flex; flex-direction: column; gap: 4px;
}
.toggle-layer button {
background: rgba(20, 20, 35, 0.92); color: #ddd; border: 1px solid #444;
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 11px;
backdrop-filter: blur(8px); text-align: left;
}
.toggle-layer button.active { border-color: #ffd700; color: #ffd700; }
.toggle-layer button:hover { background: rgba(40, 40, 60, 0.95); }
@media (max-width: 768px) { .info-panel { display: none; } .search-box input { width: 120px; } }
</style>
</head>
<body>
<div id="map"></div>
<div class="search-box">
<span style="color:#666;">&#x1F50D;</span>
<input type="text" id="search" placeholder="Search POIs..." oninput="doSearch(this.value)">
<div class="results" id="searchResults"></div>
</div>
<div class="info-panel" id="infoPanel">
<h3>&#x1F30D; Nerdhalla</h3>
<div class="stat"><span>Seed</span><span>yzZ5fr2tGa</span></div>
<div class="stat"><span>World</span><span>24000 x 24000</span></div>
<div class="stat"><span>POIs</span><span>""" + str(len(pois)) + """</span></div>
<div class="stat"><span>Items</span><span>""" + str(len(contents)) + """</span></div>
<div class="stat"><span>Biomes</span><span>9</span></div>
<hr style="border-color:#333;margin:6px 0;">
<div style="font-size:11px;color:#888;">
Click a marker for details<br>
Search by name or category
</div>
</div>
<div class="toggle-layer">
<button class="active" onclick="toggleLayer('composite')" id="btn-composite">&#x1F5FA; Composite</button>
<button onclick="toggleLayer('biome')" id="btn-biome">&#x1F33F; Biome</button>
<button onclick="toggleLayer('height')" id="btn-height">&#x26F0; Height</button>
</div>
<div class="legend" id="legend"></div>
<script>
const POIS = """ + pois_json + """;
const CONTENTS = """ + contents_json + """;
const CAT_COLORS = """ + cat_colors_json + """;
const CAT_LABELS = """ + cat_labels_json + """;
const CAT_COUNTS = """ + cat_counts_json + """;
const map = L.map('map', {
center: [2048, 2048],
zoom: -2,
minZoom: -3,
maxZoom: 3,
crs: L.CRS.Simple,
zoomSnap: 0.5,
zoomDelta: 0.5,
});
const bounds = [[0, 0], [4096, 4096]];
const layers = {};
layers.composite = L.imageOverlay('world_composite.png', bounds, { opacity: 1.0 }).addTo(map);
layers.biome = L.imageOverlay('world_biome.png', bounds, { opacity: 0.0 }).addTo(map);
layers.height = L.imageOverlay('world_height.png', bounds, { opacity: 0.0 }).addTo(map);
let activeLayer = 'composite';
function toggleLayer(name) {
if (name === activeLayer) return;
layers[activeLayer].setOpacity(0.0);
layers[name].setOpacity(1.0);
document.getElementById('btn-' + activeLayer).classList.remove('active');
document.getElementById('btn-' + name).classList.add('active');
activeLayer = name;
}
let markerLayer = L.layerGroup().addTo(map);
let allMarkers = [];
let activeCategories = new Set(Object.keys(CAT_COUNTS));
function createMarkers() {
markerLayer.clearLayers();
allMarkers = [];
POIS.forEach(function(p) {
if (!activeCategories.has(p.category)) return;
const color = CAT_COLORS[p.category] || '#888';
const icon = L.divIcon({
className: '',
html: '<div style="width:10px;height:10px;border-radius:50%;background:' + color + ';border:2px solid rgba(255,255,255,0.5);box-shadow:0 0 4px rgba(0,0,0,0.5);"></div>',
iconSize: [10, 10],
iconAnchor: [5, 5],
});
const marker = L.marker([p.py, p.px], { icon: icon }).addTo(markerLayer);
let popup = '<div class="popup-content">';
popup += '<div class="name">' + p.name + '</div>';
popup += '<div class="coords">(' + p.x + ', ' + p.z + ')</div>';
popup += '<div style="color:' + color + ';">' + p.category + '</div>';
if (p.has_dungeon) popup += '<span class="badge badge-dungeon">Dungeon</span>';
if (p.has_important) popup += '<span class="badge badge-treasure">Has Loot</span>';
const items = CONTENTS.filter(function(c) { return c.loc_id === p.id; });
if (items.length > 0) {
popup += '<hr style="border-color:#333;margin:4px 0;"><div style="font-size:11px;">';
for (let i = 0; i < Math.min(5, items.length); i++) {
popup += '<div>&bull; ' + items[i].item + ' x' + items[i].count + '</div>';
}
if (items.length > 5) {
popup += '<div style="color:#888;">... +' + (items.length - 5) + ' more</div>';
}
popup += '</div>';
}
popup += '</div>';
marker.bindPopup(popup);
allMarkers.push({ marker: marker, p: p });
});
}
function buildLegend() {
const legend = document.getElementById('legend');
const order = ['boss', 'dungeon', 'vegvisir', 'vendor', 'resource', 'altar', 'structure', 'spawner', 'point_of_interest', 'other'];
let html = '';
order.forEach(function(cat) {
if (!CAT_COUNTS[cat]) return;
const color = CAT_COLORS[cat] || '#888';
const active = activeCategories.has(cat) ? 'active' : 'inactive';
html += '<div class="legend-item ' + active + '" onclick="toggleCategory(\\'' + cat + '\\')">';
html += '<span class="dot" style="background:' + color + '"></span>';
html += '<span>' + (CAT_LABELS[cat] || cat) + '</span>';
html += '<span class="count">' + CAT_COUNTS[cat] + '</span></div>';
});
legend.innerHTML = html;
}
function toggleCategory(cat) {
if (activeCategories.has(cat)) {
activeCategories.delete(cat);
} else {
activeCategories.add(cat);
}
createMarkers();
buildLegend();
}
function doSearch(query) {
const results = document.getElementById('searchResults');
if (!query || query.length < 2) { results.style.display = 'none'; return; }
const q = query.toLowerCase();
const matches = POIS.filter(function(p) {
return p.name.toLowerCase().indexOf(q) !== -1 || p.category.toLowerCase().indexOf(q) !== -1;
}).slice(0, 20);
if (matches.length === 0) { results.style.display = 'none'; return; }
let html = '';
matches.forEach(function(p) {
html += '<div onclick="flyTo(' + p.px + ', ' + p.py + ', \\'' + p.name + '\\')">';
html += '<span style="color:' + (CAT_COLORS[p.category] || '#888') + ';">&#x25CF;</span> ';
html += p.name + ' <span class="cat">' + p.category + '</span></div>';
});
results.innerHTML = html;
results.style.display = 'block';
}
function flyTo(px, py, name) {
map.flyTo([py, px], 1, { duration: 0.5 });
document.getElementById('searchResults').style.display = 'none';
document.getElementById('search').value = name;
}
document.addEventListener('click', function(e) {
if (!e.target.closest('.search-box')) {
document.getElementById('searchResults').style.display = 'none';
}
});
createMarkers();
buildLegend();
map.fitBounds(bounds);
</script>
</body>
</html>"""
with open(VIEWER_PATH, 'w') as f:
f.write(html)
print(f"Viewer written to {VIEWER_PATH}")
if __name__ == '__main__':
print("Building Valheim map viewer...")
pois, contents = query_pois()
print(f"Loaded {len(pois)} POIs and {len(contents)} important contents")
build_viewer(pois, contents)
print("Done!")

254
src/decode_valheim_tiles.py Normal file
View file

@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""
Decode Valheim map tiles (from valheim-map.world export) into PNG images.
Tile format per sample: uint16_t biome + float height + float forestFactor = 10 bytes.
1024 x 1024 samples per tile, 4x4 tiles = 24000x24000 world.
"""
import struct, gzip, os, json, math
from PIL import Image
TILES_DIR = "/tmp/valheim_tiles/tiles"
OUTPUT_DIR = "/tmp/valheim_map_output"
MAP_JSON_PATH = "/tmp/valheim_map_data/map.json"
# Biome colors (distinct, map-friendly)
BIOME_COLORS = {
0: (30, 30, 40), # None (deep ocean/void)
1: (107, 142, 35), # Meadows - olive green
2: (85, 107, 47), # Swamp - dark olive
4: (139, 137, 137), # Mountain - grey
8: (34, 100, 34), # BlackForest - dark green
16: (210, 180, 60), # Plains - gold
32: (139, 69, 19), # AshLands - dark brown/rust
64: (200, 220, 255), # DeepNorth - ice blue
256: (25, 60, 120), # Ocean - deep blue
512: (120, 80, 150), # Mistlands - purple
}
# Biome to height-based tint mapping for compositing
BIOME_HEIGHT_KEYS = {
1: 'meadows',
2: 'swamp',
4: 'mountain',
8: 'blackforest',
16: 'plains',
32: 'ashlands',
64: 'deepnorth',
256: 'ocean',
512: 'mistlands',
}
def read_tile(tile_path):
"""Read a compressed tile and return (biomes, heights, forest_factors) as 2D arrays.
Format: flat array of struct { uint16_t biome; float height; float forestFactor; }
= 10 bytes per sample. No header. 1024*1024 = 1,048,576 samples = 10,485,760 bytes.
"""
with gzip.open(tile_path, 'rb') as f:
raw = f.read()
total_samples = 1024 * 1024
expected = total_samples * 10
actual = len(raw)
if actual != expected:
print(f" NOTE: Got {actual} bytes, expected {expected} (off by {actual - expected})")
biomes = [[0]*1024 for _ in range(1024)]
heights = [[0.0]*1024 for _ in range(1024)]
forests = [[0.0]*1024 for _ in range(1024)]
n = min(total_samples, actual // 10)
for i in range(n):
offset = i * 10
biome, height, forest = struct.unpack('<Hff', raw[offset:offset+10])
row = i // 1024
col = i % 1024
biomes[row][col] = biome
heights[row][col] = height
forests[row][col] = forest
return biomes, heights, forests
def make_biome_image(tile_biomes):
"""Create a biome classification image (1024x1024 RGB)."""
img = Image.new('RGB', (1024, 1024))
pixels = img.load()
for y in range(1024):
for x in range(1024):
biome = tile_biomes[y][x]
# Find the exact biome (bitfield - multiple can be set)
# Common Valheim biomes are powers of 2; pick highest
found = 0
for b in [512, 256, 64, 32, 16, 8, 4, 2, 1]:
if biome & b:
found = b
break
pixels[x, y] = BIOME_COLORS.get(found, (50, 50, 50))
return img
def make_height_image(tile_heights):
"""Create a heightmap image (grayscale, normalized to 0-255)."""
all_heights = [h for row in tile_heights for h in row if not math.isnan(h) and h > -1000]
if not all_heights:
return Image.new('L', (1024, 1024), 128)
h_min, h_max = min(all_heights), max(all_heights)
h_range = h_max - h_min if h_max > h_min else 1
img = Image.new('L', (1024, 1024))
pixels = img.load()
for y in range(1024):
for x in range(1024):
h = tile_heights[y][x]
if math.isnan(h) or h < -1000:
val = 0
else:
val = int((h - h_min) / h_range * 255)
pixels[x, y] = max(0, min(255, val))
return img
def make_composite_image(tile_biomes, tile_heights):
"""Create a combined biome+height map using height-shaded biome colors."""
all_heights = [h for row in tile_heights for h in row if not math.isnan(h) and h > -1000]
if not all_heights:
return make_biome_image(tile_biomes)
h_min, h_max = min(all_heights), max(all_heights)
h_range = h_max - h_min if h_max > h_min else 1
img = Image.new('RGB', (1024, 1024))
pixels = img.load()
for y in range(1024):
for x in range(1024):
biome = tile_biomes[y][x]
h = tile_heights[y][x]
# Find dominant biome
found = 0
for b in [512, 256, 64, 32, 16, 8, 4, 2, 1]:
if biome & b:
found = b
break
base = BIOME_COLORS.get(found, (50, 50, 50))
# Height shading (darker = lower, lighter = higher)
if math.isnan(h) or h < -1000:
shade = 0.5
else:
shade = 0.4 + 0.6 * ((h - h_min) / h_range)
r = int(base[0] * shade)
g = int(base[1] * shade)
b = int(base[2] * shade)
pixels[x, y] = (max(0, min(255, r)),
max(0, min(255, g)),
max(0, min(255, b)))
return img
def make_biome_and_height_tiles():
"""Process all 16 tiles into PNG images."""
os.makedirs(OUTPUT_DIR, exist_ok=True)
tile_names = sorted([f for f in os.listdir(TILES_DIR) if f.endswith('.bin.gz')])
print(f"Found {len(tile_names)} tiles")
for tile_name in tile_names:
tile_path = os.path.join(TILES_DIR, tile_name)
base = tile_name.replace('.bin.gz', '')
print(f"\nDecoding {tile_name}...")
biomes, heights, forests = read_tile(tile_path)
print(f" Biomes: {len(set(b for row in biomes for b in row))} unique values")
# Biome image
biome_img = make_biome_image(biomes)
biome_path = os.path.join(OUTPUT_DIR, f"{base}_biome.png")
biome_img.save(biome_path)
print(f" Biome: {biome_path}")
# Height image
height_img = make_height_image(heights)
height_path = os.path.join(OUTPUT_DIR, f"{base}_height.png")
height_img.save(height_path)
print(f" Height: {height_path}")
# Composite image
composite_img = make_composite_image(biomes, heights)
composite_path = os.path.join(OUTPUT_DIR, f"{base}_composite.png")
composite_img.save(composite_path)
print(f" Composite: {composite_path}")
print(f"\nAll tiles processed. Output in {OUTPUT_DIR}/")
def stitch_full_map():
"""Stitch the 4x4 tiles into a full world map."""
tile_names = sorted([f for f in os.listdir(TILES_DIR) if f.endswith('.bin.gz')])
# Parse tile coords - they're xx-yy.bin.gz
tiles = {}
for tn in tile_names:
parts = tn.replace('.bin.gz', '').split('-')
x, y = int(parts[0]), int(parts[1])
tiles[(x, y)] = tn
full_biome = Image.new('RGB', (4096, 4096))
full_height = Image.new('L', (4096, 4096))
full_composite = Image.new('RGB', (4096, 4096))
for (tx, ty), tn in sorted(tiles.items()):
print(f"Stitching tile {tn} at position ({tx}, {ty})...")
biomes, heights, _ = read_tile(os.path.join(TILES_DIR, tn))
biome_img = make_biome_image(biomes)
full_biome.paste(biome_img, (tx * 1024, ty * 1024))
height_img = make_height_image(heights)
full_height.paste(height_img, (tx * 1024, ty * 1024))
composite_img = make_composite_image(biomes, heights)
full_composite.paste(composite_img, (tx * 1024, ty * 1024))
world_biome_path = os.path.join(OUTPUT_DIR, "world_biome.png")
world_height_path = os.path.join(OUTPUT_DIR, "world_height.png")
world_composite_path = os.path.join(OUTPUT_DIR, "world_composite.png")
full_biome.save(world_biome_path)
full_height.save(world_height_path)
full_composite.save(world_composite_path)
print(f"\nFull world maps saved:")
print(f" Biome: {world_biome_path}")
print(f" Height: {world_height_path}")
print(f" Composite: {world_composite_path}")
if __name__ == '__main__':
print("=" * 60)
print("Valheim Tile Decoder")
print("=" * 60)
print(f"Reading tiles from: {TILES_DIR}")
# First check if PIL is available
try:
from PIL import Image
except ImportError:
print("ERROR: Pillow not installed. Install with: pip install Pillow")
sys.exit(1)
# Check first tile structure
sample_tile = sorted([f for f in os.listdir(TILES_DIR) if f.endswith('.bin.gz')])[0]
with gzip.open(os.path.join(TILES_DIR, sample_tile), 'rb') as f:
raw = f.read()
print(f"Sample tile {sample_tile}: {len(raw)} bytes uncompressed")
print(f" Header (first 4 bytes): {raw[:4].hex()} = {struct.unpack('<I', raw[:4])[0]}")
print(f" Expected sample size: 10 bytes per pixel × {1024*1024} pixels + 4 header = {(10*1024*1024)+4}")
print(f" Actual match: {len(raw) == (10 * 1024 * 1024) + 4}")
# Process individual tile images
make_biome_and_height_tiles()
# Stitch full world map
print("\n" + "=" * 60)
print("Stitching full world map (4096x4096)...")
print("=" * 60)
stitch_full_map()
print("\nDone!")

287
src/parse_valheim_db.py Normal file
View file

@ -0,0 +1,287 @@
#!/usr/bin/env python3
"""
Parse Valheim .db world file and extract explored/unexplored map data.
Based on reverse-engineered format from ValheimMapCombiner and valheim-save-tools.
"""
import struct, sys, os
from PIL import Image
DB_PATH = "/tmp/Nerdhalla.db"
OUTPUT_DIR = "/tmp/valheim_map_output"
def read_save_file(path):
"""Parse a Valheim .db file and return structured data."""
with open(path, 'rb') as f:
raw = f.read()
offset = 0
# 4 bytes: file length (without SHA512 hash)
file_len = struct.unpack_from('<I', raw, offset)[0]
offset += 4
# 20 bytes: data1 (player ID?)
data1 = raw[offset:offset+20]
offset += 20
# 4 bytes: world count
world_count = struct.unpack_from('<I', raw, offset)[0]
offset += 4
worlds = []
for _ in range(world_count):
# 8 bytes: world key (world ID)
world_key = raw[offset:offset+8]
offset += 8
# 51 bytes: world data 1
world_data1 = raw[offset:offset+51]
offset += 51
# 1 byte: map data bool
map_data_bool = raw[offset]
offset += 1
# 4 bytes: map data length
map_data_len = struct.unpack_from('<I', raw, offset)[0]
offset += 4
# map data
map_data_raw = raw[offset:offset+map_data_len]
offset += map_data_len
map_data = parse_map_data(map_data_raw)
worlds.append({
'world_key': world_key,
'world_data1': world_data1,
'map_data_bool': map_data_bool,
'map_data_len': map_data_len,
'map_data': map_data,
})
# Remaining data (minus 64 bytes SHA512 hash)
data2 = raw[offset:-64]
sha512_hash = raw[-64:]
return {
'file_len': file_len,
'data1': data1,
'world_count': world_count,
'worlds': worlds,
'data2': data2,
'sha512_hash': sha512_hash,
}
def parse_map_data(map_data):
"""Parse the map data section containing explored bitmap and pins."""
offset = 0
# num1 (int32) - version?
num1 = struct.unpack_from('<i', map_data, offset)[0]
offset += 4
# num2 (int32) - ?
num2 = struct.unpack_from('<i', map_data, offset)[0]
offset += 4
# Explored data - remaining until pin data
# The explored bitmap is 4096*4096/8 = 2,097,152 bytes
# But it's stored as int32 array, so we need to find the pin count
# Read int32s until we find what looks like pin count
# Pin count is typically a small number (< 1000)
# The explored bitmap is 4096*4096 bits = 2,097,152 bytes = 524,288 int32s
# Let's read the whole thing as int32 array and find the pin count
remaining = len(map_data) - offset
int32_count = remaining // 4
# The explored bitmap is 4096*4096 bits = 2,097,152 bytes
# But it might be stored differently. Let's read it all and figure it out.
# Actually, the explored data is stored as a bitmap where each bit = one map pixel
# Valheim map is 4096x4096 = 16,777,216 pixels
# At 1 bit per pixel = 2,097,152 bytes = 524,288 int32s
# But the actual data might be smaller if the map hasn't been fully explored
# Let's just read the raw bytes and try to interpret
explored_bytes = map_data[offset:]
return {
'num1': num1,
'num2': num2,
'explored_raw': explored_bytes,
}
def extract_explored_bitmap(save_data):
"""Extract the explored/unexplored bitmap from parsed save data."""
if not save_data['worlds']:
print("No worlds found in save file")
return None
world = save_data['worlds'][0]
map_data = world['map_data']
raw = map_data['explored_raw']
print(f"Explored data size: {len(raw)} bytes")
# Valheim map is 4096x4096 pixels
# Explored data is a bitmap: 1 bit per pixel
# Expected size: 4096 * 4096 / 8 = 2,097,152 bytes
# But the data might be stored as int32s (4 bytes per value)
# Let's check if it looks like a bitmap or int32 array
# Try interpreting as bitmap directly
expected_bitmap = 4096 * 4096 // 8
if len(raw) >= expected_bitmap:
print(f"Data is at least {expected_bitmap} bytes - treating as bitmap")
bitmap = raw[:expected_bitmap]
else:
print(f"Data is {len(raw)} bytes, less than expected {expected_bitmap}")
print("Trying to interpret as int32 array...")
# Read as int32s and see what we get
vals = struct.unpack_from(f'<{len(raw)//4}I', raw)
print(f"First 20 int32 values: {vals[:20]}")
print(f"Max value: {max(vals)}, Min value: {min(vals)}")
# If values are 0 or 1, it might be a per-pixel explored flag
if max(vals) <= 1:
print("Values are 0/1 - treating as per-pixel explored flags")
bitmap_bytes = bytearray()
for i in range(0, len(vals), 8):
byte = 0
for j in range(8):
if i + j < len(vals) and vals[i + j]:
byte |= (1 << j)
bitmap_bytes.append(byte)
bitmap = bytes(bitmap_bytes)
else:
# Try treating raw bytes as bitmap directly
bitmap = raw[:expected_bitmap]
return bitmap
def bitmap_to_image(bitmap, width=4096, height=4096):
"""Convert explored bitmap to a grayscale image (white=explored, black=unexplored)."""
if bitmap is None:
return None
img = Image.new('L', (width, height))
pixels = img.load()
bytes_per_row = (width + 7) // 8
for y in range(height):
row_start = y * bytes_per_row
for x in range(width):
byte_idx = row_start + (x // 8)
bit_idx = x % 8
if byte_idx < len(bitmap):
explored = (bitmap[byte_idx] >> bit_idx) & 1
pixels[x, y] = 255 if explored else 0
else:
pixels[x, y] = 0
return img
def create_fog_overlay(explored_img):
"""Create a fog-of-war overlay: explored=transparent, unexplored=dark overlay."""
if explored_img is None:
return None
# Create RGBA image: unexplored areas get a dark overlay
overlay = Image.new('RGBA', (4096, 4096))
overlay_pixels = overlay.load()
explored_pixels = explored_img.load()
for y in range(4096):
for x in range(4096):
if explored_pixels[x, y] > 128:
# Explored - transparent
overlay_pixels[x, y] = (0, 0, 0, 0)
else:
# Unexplored - dark semi-transparent
overlay_pixels[x, y] = (0, 0, 0, 200)
return overlay
def main():
print("=" * 60)
print("Valheim .db File Parser — Explored Map Data")
print("=" * 60)
print(f"\nReading: {DB_PATH}")
print(f"File size: {os.path.getsize(DB_PATH)} bytes")
save_data = read_save_file(DB_PATH)
print(f"\nWorld count: {save_data['world_count']}")
for i, world in enumerate(save_data['worlds']):
print(f"\nWorld {i}:")
print(f" World key: {world['world_key'].hex()}")
print(f" Map data bool: {world['map_data_bool']}")
print(f" Map data length: {world['map_data_len']}")
md = world['map_data']
print(f" num1: {md['num1']}")
print(f" num2: {md['num2']}")
print(f" Explored raw size: {len(md['explored_raw'])} bytes")
# Show first few bytes of explored data
print(f" First 32 bytes: {md['explored_raw'][:32].hex()}")
# Extract explored bitmap
print("\n" + "=" * 60)
print("Extracting explored bitmap...")
bitmap = extract_explored_bitmap(save_data)
if bitmap:
print(f"\nBitmap size: {len(bitmap)} bytes")
print(f"Expected for 4096x4096: {4096*4096//8} bytes")
# Convert to image
explored_img = bitmap_to_image(bitmap)
if explored_img:
explored_path = os.path.join(OUTPUT_DIR, "explored.png")
explored_img.save(explored_path)
print(f"\nExplored map saved: {explored_path}")
# Count explored vs unexplored pixels
explored_pixels = sum(1 for y in range(4096) for x in range(4096) if explored_img.getpixel((x, y)) > 128)
total = 4096 * 4096
pct = explored_pixels / total * 100
print(f"Explored: {explored_pixels:,} / {total:,} pixels ({pct:.1f}%)")
# Create fog overlay
overlay = create_fog_overlay(explored_img)
if overlay:
overlay_path = os.path.join(OUTPUT_DIR, "fog_overlay.png")
overlay.save(overlay_path)
print(f"Fog overlay saved: {overlay_path}")
# Also create a composite with the world map
composite = Image.open(os.path.join(OUTPUT_DIR, "world_composite.png")).convert('RGBA')
composite.paste(overlay, (0, 0), overlay)
fogged_path = os.path.join(OUTPUT_DIR, "world_fogged.png")
composite.save(fogged_path)
print(f"Fogged world map saved: {fogged_path}")
else:
print("Could not extract explored bitmap")
# Try alternative: read raw bytes and dump structure
print("\nDumping raw structure for analysis...")
with open(DB_PATH, 'rb') as f:
raw = f.read()
# Show first 200 bytes
print(f"First 200 bytes hex dump:")
for i in range(0, min(200, len(raw)), 16):
hex_str = ' '.join(f'{b:02x}' for b in raw[i:i+16])
ascii_str = ''.join(chr(b) if 32 <= b < 127 else '.' for b in raw[i:i+16])
print(f" {i:04x}: {hex_str:48s} {ascii_str}")
if __name__ == '__main__':
main()

View file

@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""
Python ZPackage parser for Valheim .db files.
Extracts cartography table data (explored map bitmap + pins).
"""
import struct, gzip, io, sys, os
from PIL import Image
DB_PATH = "/tmp/Nerdhalla.db"
OUTPUT_DIR = "/tmp/valheim_map_output"
# Known prefab hashes for cartography table
# Valheim uses fnv-1a 64-bit hashes for prefab names
CARTOGRAPHY_TABLE_HASH = None # We'll search for it
def fnv1a_64(data):
"""FNV-1a 64-bit hash (Valheim's prefab hashing)."""
h = 0xcbf29ce484222325
for b in data:
h ^= b
h = (h * 0x100000001b3) & 0xffffffffffffffff
return h
# Pre-calculate common prefab hashes
PREFAB_HASHES = {
'CartographyTable': None,
}
class ZPackage:
"""Read-only ZPackage parser (Valheim binary serialization format)."""
def __init__(self, data):
self.data = data
self.pos = 0
def read_int32(self):
v = struct.unpack_from('<i', self.data, self.pos)[0]
self.pos += 4
return v
def read_uint32(self):
v = struct.unpack_from('<I', self.data, self.pos)[0]
self.pos += 4
return v
def read_int64(self):
v = struct.unpack_from('<q', self.data, self.pos)[0]
self.pos += 8
return v
def read_uint64(self):
v = struct.unpack_from('<Q', self.data, self.pos)[0]
self.pos += 8
return v
def read_float(self):
v = struct.unpack_from('<f', self.data, self.pos)[0]
self.pos += 4
return v
def read_double(self):
v = struct.unpack_from('<d', self.data, self.pos)[0]
self.pos += 8
return v
def read_byte(self):
v = self.data[self.pos]
self.pos += 1
return v
def read_bool(self):
return self.read_byte() != 0
def read_bytes(self, n):
v = self.data[self.pos:self.pos+n]
self.pos += n
return v
def read_string_length(self):
"""Variable-length integer (7-bit per byte, LEB128-like)."""
result = 0
shift = 0
while True:
byte = self.read_byte()
result |= (byte & 0x7F) << shift
shift += 7
if (byte & 0x80) == 0:
break
return result
def read_string(self):
length = self.read_string_length()
if length == 0:
return ""
s = self.data[self.pos:self.pos+length].decode('utf-8', errors='replace')
self.pos += length
return s
def read_short(self):
v = struct.unpack_from('<h', self.data, self.pos)[0]
self.pos += 2
return v
def read_ushort(self):
v = struct.unpack_from('<H', self.data, self.pos)[0]
self.pos += 2
return v
def read_compressed(self):
"""Read a compressed ZPackage section."""
length = self.read_int32()
compressed = self.read_bytes(length)
try:
decompressed = gzip.decompress(compressed)
return ZPackage(decompressed)
except:
return None
def read_length_prefixed_bytes(self):
length = self.read_int32()
return self.read_bytes(length)
def skip(self, n):
self.pos += n
def remaining(self):
return len(self.data) - self.pos
def tell(self):
return self.pos
def parse_db(path):
"""Parse the .db file and extract cartography table data."""
with open(path, 'rb') as f:
raw = f.read()
print(f"File size: {len(raw)} bytes")
zp = ZPackage(raw)
# Header
world_version = zp.read_int32()
print(f"World version: {world_version}")
net_time = zp.read_double()
print(f"Net time: {net_time}")
my_id = zp.read_int64()
next_uid = zp.read_uint32()
num_zdos = zp.read_int32()
print(f"My ID: {my_id}, Next UID: {next_uid}, ZDOs: {num_zdos}")
# Calculate the hash for CartographyTable
cart_hash = fnv1a_64(b'CartographyTable')
print(f"CartographyTable hash: {cart_hash}")
# Also check for 'piece_cartographytable' (the actual in-game name)
piece_hash = fnv1a_64(b'piece_cartographytable')
print(f"piece_cartographytable hash: {piece_hash}")
# Skip through ZDOs looking for cartography table
cart_found = None
zdo_count = 0
print(f"\nScanning {num_zdos} ZDOs for cartography table...")
for i in range(num_zdos):
if i % 100000 == 0 and i > 0:
print(f" Scanned {i}/{num_zdos}...")
zdo_start = zp.tell()
# ZDO header
uid = zp.read_int64()
prefab = zp.read_int32()
owner = zp.read_int32()
data_type = zp.read_int32()
revision = zp.read_int32()
# Check if this is a cartography table
if prefab == cart_hash or prefab == piece_hash:
print(f"\n *** FOUND Cartography Table at ZDO {i} ***")
print(f" UID: {uid}, Prefab hash: {prefab}, Owner: {owner}")
print(f" Data type: {data_type}, Revision: {revision}")
cart_found = (i, uid, prefab, zp.tell())
break
# Skip ZDO data based on data_type
if data_type == 0:
# No data
pass
elif data_type == 1:
# Has persistent data
_ = zp.read_int32() # data length
# Skip the data
# Actually we need to parse it properly
# For now, just skip based on remaining data
pass
elif data_type == 2:
# Has ZDO data (key-value pairs)
num_keys = zp.read_byte()
for _ in range(num_keys):
key_hash = zp.read_int32()
value_type = zp.read_byte()
if value_type == 0: # float
zp.skip(4)
elif value_type == 1: # Vector3
zp.skip(12)
elif value_type == 2: # Quaternion
zp.skip(16)
elif value_type == 3: # int
zp.skip(4)
elif value_type == 4: # long
zp.skip(8)
elif value_type == 5: # string
_ = zp.read_string()
elif value_type == 6: # byte array
length = zp.read_int32()
zp.skip(length)
else:
print(f" Unknown value type {value_type} at {zp.tell()}")
break
elif data_type == 3:
# Both persistent and ZDO data
_ = zp.read_int32() # persistent data length
num_keys = zp.read_byte()
for _ in range(num_keys):
key_hash = zp.read_int32()
value_type = zp.read_byte()
if value_type == 0:
zp.skip(4)
elif value_type == 1:
zp.skip(12)
elif value_type == 2:
zp.skip(16)
elif value_type == 3:
zp.skip(4)
elif value_type == 4:
zp.skip(8)
elif value_type == 5:
_ = zp.read_string()
elif value_type == 6:
length = zp.read_int32()
zp.skip(length)
else:
break
# Skip sector data
# Each ZDO has sector info: 2 shorts (sector x, y) + 3 floats (position) + 4 floats (rotation)
# Actually this varies by version. Let's just skip to next ZDO by finding it.
# For world version >= 33, ZDOs have a compact format
# Let's just try to find the next ZDO by looking for the pattern
if cart_found:
print(f"\nCartography table found at ZDO index {cart_found[0]}")
else:
print(f"\nCartography table not found in {num_zdos} ZDOs")
print("The explored map data might be stored differently.")
print("Let me check the character save format instead...")
return cart_found
def main():
print("=" * 60)
print("Valheim Cartography Table Extractor")
print("=" * 60)
result = parse_db(DB_PATH)
if result:
print("\nNext steps: extract the explored bitmap from the ZDO data")
else:
print("\nThe cartography table data may be stored in a different format.")
print("Let me check the Zones section of the .db file...")
# The explored map data might be in the Zones section
# Or it might be stored per-player in .fch files
print("\nAlternative approach: parse the .db Zones section for explored data")
if __name__ == '__main__':
main()

266
src/valheim_db.py Normal file
View file

@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""Valheim World POI Database Builder
Parses locations.json into a queryable SQLite database with spatial support.
"""
import json, sqlite3, re, sys
from collections import Counter
DB_PATH = "/tmp/valheim_poi.db"
JSON_PATH = "/tmp/valheim_locations.json"
# ── Parse position strings like "(1.8, 81.4, 0.0)" ──
def parse_pos(s):
"""Returns (x, y_height, z) — x/z are map coords, y is altitude."""
m = re.match(r'\(?(-?[\d.]+),\s*(-?[\d.]+),\s*(-?[\d.]+)\)?', str(s))
if m:
return float(m.group(1)), float(m.group(2)), float(m.group(3))
return None, None, None
def classify_prefab(name):
"""Classify a prefab into a category for querying."""
categories = {
'boss': ['Eikthyrnir', 'Bonemass', 'GDKing', 'GoblinKing', 'Moder', 'Yagluth', 'SeekerQueen'],
'dungeon': ['Crypt', 'SunkenCrypt', 'Cave', 'DvergrTown', 'Mistlands_Dungeon'],
'vegvisir': ['Vegvisir'],
'altar': ['OfferingPlace', 'Runestone', 'StoneHenge'],
'vendor': ['Vendor_BlackForest', 'Haldor', 'Hildir'],
'structure': ['Castle', 'Tower', 'Ruin', 'Fortress', 'Camp'],
'resource': ['TarPit', 'Obsidian', 'Silver', 'Copper', 'Tin', 'DragonEgg', 'Mistlands_Guards'],
'spawner': ['Spawner', 'SpawnArea'],
'point_of_interest': ['StartTemple', 'FulingVillage', 'VikingBroad', 'StoneBridge'],
}
for cat, patterns in categories.items():
for p in patterns:
if p.lower() in name.lower():
return cat
return 'other'
def create_db(conn):
c = conn.cursor()
c.executescript("""
DROP TABLE IF EXISTS locations;
DROP TABLE IF EXISTS important_contents;
DROP TABLE IF EXISTS random_contents;
DROP TABLE IF EXISTS dungeon_components;
DROP TABLE IF EXISTS prefab_categories;
CREATE TABLE locations (
id INTEGER PRIMARY KEY,
prefab_name TEXT NOT NULL,
category TEXT NOT NULL,
pos_x REAL,
pos_y REAL, -- altitude/height
pos_z REAL, -- map depth
has_dungeon INTEGER DEFAULT 0,
has_important INTEGER DEFAULT 0,
has_random INTEGER DEFAULT 0
);
CREATE TABLE important_contents (
id INTEGER PRIMARY KEY,
location_id INTEGER NOT NULL,
friendly_name TEXT,
count INTEGER,
icon TEXT,
FOREIGN KEY (location_id) REFERENCES locations(id)
);
CREATE TABLE random_contents (
id INTEGER PRIMARY KEY,
location_id INTEGER NOT NULL,
type TEXT,
parent TEXT,
name TEXT,
interior INTEGER,
FOREIGN KEY (location_id) REFERENCES locations(id)
);
CREATE TABLE dungeon_components (
id INTEGER PRIMARY KEY,
location_id INTEGER NOT NULL,
delta_x REAL,
delta_y REAL,
rotation_y REAL,
parent_type TEXT,
parent_name TEXT,
FOREIGN KEY (location_id) REFERENCES locations(id)
);
CREATE INDEX idx_locations_prefab ON locations(prefab_name);
CREATE INDEX idx_locations_category ON locations(category);
CREATE INDEX idx_locations_pos ON locations(pos_x, pos_z);
CREATE INDEX idx_important_name ON important_contents(friendly_name);
CREATE INDEX idx_random_type ON random_contents(type, parent);
""")
conn.commit()
def parse_and_insert(conn):
c = conn.cursor()
loc_id = 0
imp_id = 0
rnd_id = 0
cmp_id = 0
prefab_counter = Counter()
category_counter = Counter()
imp_counter = Counter()
print("Parsing locations.json...")
with open(JSON_PATH, 'r', encoding='utf-8') as f:
data = json.load(f)
total = len(data)
print(f"Total locations: {total}")
for i, loc in enumerate(data):
prefab = loc.get('PrefabName', 'Unknown')
pos_str = loc.get('Position', '')
x, y, z = parse_pos(pos_str)
category = classify_prefab(prefab)
prefab_counter[prefab] += 1
category_counter[category] += 1
has_dungeon = 1 if 'DungeonComponents' in loc and loc['DungeonComponents'] else 0
has_important = 1 if 'ImportantContents' in loc and loc['ImportantContents'] else 0
has_random = 1 if 'RandomContents' in loc and loc['RandomContents'] else 0
loc_id += 1
c.execute("""
INSERT INTO locations (id, prefab_name, category, pos_x, pos_y, pos_z,
has_dungeon, has_important, has_random)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (loc_id, prefab, category, x, y, z, has_dungeon, has_important, has_random))
# Important contents
for ic in loc.get('ImportantContents', []):
imp_id += 1
friendly = ic.get('FriendlyName', '')
count = ic.get('Count', 1)
icon = ic.get('Icon', '')
imp_counter[friendly] += count
c.execute("""
INSERT INTO important_contents (id, location_id, friendly_name, count, icon)
VALUES (?, ?, ?, ?, ?)
""", (imp_id, loc_id, friendly, count, icon))
# Random contents
for rc in loc.get('RandomContents', []):
rnd_id += 1
c.execute("""
INSERT INTO random_contents (id, location_id, type, parent, name, interior)
VALUES (?, ?, ?, ?, ?, ?)
""", (rnd_id, loc_id, rc.get('Type',''), rc.get('Parent',''),
rc.get('Name',''), rc.get('Interior',0)))
# Dungeon components
for dc in loc.get('DungeonComponents', []):
cmp_id += 1
ik = dc.get('ImageKey', {})
c.execute("""
INSERT INTO dungeon_components (id, location_id, delta_x, delta_y,
rotation_y, parent_type, parent_name)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (cmp_id, loc_id, dc.get('DeltaX',0), dc.get('DeltaY',0),
dc.get('RotationY',0), ik.get('Type',''), ik.get('Parent','')))
if (i + 1) % 5000 == 0:
print(f" Processed {i+1}/{total}...")
conn.commit()
conn.commit()
print("\n=== Summary ===")
print(f"Locations: {loc_id}")
print(f"Important contents: {imp_id}")
print(f"Random contents: {rnd_id}")
print(f"Dungeon components: {cmp_id}")
print("\n--- By Category ---")
for cat, cnt in sorted(category_counter.items(), key=lambda x: -x[1]):
print(f" {cat}: {cnt}")
print("\n--- Top 20 Prefabs ---")
for prefab, cnt in prefab_counter.most_common(20):
print(f" {prefab}: {cnt}")
print("\n--- Top 20 Important Contents ---")
for name, cnt in imp_counter.most_common(20):
print(f" {name}: {cnt}")
return conn
def demo_queries(conn):
c = conn.cursor()
print("\n" + "="*60)
print("DEMO QUERIES")
print("="*60)
# 1. Boss locations
print("\n--- Boss Altars ---")
for row in c.execute("""
SELECT prefab_name, pos_x, pos_z FROM locations
WHERE category='boss' ORDER BY prefab_name
"""):
print(f" {row[0]}: ({row[1]:.0f}, {row[2]:.0f})")
# 2. Tar pits
print("\n--- Tar Pits ---")
for row in c.execute("""
SELECT pos_x, pos_z FROM locations WHERE prefab_name LIKE '%TarPit%'
"""):
print(f" Tar Pit at ({row[0]:.0f}, {row[1]:.0f})")
# 3. Surtling cores in dungeons
print("\n--- Surtling Core Locations ---")
for row in c.execute("""
SELECT l.prefab_name, l.pos_x, l.pos_z, SUM(ic.count) as total
FROM locations l JOIN important_contents ic ON l.id = ic.location_id
WHERE ic.friendly_name LIKE '%SurtlingCore%'
GROUP BY l.id ORDER BY total DESC LIMIT 10
"""):
print(f" {row[0]} @ ({row[1]:.0f},{row[2]:.0f}): {row[3]} cores")
# 4. Vegvisirs
print("\n--- Vegvisirs (Boss Hint Stones) ---")
for row in c.execute("""
SELECT ic.friendly_name, l.pos_x, l.pos_z
FROM locations l JOIN important_contents ic ON l.id = ic.location_id
WHERE ic.friendly_name LIKE '%Vegvisir%'
"""):
print(f" {row[0]} at ({row[1]:.0f}, {row[2]:.0f})")
# 5. Tar pit density - find clusters
print("\n--- Tar Pit Cluster Analysis ---")
rows = c.execute("""
SELECT pos_x, pos_z FROM locations WHERE prefab_name LIKE '%TarPit%'
""").fetchall()
if rows:
# Find highest density zone (grid-based)
grid = {}
grid_size = 1000 # 1000 unit cells
for x, z in rows:
gx, gz = int(x // grid_size), int(z // grid_size)
grid[(gx, gz)] = grid.get((gx, gz), 0) + 1
top = sorted(grid.items(), key=lambda x: -x[1])[:5]
for (gx, gz), cnt in top:
center_x = gx * grid_size + grid_size // 2
center_z = gz * grid_size + grid_size // 2
print(f" Zone ({center_x}, {center_z}): {cnt} tar pits in {grid_size}u radius")
if __name__ == '__main__':
import os
if os.path.exists(DB_PATH):
os.remove(DB_PATH)
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
create_db(conn)
parse_and_insert(conn)
demo_queries(conn)
conn.close()
print(f"\nDatabase saved to {DB_PATH}")
print(f"Size: {os.path.getsize(DB_PATH) / 1024 / 1024:.1f} MB")