Compare commits

...

No commits in common. "main" and "master" have entirely different histories.
main ... master

64 changed files with 1996 additions and 788 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/

409
README.md
View file

@ -1,150 +1,287 @@
# Nerdhalla Valheim World Map
**Vanilla Valheim community server tools** — no mods, no BepInEx. Everything runs off the world save file.
> **Seed:** `yzZ5fr2tGa` | **World:** 24000 × 24000 | **Biomes:** 9 | **POIs:** 11,309
## 🌐 Live Sites
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.
| Site | URL | Purpose |
|---|---|---|
| **Nerdhalla Community** | https://nerdhalla.nerdcade.cc | Map viewer, POI browser, server status |
| **Forgejo (source)** | https://git.sweeney.fyi/aelith/valheim-map | Git repo for all code |
---
## 🗺️ What's Here
### Data Pipeline
| Step | Script | Output |
|---|---|---|
| Decode tiles | `decode_valheim_tiles.py` | 16 × 1024×1024 PNGs (composite, biome, height) |
| Stitch tiles | `valheim_decode_tiles.py` | 4096×4096 world images |
| Build POI DB | `build_db.py` | SQLite DB with 11,309 locations |
| Build viewer | `build_viewer.py` | Leaflet HTML with 3 map layers |
| Parse .db | `parse_valheim_db.py` | ZPackage → JSON conversion |
### Web Viewer (`index.html`)
- Leaflet map with 3 layers: composite, biome, height
- 11,309 POI markers with category icons (boss, dungeon, resource, etc.)
- Coordinate display on hover
- POI browser with search + category filter
- Server status indicator (checks Nerdcade via SSH)
### API Server (`api_server.py`)
- `GET /api/status` — Nerdcade server online/offline check
- `GET /api/pois?limit=N&search=X&category=Y` — POI queries
### Infrastructure
- **Caddy** — reverse proxy with auto-TLS for nerdhalla.nerdcade.cc + git.sweeney.fyi
- **Forgejo** — bare metal (migrated from Docker), git.sweeney.fyi:3000
- **nerdhalla-api** — systemd service, port 8081
## 📊 World Stats
| Property | Value |
|---|---|
| Seed | `yzZ5fr2tGa` |
| Map Size | ~10 km × 10 km (4096 × 4096 px) |
| Biomes | Meadows, Black Forest, Swamp, Mountain, Plains, Ocean, Mistlands |
| Total POIs | 11,309 |
| Best Tar Pit | (1210, -4159) — 5 tar pits within 200m |
## 🚧 Roadmap
### Phase 1 — POI Database ✅
- [x] Decode 16 map tiles from world save
- [x] Stitch into 4096×4096 composite/biome/height images
- [x] Build SQLite DB from `locations.json` (11,309 POIs)
- [x] Spatial queries for base location hunting
### Phase 2 — Web Viewer ✅
- [x] Leaflet map with 3 tile layers
- [x] POI markers with category icons
- [x] Search + category filter
- [x] Coordinate display
- [x] Server status indicator
- [x] TLS via Caddy
- [x] API backend for POI queries
### Phase 3 — Fog-of-War ⏳
- [ ] **Blocked:** Need player `.fch` files from local machines
- [ ] Extract explored/unexplored bitmap per player
- [ ] Overlay on map viewer
- [ ] Merge multiple players' exploration data
### Phase 4 — Cartography Sync 🔮
- [ ] **Undecided approach:**
- Option 1: ServerSideMap mod (requires BepInEx — some players don't want this)
- Option 2: Periodic merge script (pull `.fch` files, merge explored data)
- Option 3: Cartography table automation (in-game solution)
### Phase 5 — Automation 🔮
- [ ] Cron job to periodically pull `Nerdhalla.db` from Nerdcade
- [ ] Auto-regenerate map tiles on world update
- [ ] Push updated data to site
## 🛠️ Infrastructure
### Services on Boston-VPS
| Service | Type | Port | Notes |
|---|---|---|---|
| Caddy | Bare metal | 80/443 | Reverse proxy, auto-TLS |
| Forgejo | Bare metal | 3000 | Git server, git.sweeney.fyi |
| nerdhalla-api | Bare metal | 8081 | Python API server |
| Hermes Gateway | Bare metal | 8642/8644 | Fleet COO agent |
### Docker Cleanup (June 2026)
- Forgejo migrated from Docker to bare metal (saved ~450 MB virtual overhead)
- Dead containers removed: `hermes-fa98be67` (exited), `vllm-compression` (never started)
- 9.2 GB reclaimed from unused images
- Docker still installed but only used if needed
## 🔧 Quick Commands
## Quick Start
```bash
# Restart API
sudo systemctl restart nerdhalla-api
# Restart Forgejo
sudo systemctl restart forgejo
# Restart Caddy
sudo systemctl reload caddy
# View logs
sudo journalctl -u nerdhalla-api -n 50 --no-pager
sudo journalctl -u forgejo -n 50 --no-pager
sudo tail -f /var/log/caddy/nerdhalla.log
# Serve the map viewer
cd ~/projects/valheim-map/output && python3 -m http.server 8765
# Open: http://72.60.69.120:8765/index.html
```
## 📁 File Layout
**Live:** http://72.60.69.120:8765/index.html (port 8765, ufw allowed)
---
## Project Structure
```
/var/www/nerdhalla/ # Site root
├── index.html # Main site
├── api_server.py # Python API
├── world_composite.png # 4096×4096 map images
├── world_biome.png
├── world_height.png
├── 00-00_*.png # Individual tile images (16 tiles × 3 layers)
└── ...
/srv/forgejo/ # Forgejo data
└── data/
├── gitea/ # Config, DB, attachments
└── git/repositories/ # Git repos
/etc/caddy/
├── Caddyfile # Main config (imports sites-enabled/)
└── sites-enabled/
├── nerdhalla.conf # nerdhalla.nerdcade.cc
└── git.conf # git.sweeney.fyi
~/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
```
## 🔗 Related
**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
- **Nerdcade server:** `aelith@100.86.206.39:46129`
- **World saves:** `/home/amp/.ampdata/instances/Valheim/.config/unity3d/IronGate/Valheim/worlds_local/`
- **Forgejo API token:** stored in Hermes memory
---
## 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

View file

@ -1,95 +0,0 @@
#!/usr/bin/env python3
"""Nerdhalla API server — serves POI data and server status."""
import json
import sqlite3
import subprocess
import sys
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
DB_PATH = "/tmp/valheim_poi.db"
NERDCADE_SSH = "aelith@100.86.206.39"
NERDCADE_PORT = 46129
class APIHandler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
params = parse_qs(parsed.query)
if path == "/api/status":
self._handle_status()
elif path == "/api/pois":
self._handle_pois(params)
else:
self.send_error(404, "Not found")
def _handle_status(self):
try:
result = subprocess.run(
["ssh", "-p", str(NERDCADE_PORT), "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=no", NERDCADE_SSH,
"systemctl --user is-active amp* 2>/dev/null || echo 'inactive'"],
capture_output=True, text=True, timeout=10
)
online = "active" in result.stdout.lower()
data = {"online": online, "players": None}
except Exception:
data = {"online": False, "players": None}
self._json_response(data)
def _handle_pois(self, params):
try:
limit = min(int(params.get("limit", [500])[0]), 11309)
offset = int(params.get("offset", [0])[0])
biome = params.get("biome", [None])[0]
search = params.get("search", [None])[0]
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
where_clauses = []
bindings = []
if biome:
where_clauses.append("category = ?")
bindings.append(biome)
if search:
where_clauses.append("prefab_name LIKE ?")
bindings.append(f"%{search}%")
where = ""
if where_clauses:
where = "WHERE " + " AND ".join(where_clauses)
cur.execute(f"SELECT pos_x, pos_y, pos_z, prefab_name, category FROM locations {where} ORDER BY prefab_name LIMIT ? OFFSET ?", bindings + [limit, offset])
rows = [{
"x": r["pos_x"],
"y": r["pos_z"], # Valheim uses Z as north-south
"prefab": r["prefab_name"],
"category": r["category"]
} for r in cur.fetchall()]
conn.close()
self._json_response(rows)
except Exception as e:
self._json_response({"error": str(e)}, status=500)
def _json_response(self, data, status=200):
body = json.dumps(data).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
sys.stderr.write("[API] %s - %s\n" % (self.client_address[0], format % args))
if __name__ == "__main__":
port = 8081
server = HTTPServer(("127.0.0.1", port), APIHandler)
print(f"[Nerdhalla API] Listening on 127.0.0.1:{port}")
server.serve_forever()

BIN
data/Nerdhalla.fwl Normal file

Binary file not shown.

View file

@ -1,536 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nerdhalla — Valheim Server</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚔️</text></svg>">
<style>
:root {
--bg: #0a0a0f;
--surface: #14141f;
--surface2: #1a1a2e;
--accent: #c8a84e;
--accent2: #8b6f3a;
--text: #d4d4dc;
--text2: #8888a0;
--green: #4ade80;
--red: #f87171;
--border: #2a2a3e;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
/* Header */
.header {
background: linear-gradient(135deg, var(--surface) 0%, #0d0d1a 100%);
border-bottom: 1px solid var(--border);
padding: 1.5rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.header-icon { font-size: 2rem; }
.header h1 {
font-size: 1.5rem;
color: var(--accent);
font-weight: 700;
letter-spacing: 0.05em;
}
.header .subtitle {
font-size: 0.85rem;
color: var(--text2);
margin-top: 0.15rem;
}
.server-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.9rem;
}
.status-dot {
width: 10px; height: 10px;
border-radius: 50%;
display: inline-block;
}
.status-dot.online { background: var(--green); box-shadow: 0 0 8px var(--green); }
.status-dot.offline { background: var(--red); box-shadow: 0 0 8px var(--red); }
.status-dot.unknown { background: var(--text2); }
/* Nav */
.nav {
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
gap: 0;
overflow-x: auto;
}
.nav a {
padding: 0.75rem 1.5rem;
color: var(--text2);
text-decoration: none;
font-size: 0.9rem;
border-bottom: 2px solid transparent;
transition: all 0.2s;
white-space: nowrap;
}
.nav a:hover, .nav a.active {
color: var(--accent);
border-bottom-color: var(--accent);
background: rgba(200, 168, 78, 0.05);
}
/* Content */
.content { max-width: 1200px; margin: 0 auto; padding: 2rem; }
.page { display: none; }
.page.active { display: block; }
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.card h2 {
font-size: 1.1rem;
color: var(--accent);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.stat-card {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
text-align: center;
}
.stat-card .value {
font-size: 1.8rem;
font-weight: 700;
color: var(--accent);
}
.stat-card .label {
font-size: 0.8rem;
color: var(--text2);
margin-top: 0.25rem;
}
/* Map */
#map {
height: 600px;
border-radius: 8px;
border: 1px solid var(--border);
}
/* Info table */
.info-table { width: 100%; border-collapse: collapse; }
.info-table td {
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
.info-table td:first-child {
color: var(--text2);
width: 200px;
font-weight: 500;
}
.info-table tr:last-child td { border-bottom: none; }
/* Footer */
.footer {
text-align: center;
padding: 2rem;
color: var(--text2);
font-size: 0.8rem;
border-top: 1px solid var(--border);
}
.footer a { color: var(--accent); text-decoration: none; }
/* Responsive */
@media (max-width: 768px) {
.header { flex-direction: column; align-items: flex-start; }
.header-left { width: 100%; }
.server-status { width: 100%; justify-content: center; }
#map { height: 400px; }
}
</style>
</head>
<body>
<!-- Header -->
<div class="header">
<div class="header-left">
<div class="header-icon">⚔️</div>
<div>
<h1>Nerdhalla</h1>
<div class="subtitle">Valheim Community Server</div>
</div>
</div>
<div class="server-status" id="serverStatus">
<span class="status-dot unknown" id="statusDot"></span>
<span id="statusText">Checking server...</span>
</div>
</div>
<!-- Navigation -->
<nav class="nav">
<a href="#" class="active" data-page="home">🏠 Home</a>
<a href="#" data-page="map">🗺️ World Map</a>
<a href="#" data-page="world">🌍 World Info</a>
<a href="#" data-page="pois">📍 Points of Interest</a>
</nav>
<!-- Content -->
<div class="content">
<!-- Home Page -->
<div class="page active" id="page-home">
<div class="card">
<h2>🏰 Welcome to Nerdhalla</h2>
<p style="line-height:1.7; color: var(--text2);">
A vanilla Valheim community server — no mods, no BepInEx, just pure Viking survival.
Explore a hand-mapped world, find the best base locations, and conquer the tenth world together.
</p>
</div>
<div class="card-grid">
<div class="stat-card">
<div class="value" id="statSeed">yzZ5fr2tGa</div>
<div class="label">World Seed</div>
</div>
<div class="stat-card">
<div class="value" id="statBiomes">7</div>
<div class="label">Biomes</div>
</div>
<div class="stat-card">
<div class="value" id="statPOIs">11,309</div>
<div class="label">Points of Interest</div>
</div>
<div class="stat-card">
<div class="value" id="statMapSize">4,096</div>
<div class="label">Map Size (px)</div>
</div>
</div>
<div class="card">
<h2>🎯 Best Tar Pit Location</h2>
<p style="color: var(--text2); margin-bottom: 0.5rem;">
<strong style="color: var(--accent);">(1210, -4159)</strong> — 5 tar pits within 200m radius.
Prime spot for a base with easy tar access.
</p>
<p style="color: var(--text2); font-size: 0.85rem;">
Use the <a href="#" onclick="switchPage('map')" style="color: var(--accent);">World Map</a> to explore more locations.
</p>
</div>
<div class="card">
<h2>📋 Quick Links</h2>
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
<a href="#" onclick="switchPage('map')" style="background: var(--accent2); color: #fff; padding: 0.6rem 1.2rem; border-radius: 6px; text-decoration: none; font-size: 0.9rem;">🗺️ Open World Map</a>
<a href="#" onclick="switchPage('world')" style="background: var(--surface2); color: var(--text); padding: 0.6rem 1.2rem; border-radius: 6px; text-decoration: none; font-size: 0.9rem; border: 1px solid var(--border);">🌍 World Details</a>
<a href="#" onclick="switchPage('pois')" style="background: var(--surface2); color: var(--text); padding: 0.6rem 1.2rem; border-radius: 6px; text-decoration: none; font-size: 0.9rem; border: 1px solid var(--border);">📍 Browse POIs</a>
</div>
</div>
</div>
<!-- Map Page -->
<div class="page" id="page-map">
<div class="card" style="padding: 0; overflow: hidden;">
<div id="map"></div>
</div>
<div class="card" style="margin-top: 1rem;">
<h2>🗺️ Map Controls</h2>
<div style="display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.85rem; color: var(--text2);">
<span>🖱️ Click POI markers for details</span>
<span>🔍 Scroll to zoom</span>
<span>📦 Layer selector in top-right</span>
<span>🔎 Search box for locations</span>
</div>
</div>
</div>
<!-- World Info Page -->
<div class="page" id="page-world">
<div class="card">
<h2>🌍 World Settings</h2>
<table class="info-table">
<tr><td>Seed</td><td><code>yzZ5fr2tGa</code></td></tr>
<tr><td>World Name</td><td>Nerdhalla</td></tr>
<tr><td>Difficulty</td><td>Normal (combat, raids, resources)</td></tr>
<tr><td>Map Size</td><td>~10 km × 10 km (4,096 × 4,096 px tiles)</td></tr>
<tr><td>Biomes</td><td>Meadows, Black Forest, Swamp, Mountain, Plains, Ocean, Mistlands</td></tr>
<tr><td>Total POIs</td><td>11,309 locations catalogued</td></tr>
</table>
</div>
<div class="card">
<h2>📊 Biome Distribution</h2>
<div id="biomeChart" style="height: 200px; display: flex; align-items: flex-end; gap: 0.5rem; padding: 1rem 0;">
<div style="flex:1; text-align:center;">
<div style="background:#7ec850; height:120px; border-radius:4px 4px 0 0;"></div>
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Meadows</div>
</div>
<div style="flex:1; text-align:center;">
<div style="background:#2d5a1e; height:160px; border-radius:4px 4px 0 0;"></div>
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Black Forest</div>
</div>
<div style="flex:1; text-align:center;">
<div style="background:#3a3a1a; height:90px; border-radius:4px 4px 0 0;"></div>
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Swamp</div>
</div>
<div style="flex:1; text-align:center;">
<div style="background:#8b8b8b; height:140px; border-radius:4px 4px 0 0;"></div>
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Mountain</div>
</div>
<div style="flex:1; text-align:center;">
<div style="background:#c8a84e; height:100px; border-radius:4px 4px 0 0;"></div>
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Plains</div>
</div>
<div style="flex:1; text-align:center;">
<div style="background:#4a6fa5; height:180px; border-radius:4px 4px 0 0;"></div>
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Ocean</div>
</div>
<div style="flex:1; text-align:center;">
<div style="background:#5a2d7a; height:70px; border-radius:4px 4px 0 0;"></div>
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Mistlands</div>
</div>
</div>
<p style="font-size:0.8rem; color:var(--text2); text-align:center;">Approximate — based on tile analysis</p>
</div>
</div>
<!-- POIs Page -->
<div class="page" id="page-pois">
<div class="card">
<h2>📍 Points of Interest</h2>
<p style="color: var(--text2); margin-bottom: 1rem;">
All 11,309 locations from the Nerdhalla world, searchable and categorized.
</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;">
<input type="text" id="poiSearch" placeholder="Search POIs..." style="flex:1; min-width:200px; padding:0.6rem; background:var(--surface2); border:1px solid var(--border); border-radius:6px; color:var(--text); font-size:0.9rem;">
<select id="poiCategory" style="padding:0.6rem; background:var(--surface2); border:1px solid var(--border); border-radius:6px; color:var(--text); font-size:0.9rem;">
<option value="">All Categories</option>
</select>
</div>
<div id="poiResults" style="max-height: 500px; overflow-y: auto;">
<p style="color: var(--text2); text-align: center; padding: 2rem;">
Loading POI data from database...
</p>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="footer">
<p>Nerdhalla — Vanilla Valheim Community Server</p>
<p style="margin-top: 0.3rem;">
<a href="http://git.sweeney.fyi:3000/aelith/valheim-map" target="_blank">Source on Forgejo</a>
&middot; Powered by <a href="https://caddyserver.com" target="_blank">Caddy</a>
&middot; Map by <a href="https://leafletjs.com" target="_blank">Leaflet</a>
</p>
</div>
<!-- Leaflet -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-search@3.0.10/dist/leaflet-search.src.js"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet-search@3.0.10/dist/leaflet-search.src.css" />
<script>
// ====== Page Navigation ======
function switchPage(name) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav a').forEach(a => a.classList.remove('active'));
document.getElementById('page-' + name).classList.add('active');
document.querySelector(`.nav a[data-page="${name}"]`).classList.add('active');
if (name === 'map' && map) setTimeout(() => map.invalidateSize(), 100);
if (name === 'pois') loadPOIs();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
document.querySelectorAll('.nav a').forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
switchPage(a.dataset.page);
});
});
// ====== Server Status ======
async function checkServer() {
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
try {
const resp = await fetch('/api/status');
const data = await resp.json();
if (data.online) {
dot.className = 'status-dot online';
text.textContent = `🟢 Online — ${data.players || 0} player(s)`;
} else {
dot.className = 'status-dot offline';
text.textContent = '🔴 Offline';
}
} catch {
dot.className = 'status-dot unknown';
text.textContent = '❓ Status unavailable';
}
}
checkServer();
setInterval(checkServer, 60000);
// ====== Map ======
let map = null;
function initMap() {
if (map) return;
map = L.map('map', {
center: [0, 0],
zoom: 1,
maxZoom: 5,
minZoom: 0,
crs: L.CRS.Simple,
zoomControl: true
});
const bounds = [[0, 0], [4096, 4096]];
// Tile layers
const composite = L.imageOverlay('world_composite.png', bounds, { opacity: 1 });
const biome = L.imageOverlay('world_biome.png', bounds, { opacity: 0.7 });
const height = L.imageOverlay('world_height.png', bounds, { opacity: 0.5 });
const baseMaps = {
"Composite": composite,
"Biome": biome,
"Height": height
};
composite.addTo(map);
map.fitBounds(bounds);
L.control.layers(baseMaps, null, { collapsed: false }).addTo(map);
// Coordinate display
const coordDisplay = L.control({ position: 'bottomleft' });
coordDisplay.onAdd = function() {
const div = L.DomUtil.create('div', 'coord-display');
div.style.cssText = 'background:rgba(20,20,31,0.9); color:#d4d4dc; padding:4px 10px; border-radius:4px; font-size:12px; border:1px solid #2a2a3e;';
div.innerHTML = 'Move cursor for coordinates';
return div;
};
coordDisplay.addTo(map);
map.on('mousemove', function(e) {
const x = Math.round(e.latlng.lng);
const y = Math.round(e.latlng.lat);
document.querySelector('.coord-display').innerHTML = `📍 (${x}, ${y})`;
});
// POI markers from database
fetch('/api/pois?limit=500')
.then(r => r.json())
.then(pois => {
const poiIcons = {
'point_of_interest': L.divIcon({ html: '📍', className: 'poi-icon', iconSize: [20, 20] }),
'boss': L.divIcon({ html: '👑', className: 'poi-icon', iconSize: [20, 20] }),
'dungeon': L.divIcon({ html: '🏚️', className: 'poi-icon', iconSize: [20, 20] }),
'resource': L.divIcon({ html: '⛏️', className: 'poi-icon', iconSize: [20, 20] }),
'structure': L.divIcon({ html: '🏠', className: 'poi-icon', iconSize: [20, 20] }),
'spawner': L.divIcon({ html: '👾', className: 'poi-icon', iconSize: [20, 20] }),
'altar': L.divIcon({ html: '🪦', className: 'poi-icon', iconSize: [20, 20] }),
'vendor': L.divIcon({ html: '🏪', className: 'poi-icon', iconSize: [20, 20] }),
'other': L.divIcon({ html: '❓', className: 'poi-icon', iconSize: [20, 20] }),
'default': L.divIcon({ html: '📍', className: 'poi-icon', iconSize: [20, 20] })
};
const markerLayer = L.layerGroup();
pois.forEach(p => {
const icon = poiIcons[p.category] || poiIcons['default'];
const m = L.marker([p.y, p.x], { icon }).bindPopup(
`<b>${p.prefab || 'Unknown'}</b><br>📍 (${Math.round(p.x)}, ${Math.round(p.y)})<br>📂 ${p.category || 'Unknown'}`
);
markerLayer.addLayer(m);
});
markerLayer.addTo(map);
L.control.layers(null, { "POIs (11k+)": markerLayer }, { collapsed: true }).addTo(map);
});
}
// Init map when page loads or switches to map
document.addEventListener('DOMContentLoaded', () => {
// Defer map init until tab is clicked
});
document.querySelector('.nav a[data-page="map"]').addEventListener('click', () => {
setTimeout(initMap, 200);
});
// ====== POI Browser ======
let allPOIs = [];
async function loadPOIs() {
const results = document.getElementById('poiResults');
if (allPOIs.length > 0) return renderPOIs();
try {
const resp = await fetch('/api/pois?limit=11309');
allPOIs = await resp.json();
// Populate categories
const cats = new Set(allPOIs.map(p => p.category).filter(Boolean));
const sel = document.getElementById('poiCategory');
cats.forEach(c => {
const opt = document.createElement('option');
opt.value = c;
opt.textContent = c.replace(/_/g, ' ');
sel.appendChild(opt);
});
renderPOIs();
} catch {
results.innerHTML = '<p style="color: var(--red); text-align: center; padding: 2rem;">Failed to load POIs</p>';
}
}
function renderPOIs() {
const search = document.getElementById('poiSearch').value.toLowerCase();
const cat = document.getElementById('poiCategory').value;
const filtered = allPOIs.filter(p => {
const name = (p.prefab || '').toLowerCase();
const catVal = (p.category || '');
return name.includes(search) && (!cat || catVal === cat);
});
const results = document.getElementById('poiResults');
if (filtered.length === 0) {
results.innerHTML = '<p style="color: var(--text2); text-align: center; padding: 2rem;">No matching POIs</p>';
return;
}
results.innerHTML = filtered.slice(0, 200).map(p => `
<div style="padding:0.5rem; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center;">
<span><strong>${p.prefab || 'Unknown'}</strong> <span style="color:var(--text2);font-size:0.8rem;">(${Math.round(p.x)}, ${Math.round(p.y)})</span></span>
<span style="color:var(--text2);font-size:0.8rem;">${(p.category || '').replace(/_/g, ' ')}</span>
</div>
`).join('');
if (filtered.length > 200) {
results.innerHTML += `<p style="color:var(--text2);text-align:center;padding:0.5rem;font-size:0.8rem;">Showing 200 of ${filtered.length} results</p>`;
}
}
document.getElementById('poiSearch').addEventListener('input', renderPOIs);
document.getElementById('poiCategory').addEventListener('change', renderPOIs);
</script>
</body>
</html>

View file

@ -1,21 +0,0 @@
nerdhalla.nerdcade.cc {
root * /var/www/nerdhalla
file_server
# API reverse proxy
handle_path /api/* {
reverse_proxy 127.0.0.1:8081
}
# Security headers
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
# Logs
log {
output file /var/log/caddy/nerdhalla.log
}
}

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)

View file

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View file

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View file

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 204 KiB

View file

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 142 KiB

View file

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View file

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 233 KiB

View file

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 162 KiB

View file

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

View file

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View file

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 163 KiB

View file

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 127 KiB

View file

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View file

Before

Width:  |  Height:  |  Size: 406 KiB

After

Width:  |  Height:  |  Size: 406 KiB

View file

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 277 KiB

View file

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View file

Before

Width:  |  Height:  |  Size: 422 KiB

After

Width:  |  Height:  |  Size: 422 KiB

View file

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 279 KiB

View file

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View file

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 256 KiB

View file

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 148 KiB

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 159 KiB

View file

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

View file

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View file

Before

Width:  |  Height:  |  Size: 411 KiB

After

Width:  |  Height:  |  Size: 411 KiB

View file

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 274 KiB

View file

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View file

Before

Width:  |  Height:  |  Size: 402 KiB

After

Width:  |  Height:  |  Size: 402 KiB

View file

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 270 KiB

View file

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View file

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 275 KiB

View file

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 156 KiB

View file

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View file

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View file

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View file

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View file

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 224 KiB

View file

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

View file

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View file

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 199 KiB

View file

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

View file

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

View file

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

View file

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

252
output/index.html Normal file

File diff suppressed because one or more lines are too long

View file

Before

Width:  |  Height:  |  Size: 374 KiB

After

Width:  |  Height:  |  Size: 374 KiB

View file

Before

Width:  |  Height:  |  Size: 3.6 MiB

After

Width:  |  Height:  |  Size: 3.6 MiB

View file

Before

Width:  |  Height:  |  Size: 2.3 MiB

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")